mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
3264 lines
117 KiB
HTML
3264 lines
117 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>microWakeWord Personal Samples</title>
|
||
<style>
|
||
:root{
|
||
--bg: #070709;
|
||
--panel: rgba(18, 18, 22, 0.8);
|
||
--panel2: rgba(24, 24, 30, 0.88);
|
||
--text: #ececf1;
|
||
--muted: #a9a9b3;
|
||
--line: rgba(255,255,255,0.1);
|
||
--orange: #ff8a2a;
|
||
--orange2:#ffc07f;
|
||
--ok:#39d4a0;
|
||
--warn:#ffb020;
|
||
--err:#ff5757;
|
||
--shadow: 0 20px 54px rgba(0,0,0,0.45);
|
||
--radius: 18px;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
margin: 0;
|
||
color: var(--text);
|
||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background:
|
||
radial-gradient(900px 500px at 12% 6%, rgba(255, 138, 42, 0.12), transparent 55%),
|
||
radial-gradient(720px 420px at 82% 14%, rgba(255, 192, 127, 0.1), transparent 60%),
|
||
radial-gradient(820px 620px at 50% 100%, rgba(255, 138, 42, 0.06), transparent 55%),
|
||
linear-gradient(180deg, #050506 0%, #0b0b11 100%);
|
||
}
|
||
|
||
.wrap { max-width: 980px; margin: 0 auto; padding: 28px 18px 42px; }
|
||
.topbar { display:flex; align-items:center; justify-content:space-between; gap: 12px; margin-bottom: 14px; }
|
||
.brand { display:flex; align-items:center; gap:12px; }
|
||
.logo {
|
||
width: 42px; height: 42px; border-radius: 14px;
|
||
border: 1px solid rgba(255,138,42,0.3);
|
||
background:
|
||
radial-gradient(circle at 30% 30%, rgba(255,176,102,0.52), rgba(255,138,42,0.24) 44%, rgba(0,0,0,0) 72%),
|
||
linear-gradient(180deg, rgba(255,138,42,0.22), rgba(255,138,42,0.06));
|
||
box-shadow: 0 12px 30px rgba(255,138,42,0.1);
|
||
}
|
||
|
||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0.2px; }
|
||
h3 { margin: 0 0 10px; font-size: 18px; }
|
||
p { margin: 0 0 14px; color: var(--muted); line-height: 1.5; }
|
||
|
||
.card {
|
||
border: 1px solid var(--line);
|
||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||
border-radius: var(--radius);
|
||
padding: 18px;
|
||
margin-top: 14px;
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||
.space { justify-content: space-between; }
|
||
.stack { display: grid; gap: 12px; }
|
||
.muted { color: var(--muted); }
|
||
|
||
input[type="text"],
|
||
input[type="number"],
|
||
input[type="password"],
|
||
select {
|
||
padding: 11px 12px;
|
||
font-size: 15px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
background: rgba(0,0,0,0.35);
|
||
color: var(--text);
|
||
outline: none;
|
||
}
|
||
input[type="text"] { width: 420px; max-width: 100%; }
|
||
input[type="password"] { width: 260px; max-width: 100%; }
|
||
input[type="number"] { width: 132px; }
|
||
input::placeholder { color: rgba(236,236,241,0.36); }
|
||
|
||
button {
|
||
padding: 10px 14px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.14);
|
||
background: rgba(255,255,255,0.06);
|
||
color: var(--text);
|
||
transition: transform 0.04s ease, border-color .15s ease, background .15s ease;
|
||
}
|
||
button:hover { border-color: rgba(255,138,42,0.36); background: rgba(255,255,255,0.08); }
|
||
button:active { transform: translateY(1px); }
|
||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
|
||
.primary {
|
||
border-color: rgba(255,138,42,0.42);
|
||
background: linear-gradient(180deg, rgba(255,138,42,0.24), rgba(255,138,42,0.12));
|
||
}
|
||
|
||
.pill {
|
||
display:inline-block;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.pill.ok { color: var(--ok); border-color: rgba(57,212,160,0.25); background: rgba(57,212,160,0.08); }
|
||
.pill.warn { color: var(--warn); border-color: rgba(255,176,32,0.25); background: rgba(255,176,32,0.08); }
|
||
.pill.err { color: var(--err); border-color: rgba(255,87,87,0.25); background: rgba(255,87,87,0.08); }
|
||
|
||
.statGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.stat {
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.04);
|
||
}
|
||
.stat .label {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 6px;
|
||
}
|
||
.stat .value { font-size: 24px; font-weight: 700; }
|
||
|
||
.dropzone {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 18px;
|
||
border-radius: 16px;
|
||
border: 1px dashed rgba(255,192,127,0.32);
|
||
background:
|
||
linear-gradient(180deg, rgba(255,138,42,0.08), rgba(255,138,42,0.04)),
|
||
rgba(255,255,255,0.02);
|
||
}
|
||
|
||
input[type="file"] {
|
||
width: 100%;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.progressShell {
|
||
width: 100%;
|
||
height: 16px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(255,255,255,0.08);
|
||
}
|
||
.progressFill {
|
||
height: 100%;
|
||
width: 0%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, rgba(255,138,42,0.88), rgba(255,192,127,0.94));
|
||
box-shadow: inset 0 0 18px rgba(255,255,255,0.18);
|
||
transition: width 0.18s ease;
|
||
}
|
||
|
||
.fileList {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
.fileItem {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.03);
|
||
}
|
||
.fileMeta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 6px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.field strong {
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.field input,
|
||
.field select {
|
||
width: 100%;
|
||
}
|
||
|
||
.autoGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
.autoGrid .wide { grid-column: 1 / -1; }
|
||
.checkField {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.09);
|
||
background: rgba(255,255,255,0.035);
|
||
color: var(--muted);
|
||
line-height: 1.4;
|
||
}
|
||
.checkField input { margin-top: 3px; }
|
||
.checkField[hidden] { display: none !important; }
|
||
.checkField strong { color: var(--text); display: block; margin-bottom: 3px; }
|
||
.autoActions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||
.autoAudit {
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(0,0,0,0.22);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.firmwareGrid {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(220px, 280px);
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.firmwareHero {
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-height: 176px;
|
||
background:
|
||
radial-gradient(520px 220px at 88% 0%, rgba(255,192,127,0.16), transparent 62%),
|
||
linear-gradient(135deg, rgba(255,138,42,0.12), rgba(255,255,255,0.035) 44%, rgba(255,255,255,0.02));
|
||
}
|
||
|
||
.firmwareHero::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: auto -80px -120px auto;
|
||
width: 280px;
|
||
height: 280px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,192,127,0.12);
|
||
background: radial-gradient(circle, rgba(255,138,42,0.12), transparent 66%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.firmwareHero > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.firmwareKicker {
|
||
color: var(--orange2);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.firmwareHero h3 {
|
||
font-size: clamp(24px, 4vw, 34px);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.firmwareHero p {
|
||
max-width: 680px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.firmwareTaterOnlyNotice {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
border-color: rgba(255,138,42,0.34);
|
||
background:
|
||
linear-gradient(135deg, rgba(255,138,42,0.15), rgba(255,255,255,0.035)),
|
||
var(--panel2);
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.firmwareTaterOnlyNotice strong {
|
||
color: var(--orange2);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.firmwareSteps {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.firmwareStepChip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(0,0,0,0.2);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.firmwareStepChip b {
|
||
color: var(--orange2);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.runtimeWakeWordCard {
|
||
border-color: rgba(57,212,160,0.22);
|
||
background:
|
||
linear-gradient(135deg, rgba(57,212,160,0.09), rgba(255,138,42,0.08)),
|
||
var(--panel2);
|
||
}
|
||
|
||
.runtimeWakeWordHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
}
|
||
|
||
.runtimeWakeWordTitle {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.runtimeWakeWordBadge {
|
||
flex: 0 0 auto;
|
||
display: grid;
|
||
place-items: center;
|
||
min-width: 68px;
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(57,212,160,0.34);
|
||
background: rgba(57,212,160,0.1);
|
||
color: var(--ok);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.runtimeWakeWordTitle h3 {
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.runtimeWakeWordTitle p {
|
||
max-width: 760px;
|
||
margin-bottom: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.runtimeWakeWordActions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.runtimeWakeWordLinks {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.runtimeWakeWordItem {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(0,0,0,0.18);
|
||
}
|
||
|
||
.runtimeWakeWordItem strong {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.runtimeWakeWordUrls {
|
||
display: grid;
|
||
gap: 3px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.runtimeWakeWordUrl {
|
||
display: block;
|
||
overflow: hidden;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.runtimeWakeWordMeta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.runtimeWakeWordMeta .pill {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.runtimeWakeWordItem button {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.firmwareLayout {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
|
||
.firmwarePanel {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.firmwarePanelHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.firmwarePanelTitle {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.firmwareStepBadge {
|
||
flex: 0 0 auto;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 11px;
|
||
border: 1px solid rgba(255,138,42,0.36);
|
||
background: rgba(255,138,42,0.12);
|
||
color: var(--orange2);
|
||
font-weight: 800;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.firmwarePanel h3 {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.firmwarePanel p {
|
||
margin-bottom: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.firmwareTargetGrid {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) minmax(120px, 160px);
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.firmwareFields {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.firmwareSettingsSection {
|
||
display: grid;
|
||
gap: 14px;
|
||
padding: 16px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background:
|
||
linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.022)),
|
||
rgba(255,255,255,0.02);
|
||
}
|
||
|
||
.firmwareSettingsGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.wakeSoundPreviewRow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-self: end;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
.wakeSoundPreviewStatus {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.readOnlyValue {
|
||
min-height: 43px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 11px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,192,127,0.18);
|
||
background:
|
||
linear-gradient(180deg, rgba(255,138,42,0.08), rgba(255,255,255,0.025)),
|
||
rgba(0,0,0,0.24);
|
||
color: var(--text);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.readOnlyValue::after {
|
||
content: "Locked";
|
||
flex: 0 0 auto;
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,192,127,0.22);
|
||
color: var(--orange2);
|
||
background: rgba(255,138,42,0.08);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.firmwareActionsPanel {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) auto;
|
||
gap: 16px;
|
||
align-items: center;
|
||
margin-top: 0;
|
||
border-color: rgba(255,138,42,0.16);
|
||
background:
|
||
linear-gradient(135deg, rgba(255,138,42,0.1), rgba(255,255,255,0.035)),
|
||
var(--panel2);
|
||
}
|
||
|
||
.firmwareActions {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.usbFlashHint {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
max-width: 420px;
|
||
}
|
||
|
||
.studioHero {
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-height: 176px;
|
||
background:
|
||
radial-gradient(520px 220px at 88% 0%, rgba(255,192,127,0.16), transparent 62%),
|
||
linear-gradient(135deg, rgba(255,138,42,0.12), rgba(255,255,255,0.035) 44%, rgba(255,255,255,0.02));
|
||
}
|
||
|
||
.studioHero.trainerHero {
|
||
background:
|
||
radial-gradient(560px 240px at 88% 0%, rgba(255,192,127,0.15), transparent 62%),
|
||
radial-gradient(360px 220px at 4% 92%, rgba(57,212,160,0.08), transparent 64%),
|
||
linear-gradient(135deg, rgba(255,138,42,0.12), rgba(255,255,255,0.035) 44%, rgba(255,255,255,0.02));
|
||
}
|
||
|
||
.studioHero.captureHero {
|
||
background:
|
||
radial-gradient(540px 220px at 84% 0%, rgba(137,212,255,0.12), transparent 62%),
|
||
radial-gradient(420px 260px at 0% 88%, rgba(255,138,42,0.1), transparent 66%),
|
||
linear-gradient(135deg, rgba(255,255,255,0.055), rgba(255,138,42,0.06) 46%, rgba(255,255,255,0.02));
|
||
}
|
||
|
||
.studioHero::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: auto -80px -120px auto;
|
||
width: 280px;
|
||
height: 280px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,192,127,0.12);
|
||
background: radial-gradient(circle, rgba(255,138,42,0.12), transparent 66%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.studioHero > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.studioKicker {
|
||
color: var(--orange2);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.studioHero h3 {
|
||
font-size: clamp(24px, 4vw, 34px);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.studioHero p {
|
||
max-width: 700px;
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.studioSteps {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.studioStepChip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(0,0,0,0.2);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.studioStepChip b {
|
||
color: var(--orange2);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.studioPanel {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.studioPanelHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
}
|
||
|
||
.studioPanelTitle {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.studioStepBadge {
|
||
flex: 0 0 auto;
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 11px;
|
||
border: 1px solid rgba(255,138,42,0.36);
|
||
background: rgba(255,138,42,0.12);
|
||
color: var(--orange2);
|
||
font-weight: 800;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.studioPanel h3 {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.studioPanel p,
|
||
.studioActionsPanel p {
|
||
margin-bottom: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.phraseGrid {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) minmax(180px, 240px) auto;
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.phraseActions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.sampleProgressCard {
|
||
padding: 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background:
|
||
linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)),
|
||
rgba(255,255,255,0.02);
|
||
}
|
||
|
||
.studioActionsPanel {
|
||
display: grid;
|
||
gap: 16px;
|
||
margin-top: 0;
|
||
border-color: rgba(255,138,42,0.16);
|
||
background:
|
||
linear-gradient(135deg, rgba(255,138,42,0.1), rgba(255,255,255,0.035)),
|
||
var(--panel2);
|
||
}
|
||
|
||
.capturedControlPanel {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) auto;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.capturedActions {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.sampleLibraryHeader {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) auto;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.sampleTypeTabs {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 6px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(0,0,0,0.18);
|
||
}
|
||
|
||
.sampleTypeBtn {
|
||
min-width: 124px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
background: transparent;
|
||
}
|
||
|
||
.sampleTypeBtn.active {
|
||
border-color: rgba(255,138,42,0.42);
|
||
background: rgba(255,138,42,0.16);
|
||
color: var(--orange2);
|
||
}
|
||
|
||
.paginationControls {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
padding: 12px 0;
|
||
}
|
||
|
||
.paginationControls .pageBtn {
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
color: var(--text, #fff);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.paginationControls .pageBtn:disabled {
|
||
opacity: 0.3;
|
||
cursor: default;
|
||
}
|
||
|
||
.paginationControls .pageInfo {
|
||
font-size: 13px;
|
||
color: var(--muted, #888);
|
||
}
|
||
|
||
.paginationControls .pageJump {
|
||
font-size: 13px;
|
||
color: var(--muted, #888);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.paginationControls .pageInput {
|
||
width: 40px;
|
||
padding: 4px 6px;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: 4px;
|
||
color: var(--text, #fff);
|
||
}
|
||
|
||
.paginationControls .pageJumpBtn {
|
||
padding: 4px 10px;
|
||
font-size: 13px;
|
||
background: rgba(255,255,255,0.1);
|
||
border: 1px solid rgba(255,255,255,0.15);
|
||
border-radius: 4px;
|
||
color: var(--text, #fff);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.paginationControls .pageJumpBtn:hover {
|
||
background: rgba(255,255,255,0.18);
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin: 4px 0 0;
|
||
}
|
||
|
||
.tabBtn {
|
||
min-width: 140px;
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.04);
|
||
}
|
||
|
||
.tabBtn.active {
|
||
border-color: rgba(255,138,42,0.42);
|
||
background: linear-gradient(180deg, rgba(255,138,42,0.2), rgba(255,138,42,0.08));
|
||
color: var(--orange2);
|
||
}
|
||
|
||
.viewStack[hidden] {
|
||
display: none !important;
|
||
}
|
||
|
||
.capturedList {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.captureCard {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.03);
|
||
}
|
||
|
||
.captureTitle {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.captureSubtitle {
|
||
margin: 4px 0 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.audioPlayer {
|
||
width: 100%;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.captureActions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.emptyState {
|
||
padding: 18px;
|
||
border-radius: 16px;
|
||
border: 1px dashed rgba(255,255,255,0.12);
|
||
background: rgba(255,255,255,0.03);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.consoleOverlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
padding: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(4, 5, 10, 0.5);
|
||
backdrop-filter: blur(10px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||
z-index: 10000;
|
||
}
|
||
|
||
.consoleOverlay.open {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.consoleWindow {
|
||
width: min(1040px, calc(100vw - 36px));
|
||
height: min(76vh, 760px);
|
||
display: grid;
|
||
grid-template-rows: auto 1fr;
|
||
gap: 14px;
|
||
padding: 18px;
|
||
border-radius: 22px;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
background:
|
||
linear-gradient(180deg, rgba(17, 20, 28, 0.78), rgba(8, 10, 16, 0.92)),
|
||
rgba(8, 10, 16, 0.75);
|
||
box-shadow: 0 28px 84px rgba(0,0,0,0.58);
|
||
backdrop-filter: blur(18px) saturate(1.12);
|
||
transform-origin: center;
|
||
}
|
||
|
||
.trainAction {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.trainAction .primary {
|
||
min-width: min(320px, 100%);
|
||
}
|
||
|
||
.trainFooter {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
.consoleWindow.wobble {
|
||
animation: consoleWobble 560ms cubic-bezier(0.22, 0.85, 0.25, 1);
|
||
}
|
||
|
||
.consoleHeader {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
}
|
||
|
||
.consoleTitle {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.consoleHint {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.consoleLog {
|
||
overflow: auto;
|
||
min-height: 0;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background:
|
||
linear-gradient(180deg, rgba(6, 9, 14, 0.9), rgba(4, 6, 10, 0.96)),
|
||
rgba(0,0,0,0.65);
|
||
padding: 16px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||
}
|
||
|
||
.consoleLine {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: #d8e2f1;
|
||
}
|
||
|
||
.consoleLine + .consoleLine {
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.consoleMuted { color: #93a0b5; }
|
||
.consoleText { color: #d8e2f1; }
|
||
.consoleCmd { color: #89d4ff; }
|
||
.consoleWarn { color: #ffc46f; }
|
||
.consoleErr { color: #ff8f8f; }
|
||
.consoleOk { color: #6ee0af; }
|
||
.consoleSection { color: #ffd89b; font-weight: 600; letter-spacing: 0.02em; }
|
||
|
||
@keyframes consoleWobble {
|
||
0% { transform: translateY(24px) scale(0.93) rotate(-1.2deg); }
|
||
34% { transform: translateY(-6px) scale(1.02) rotate(0.85deg); }
|
||
58% { transform: translateY(2px) scale(0.99) rotate(-0.45deg); }
|
||
78% { transform: translateY(-1px) scale(1.01) rotate(0.2deg); }
|
||
100% { transform: translateY(0) scale(1) rotate(0deg); }
|
||
}
|
||
|
||
.trimOverlay {
|
||
position: fixed; inset: 0; padding: 22px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(4,5,10,0.6); backdrop-filter: blur(10px);
|
||
opacity: 0; visibility: hidden; pointer-events: none;
|
||
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||
z-index: 11000;
|
||
}
|
||
.trimOverlay.open { opacity: 1; visibility: visible; pointer-events: auto; }
|
||
|
||
.trimDialog {
|
||
width: min(960px, calc(100vw - 36px));
|
||
max-height: min(90vh, 900px);
|
||
display: grid; grid-template-rows: auto 1fr auto auto auto; gap: 12px;
|
||
padding: 18px; border-radius: 22px;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
background: linear-gradient(180deg, rgba(17,20,28,0.82), rgba(8,10,16,0.94));
|
||
box-shadow: 0 28px 84px rgba(0,0,0,0.58);
|
||
backdrop-filter: blur(18px) saturate(1.12);
|
||
}
|
||
.trimHeader { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||
.trimTitle { margin: 0; font-size: 18px; }
|
||
.trimHint { margin: 6px 0 0; font-size: 13px; color: var(--muted); }
|
||
|
||
.trimCanvasWrap {
|
||
position: relative; width: 100%; min-height: 120px;
|
||
border-radius: 14px; border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(0,0,0,0.4); overflow: hidden;
|
||
}
|
||
.trimCanvas { width: 100%; height: 100%; display: block; }
|
||
|
||
.trimHandle {
|
||
position: absolute; top: 0; width: 48px; height: 100%;
|
||
cursor: ew-resize; pointer-events: auto; touch-action: none;
|
||
transform: translateX(-50%);
|
||
}
|
||
.trimHandle::after {
|
||
content: ''; position: absolute; top: 10%; bottom: 10%; left: 50%;
|
||
transform: translateX(-50%); width: 3px; border-radius: 2px;
|
||
background: var(--orange); box-shadow: 0 0 6px rgba(255,138,42,0.5);
|
||
}
|
||
.trimHandle::before {
|
||
content: ''; position: absolute; top: 50%; left: 50%;
|
||
transform: translate(-50%,-50%); width: 10px; height: 24px;
|
||
border-radius: 5px; border: 1px solid rgba(255,138,42,0.5);
|
||
background: rgba(255,138,42,0.15);
|
||
}
|
||
|
||
.trimTimeInfo {
|
||
display: flex; align-items: center; justify-content: center;
|
||
gap: 12px; font-size: 14px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.trimSeparator { color: var(--muted); }
|
||
.trimVadInfo { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||
.trimActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.trimActions button { flex: 1; min-width: 120px; }
|
||
|
||
.pill.trimBadge {
|
||
color: #89d4ff;
|
||
border-color: rgba(137,212,255,0.25);
|
||
background: rgba(137,212,255,0.08);
|
||
}
|
||
.trimBtn {
|
||
border-color: rgba(255,138,42,0.3);
|
||
background: rgba(255,138,42,0.1);
|
||
}
|
||
.trimBtn:hover {
|
||
border-color: rgba(255,138,42,0.5);
|
||
background: rgba(255,138,42,0.18);
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.wrap { padding: 18px 14px 30px; }
|
||
input[type="text"] { width: 100%; }
|
||
.fileItem { align-items: flex-start; flex-direction: column; }
|
||
.firmwareGrid { grid-template-columns: 1fr; }
|
||
.autoGrid { grid-template-columns: 1fr; }
|
||
.autoGrid .wide { grid-column: auto; }
|
||
.firmwareLayout,
|
||
.firmwareTargetGrid,
|
||
.firmwareActionsPanel {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.firmwareActions {
|
||
justify-content: stretch;
|
||
}
|
||
.firmwareActions button {
|
||
width: 100%;
|
||
}
|
||
.runtimeWakeWordHeader,
|
||
.runtimeWakeWordTitle {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.runtimeWakeWordItem {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.runtimeWakeWordItem button {
|
||
width: 100%;
|
||
}
|
||
.studioPanelHeader,
|
||
.capturedControlPanel {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.studioPanelHeader,
|
||
.capturedControlPanel {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.phraseGrid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.phraseActions,
|
||
.capturedActions {
|
||
justify-content: stretch;
|
||
}
|
||
.phraseActions button,
|
||
.capturedActions button {
|
||
width: 100%;
|
||
}
|
||
.sampleLibraryHeader {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.consoleOverlay { padding: 12px; }
|
||
.consoleWindow {
|
||
width: 100%;
|
||
height: min(82vh, 760px);
|
||
padding: 14px;
|
||
}
|
||
.consoleHeader {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.trainFooter {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.trainAction .primary,
|
||
.trainFooter button {
|
||
width: 100%;
|
||
}
|
||
.trimOverlay { padding: 8px; }
|
||
.trimDialog {
|
||
width: 100%; height: 96vh;
|
||
padding: 14px; grid-template-rows: auto 1fr auto auto auto;
|
||
}
|
||
.trimCanvasWrap { min-height: 100px; }
|
||
.trimHeader { flex-direction: column; align-items: stretch; }
|
||
.trimActions { flex-direction: column; }
|
||
.trimActions button { width: 100%; min-width: unset; }
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="topbar">
|
||
<div class="brand">
|
||
<div class="logo"></div>
|
||
<div>
|
||
<h1>microWakeWord Trainer Studio</h1>
|
||
<p>Train wake words, review captured clips, and publish live model URLs for Tater Native satellites.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button id="tabTrainer" class="tabBtn active" type="button">Trainer</button>
|
||
<button id="tabAuto" class="tabBtn" type="button">Auto Training</button>
|
||
<button id="tabFirmware" class="tabBtn" type="button">Wake Words</button>
|
||
<button id="tabCaptured" class="tabBtn" type="button">Captured Audio</button>
|
||
<button id="tabSamples" class="tabBtn" type="button">Samples</button>
|
||
</div>
|
||
|
||
<div id="trainerView" class="viewStack stack">
|
||
<div class="card studioHero trainerHero">
|
||
<div class="row space">
|
||
<div>
|
||
<div class="studioKicker">Training Studio</div>
|
||
<h3>Build a Personal Wake Word</h3>
|
||
<p>Start with a phrase, review your positive and negative sample counts, then launch the training pipeline with a live console so every step is visible.</p>
|
||
<div class="studioSteps" aria-label="Training steps">
|
||
<span class="studioStepChip"><b>1</b> Phrase + voice</span>
|
||
<span class="studioStepChip"><b>2</b> Review sample counts</span>
|
||
<span class="studioStepChip"><b>3</b> Train model</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">1</span>
|
||
<div>
|
||
<h3>Phrase + Voice</h3>
|
||
<p>Name the wake phrase and choose the language/voice set used to generate training audio.</p>
|
||
</div>
|
||
</div>
|
||
<span id="sessionPill" class="pill">No session</span>
|
||
</div>
|
||
|
||
<div class="phraseGrid">
|
||
<label class="field">
|
||
<strong>Wake Phrase</strong>
|
||
<input id="phrase" type="text" placeholder='e.g. "tater totterson"' />
|
||
</label>
|
||
<label class="field">
|
||
<strong>Language</strong>
|
||
<select id="language">
|
||
<option value="en" selected>English (en)</option>
|
||
</select>
|
||
</label>
|
||
<div class="phraseActions">
|
||
<button id="startSessionBtn" class="primary">Start session</button>
|
||
<button id="ttsBtn" disabled>Test TTS</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card studioActionsPanel">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">2</span>
|
||
<div>
|
||
<h3>Train Wake Word</h3>
|
||
<p>Device-captured positives and reviewed negatives are used when present. Manual samples are managed from the Samples tab.</p>
|
||
</div>
|
||
</div>
|
||
<span id="trainState" class="pill">Not started</span>
|
||
</div>
|
||
<div class="statGrid">
|
||
<div class="stat">
|
||
<span class="label">Positive Samples</span>
|
||
<span class="value" id="positiveSampleCount">0</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="label">Negative Samples</span>
|
||
<span class="value" id="negativeTrainingCount">0</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="label">Training Format</span>
|
||
<span class="value" style="font-size:18px;">16 kHz / mono / 16-bit WAV</span>
|
||
</div>
|
||
</div>
|
||
<div class="trainAction">
|
||
<button id="trainBtn" class="primary" disabled>Start training</button>
|
||
</div>
|
||
<div class="trainFooter">
|
||
<span class="muted">The training console opens automatically when a run starts.</span>
|
||
<button id="openConsoleBtn" disabled>Open console</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="autoView" class="viewStack stack" hidden>
|
||
<div class="card studioHero trainerHero">
|
||
<div class="row space">
|
||
<div>
|
||
<div class="studioKicker">False-Positive Loop</div>
|
||
<h3>Auto Training</h3>
|
||
<p>Sort false wakes into negatives, recover spoken close misses as positives, clean up confirmed wakes, retrain on your schedule, and refresh connected satellites.</p>
|
||
<div class="studioSteps" aria-label="Auto Training steps">
|
||
<span class="studioStepChip"><b>1</b> Transcribe wakes</span>
|
||
<span class="studioStepChip"><b>2</b> Collect negatives</span>
|
||
<span class="studioStepChip"><b>3</b> Retrain + refresh</span>
|
||
</div>
|
||
</div>
|
||
<span id="autoStatus" class="pill">Disabled</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">1</span>
|
||
<div>
|
||
<h3>Review Rules</h3>
|
||
<p>False-wake sorting is always conservative. Cleanup and close-miss promotion remain optional, and empty STT results stay in the inbox.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label class="checkField">
|
||
<input id="autoEnabled" type="checkbox" />
|
||
<span><strong>Enable Auto Training</strong>Eligible wake triggers will be queued for local Faster Whisper transcription.</span>
|
||
</label>
|
||
<label class="checkField">
|
||
<input id="autoDeleteConfirmedWakes" type="checkbox" />
|
||
<span><strong>Delete confirmed good wakes</strong>When a normal wake trigger contains the configured phrase, remove it from Captured Audio instead of keeping it for manual review.</span>
|
||
</label>
|
||
<label class="checkField">
|
||
<input id="autoPromoteCloseMisses" type="checkbox" />
|
||
<span><strong>Promote confirmed close misses</strong>Transcribe close misses that passed VAD and move them to Positive samples only when STT finds the configured phrase.</span>
|
||
</label>
|
||
<div class="autoGrid">
|
||
<label class="field">
|
||
<strong>Wake phrase</strong>
|
||
<input id="autoWakePhrase" type="text" placeholder='e.g. "hey tater"' />
|
||
</label>
|
||
<label class="field">
|
||
<strong>STT language</strong>
|
||
<input id="autoLanguage" type="text" value="en" placeholder="en" />
|
||
</label>
|
||
<label class="field wide">
|
||
<strong>Faster Whisper model</strong>
|
||
<input id="autoSttModel" type="text" value="small.en" placeholder="small.en" />
|
||
</label>
|
||
<label class="field">
|
||
<strong>STT device</strong>
|
||
<select id="autoSttDevice">
|
||
<option value="auto" selected>Auto (prefer CUDA)</option>
|
||
<option value="cuda">CUDA</option>
|
||
<option value="cpu">CPU</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<strong>Compute type</strong>
|
||
<select id="autoSttComputeType">
|
||
<option value="auto" selected>Auto (float16 CUDA / int8 CPU)</option>
|
||
<option value="float16">float16</option>
|
||
<option value="int8_float16">int8_float16</option>
|
||
<option value="int8">int8</option>
|
||
<option value="float32">float32</option>
|
||
<option value="default">CTranslate2 default</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<strong>Minimum transcript characters</strong>
|
||
<input id="autoMinimumChars" type="number" min="1" max="100" value="2" />
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">2</span>
|
||
<div>
|
||
<h3>Training Schedule</h3>
|
||
<p>A scheduled run starts only after enough new auto-reviewed negatives have accumulated.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="autoGrid">
|
||
<label class="field">
|
||
<strong>Run training</strong>
|
||
<select id="autoScheduleHours">
|
||
<option value="0">Manually only</option>
|
||
<option value="6">Every 6 hours</option>
|
||
<option value="12">Every 12 hours</option>
|
||
<option value="24" selected>Every day</option>
|
||
<option value="48">Every 2 days</option>
|
||
<option value="168">Every week</option>
|
||
</select>
|
||
</label>
|
||
<label class="field">
|
||
<strong>Minimum new negatives</strong>
|
||
<input id="autoMinimumNegatives" type="number" min="1" max="10000" value="3" />
|
||
</label>
|
||
</div>
|
||
<div class="statGrid">
|
||
<div class="stat"><span class="label">Pending Negatives</span><span class="value" id="autoPendingNegatives">0</span></div>
|
||
<div class="stat"><span class="label">Next Scheduled Check</span><span class="value" id="autoNextRun" style="font-size:16px;">Manual</span></div>
|
||
<div class="stat"><span class="label">Last Training</span><span class="value" id="autoLastTraining" style="font-size:16px;">Never</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">3</span>
|
||
<div>
|
||
<h3>Publish + Satellite Refresh</h3>
|
||
<p>The trainer publishes a LAN-reachable model URL, then asks Tater to re-push live settings so the firmware downloads the updated model at the same URL.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="autoGrid">
|
||
<label class="field wide">
|
||
<strong>Trainer public URL</strong>
|
||
<input id="autoAdvertisedUrl" type="text" placeholder="Auto-detect this host's LAN IP" />
|
||
<span id="autoDetectedUrl" class="muted">Auto-detected when left blank.</span>
|
||
</label>
|
||
<label class="field wide">
|
||
<strong>Tater URL</strong>
|
||
<input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" />
|
||
</label>
|
||
<label class="field">
|
||
<strong>Satellite selector (optional)</strong>
|
||
<input id="autoTaterSelector" type="text" placeholder="Blank refreshes all connected sats" />
|
||
</label>
|
||
<label class="field">
|
||
<strong>Tater API token (if enabled)</strong>
|
||
<input id="autoTaterToken" type="password" placeholder="Not configured" autocomplete="off" />
|
||
</label>
|
||
</div>
|
||
<label class="checkField">
|
||
<input id="autoNotifySatellites" type="checkbox" checked />
|
||
<span><strong>Refresh satellites after successful training</strong>Uses Tater's existing native satellite settings API.</span>
|
||
</label>
|
||
<label id="autoClearTokenRow" class="checkField" hidden>
|
||
<input id="autoClearTaterToken" type="checkbox" />
|
||
<span><strong>Clear the saved Tater token</strong>The token is otherwise preserved when the password field is blank.</span>
|
||
</label>
|
||
</section>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="autoActions">
|
||
<button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button>
|
||
<button id="autoReviewNowBtn" type="button">Review inbox now</button>
|
||
<button id="autoTrainNowBtn" type="button">Train now</button>
|
||
<button id="autoNotifyNowBtn" type="button">Refresh satellites now</button>
|
||
</div>
|
||
<div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="capturedView" class="viewStack stack" hidden>
|
||
<div class="card studioHero captureHero">
|
||
<div class="row space">
|
||
<div>
|
||
<div class="studioKicker">Capture Review</div>
|
||
<h3>Captured Audio</h3>
|
||
<p>Listen to clips sent by your sats, turn good wake-word examples into personal samples, and save false positives as negatives for the next training run.</p>
|
||
<div class="studioSteps" aria-label="Captured audio review steps">
|
||
<span class="studioStepChip"><b>1</b> Review inbox</span>
|
||
<span class="studioStepChip"><b>2</b> Listen + sort</span>
|
||
<span class="studioStepChip"><b>3</b> Retrain smarter</span>
|
||
</div>
|
||
</div>
|
||
<span id="capturedStatus" class="pill">Inbox idle</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="capturedControlPanel">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">1</span>
|
||
<div>
|
||
<h3>Review Queue</h3>
|
||
<p>Refresh clips from the trainer inbox and keep an eye on how many reviewed negatives and personal samples are ready.</p>
|
||
</div>
|
||
</div>
|
||
<div class="row capturedActions">
|
||
<button id="refreshCapturedBtn" type="button">Refresh inbox</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="statGrid">
|
||
<div class="stat">
|
||
<span class="label">Inbox</span>
|
||
<span class="value" id="capturedCount">0</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="label">Reviewed Negatives</span>
|
||
<span class="value" id="negativeCount">0</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="label">Personal Samples</span>
|
||
<span class="value" id="capturedPersonalCount">0</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">2</span>
|
||
<div>
|
||
<h3>Listen + Sort</h3>
|
||
<p>Approve strong wake-word clips into personal samples, or mark false positives as reviewed negatives.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="capturedList" class="capturedList">
|
||
<div class="emptyState">No captured audio yet.</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="samplesView" class="viewStack stack" hidden>
|
||
<div class="card studioHero captureHero">
|
||
<div class="row space">
|
||
<div>
|
||
<div class="studioKicker">Sample Library</div>
|
||
<h3>Current Training Samples</h3>
|
||
<p>Listen to saved positive and negative samples, remove anything that does not belong, or manually import seed recordings when you need a starting point.</p>
|
||
<div class="studioSteps" aria-label="Sample library steps">
|
||
<span class="studioStepChip"><b>1</b> Review positives</span>
|
||
<span class="studioStepChip"><b>2</b> Review negatives</span>
|
||
<span class="studioStepChip"><b>3</b> Optional import</span>
|
||
</div>
|
||
</div>
|
||
<span id="status" class="pill">Samples idle</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div id="sampleLibraryHeader" class="sampleLibraryHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">1</span>
|
||
<div>
|
||
<h3>Saved Samples</h3>
|
||
<p>Personal samples are positive examples. Negative samples are false wakes and hard negatives.</p>
|
||
</div>
|
||
</div>
|
||
<div class="sampleTypeTabs" aria-label="Sample type tabs">
|
||
<button id="sampleTabPersonal" class="sampleTypeBtn active" type="button">Personal <span id="samplePersonalCount">0</span></button>
|
||
<button id="sampleTabNegative" class="sampleTypeBtn" type="button">Negative <span id="sampleNegativeCount">0</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button id="refreshSamplesBtn" type="button">Refresh samples</button>
|
||
<button id="clearBtn" type="button" disabled>Clear personal samples</button>
|
||
<button id="clearNegativeBtn" type="button" disabled>Clear negative samples</button>
|
||
</div>
|
||
|
||
<div id="sampleLibraryList" class="capturedList">
|
||
<div class="emptyState">No samples saved yet.</div>
|
||
</div>
|
||
<div id="samplePagination"></div>
|
||
</section>
|
||
|
||
<section class="card studioPanel stack">
|
||
<div class="studioPanelHeader">
|
||
<div class="studioPanelTitle">
|
||
<span class="studioStepBadge">2</span>
|
||
<div>
|
||
<h3>Manual Sample Import</h3>
|
||
<p>Optional backup path for seeding positives before your device has captured enough real wake audio.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dropzone">
|
||
<div>
|
||
<strong>Select one or many files</strong>
|
||
<p class="muted" style="margin-top:6px;">WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM are all fine when ffmpeg is available. Files are converted into <code>personal_samples/</code>.</p>
|
||
</div>
|
||
<input id="sampleFiles" type="file" accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus" multiple />
|
||
<span id="selectedPill" class="pill">No files selected</span>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<button id="uploadBtn" class="primary" disabled>Upload selected samples</button>
|
||
</div>
|
||
|
||
<div class="sampleProgressCard stack">
|
||
<div class="row space">
|
||
<strong id="progressLabel">No upload in progress</strong>
|
||
<span id="progressPct" class="muted">0%</span>
|
||
</div>
|
||
<div class="progressShell">
|
||
<div id="progressFill" class="progressFill"></div>
|
||
</div>
|
||
<div id="progressDetail" class="muted">When you upload, each file is checked and converted only if needed before it is written into <code>personal_samples/</code>.</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="firmwareView" class="viewStack stack" hidden>
|
||
<div class="card firmwareHero">
|
||
<div class="row space">
|
||
<div>
|
||
<div class="firmwareKicker">Wake Word Catalog</div>
|
||
<h3>Trained Wake Words</h3>
|
||
<p>Every completed training run publishes a local JSON package and model file. Copy the JSON URL into the native satellite settings in Tater to switch wake words live.</p>
|
||
<div class="firmwareSteps" aria-label="Wake word publishing steps">
|
||
<span class="firmwareStepChip"><b>1</b> Train model</span>
|
||
<span class="firmwareStepChip"><b>2</b> Copy JSON URL</span>
|
||
<span class="firmwareStepChip"><b>3</b> Paste in Tater</span>
|
||
</div>
|
||
</div>
|
||
<span id="firmwareStatus" class="pill">Catalog idle</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card firmwareTaterOnlyNotice">
|
||
<strong>Tater Native</strong>
|
||
<span>These generated wake-word URLs are for Tater Native satellites and are loaded from the satellite settings in Tater.</span>
|
||
</div>
|
||
|
||
<div class="card runtimeWakeWordCard">
|
||
<div class="runtimeWakeWordHeader">
|
||
<div class="runtimeWakeWordTitle">
|
||
<span class="runtimeWakeWordBadge">v1</span>
|
||
<div>
|
||
<div class="firmwareKicker">Live Model Switching</div>
|
||
<h3>Copy trained wake-word URLs</h3>
|
||
<p>The JSON file points Tater and the satellite at the matching model plus trainer tuning values.</p>
|
||
</div>
|
||
</div>
|
||
<div class="runtimeWakeWordActions">
|
||
<span class="pill ok">No reflash needed</span>
|
||
<button id="refreshWakeWordsBtn" type="button">Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div id="runtimeWakeWordLinks" class="runtimeWakeWordLinks">
|
||
<div class="emptyState">Train a wake word first, then its live model URL will appear here.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="consoleOverlay" class="consoleOverlay" aria-hidden="true">
|
||
<div id="consoleWindow" class="consoleWindow" role="dialog" aria-modal="true" aria-labelledby="consoleTitle">
|
||
<div class="consoleHeader">
|
||
<div>
|
||
<h3 id="consoleTitle" class="consoleTitle">Training Console</h3>
|
||
<p id="consoleHint" class="consoleHint">Live training output appears here with color-coded console styling.</p>
|
||
</div>
|
||
<button id="closeConsoleBtn">Close</button>
|
||
</div>
|
||
<div id="trainLog" class="consoleLog" aria-live="polite">
|
||
<div class="consoleLine consoleMuted">(no training started)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="trimOverlay" class="trimOverlay" aria-hidden="true">
|
||
<div id="trimDialog" class="trimDialog" role="dialog" aria-modal="true">
|
||
<div class="trimHeader">
|
||
<div>
|
||
<h3 id="trimTitle" class="trimTitle">Trim Audio</h3>
|
||
<p id="trimHint" class="trimHint">Drag the handles to select a region, then save as a new sample.</p>
|
||
</div>
|
||
<button id="closeTrimBtn" type="button">Close</button>
|
||
</div>
|
||
<div class="trimCanvasWrap">
|
||
<canvas id="trimCanvas" class="trimCanvas"></canvas>
|
||
<div id="trimStartHandle" class="trimHandle" data-handle="start"></div>
|
||
<div id="trimEndHandle" class="trimHandle" data-handle="end"></div>
|
||
</div>
|
||
<div class="trimTimeInfo">
|
||
<span id="trimStartTime">0.00s</span>
|
||
<span class="trimSeparator">--</span>
|
||
<span id="trimEndTime">0.00s</span>
|
||
<span class="trimSeparator">|</span>
|
||
<span id="trimDuration">Duration: 0.00s</span>
|
||
</div>
|
||
<div id="trimVadInfo" class="trimVadInfo">
|
||
<span class="pill ok">VAD detected speech</span>
|
||
<span id="trimVadSegments" class="muted"></span>
|
||
</div>
|
||
<div class="trimActions">
|
||
<button id="trimPlayBtn" type="button">Play selection</button>
|
||
<button id="trimSelectFirstVadBtn" type="button">Select first VAD</button>
|
||
<button id="trimSaveBtn" class="primary" type="button">Save Trim</button>
|
||
<button id="trimCancelBtn" type="button">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const uiState = {
|
||
session: null,
|
||
training: null,
|
||
availableLanguages: [],
|
||
selectedFiles: [],
|
||
captured: { items: [], captured_count: 0, negative_count: 0, personal_count: 0 },
|
||
samples: { personal: [], negative: [], personal_count: 0, negative_count: 0, activeBucket: "personal", pages: { personal: 0, negative: 0 } },
|
||
firmware: { wakeWords: [] },
|
||
autoTrain: null,
|
||
uploadBusy: false,
|
||
reviewBusy: false,
|
||
firmwareBusy: false,
|
||
autoBusy: false,
|
||
trainingPoller: null,
|
||
activeView: "trainer",
|
||
};
|
||
const SAMPLE_PAGE_SIZE = 50;
|
||
|
||
// --- Trim Waveform Module ---
|
||
const TrimWaveform = {
|
||
audioBuffer: null,
|
||
duration: 0,
|
||
startRatio: 0,
|
||
endRatio: 1,
|
||
vadSegments: [],
|
||
isDragging: null,
|
||
|
||
async init(bucket, fileName) {
|
||
const audioUrl = `/api/audio/${encodeURIComponent(bucket)}/${encodeURIComponent(fileName)}`;
|
||
const resp = await fetch(audioUrl);
|
||
const arrayBuf = await resp.arrayBuffer();
|
||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
this.audioBuffer = await ctx.decodeAudioData(arrayBuf);
|
||
this.duration = this.audioBuffer.duration;
|
||
ctx.close();
|
||
|
||
try {
|
||
const vadData = await api(
|
||
`/api/samples/${encodeURIComponent(bucket)}/${encodeURIComponent(fileName)}/vad`,
|
||
{ method: 'POST' }
|
||
);
|
||
this.vadSegments = vadData.segments || [];
|
||
} catch (e) {
|
||
console.warn('VAD failed:', e);
|
||
this.vadSegments = [];
|
||
}
|
||
|
||
this.startRatio = 0;
|
||
this.endRatio = 1;
|
||
if (this.vadSegments.length > 0) {
|
||
this.startRatio = this.vadSegments[0].start / this.duration;
|
||
this.endRatio = this.vadSegments[0].end / this.duration;
|
||
}
|
||
return { duration: this.duration, vadCount: this.vadSegments.length };
|
||
},
|
||
|
||
draw() {
|
||
const canvas = $('trimCanvas');
|
||
if (!canvas || !this.audioBuffer) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
const w = rect.width, h = rect.height, mid = h / 2;
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
// Full waveform (dim)
|
||
const data = this.audioBuffer.getChannelData(0);
|
||
const step = Math.max(1, Math.floor(data.length / w));
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < w; i++) {
|
||
let mn = 1, mx = -1;
|
||
for (let j = 0; j < step; j++) {
|
||
const v = data[i * step + j] || 0;
|
||
if (v < mn) mn = v;
|
||
if (v > mx) mx = v;
|
||
}
|
||
ctx.moveTo(i, mid + mn * mid * 0.9);
|
||
ctx.lineTo(i, mid + mx * mid * 0.9);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Selection waveform (bright)
|
||
const selStartPx = this.startRatio * w;
|
||
const selEndPx = this.endRatio * w;
|
||
ctx.strokeStyle = 'rgba(255,138,42,0.7)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
for (let i = Math.floor(selStartPx); i <= Math.floor(selEndPx); i++) {
|
||
let mn = 1, mx = -1;
|
||
for (let j = 0; j < step; j++) {
|
||
const v = data[i * step + j] || 0;
|
||
if (v < mn) mn = v;
|
||
if (v > mx) mx = v;
|
||
}
|
||
ctx.moveTo(i, mid + mn * mid * 0.9);
|
||
ctx.lineTo(i, mid + mx * mid * 0.9);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Dim areas outside selection
|
||
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||
ctx.fillRect(0, 0, selStartPx, h);
|
||
ctx.fillRect(selEndPx, 0, w - selEndPx, h);
|
||
|
||
// VAD segment markers (green lines)
|
||
this.vadSegments.forEach(seg => {
|
||
const x = (seg.start / this.duration) * w;
|
||
ctx.strokeStyle = 'rgba(57,212,160,0.4)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, 0);
|
||
ctx.lineTo(x, h);
|
||
ctx.stroke();
|
||
});
|
||
|
||
// Update handle positions
|
||
$('trimStartHandle').style.left = (this.startRatio * 100) + '%';
|
||
$('trimEndHandle').style.left = (this.endRatio * 100) + '%';
|
||
},
|
||
|
||
getStartTime() { return this.startRatio * this.duration; },
|
||
getEndTime() { return this.endRatio * this.duration; },
|
||
setStartSeconds(s) {
|
||
this.startRatio = Math.max(0, Math.min(s / this.duration, this.endRatio - 0.001));
|
||
this.draw(); updateTrimTimeDisplay();
|
||
},
|
||
setEndSeconds(s) {
|
||
this.endRatio = Math.min(1, Math.max(s / this.duration, this.startRatio + 0.001));
|
||
this.draw(); updateTrimTimeDisplay();
|
||
},
|
||
|
||
playSelection() {
|
||
if (!this.audioBuffer) return;
|
||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const src = audioCtx.createBufferSource();
|
||
src.buffer = this.audioBuffer;
|
||
src.start(0, this.getStartTime(), this.getEndTime() - this.getStartTime());
|
||
src.connect(audioCtx.destination);
|
||
src.onended = () => audioCtx.close();
|
||
},
|
||
|
||
async getTrimmedWavBlob() {
|
||
const buf = this.audioBuffer;
|
||
const startSample = Math.floor(this.getStartTime() * buf.sampleRate);
|
||
const endSample = Math.min(Math.floor(this.getEndTime() * buf.sampleRate), buf.length);
|
||
const numSamples = endSample - startSample;
|
||
const targetRate = 16000;
|
||
|
||
let pcmFloat32;
|
||
if (buf.sampleRate === targetRate) {
|
||
pcmFloat32 = buf.getChannelData(0).slice(startSample, endSample);
|
||
} else {
|
||
const offlineCtx = new OfflineAudioContext(1, numSamples * (targetRate / buf.sampleRate) | 0, targetRate);
|
||
const src = offlineCtx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.start(0, this.getStartTime(), this.getEndTime() - this.getStartTime());
|
||
src.connect(offlineCtx.destination);
|
||
const rendered = await offlineCtx.startRendering();
|
||
pcmFloat32 = rendered.getChannelData(0);
|
||
}
|
||
|
||
const int16 = new Int16Array(pcmFloat32.length);
|
||
for (let i = 0; i < pcmFloat32.length; i++) {
|
||
int16[i] = Math.max(-32768, Math.min(32767, Math.round(pcmFloat32[i] * 32767)));
|
||
}
|
||
|
||
const dataSize = int16.length * 2;
|
||
const wavSize = 36 + dataSize;
|
||
const wavBuf = new ArrayBuffer(44 + dataSize);
|
||
const view = new DataView(wavBuf);
|
||
view.setUint32(0, 0x52494646, false);
|
||
view.setUint32(4, wavSize, true);
|
||
view.setUint32(8, 0x57415645, false);
|
||
view.setUint32(12, 0x666d7420, false);
|
||
view.setUint32(16, 16, true);
|
||
view.setUint16(20, 1, true);
|
||
view.setUint16(22, 1, true);
|
||
view.setUint32(24, targetRate, true);
|
||
view.setUint32(28, targetRate * 2, true);
|
||
view.setUint16(32, 2, true);
|
||
view.setUint16(34, 16, true);
|
||
view.setUint32(36, 0x64617461, false);
|
||
view.setUint32(40, dataSize, true);
|
||
for (let i = 0; i < int16.length; i++) {
|
||
view.setInt16(44 + i * 2, int16[i], true);
|
||
}
|
||
return new Blob([wavBuf], { type: 'audio/wav' });
|
||
},
|
||
|
||
onPointerDown(handleType, e) {
|
||
this.isDragging = handleType;
|
||
e.preventDefault();
|
||
},
|
||
onPointerMove(e) {
|
||
if (!this.isDragging) return;
|
||
e.preventDefault();
|
||
const canvas = $('trimCanvas');
|
||
const rect = canvas.getBoundingClientRect();
|
||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||
let ratio = (clientX - rect.left) / rect.width;
|
||
ratio = Math.max(0, Math.min(1, ratio));
|
||
if (this.isDragging === 'start') {
|
||
this.startRatio = Math.max(0, Math.min(ratio, this.endRatio - 0.002));
|
||
} else {
|
||
this.endRatio = Math.min(1, Math.max(ratio, this.startRatio + 0.002));
|
||
}
|
||
this.draw();
|
||
updateTrimTimeDisplay();
|
||
},
|
||
onPointerUp() { this.isDragging = null; },
|
||
|
||
destroy() {
|
||
this.audioBuffer = null;
|
||
this.vadSegments = [];
|
||
this.isDragging = null;
|
||
}
|
||
};
|
||
|
||
// --- Trim Modal Functions ---
|
||
let trimBucket = null;
|
||
let trimFileName = null;
|
||
|
||
async function openTrimModal(bucket, fileName) {
|
||
trimBucket = bucket;
|
||
trimFileName = fileName;
|
||
const overlay = $('trimOverlay');
|
||
overlay.classList.add('open');
|
||
overlay.setAttribute('aria-hidden', 'false');
|
||
$('trimHint').textContent = 'Loading audio...';
|
||
$('trimSaveBtn').disabled = true;
|
||
$('trimPlayBtn').disabled = true;
|
||
|
||
try {
|
||
const info = await TrimWaveform.init(bucket, fileName);
|
||
$('trimHint').textContent = `Drag the handles to select a region, then save as a new sample. Duration: ${info.duration.toFixed(2)}s`;
|
||
$('trimSaveBtn').disabled = false;
|
||
$('trimPlayBtn').disabled = false;
|
||
|
||
if (info.vadCount > 0) {
|
||
$('trimVadInfo').style.display = 'flex';
|
||
$('trimVadSegments').textContent = `${info.vadCount} speech segment${info.vadCount > 1 ? 's' : ''} detected (first auto-selected)`;
|
||
} else {
|
||
$('trimVadInfo').style.display = 'none';
|
||
}
|
||
|
||
requestAnimationFrame(() => {
|
||
TrimWaveform.draw();
|
||
updateTrimTimeDisplay();
|
||
});
|
||
} catch (e) {
|
||
$('trimHint').textContent = 'Failed to load audio: ' + e.message;
|
||
alert('Failed to load audio for trimming: ' + e.message);
|
||
closeTrimModal();
|
||
}
|
||
}
|
||
|
||
function closeTrimModal() {
|
||
$('trimOverlay').classList.remove('open');
|
||
$('trimOverlay').setAttribute('aria-hidden', 'true');
|
||
$('trimSaveBtn').disabled = false;
|
||
$('trimSaveBtn').textContent = 'Save Trim';
|
||
$('trimPlayBtn').disabled = false;
|
||
TrimWaveform.destroy();
|
||
trimBucket = null;
|
||
trimFileName = null;
|
||
}
|
||
|
||
function updateTrimTimeDisplay() {
|
||
const start = TrimWaveform.getStartTime();
|
||
const end = TrimWaveform.getEndTime();
|
||
$('trimStartTime').textContent = start.toFixed(2) + 's';
|
||
$('trimEndTime').textContent = end.toFixed(2) + 's';
|
||
$('trimDuration').textContent = 'Duration: ' + (end - start).toFixed(2) + 's';
|
||
}
|
||
|
||
function setPill(el, text, cls) {
|
||
el.className = "pill " + (cls || "");
|
||
el.textContent = text;
|
||
}
|
||
|
||
function setActiveView(view) {
|
||
uiState.activeView = ["auto", "captured", "samples", "firmware"].includes(view) ? view : "trainer";
|
||
$("trainerView").hidden = uiState.activeView !== "trainer";
|
||
$("autoView").hidden = uiState.activeView !== "auto";
|
||
$("capturedView").hidden = uiState.activeView !== "captured";
|
||
$("samplesView").hidden = uiState.activeView !== "samples";
|
||
$("firmwareView").hidden = uiState.activeView !== "firmware";
|
||
$("tabTrainer").classList.toggle("active", uiState.activeView === "trainer");
|
||
$("tabAuto").classList.toggle("active", uiState.activeView === "auto");
|
||
$("tabCaptured").classList.toggle("active", uiState.activeView === "captured");
|
||
$("tabSamples").classList.toggle("active", uiState.activeView === "samples");
|
||
$("tabFirmware").classList.toggle("active", uiState.activeView === "firmware");
|
||
}
|
||
|
||
async function api(path, opts) {
|
||
const res = await fetch(path, opts);
|
||
const ct = res.headers.get("content-type") || "";
|
||
const data = ct.includes("application/json") ? await res.json() : await res.text();
|
||
if (!res.ok) throw new Error(typeof data === "string" ? data : (data.error || JSON.stringify(data)));
|
||
return data;
|
||
}
|
||
|
||
function isNearBottom(el, px = 40) {
|
||
return (el.scrollHeight - el.scrollTop - el.clientHeight) <= px;
|
||
}
|
||
|
||
function setConsoleLogAutoScroll(el, text) {
|
||
const stick = isNearBottom(el);
|
||
el.innerHTML = renderConsoleHtml(text);
|
||
if (stick) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function describeFormat(info) {
|
||
if (!info) return "unknown format";
|
||
if (!info.sample_rate) return (info.container || "unknown").toUpperCase();
|
||
const channels = info.channels === 1 ? "mono" : `${info.channels} ch`;
|
||
return `${info.sample_rate} Hz, ${channels}, ${info.sample_width_bits || "?"}-bit`;
|
||
}
|
||
|
||
function updateProgress(fraction, label, detail) {
|
||
const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
|
||
$("progressFill").style.width = `${(clamped * 100).toFixed(1)}%`;
|
||
$("progressPct").textContent = `${Math.round(clamped * 100)}%`;
|
||
$("progressLabel").textContent = label || "No upload in progress";
|
||
$("progressDetail").textContent = detail || "";
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
return escapeHtml(text).replaceAll('"', """);
|
||
}
|
||
|
||
function formatTimestamp(value) {
|
||
if (!value) return "";
|
||
const parsed = new Date(value);
|
||
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
|
||
}
|
||
|
||
function renderAutoTrain(payload, populateForm = true) {
|
||
const data = payload || {};
|
||
const config = data.config || {};
|
||
const state = data.state || {};
|
||
const runtime = data.runtime || {};
|
||
uiState.autoTrain = data;
|
||
|
||
if (populateForm) {
|
||
$("autoEnabled").checked = Boolean(config.enabled);
|
||
$("autoWakePhrase").value = config.wake_phrase || uiState.session?.raw_phrase || "";
|
||
$("autoLanguage").value = config.language || uiState.session?.language || "en";
|
||
$("autoSttModel").value = config.stt_model || "small.en";
|
||
$("autoSttDevice").value = config.stt_device || "auto";
|
||
$("autoSttComputeType").value = config.stt_compute_type || "auto";
|
||
$("autoMinimumChars").value = String(config.minimum_transcript_chars ?? 2);
|
||
$("autoDeleteConfirmedWakes").checked = Boolean(config.delete_confirmed_wakes);
|
||
$("autoPromoteCloseMisses").checked = Boolean(config.promote_close_misses);
|
||
$("autoScheduleHours").value = String(config.schedule_hours ?? 24);
|
||
$("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3);
|
||
$("autoAdvertisedUrl").value = config.advertised_base_url || "";
|
||
$("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501";
|
||
$("autoTaterSelector").value = config.tater_selector || "";
|
||
$("autoTaterToken").value = "";
|
||
$("autoTaterToken").placeholder = config.tater_api_token_configured ? "Saved token (leave blank to keep)" : "Not configured";
|
||
$("autoNotifySatellites").checked = config.notify_satellites !== false;
|
||
$("autoClearTokenRow").hidden = !config.tater_api_token_configured;
|
||
$("autoClearTaterToken").checked = false;
|
||
}
|
||
|
||
$("autoDetectedUrl").textContent = config.advertised_base_url
|
||
? `Using configured URL: ${config.advertised_base_url}`
|
||
: `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`;
|
||
$("autoPendingNegatives").textContent = String(Number(state.pending_negative_count || 0));
|
||
$("autoNextRun").textContent = state.next_run_at ? formatTimestamp(state.next_run_at) : "Manual";
|
||
const lastExit = state.last_train_exit_code;
|
||
$("autoLastTraining").textContent = state.last_train_finished_at
|
||
? `${formatTimestamp(state.last_train_finished_at)}${lastExit === null || lastExit === undefined ? "" : ` · exit ${lastExit}`}`
|
||
: "Never";
|
||
|
||
if (runtime.review_running) {
|
||
setPill($("autoStatus"), `Transcribing ${runtime.review_file || "wake"}`, "warn");
|
||
} else if (uiState.training?.running && config.enabled) {
|
||
setPill($("autoStatus"), "Training running", "warn");
|
||
} else if (config.enabled) {
|
||
setPill($("autoStatus"), "Enabled", "ok");
|
||
} else {
|
||
setPill($("autoStatus"), "Disabled", "");
|
||
}
|
||
|
||
const audit = [];
|
||
if (state.last_review_result) audit.push(`Last review: ${String(state.last_review_result).replaceAll("_", " ")}`);
|
||
if (state.last_review_file) audit.push(state.last_review_file);
|
||
if (state.last_review_transcript) audit.push(`STT: “${state.last_review_transcript}”`);
|
||
if (state.last_review_error) audit.push(`Error: ${state.last_review_error}`);
|
||
if (state.last_stt_device) audit.push(`STT runtime: ${state.last_stt_device} / ${state.last_stt_compute_type || "default"}`);
|
||
if (state.last_notify_at) {
|
||
audit.push(state.last_notify_error
|
||
? `Satellite refresh failed: ${state.last_notify_error}`
|
||
: `Satellite refresh: ${state.last_notify_count ?? "requested"} connected at ${formatTimestamp(state.last_notify_at)}`);
|
||
}
|
||
$("autoAudit").textContent = audit.join(" · ") || "No automatic review has run yet.";
|
||
syncButtons();
|
||
}
|
||
|
||
async function refreshAutoTrain(populateForm = true) {
|
||
const data = await api("/api/auto_train", { method: "GET" });
|
||
renderAutoTrain(data, populateForm);
|
||
return data;
|
||
}
|
||
|
||
function autoTrainFormPayload() {
|
||
const payload = {
|
||
enabled: $("autoEnabled").checked,
|
||
wake_phrase: ($("autoWakePhrase").value || "").trim(),
|
||
language: ($("autoLanguage").value || "en").trim(),
|
||
stt_model: ($("autoSttModel").value || "").trim(),
|
||
stt_device: $("autoSttDevice").value || "auto",
|
||
stt_compute_type: $("autoSttComputeType").value || "auto",
|
||
minimum_transcript_chars: Number($("autoMinimumChars").value || 2),
|
||
delete_confirmed_wakes: $("autoDeleteConfirmedWakes").checked,
|
||
promote_close_misses: $("autoPromoteCloseMisses").checked,
|
||
schedule_hours: Number($("autoScheduleHours").value || 0),
|
||
minimum_new_negatives: Number($("autoMinimumNegatives").value || 3),
|
||
advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(),
|
||
tater_url: ($("autoTaterUrl").value || "").trim(),
|
||
tater_selector: ($("autoTaterSelector").value || "").trim(),
|
||
notify_satellites: $("autoNotifySatellites").checked,
|
||
clear_tater_api_token: $("autoClearTaterToken").checked,
|
||
};
|
||
const token = ($("autoTaterToken").value || "").trim();
|
||
if (token) payload.tater_api_token = token;
|
||
return payload;
|
||
}
|
||
|
||
async function saveAutoTrain() {
|
||
uiState.autoBusy = true;
|
||
syncButtons();
|
||
setPill($("autoStatus"), "Saving...", "warn");
|
||
try {
|
||
const data = await api("/api/auto_train", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(autoTrainFormPayload()),
|
||
});
|
||
renderAutoTrain(data, true);
|
||
setPill($("autoStatus"), data.config?.enabled ? "Saved + enabled" : "Saved + disabled", data.config?.enabled ? "ok" : "");
|
||
await refreshTrainedWakeWords().catch(() => {});
|
||
return data;
|
||
} finally {
|
||
uiState.autoBusy = false;
|
||
syncButtons();
|
||
}
|
||
}
|
||
|
||
async function runAutoTrainAction(action) {
|
||
uiState.autoBusy = true;
|
||
syncButtons();
|
||
setPill($("autoStatus"), "Working...", "warn");
|
||
try {
|
||
const data = await api("/api/auto_train/action", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action }),
|
||
});
|
||
renderAutoTrain(data, false);
|
||
if (action === "review_now") {
|
||
setPill($("autoStatus"), `${Number(data.queued || 0)} clip${Number(data.queued || 0) === 1 ? "" : "s"} queued`, "ok");
|
||
} else if (action === "train_now") {
|
||
setPill($("autoStatus"), "Training started", "warn");
|
||
await refreshSession();
|
||
pollTraining();
|
||
} else {
|
||
setPill($("autoStatus"), `Satellite refresh requested${data.count === null || data.count === undefined ? "" : ` for ${data.count}`}`, "ok");
|
||
}
|
||
return data;
|
||
} finally {
|
||
uiState.autoBusy = false;
|
||
syncButtons();
|
||
}
|
||
}
|
||
|
||
function captureBadge(item) {
|
||
if (item.blocked_by_vad) return { label: "Blocked by VAD", cls: "warn" };
|
||
const eventType = String(item?.event_type || "").toLowerCase();
|
||
if (eventType.includes("close")) return { label: item.capture_label || "Close miss", cls: "warn" };
|
||
if (eventType.includes("false")) return { label: item.capture_label || "False trigger", cls: "err" };
|
||
if (eventType.includes("wake") || eventType.includes("detect")) return { label: item.capture_label || "Wake trigger", cls: "ok" };
|
||
return { label: item.capture_label || "Captured", cls: "" };
|
||
}
|
||
|
||
function formatDetectionProfile(value) {
|
||
return String(value || "")
|
||
.replaceAll("_", " ")
|
||
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||
}
|
||
|
||
function renderCapturedItems(payload) {
|
||
const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 };
|
||
uiState.captured = data;
|
||
|
||
$("capturedCount").textContent = String(Number(data.captured_count || 0));
|
||
$("negativeCount").textContent = String(Number(data.negative_count || 0));
|
||
$("capturedPersonalCount").textContent = String(Number(data.personal_count || uiState.session?.takes_received || 0));
|
||
if (!Number.isFinite(Number(uiState.samples?.negative_count))) {
|
||
uiState.samples.negative_count = Number(data.negative_count || 0);
|
||
}
|
||
if (!Number.isFinite(Number(uiState.samples?.personal_count))) {
|
||
uiState.samples.personal_count = Number(data.personal_count || uiState.session?.takes_received || 0);
|
||
}
|
||
updateTrainingSampleCounts();
|
||
|
||
const items = Array.isArray(data.items) ? data.items : [];
|
||
if (!items.length) {
|
||
$("capturedList").innerHTML = `<div class="emptyState">No captured audio yet. When the sats start sending clips, they will show up here for review.</div>`;
|
||
return;
|
||
}
|
||
|
||
$("capturedList").innerHTML = items.map((item) => {
|
||
const badge = captureBadge(item);
|
||
const meta = [];
|
||
if (item.source_device) meta.push(`<span class="pill">${escapeHtml(item.source_device)}</span>`);
|
||
if (item.wake_word) meta.push(`<span class="pill">${escapeHtml(item.wake_word)}</span>`);
|
||
if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`);
|
||
if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`);
|
||
if (item.detection_profile) meta.push(`<span class="pill">profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}</span>`);
|
||
if (item.auto_review_status) meta.push(`<span class="pill ${item.auto_review_status === "error" ? "err" : "warn"}">auto ${escapeHtml(String(item.auto_review_status).replaceAll("_", " "))}</span>`);
|
||
if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`<span class="pill">peak cutoff ${escapeHtml(item.peak_probability_cutoff)}</span>`);
|
||
if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`<span class="pill">avg cutoff ${escapeHtml(item.probability_cutoff)}</span>`);
|
||
if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) {
|
||
meta.push(`<span class="pill">windows ${escapeHtml(item.active_window_count)}/${escapeHtml(item.min_active_windows)}</span>`);
|
||
}
|
||
if (item.rise_score !== null && item.rise_score !== undefined) meta.push(`<span class="pill">rise ${escapeHtml(item.rise_score)}</span>`);
|
||
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) meta.push(`<span class="pill">VAD max ${escapeHtml(item.vad_max_probability)}</span>`);
|
||
if (item.vad_average_probability !== null && item.vad_average_probability !== undefined) meta.push(`<span class="pill">VAD avg ${escapeHtml(item.vad_average_probability)}</span>`);
|
||
if (Array.isArray(item.probability_history) && item.probability_history.length) {
|
||
meta.push(`<span class="pill" title="${escapeAttr(item.probability_history.join(", "))}">history ${escapeHtml(item.probability_history.length)}</span>`);
|
||
}
|
||
const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit";
|
||
const when = formatTimestamp(item.captured_at || item.received_at);
|
||
const actionDisabled = uiState.reviewBusy ? "disabled" : "";
|
||
return `
|
||
<div class="captureCard">
|
||
<div class="row space">
|
||
<div>
|
||
<p class="captureTitle">${escapeHtml(item.original_name || item.saved_as)}</p>
|
||
<p class="captureSubtitle">
|
||
${when ? `Captured ${escapeHtml(when)}.` : "Captured clip."}
|
||
${escapeHtml(item.message || "")}
|
||
</p>
|
||
</div>
|
||
<span class="pill ${badge.cls}">${escapeHtml(badge.label)}</span>
|
||
</div>
|
||
<div class="fileMeta">${meta.join("") || `<span class="muted">No metadata attached</span>`}</div>
|
||
${item.transcript ? `<div class="autoAudit"><strong>STT transcript</strong><br>${escapeHtml(item.transcript)}</div>` : ""}
|
||
${item.auto_review_error ? `<div class="muted">Auto review error: ${escapeHtml(item.auto_review_error)}</div>` : ""}
|
||
<audio class="audioPlayer" controls preload="none" src="${escapeHtml(item.audio_url || `/api/audio/captured/${encodeURIComponent(item.saved_as)}`)}"></audio>
|
||
<div class="muted">Stored as ${escapeHtml(item.saved_as)} · ${escapeHtml(formatSummary)}</div>
|
||
<div class="captureActions">
|
||
<button type="button" data-action="approve_personal" data-name="${escapeHtml(item.saved_as)}" ${actionDisabled}>Add to personal samples</button>
|
||
<button type="button" data-action="mark_negative" data-name="${escapeHtml(item.saved_as)}" ${actionDisabled}>Mark negative</button>
|
||
<button type="button" data-action="discard" data-name="${escapeHtml(item.saved_as)}" ${actionDisabled}>Discard</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
async function refreshCapturedAudio() {
|
||
const data = await api("/api/captured_audio", { method: "GET" });
|
||
renderCapturedItems(data);
|
||
setPill($("capturedStatus"), data.captured_count ? `${data.captured_count} clip${data.captured_count === 1 ? "" : "s"} waiting` : "Inbox idle", data.captured_count ? "warn" : "");
|
||
syncButtons();
|
||
return data;
|
||
}
|
||
|
||
function updateTrainingSampleCounts() {
|
||
const personalCount = Number(uiState.samples?.personal_count ?? uiState.session?.takes_received ?? 0);
|
||
const negativeCount = Number(uiState.samples?.negative_count ?? uiState.captured?.negative_count ?? 0);
|
||
$("positiveSampleCount").textContent = String(personalCount);
|
||
$("negativeTrainingCount").textContent = String(negativeCount);
|
||
$("capturedPersonalCount").textContent = String(personalCount);
|
||
$("negativeCount").textContent = String(negativeCount);
|
||
$("samplePersonalCount").textContent = String(personalCount);
|
||
$("sampleNegativeCount").textContent = String(negativeCount);
|
||
}
|
||
|
||
function buildSampleCardHtml(item, bucket) {
|
||
const when = formatTimestamp(item.reviewed_at || item.received_at || item.created_at);
|
||
const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit";
|
||
const badge = bucket === "negative" ? { label: "Negative", cls: "err" } : { label: "Positive", cls: "ok" };
|
||
let trimBadgeHtml = '';
|
||
if (item.trimmed) {
|
||
trimBadgeHtml = `<span class="pill trimBadge">Trimmed from ${escapeHtml(item.source_file || '')}</span>`;
|
||
}
|
||
const subtitleParts = [];
|
||
if (item.original_name && item.original_name !== item.saved_as) subtitleParts.push(`From ${item.original_name}`);
|
||
if (when) subtitleParts.push(`Saved ${when}`);
|
||
if (item.message) subtitleParts.push(item.message);
|
||
if (item.auto_negative) subtitleParts.push("Auto-reviewed false positive");
|
||
if (item.auto_positive) subtitleParts.push("Auto-promoted close miss");
|
||
let revertBtn = '';
|
||
if (item.trimmed) {
|
||
revertBtn = `<button type="button" data-sample-revert="${escapeAttr(item.saved_as)}" data-bucket="${escapeAttr(bucket)}">Revert</button>`;
|
||
}
|
||
return `
|
||
<div class="captureCard">
|
||
<div class="row space">
|
||
<div>
|
||
<p class="captureTitle">${escapeHtml(item.saved_as)}</p>
|
||
<p class="captureSubtitle">${escapeHtml(subtitleParts.join(" · ") || "Saved training sample.")}</p>
|
||
</div>
|
||
<span class="pill ${badge.cls}">${badge.label}</span>
|
||
${trimBadgeHtml}
|
||
</div>
|
||
${item.transcript ? `<div class="autoAudit"><strong>STT transcript</strong><br>${escapeHtml(item.transcript)}</div>` : ""}
|
||
<audio class="audioPlayer" controls preload="none" src="${escapeAttr(item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`)}?t=${encodeURIComponent(item.created_at || '')}"></audio>
|
||
<div class="muted">Stored in ${bucket === "negative" ? "negative_samples" : "personal_samples"} · ${escapeHtml(formatSummary)}</div>
|
||
<div class="captureActions">
|
||
${revertBtn}
|
||
<button type="button" data-sample-trim="${escapeAttr(item.saved_as)}" data-bucket="${escapeAttr(bucket)}" class="trimBtn">Trim</button>
|
||
<button type="button" data-sample-remove="${escapeAttr(item.saved_as)}" data-bucket="${escapeAttr(bucket)}" ${uiState.reviewBusy ? "disabled" : ""}>Remove sample</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSampleLibrary(payload) {
|
||
const data = payload || { personal: [], negative: [], personal_count: 0, negative_count: 0 };
|
||
uiState.samples = {
|
||
...uiState.samples,
|
||
...data,
|
||
personal: Array.isArray(data.personal) ? data.personal : [],
|
||
negative: Array.isArray(data.negative) ? data.negative : [],
|
||
};
|
||
updateTrainingSampleCounts();
|
||
|
||
const activeBucket = uiState.samples.activeBucket === "negative" ? "negative" : "personal";
|
||
$("sampleTabPersonal").classList.toggle("active", activeBucket === "personal");
|
||
$("sampleTabNegative").classList.toggle("active", activeBucket === "negative");
|
||
|
||
const items = activeBucket === "negative" ? uiState.samples.negative : uiState.samples.personal;
|
||
const label = activeBucket === "negative" ? "negative" : "personal";
|
||
if (!items.length) {
|
||
$("sampleLibraryList").innerHTML = `<div class="emptyState">No ${label} samples saved yet.</div>`;
|
||
$("samplePagination").innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
// Paginate
|
||
const pages = uiState.samples.pages || { personal: 0, negative: 0 };
|
||
let page = pages[activeBucket] || 0;
|
||
const totalPages = Math.ceil(items.length / SAMPLE_PAGE_SIZE);
|
||
if (page >= totalPages) page = Math.max(totalPages - 1, 0);
|
||
const start = page * SAMPLE_PAGE_SIZE;
|
||
const pageItems = items.slice(start, start + SAMPLE_PAGE_SIZE);
|
||
|
||
$("sampleLibraryList").innerHTML = pageItems.map((item) => buildSampleCardHtml(item, activeBucket)).join("");
|
||
|
||
// Pagination controls
|
||
const pagination = $("samplePagination");
|
||
if (totalPages > 1) {
|
||
const prevDisabled = page === 0 ? "disabled" : "";
|
||
const nextDisabled = page >= totalPages - 1 ? "disabled" : "";
|
||
pagination.innerHTML = `
|
||
<div class="paginationControls">
|
||
<button type="button" ${prevDisabled} class="pageBtn" data-page="prev">‹ Prev</button>
|
||
<span class="pageInfo">${page + 1} / ${totalPages} (${items.length} total)</span>
|
||
<span class="pageJump">Go to page <input type="number" min="1" max="${totalPages}" value="${page + 1}" class="pageInput" data-total="${totalPages}"> <button type="button" class="pageJumpBtn">Go</button></span>
|
||
<button type="button" ${nextDisabled} class="pageBtn" data-page="next">Next ›</button>
|
||
</div>
|
||
`;
|
||
} else {
|
||
pagination.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
function rerenderReviewLists() {
|
||
renderCapturedItems(uiState.captured);
|
||
renderSampleLibrary(uiState.samples);
|
||
syncButtons();
|
||
}
|
||
|
||
async function refreshSamples() {
|
||
const data = await api("/api/samples", { method: "GET" });
|
||
renderSampleLibrary(data);
|
||
syncButtons();
|
||
return data;
|
||
}
|
||
|
||
function setSampleBucket(bucket) {
|
||
uiState.samples.activeBucket = bucket === "negative" ? "negative" : "personal";
|
||
renderSampleLibrary(uiState.samples);
|
||
syncButtons();
|
||
}
|
||
|
||
async function removeSample(bucket, fileName) {
|
||
if (!bucket || !fileName) return;
|
||
const ok = confirm(`Remove ${fileName} from ${bucket === "negative" ? "negative_samples" : "personal_samples"}?`);
|
||
if (!ok) return;
|
||
|
||
try {
|
||
uiState.reviewBusy = true;
|
||
setPill($("status"), "Removing sample...", "warn");
|
||
syncButtons();
|
||
await api(`/api/samples/${encodeURIComponent(bucket)}/${encodeURIComponent(fileName)}`, { method: "DELETE" });
|
||
await refreshSamples();
|
||
await refreshSession();
|
||
setPill($("status"), "Sample removed", "ok");
|
||
} catch (error) {
|
||
setPill($("status"), "Remove failed", "err");
|
||
alert(error.message);
|
||
} finally {
|
||
uiState.reviewBusy = false;
|
||
}
|
||
}
|
||
|
||
function consoleLineClass(line) {
|
||
const trimmed = String(line || "").trim();
|
||
const lower = trimmed.toLowerCase();
|
||
|
||
if (!trimmed) return "consoleMuted";
|
||
if (trimmed.startsWith("→")) return "consoleCmd";
|
||
if (/^={3,}/.test(trimmed) || trimmed.includes("=====")) return "consoleSection";
|
||
if (trimmed.includes("✗") || lower.includes("traceback") || lower.includes("error") || lower.includes("failed") || lower.includes("crashed")) return "consoleErr";
|
||
if (trimmed.includes("✅") || trimmed.includes("✓") || lower.includes("finished") || lower.includes("success")) return "consoleOk";
|
||
if (trimmed.includes("⚠") || lower.includes("warning")) return "consoleWarn";
|
||
return "consoleText";
|
||
}
|
||
|
||
function renderConsoleHtml(text) {
|
||
const content = String(text || "(no training started)");
|
||
return content.split("\n").map((line) => {
|
||
const safe = line ? escapeHtml(line) : " ";
|
||
return `<div class="consoleLine ${consoleLineClass(line)}">${safe}</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function setConsoleMode(title, hint) {
|
||
$("consoleTitle").textContent = title || "Training Console";
|
||
$("consoleHint").textContent = hint || "Live output appears here with color-coded console styling.";
|
||
}
|
||
|
||
function openConsole(wobble = true, title = "Training Console", hint = "Live training output appears here with color-coded console styling.") {
|
||
setConsoleMode(title, hint);
|
||
const overlay = $("consoleOverlay");
|
||
const win = $("consoleWindow");
|
||
overlay.classList.add("open");
|
||
overlay.setAttribute("aria-hidden", "false");
|
||
win.setAttribute("tabindex", "-1");
|
||
try {
|
||
win.focus({ preventScroll: true });
|
||
} catch (_) {
|
||
win.focus();
|
||
}
|
||
if (wobble) {
|
||
win.classList.remove("wobble");
|
||
void win.offsetWidth;
|
||
win.classList.add("wobble");
|
||
}
|
||
}
|
||
|
||
function closeConsole() {
|
||
$("consoleOverlay").classList.remove("open");
|
||
$("consoleOverlay").setAttribute("aria-hidden", "true");
|
||
}
|
||
|
||
function renderSelectedFiles() {
|
||
const files = uiState.selectedFiles;
|
||
$("selectedPill").textContent = files.length
|
||
? `${files.length} file${files.length === 1 ? "" : "s"} selected`
|
||
: "No files selected";
|
||
}
|
||
|
||
function renderRuntimeWakeWordLinks() {
|
||
const container = $("runtimeWakeWordLinks");
|
||
if (!container) return;
|
||
const wakeWords = Array.isArray(uiState.firmware.wakeWords) ? uiState.firmware.wakeWords : [];
|
||
const rows = wakeWords
|
||
.map((item) => ({
|
||
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
|
||
url: String(item.json_url || "").trim(),
|
||
modelUrl: String(item.model_url || "").trim(),
|
||
threshold: item.threshold,
|
||
slidingWindow: item.sliding_window,
|
||
closeMissThreshold: item.close_miss_threshold,
|
||
quantization: String(item.quantization || "").trim(),
|
||
modelFormat: String(item.model_format || "").trim(),
|
||
recall: item.calibration_recall,
|
||
falseAcceptsPerHour: item.calibration_false_accepts_per_hour,
|
||
}))
|
||
.filter((item) => item.url);
|
||
|
||
if (!rows.length) {
|
||
container.innerHTML = `<div class="emptyState">Train a wake word first, then its live model URL will appear here.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = rows.map((item) => {
|
||
const meta = [];
|
||
if (item.threshold !== null && item.threshold !== undefined) meta.push(`<span class="pill">threshold ${escapeHtml(item.threshold)}</span>`);
|
||
if (item.slidingWindow !== null && item.slidingWindow !== undefined) meta.push(`<span class="pill">window ${escapeHtml(item.slidingWindow)}</span>`);
|
||
if (item.closeMissThreshold !== null && item.closeMissThreshold !== undefined) meta.push(`<span class="pill">close miss ${escapeHtml(item.closeMissThreshold)}</span>`);
|
||
if (item.quantization) meta.push(`<span class="pill">${escapeHtml(item.quantization)}</span>`);
|
||
const recall = Number(item.recall);
|
||
const faph = Number(item.falseAcceptsPerHour);
|
||
if (Number.isFinite(recall)) meta.push(`<span class="pill">recall ${(recall * 100).toFixed(1)}%</span>`);
|
||
if (Number.isFinite(faph)) meta.push(`<span class="pill">FA/h ${escapeHtml(faph)}</span>`);
|
||
return `
|
||
<div class="runtimeWakeWordItem">
|
||
<div>
|
||
<strong>${escapeHtml(item.label)}</strong>
|
||
<div class="runtimeWakeWordUrls">
|
||
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">JSON: ${escapeHtml(item.url)}</a>
|
||
${item.modelUrl ? `<a class="runtimeWakeWordUrl" href="${escapeAttr(item.modelUrl)}" target="_blank" rel="noreferrer">Model: ${escapeHtml(item.modelUrl)}</a>` : ""}
|
||
</div>
|
||
${meta.length ? `<div class="runtimeWakeWordMeta">${meta.join("")}</div>` : ""}
|
||
</div>
|
||
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
async function refreshTrainedWakeWords() {
|
||
uiState.firmwareBusy = true;
|
||
setPill($("firmwareStatus"), "Loading wake words...", "warn");
|
||
syncButtons();
|
||
try {
|
||
const data = await api("/api/trained_wake_words/catalog", { method: "GET" });
|
||
const wakeWords = Array.isArray(data?.wake_words) ? data.wake_words : [];
|
||
uiState.firmware.wakeWords = wakeWords;
|
||
renderRuntimeWakeWordLinks();
|
||
setPill(
|
||
$("firmwareStatus"),
|
||
wakeWords.length ? `${wakeWords.length} trained wake word${wakeWords.length === 1 ? "" : "s"}` : "No trained wake words",
|
||
wakeWords.length ? "ok" : "warn"
|
||
);
|
||
return data;
|
||
} finally {
|
||
uiState.firmwareBusy = false;
|
||
syncButtons();
|
||
}
|
||
}
|
||
|
||
async function copyTextToClipboard(text) {
|
||
const value = String(text || "");
|
||
if (!value) return false;
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return true;
|
||
}
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = value;
|
||
textarea.setAttribute("readonly", "");
|
||
textarea.style.position = "fixed";
|
||
textarea.style.opacity = "0";
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand("copy");
|
||
textarea.remove();
|
||
return copied;
|
||
}
|
||
|
||
function renderLanguageOptions(languages, preferredLanguage) {
|
||
const select = $("language");
|
||
const current = preferredLanguage || select.value || "en";
|
||
const items = Array.isArray(languages) && languages.length
|
||
? languages
|
||
: [{ code: "en", label: "English (en)" }];
|
||
|
||
uiState.availableLanguages = items;
|
||
select.innerHTML = items.map((item) => {
|
||
const code = String(item.code || "").trim().toLowerCase();
|
||
const label = String(item.label || code || "Language");
|
||
return `<option value="${escapeHtml(code)}">${escapeHtml(label)}</option>`;
|
||
}).join("");
|
||
|
||
const availableCodes = new Set(items.map((item) => String(item.code || "").trim().toLowerCase()).filter(Boolean));
|
||
select.value = availableCodes.has(current) ? current : (items[0]?.code || "en");
|
||
}
|
||
|
||
function syncButtons() {
|
||
const hasSession = Boolean(uiState.session?.safe_word);
|
||
const training = uiState.training || {};
|
||
const hasPhrase = Boolean(($("phrase").value || "").trim());
|
||
const hasSelected = uiState.selectedFiles.length > 0;
|
||
const sampleCount = Number(uiState.samples?.personal_count ?? uiState.session?.takes_received ?? 0);
|
||
const hasConsole = Boolean(training.running || training.exit_code !== null || (training.log_lines || []).length);
|
||
const negativeCount = Number(uiState.samples?.negative_count ?? uiState.captured?.negative_count ?? 0);
|
||
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
|
||
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
|
||
$("clearBtn").disabled = sampleCount === 0 || uiState.uploadBusy || uiState.reviewBusy;
|
||
$("openConsoleBtn").disabled = !hasConsole;
|
||
$("trainBtn").disabled = !hasSession || uiState.uploadBusy || Boolean(training.running);
|
||
$("startSessionBtn").disabled = uiState.uploadBusy;
|
||
$("refreshCapturedBtn").disabled = uiState.reviewBusy;
|
||
$("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0;
|
||
$("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy;
|
||
const refreshWakeWordsBtn = $("refreshWakeWordsBtn");
|
||
if (refreshWakeWordsBtn) {
|
||
refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
|
||
}
|
||
for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn"]) {
|
||
const button = $(id);
|
||
if (button) button.disabled = uiState.autoBusy || (id === "autoTrainNowBtn" && Boolean(training.running));
|
||
}
|
||
}
|
||
|
||
function refreshSessionUI(session) {
|
||
uiState.session = session;
|
||
renderLanguageOptions(session?.available_languages || uiState.availableLanguages, session?.language);
|
||
|
||
if (session?.raw_phrase) {
|
||
$("phrase").value = session.raw_phrase;
|
||
}
|
||
|
||
const uploaded = Number(session?.takes_received || 0);
|
||
if (!Number.isFinite(Number(uiState.samples?.personal_count))) {
|
||
uiState.samples.personal_count = uploaded;
|
||
}
|
||
updateTrainingSampleCounts();
|
||
uiState.training = session?.training || uiState.training;
|
||
|
||
if (session?.safe_word) {
|
||
setPill($("sessionPill"), `Session: ${session.safe_word} (${session.language || "en"})`, "ok");
|
||
if (!uiState.uploadBusy) {
|
||
setPill($("status"), uploaded ? `Ready with ${uploaded} sample${uploaded === 1 ? "" : "s"}` : "Ready to upload", uploaded ? "ok" : "warn");
|
||
}
|
||
} else {
|
||
setPill($("sessionPill"), "No session");
|
||
if (!uiState.uploadBusy) {
|
||
setPill($("status"), uploaded ? `${uploaded} sample${uploaded === 1 ? "" : "s"} waiting on disk` : "Idle");
|
||
}
|
||
}
|
||
|
||
syncButtons();
|
||
}
|
||
|
||
async function refreshSession() {
|
||
const session = await api("/api/session", { method: "GET" });
|
||
refreshSessionUI(session);
|
||
return session;
|
||
}
|
||
|
||
function uploadOneFile(file, index, total) {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
const formData = new FormData();
|
||
const base = index / total;
|
||
const span = 1 / total;
|
||
|
||
formData.append("file", file, file.name);
|
||
|
||
xhr.open("POST", "/api/upload_personal_sample");
|
||
xhr.responseType = "json";
|
||
|
||
xhr.upload.onprogress = (event) => {
|
||
if (!event.lengthComputable) return;
|
||
const ratio = event.total ? event.loaded / event.total : 0;
|
||
updateProgress(
|
||
base + (ratio * span * 0.68),
|
||
`Uploading ${file.name} (${index + 1}/${total})`,
|
||
"Sending the file to the server."
|
||
);
|
||
};
|
||
|
||
xhr.upload.onload = () => {
|
||
updateProgress(
|
||
base + (span * 0.74),
|
||
`Checking ${file.name}`,
|
||
"Inspecting the incoming audio format."
|
||
);
|
||
};
|
||
|
||
xhr.onreadystatechange = () => {
|
||
if (xhr.readyState >= 2) {
|
||
updateProgress(
|
||
base + (span * 0.9),
|
||
`Normalizing ${file.name}`,
|
||
"Converting to 16 kHz mono 16-bit PCM WAV if needed."
|
||
);
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
const data = xhr.response || (() => {
|
||
try { return JSON.parse(xhr.responseText || "{}"); } catch { return null; }
|
||
})();
|
||
|
||
if (xhr.status >= 200 && xhr.status < 300 && data) {
|
||
updateProgress(
|
||
base + span,
|
||
data.converted ? `Converted ${file.name}` : `Validated ${file.name}`,
|
||
`${data.saved_as} saved into personal_samples.`
|
||
);
|
||
resolve(data);
|
||
return;
|
||
}
|
||
|
||
const errorMessage = data && data.error ? data.error : `Upload failed for ${file.name}`;
|
||
reject(new Error(errorMessage));
|
||
};
|
||
|
||
xhr.onerror = () => reject(new Error(`Upload failed for ${file.name}`));
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
async function uploadSelectedFiles() {
|
||
if (!uiState.session?.safe_word) {
|
||
alert("Start a session first.");
|
||
return;
|
||
}
|
||
if (!uiState.selectedFiles.length) {
|
||
alert("Choose one or more audio files first.");
|
||
return;
|
||
}
|
||
|
||
uiState.uploadBusy = true;
|
||
syncButtons();
|
||
setPill($("status"), "Uploading samples...", "warn");
|
||
|
||
try {
|
||
const files = [...uiState.selectedFiles];
|
||
let convertedCount = 0;
|
||
let validatedCount = 0;
|
||
for (let i = 0; i < files.length; i += 1) {
|
||
const file = files[i];
|
||
const result = await uploadOneFile(file, i, files.length);
|
||
if (result.converted) convertedCount += 1;
|
||
else validatedCount += 1;
|
||
}
|
||
|
||
uiState.selectedFiles = [];
|
||
$("sampleFiles").value = "";
|
||
renderSelectedFiles();
|
||
|
||
const [session] = await Promise.all([refreshSession(), refreshSamples()]);
|
||
const uploaded = Number(session?.takes_received || 0);
|
||
setPill($("status"), `Uploaded ${uploaded} sample${uploaded === 1 ? "" : "s"}`, "ok");
|
||
const parts = [];
|
||
if (convertedCount) parts.push(`${convertedCount} converted successfully`);
|
||
if (validatedCount) parts.push(`${validatedCount} already in the correct format`);
|
||
const summary = parts.length ? parts.join(", ") : "Files processed successfully";
|
||
updateProgress(1, "Upload and conversion complete", `${summary}. Saved to personal_samples.`);
|
||
} catch (error) {
|
||
setPill($("status"), "Upload failed", "err");
|
||
updateProgress(0, "Upload failed", error.message);
|
||
alert(error.message);
|
||
} finally {
|
||
uiState.uploadBusy = false;
|
||
syncButtons();
|
||
}
|
||
}
|
||
|
||
async function handleCapturedAction(fileName, action) {
|
||
if (!fileName || !action) return;
|
||
if (action === "discard") {
|
||
const ok = confirm(`Discard ${fileName} from the captured-audio inbox?`);
|
||
if (!ok) return;
|
||
}
|
||
|
||
uiState.reviewBusy = true;
|
||
setPill($("capturedStatus"), "Applying review action...", "warn");
|
||
syncButtons();
|
||
|
||
try {
|
||
await api(`/api/captured_audio/${encodeURIComponent(fileName)}/${action}`, { method: "POST" });
|
||
await Promise.all([refreshSession(), refreshCapturedAudio(), refreshSamples()]);
|
||
|
||
if (action === "approve_personal") {
|
||
setPill($("capturedStatus"), "Moved into personal samples", "ok");
|
||
setPill($("status"), "Captured clip added to personal samples", "ok");
|
||
} else if (action === "mark_negative") {
|
||
setPill($("capturedStatus"), "Moved into reviewed negatives", "ok");
|
||
} else {
|
||
setPill($("capturedStatus"), "Captured clip discarded", "");
|
||
}
|
||
} catch (error) {
|
||
setPill($("capturedStatus"), "Review action failed", "err");
|
||
alert(error.message);
|
||
} finally {
|
||
uiState.reviewBusy = false;
|
||
rerenderReviewLists();
|
||
}
|
||
}
|
||
|
||
async function startTrainingWithPrompt() {
|
||
const [session, samples] = await Promise.all([refreshSession(), refreshSamples()]);
|
||
const uploaded = Number(samples?.personal_count ?? session?.takes_received ?? 0);
|
||
|
||
let allowNoPersonal = false;
|
||
if (uploaded === 0) {
|
||
const ok = confirm(
|
||
`No positive samples are saved yet.\n\nTrain anyway without personal voices?`
|
||
);
|
||
if (!ok) {
|
||
setPill($("status"), "Training canceled", "warn");
|
||
return;
|
||
}
|
||
allowNoPersonal = true;
|
||
}
|
||
|
||
uiState.training = { running: true, exit_code: null, log_lines: [] };
|
||
setConsoleLogAutoScroll($("trainLog"), "===== Training Console =====\nWaiting for training output...");
|
||
openConsole(true, "Training Console", "Live training output appears here with color-coded console styling.");
|
||
syncButtons();
|
||
setPill($("status"), "Starting training...", "warn");
|
||
setPill($("trainState"), "Training running", "warn");
|
||
|
||
await api("/api/train", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ allow_no_personal: allowNoPersonal }),
|
||
});
|
||
|
||
pollTraining();
|
||
}
|
||
|
||
async function pollTraining() {
|
||
if (uiState.trainingPoller) return;
|
||
uiState.trainingPoller = true;
|
||
|
||
try {
|
||
const logEl = $("trainLog");
|
||
|
||
for (;;) {
|
||
try {
|
||
const status = await api("/api/train_status", { method: "GET" });
|
||
const training = status.training || {};
|
||
uiState.training = training;
|
||
const lines = training.log_lines || [];
|
||
const text = lines.length ? lines.join("\n") : "(no output yet)";
|
||
setConsoleLogAutoScroll(logEl, text);
|
||
syncButtons();
|
||
|
||
if (training.running) {
|
||
setPill($("status"), "Training running...", "warn");
|
||
setPill($("trainState"), "Training running", "warn");
|
||
} else {
|
||
if (training.exit_code === 0) {
|
||
setPill($("status"), "Training finished", "ok");
|
||
setPill($("trainState"), "Training finished", "ok");
|
||
} else if (training.exit_code !== null) {
|
||
setPill($("status"), `Training ended (exit=${training.exit_code})`, "err");
|
||
setPill($("trainState"), `Exit ${training.exit_code}`, "err");
|
||
} else {
|
||
setPill($("trainState"), "Not started");
|
||
}
|
||
break;
|
||
}
|
||
} catch (_) {}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||
}
|
||
} finally {
|
||
uiState.trainingPoller = null;
|
||
}
|
||
}
|
||
|
||
$("phrase").addEventListener("input", syncButtons);
|
||
$("tabTrainer").addEventListener("click", () => setActiveView("trainer"));
|
||
$("tabAuto").addEventListener("click", () => {
|
||
setActiveView("auto");
|
||
refreshAutoTrain(true).catch((error) => {
|
||
setPill($("autoStatus"), "Status failed", "err");
|
||
alert("Auto Training status failed: " + error.message);
|
||
});
|
||
});
|
||
$("tabCaptured").addEventListener("click", () => setActiveView("captured"));
|
||
$("tabSamples").addEventListener("click", () => {
|
||
setActiveView("samples");
|
||
refreshSamples().catch((error) => {
|
||
setPill($("status"), "Sample refresh failed", "err");
|
||
alert("Sample refresh failed: " + error.message);
|
||
});
|
||
});
|
||
$("tabFirmware").addEventListener("click", () => {
|
||
setActiveView("firmware");
|
||
refreshTrainedWakeWords().catch((error) => {
|
||
setPill($("firmwareStatus"), "Wake word list failed", "err");
|
||
alert("Wake word list failed: " + error.message);
|
||
});
|
||
});
|
||
|
||
$("runtimeWakeWordLinks").addEventListener("click", async (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
const button = target?.closest("[data-runtime-wake-url]");
|
||
if (!(button instanceof HTMLButtonElement)) return;
|
||
try {
|
||
const copied = await copyTextToClipboard(button.dataset.runtimeWakeUrl || "");
|
||
if (!copied) throw new Error("Clipboard unavailable");
|
||
const previousText = button.textContent || "Copy URL";
|
||
button.textContent = "Copied";
|
||
setPill($("firmwareStatus"), "JSON URL copied", "ok");
|
||
setTimeout(() => {
|
||
button.textContent = previousText;
|
||
}, 1200);
|
||
} catch (error) {
|
||
setPill($("firmwareStatus"), "Copy failed", "warn");
|
||
alert("Copy failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
$("sampleFiles").addEventListener("change", () => {
|
||
uiState.selectedFiles = Array.from($("sampleFiles").files || []);
|
||
renderSelectedFiles();
|
||
syncButtons();
|
||
});
|
||
|
||
$("refreshWakeWordsBtn").addEventListener("click", async () => {
|
||
try {
|
||
await refreshTrainedWakeWords();
|
||
} catch (error) {
|
||
setPill($("firmwareStatus"), "Wake word list failed", "err");
|
||
alert("Wake word list failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
$("autoSaveBtn").addEventListener("click", async () => {
|
||
try {
|
||
await saveAutoTrain();
|
||
} catch (error) {
|
||
setPill($("autoStatus"), "Save failed", "err");
|
||
alert("Auto Training save failed: " + error.message);
|
||
}
|
||
});
|
||
$("autoReviewNowBtn").addEventListener("click", async () => {
|
||
try {
|
||
await runAutoTrainAction("review_now");
|
||
} catch (error) {
|
||
setPill($("autoStatus"), "Review failed", "err");
|
||
alert("Auto review failed: " + error.message);
|
||
}
|
||
});
|
||
$("autoTrainNowBtn").addEventListener("click", async () => {
|
||
try {
|
||
await runAutoTrainAction("train_now");
|
||
} catch (error) {
|
||
setPill($("autoStatus"), "Training failed", "err");
|
||
alert("Auto training failed: " + error.message);
|
||
}
|
||
});
|
||
$("autoNotifyNowBtn").addEventListener("click", async () => {
|
||
try {
|
||
await runAutoTrainAction("notify_now");
|
||
} catch (error) {
|
||
setPill($("autoStatus"), "Refresh failed", "err");
|
||
alert("Satellite refresh failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
$("openConsoleBtn").addEventListener("click", () => {
|
||
setConsoleLogAutoScroll($("trainLog"), (uiState.training?.log_lines || []).join("\n") || "(no training started)");
|
||
openConsole(true, "Training Console", "Live training output appears here with color-coded console styling.");
|
||
});
|
||
|
||
$("closeConsoleBtn").addEventListener("click", () => {
|
||
closeConsole();
|
||
});
|
||
|
||
$("consoleOverlay").addEventListener("click", (event) => {
|
||
if (event.target === $("consoleOverlay")) {
|
||
closeConsole();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
closeConsole();
|
||
}
|
||
});
|
||
|
||
$("ttsBtn").addEventListener("click", () => {
|
||
const phrase = ($("phrase").value || "").trim();
|
||
if (!phrase) return;
|
||
|
||
const language = ($("language").value || "en").trim().toLowerCase();
|
||
const utterance = new SpeechSynthesisUtterance(phrase);
|
||
utterance.lang = language;
|
||
speechSynthesis.cancel();
|
||
speechSynthesis.speak(utterance);
|
||
});
|
||
|
||
$("startSessionBtn").addEventListener("click", async () => {
|
||
const phrase = ($("phrase").value || "").trim();
|
||
if (!phrase) {
|
||
alert("Enter a wake word phrase first.");
|
||
return;
|
||
}
|
||
|
||
const language = ($("language").value || "en").trim().toLowerCase();
|
||
|
||
try {
|
||
setPill($("sessionPill"), "Starting...", "warn");
|
||
const session = await api("/api/start_session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
phrase,
|
||
language,
|
||
}),
|
||
});
|
||
|
||
refreshSessionUI(session);
|
||
updateProgress(0, "No upload in progress", "Choose files and upload when you are ready.");
|
||
} catch (error) {
|
||
setPill($("sessionPill"), "Session failed", "err");
|
||
setPill($("status"), "Session failed", "err");
|
||
alert("Start session failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
$("uploadBtn").addEventListener("click", uploadSelectedFiles);
|
||
$("refreshSamplesBtn").addEventListener("click", async () => {
|
||
try {
|
||
await refreshSamples();
|
||
setPill($("status"), "Samples refreshed", "ok");
|
||
} catch (error) {
|
||
setPill($("status"), "Sample refresh failed", "err");
|
||
alert("Sample refresh failed: " + error.message);
|
||
}
|
||
});
|
||
$("sampleTabPersonal").addEventListener("click", () => setSampleBucket("personal"));
|
||
$("sampleTabNegative").addEventListener("click", () => setSampleBucket("negative"));
|
||
function navigateToSamplePage(page) {
|
||
const header = document.getElementById("sampleLibraryHeader");
|
||
const topY = header.getBoundingClientRect().top + window.scrollY;
|
||
const activeBucket = uiState.samples.activeBucket === "negative" ? "negative" : "personal";
|
||
const pages = uiState.samples.pages || { personal: 0, negative: 0 };
|
||
pages[activeBucket] = page;
|
||
uiState.samples.pages = pages;
|
||
renderSampleLibrary(uiState.samples);
|
||
syncButtons();
|
||
window.scrollTo(0, topY);
|
||
}
|
||
$("samplePagination").addEventListener("click", (event) => {
|
||
const btn = event.target.closest(".pageBtn[data-page]");
|
||
if (btn) {
|
||
btn.blur();
|
||
const activeBucket = uiState.samples.activeBucket === "negative" ? "negative" : "personal";
|
||
const pages = uiState.samples.pages || { personal: 0, negative: 0 };
|
||
let page = pages[activeBucket] || 0;
|
||
if (btn.dataset.page === "prev") page = Math.max(page - 1, 0);
|
||
else if (btn.dataset.page === "next") page = Math.min(page + 1, 999);
|
||
navigateToSamplePage(page);
|
||
return;
|
||
}
|
||
const jumpBtn = event.target.closest(".pageJumpBtn");
|
||
if (jumpBtn) {
|
||
const input = jumpBtn.parentElement.querySelector(".pageInput");
|
||
const totalPages = parseInt(input.dataset.total) || 1;
|
||
let page = parseInt(input.value) - 1;
|
||
if (isNaN(page) || page < 0) page = 0;
|
||
if (page >= totalPages) page = totalPages - 1;
|
||
input.blur();
|
||
navigateToSamplePage(page);
|
||
}
|
||
});
|
||
$("sampleLibraryList").addEventListener("click", async (event) => {
|
||
// Revert trimmed sample
|
||
const revertBtn = event.target.closest("button[data-sample-revert][data-bucket]");
|
||
if (revertBtn) {
|
||
const ok = confirm(`Revert ${revertBtn.dataset.sampleRevert} to the original (pre-trim) version?`);
|
||
if (!ok) return;
|
||
try {
|
||
const form = new FormData();
|
||
form.append('bucket', revertBtn.dataset.bucket);
|
||
form.append('file_name', revertBtn.dataset.sampleRevert);
|
||
const result = await api('/api/samples/revert', { method: 'POST', body: form });
|
||
await refreshSamples();
|
||
setPill($("status"), result.message || 'Reverted', 'ok');
|
||
} catch (err) {
|
||
alert('Revert failed: ' + err.message);
|
||
}
|
||
return;
|
||
}
|
||
// Open trim modal
|
||
const trimBtn = event.target.closest("button[data-sample-trim][data-bucket]");
|
||
if (trimBtn) {
|
||
openTrimModal(trimBtn.dataset.bucket, trimBtn.dataset.sampleTrim);
|
||
return;
|
||
}
|
||
// Remove sample
|
||
const button = event.target.closest("button[data-sample-remove][data-bucket]");
|
||
if (!button) return;
|
||
await removeSample(button.dataset.bucket, button.dataset.sampleRemove);
|
||
});
|
||
$("refreshCapturedBtn").addEventListener("click", async () => {
|
||
try {
|
||
await refreshCapturedAudio();
|
||
} catch (error) {
|
||
setPill($("capturedStatus"), "Refresh failed", "err");
|
||
alert("Refresh failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
$("clearNegativeBtn").addEventListener("click", async () => {
|
||
const count = Number(uiState.samples?.negative_count ?? uiState.captured?.negative_count ?? 0);
|
||
const ok = confirm(`Clear ${count} reviewed negative sample${count === 1 ? "" : "s"} from negative_samples?`);
|
||
if (!ok) return;
|
||
|
||
try {
|
||
uiState.reviewBusy = true;
|
||
setPill($("status"), "Clearing negative samples...", "warn");
|
||
syncButtons();
|
||
await api("/api/reset_negative_samples", { method: "POST" });
|
||
await Promise.all([refreshCapturedAudio(), refreshSamples()]);
|
||
setPill($("status"), "Negative samples cleared", "ok");
|
||
} catch (error) {
|
||
setPill($("status"), "Clear failed", "err");
|
||
alert("Clear failed: " + error.message);
|
||
} finally {
|
||
uiState.reviewBusy = false;
|
||
rerenderReviewLists();
|
||
}
|
||
});
|
||
|
||
$("capturedList").addEventListener("click", async (event) => {
|
||
const button = event.target.closest("button[data-action][data-name]");
|
||
if (!button) return;
|
||
await handleCapturedAction(button.dataset.name, button.dataset.action);
|
||
});
|
||
|
||
$("clearBtn").addEventListener("click", async () => {
|
||
const count = Number(uiState.samples?.personal_count ?? uiState.session?.takes_received ?? 0);
|
||
const ok = confirm(`Clear ${count} personal sample${count === 1 ? "" : "s"} from personal_samples?`);
|
||
if (!ok) return;
|
||
|
||
try {
|
||
uiState.uploadBusy = true;
|
||
syncButtons();
|
||
setPill($("status"), "Clearing samples...", "warn");
|
||
updateProgress(0, "Clearing personal samples", "Removing saved WAV files from personal_samples.");
|
||
await api("/api/reset_recordings", { method: "POST" });
|
||
await Promise.all([refreshSession(), refreshSamples()]);
|
||
updateProgress(0, "No upload in progress", "personal_samples is empty.");
|
||
setPill($("status"), "Personal samples cleared", "ok");
|
||
} catch (error) {
|
||
setPill($("status"), "Clear failed", "err");
|
||
alert("Clear failed: " + error.message);
|
||
} finally {
|
||
uiState.uploadBusy = false;
|
||
syncButtons();
|
||
}
|
||
});
|
||
|
||
$("trainBtn").addEventListener("click", async () => {
|
||
try {
|
||
await startTrainingWithPrompt();
|
||
} catch (error) {
|
||
uiState.training = { running: false, exit_code: 1, log_lines: [String(error.message || error)] };
|
||
setConsoleLogAutoScroll($("trainLog"), String(error.message || error));
|
||
openConsole(true, "Training Console", "Live training output appears here with color-coded console styling.");
|
||
syncButtons();
|
||
setPill($("status"), "Train failed", "err");
|
||
setPill($("trainState"), "Start failed", "err");
|
||
alert("Train failed: " + error.message);
|
||
}
|
||
});
|
||
|
||
async function bootstrap() {
|
||
setActiveView("trainer");
|
||
renderSelectedFiles();
|
||
updateProgress(0, "No upload in progress", "Choose files and upload when you are ready.");
|
||
|
||
try {
|
||
await refreshSession();
|
||
await refreshSamples();
|
||
await refreshCapturedAudio();
|
||
await refreshAutoTrain(true);
|
||
} catch (_) {}
|
||
|
||
try {
|
||
await refreshTrainedWakeWords();
|
||
} catch (error) {
|
||
setPill($("firmwareStatus"), "Wake word list failed", "err");
|
||
uiState.firmware.logLines = [`Wake word list load failed: ${error.message}`];
|
||
}
|
||
|
||
try {
|
||
const trainStatus = await api("/api/train_status", { method: "GET" });
|
||
const training = trainStatus.training || {};
|
||
uiState.training = training;
|
||
if ((training.log_lines || []).length) {
|
||
setConsoleLogAutoScroll($("trainLog"), training.log_lines.join("\n"));
|
||
}
|
||
if (training.running) {
|
||
setPill($("trainState"), "Training running", "warn");
|
||
openConsole(false, "Training Console", "Live training output appears here with color-coded console styling.");
|
||
pollTraining();
|
||
} else if (training.exit_code === 0) {
|
||
setPill($("trainState"), "Training finished", "ok");
|
||
} else if (training.exit_code !== null) {
|
||
setPill($("trainState"), `Exit ${training.exit_code}`, "err");
|
||
}
|
||
syncButtons();
|
||
} catch (_) {}
|
||
}
|
||
|
||
// --- Trim Modal Event Listeners ---
|
||
$("closeTrimBtn").addEventListener('click', closeTrimModal);
|
||
$("trimCancelBtn").addEventListener('click', closeTrimModal);
|
||
$("trimOverlay").addEventListener('click', (e) => {
|
||
if (e.target === $("trimOverlay")) closeTrimModal();
|
||
});
|
||
$("trimPlayBtn").addEventListener('click', () => TrimWaveform.playSelection());
|
||
$("trimSelectFirstVadBtn").addEventListener('click', () => {
|
||
if (TrimWaveform.vadSegments.length > 0) {
|
||
const seg = TrimWaveform.vadSegments[0];
|
||
TrimWaveform.setStartSeconds(seg.start);
|
||
TrimWaveform.setEndSeconds(seg.end);
|
||
}
|
||
});
|
||
|
||
$("trimSaveBtn").addEventListener('click', async () => {
|
||
const start = TrimWaveform.getStartTime();
|
||
const end = TrimWaveform.getEndTime();
|
||
$("trimSaveBtn").disabled = true;
|
||
$("trimSaveBtn").textContent = 'Saving...';
|
||
|
||
try {
|
||
const blob = await TrimWaveform.getTrimmedWavBlob();
|
||
const form = new FormData();
|
||
form.append('file', blob, 'trimmed.wav');
|
||
form.append('bucket', trimBucket);
|
||
form.append('source_file', trimFileName);
|
||
form.append('start_time', start.toFixed(3));
|
||
form.append('end_time', end.toFixed(3));
|
||
|
||
const result = await api('/api/samples/trim', {
|
||
method: 'POST',
|
||
body: form,
|
||
});
|
||
|
||
closeTrimModal();
|
||
await refreshSamples();
|
||
setPill($("status"), result.message || 'Trim saved', 'ok');
|
||
} catch (e) {
|
||
alert('Trim failed: ' + e.message);
|
||
$("trimSaveBtn").disabled = false;
|
||
$("trimSaveBtn").textContent = 'Save Trim';
|
||
}
|
||
});
|
||
|
||
// Handle drag (mouse + touch)
|
||
$("trimStartHandle").addEventListener('mousedown', (e) => TrimWaveform.onPointerDown('start', e));
|
||
$("trimEndHandle").addEventListener('mousedown', (e) => TrimWaveform.onPointerDown('end', e));
|
||
$("trimStartHandle").addEventListener('touchstart', (e) => TrimWaveform.onPointerDown('start', e), { passive: false });
|
||
$("trimEndHandle").addEventListener('touchstart', (e) => TrimWaveform.onPointerDown('end', e), { passive: false });
|
||
document.addEventListener('mousemove', (e) => TrimWaveform.onPointerMove(e));
|
||
document.addEventListener('touchmove', (e) => TrimWaveform.onPointerMove(e), { passive: false });
|
||
document.addEventListener('mouseup', () => TrimWaveform.onPointerUp());
|
||
document.addEventListener('touchend', () => TrimWaveform.onPointerUp());
|
||
|
||
// Redraw waveform on window resize
|
||
window.addEventListener('resize', () => {
|
||
if ($('trimOverlay').classList.contains('open')) TrimWaveform.draw();
|
||
});
|
||
|
||
bootstrap();
|
||
setInterval(() => {
|
||
if (uiState.activeView !== "auto" || uiState.autoBusy) return;
|
||
refreshAutoTrain(false).catch(() => {});
|
||
}, 2500);
|
||
</script>
|
||
</body>
|
||
</html>
|