Files
2026-07-24 23:16:08 -05:00

3425 lines
124 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.taterLinkOverlay {
position: fixed; inset: 0; padding: 22px;
display: flex; align-items: center; justify-content: center;
background: rgba(4,5,10,0.62); backdrop-filter: blur(12px);
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity 0.18s ease, visibility 0.18s ease;
z-index: 12000;
}
.taterLinkOverlay.open { opacity: 1; visibility: visible; pointer-events: auto; }
.taterLinkDialog {
width: min(560px, calc(100vw - 36px));
display: grid; gap: 18px; padding: 22px; border-radius: 24px;
border: 1px solid rgba(255,138,42,0.28);
background:
radial-gradient(circle at top right, rgba(255,138,42,0.16), transparent 46%),
linear-gradient(180deg, rgba(17,20,28,0.96), rgba(8,10,16,0.98));
box-shadow: 0 30px 90px rgba(0,0,0,0.64);
}
.taterLinkCodePanel {
display: grid; gap: 10px; text-align: center; padding: 24px;
border-radius: 18px; border: 1px solid rgba(255,138,42,0.28);
background: rgba(255,138,42,0.09);
}
.taterLinkCode {
color: var(--orange2); font: 800 clamp(30px, 8vw, 46px)/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.12em;
}
.taterLinkSuccess {
display: grid; justify-items: center; gap: 12px; padding: 28px; text-align: center;
}
.taterLinkSuccessMark {
display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%;
background: rgba(57,212,160,0.15); border: 1px solid rgba(57,212,160,0.42);
color: #6ee0af; font-size: 24px; font-weight: 900;
}
.taterLinkActions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.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 to Tater</h3>
<p>The trainer publishes a LAN-reachable model URL, then tells Tater to make the newly trained wake word active on every satellite.</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>
</div>
<div class="taterLinkActions">
<span id="autoTaterLinkStatus" class="pill">Not linked</span>
<button id="autoLinkTaterBtn" class="primary" type="button">Link Tater</button>
<button id="autoUnlinkTaterBtn" class="danger" type="button" hidden>Unlink</button>
</div>
<label class="checkField">
<input id="autoNotifySatellites" type="checkbox" checked />
<span><strong>Activate the new word after successful training</strong>Tater applies it globally and updates every connected satellite.</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">Publish current wake word 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>
<div id="taterLinkOverlay" class="taterLinkOverlay" aria-hidden="true">
<div class="taterLinkDialog" role="dialog" aria-modal="true" aria-labelledby="taterLinkTitle">
<div class="trimHeader">
<div>
<h3 id="taterLinkTitle" class="trimTitle">Link Tater</h3>
<p id="taterLinkHint" class="trimHint">Enter the short-lived code shown in Tater Voice Settings.</p>
</div>
<button id="closeTaterLinkBtn" type="button">Close</button>
</div>
<div id="taterLinkBody">
<div class="emptyState">Enter the secure pairing code from Tater.</div>
</div>
<div id="taterLinkModalStatus" class="muted">Waiting for the Tater code.</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttr(text) {
return escapeHtml(text).replaceAll('"', "&quot;");
}
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 || {};
const trainerLink = data.trainer_link || {};
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";
$("autoNotifySatellites").checked = config.notify_satellites !== false;
}
const taterLinked = Boolean(trainerLink.linked);
setPill(
$("autoTaterLinkStatus"),
taterLinked ? `Linked${trainerLink.tater_name ? ` to ${trainerLink.tater_name}` : ""}` : "Not linked",
taterLinked ? "ok" : "warn"
);
$("autoLinkTaterBtn").textContent = taterLinked ? "Relink Tater" : "Link Tater";
$("autoUnlinkTaterBtn").hidden = !taterLinked;
$("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
? `Wake-word publish failed: ${state.last_notify_error}`
: `Wake word published to ${state.last_notify_count ?? "all"} connected satellite(s) 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(),
notify_satellites: $("autoNotifySatellites").checked,
};
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"), `Wake word published${data.count === null || data.count === undefined ? "" : ` to ${data.count} satellite(s)`}`, "ok");
}
return data;
} finally {
uiState.autoBusy = false;
syncButtons();
}
}
function closeTaterLinkModal() {
$("taterLinkOverlay").classList.remove("open");
$("taterLinkOverlay").setAttribute("aria-hidden", "true");
}
function showTaterLinkSuccess(status) {
const taterName = status?.tater_name ? ` to ${escapeHtml(status.tater_name)}` : "";
$("taterLinkTitle").textContent = "Tater linked";
$("taterLinkHint").textContent = "This trainer can now securely publish wake-word updates.";
$("taterLinkBody").innerHTML = `
<div class="taterLinkSuccess">
<div class="taterLinkSuccessMark" aria-hidden="true">✓</div>
<strong>Successfully linked${taterName}</strong>
<span class="muted">The private link key is stored locally and is never shown again.</span>
</div>
`;
$("taterLinkModalStatus").textContent = "You can close this popup.";
}
async function openTaterLinkModal() {
$("taterLinkTitle").textContent = "Link Tater";
$("taterLinkHint").textContent = "Enter the short-lived code shown in Tater Voice Settings.";
const taterUrl = ($("autoTaterUrl").value || "http://127.0.0.1:8501").trim();
$("taterLinkBody").innerHTML = `
<div class="stack">
<label class="field">
<strong>Tater address</strong>
<input id="taterLinkUrl" type="text" value="${escapeAttr(taterUrl)}" placeholder="http://127.0.0.1:8501" />
</label>
<div class="taterLinkCodePanel">
<span class="muted">Tater pairing code</span>
<input id="taterLinkCode" class="taterLinkCode" type="text" inputmode="text" autocomplete="off"
maxlength="9" placeholder="ABCD-EFGH" />
<span class="muted">In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.</span>
</div>
<button id="claimTaterLinkBtn" class="primary" type="button">Link Tater</button>
</div>
`;
$("taterLinkModalStatus").textContent = "Waiting for the Tater code.";
$("taterLinkOverlay").classList.add("open");
$("taterLinkOverlay").setAttribute("aria-hidden", "false");
const codeInput = $("taterLinkCode");
const urlInput = $("taterLinkUrl");
const submit = $("claimTaterLinkBtn");
codeInput.addEventListener("input", () => {
const raw = String(codeInput.value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
codeInput.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
});
submit.addEventListener("click", async () => {
const pairingCode = String(codeInput.value || "").trim();
const targetUrl = String(urlInput.value || "").trim();
if (!pairingCode || !targetUrl) {
$("taterLinkModalStatus").textContent = "Tater address and pairing code are required.";
return;
}
submit.disabled = true;
codeInput.disabled = true;
urlInput.disabled = true;
$("taterLinkModalStatus").textContent = "Linking securely with Tater...";
try {
const result = await api("/api/tater_link/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tater_url: targetUrl, pairing_code: pairingCode }),
});
$("autoTaterUrl").value = targetUrl;
showTaterLinkSuccess(result);
await refreshAutoTrain(false);
} catch (error) {
submit.disabled = false;
codeInput.disabled = false;
urlInput.disabled = false;
$("taterLinkModalStatus").textContent = `Link failed: ${error.message}`;
}
});
window.setTimeout(() => codeInput.focus(), 50);
}
async function unlinkTater() {
if (!window.confirm("Unlink this trainer from Tater? Wake-word publishing will stop until it is linked again.")) return;
uiState.autoBusy = true;
syncButtons();
try {
await api("/api/tater_link/unlink", { method: "POST" });
await refreshAutoTrain(false);
setPill($("autoTaterLinkStatus"), "Not linked", "warn");
} 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) : "&nbsp;";
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", "autoLinkTaterBtn", "autoUnlinkTaterBtn"]) {
const button = $(id);
if (button) {
button.disabled =
uiState.autoBusy ||
(id === "autoTrainNowBtn" && Boolean(training.running)) ||
(id === "autoNotifyNowBtn" && !Boolean(uiState.autoTrain?.trainer_link?.linked));
}
}
}
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"), "Publish failed", "err");
alert("Wake-word publish failed: " + error.message);
}
});
$("autoLinkTaterBtn").addEventListener("click", () => {
openTaterLinkModal().catch((error) => {
setPill($("autoTaterLinkStatus"), "Link failed", "err");
alert("Tater link failed: " + error.message);
});
});
$("autoUnlinkTaterBtn").addEventListener("click", () => {
unlinkTater().catch((error) => {
setPill($("autoTaterLinkStatus"), "Unlink failed", "err");
alert("Tater unlink failed: " + error.message);
});
});
$("closeTaterLinkBtn").addEventListener("click", closeTaterLinkModal);
$("taterLinkOverlay").addEventListener("click", (event) => {
if (event.target === $("taterLinkOverlay")) closeTaterLinkModal();
});
$("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") {
closeTaterLinkModal();
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>