Files
2026-06-15 07:06:23 -05:00

4068 lines
142 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%;
}
.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;
}
.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;
}
.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;
}
.runtimeWakeWordUrl {
display: block;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
.firmwareLogModal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.64);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 12000;
}
.firmwareLogModal.active {
visibility: visible;
pointer-events: auto;
animation: firmwareLogBackdropIn 280ms ease-out both;
}
.firmwareLogDialog {
width: min(1320px, 98vw);
height: min(94vh, 1040px);
display: flex;
flex-direction: column;
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.88), rgba(8, 10, 16, 0.96)),
rgba(8, 10, 16, 0.88);
box-shadow: 0 28px 84px rgba(0,0,0,0.58);
backdrop-filter: blur(18px) saturate(1.12);
overflow: hidden;
opacity: 0;
transform: translateY(14px) scale(0.97);
}
.firmwareLogModal.active .firmwareLogDialog {
animation: firmwareLogDialogIn 300ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.firmwareLogHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.firmwareLogTitle {
margin: 0;
font-size: 18px;
}
.firmwareLogMeta {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.firmwareLogStatus {
color: #b7c7e6;
font-size: 13px;
}
.firmwareLogConsole {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border: 1px solid rgba(255,138,42,0.22);
border-radius: 16px;
padding: 12px 14px;
background:
linear-gradient(180deg, rgba(15, 18, 25, 0.98), rgba(10, 12, 18, 0.98)),
radial-gradient(circle at top, rgba(255,138,42,0.08), transparent 40%);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.05),
inset 0 -1px 0 rgba(0,0,0,0.35);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.45;
color: #dbe8ff;
}
.firmwareLogLine {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 2px 0;
}
.firmwareLogLevel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 58px;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
border: 1px solid rgba(255,255,255,0.12);
white-space: nowrap;
}
.firmwareLogMessage {
white-space: pre-wrap;
word-break: break-word;
}
.firmwareLogLine.tone-info .firmwareLogLevel {
background: rgba(70, 120, 255, 0.16);
border-color: rgba(108, 152, 255, 0.28);
color: #bdd3ff;
}
.firmwareLogLine.tone-warn .firmwareLogLevel {
background: rgba(245, 167, 36, 0.14);
border-color: rgba(245, 167, 36, 0.32);
color: #ffd696;
}
.firmwareLogLine.tone-error .firmwareLogLevel {
background: rgba(226, 76, 76, 0.15);
border-color: rgba(255, 111, 111, 0.34);
color: #ffc1c1;
}
.firmwareLogLine.tone-debug .firmwareLogLevel {
background: rgba(92, 214, 178, 0.14);
border-color: rgba(92, 214, 178, 0.28);
color: #bffff0;
}
.firmwareLogEmpty {
color: var(--muted);
}
@keyframes firmwareLogBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes firmwareLogDialogIn {
from { opacity: 0; transform: translateY(14px) scale(0.97); }
62% { transform: translateY(-3px) scale(1.01); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.consoleMuted { color: #93a0b5; }
.consoleText { color: #d8e2f1; }
.consoleCmd { color: #89d4ff; }
.consoleWarn { color: #ffc46f; }
.consoleErr { color: #ff8f8f; }
.consoleOk { color: #6ee0af; }
.consoleSection { color: #ffd89b; font-weight: 600; letter-spacing: 0.02em; }
@keyframes consoleWobble {
0% { transform: translateY(24px) scale(0.93) rotate(-1.2deg); }
34% { transform: translateY(-6px) scale(1.02) rotate(0.85deg); }
58% { transform: translateY(2px) scale(0.99) rotate(-0.45deg); }
78% { transform: translateY(-1px) scale(1.01) rotate(0.2deg); }
100% { transform: translateY(0) scale(1) rotate(0deg); }
}
.trimOverlay {
position: fixed; inset: 0; padding: 22px;
display: flex; align-items: center; justify-content: center;
background: rgba(4,5,10,0.6); backdrop-filter: blur(10px);
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity 0.18s ease, visibility 0.18s ease;
z-index: 11000;
}
.trimOverlay.open { opacity: 1; visibility: visible; pointer-events: auto; }
.trimDialog {
width: min(960px, calc(100vw - 36px));
max-height: min(90vh, 900px);
display: grid; grid-template-rows: auto 1fr auto auto auto; gap: 12px;
padding: 18px; border-radius: 22px;
border: 1px solid rgba(255,255,255,0.12);
background: linear-gradient(180deg, rgba(17,20,28,0.82), rgba(8,10,16,0.94));
box-shadow: 0 28px 84px rgba(0,0,0,0.58);
backdrop-filter: blur(18px) saturate(1.12);
}
.trimHeader { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.trimTitle { margin: 0; font-size: 18px; }
.trimHint { margin: 6px 0 0; font-size: 13px; color: var(--muted); }
.trimCanvasWrap {
position: relative; width: 100%; min-height: 120px;
border-radius: 14px; border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.4); overflow: hidden;
}
.trimCanvas { width: 100%; height: 100%; display: block; }
.trimHandle {
position: absolute; top: 0; width: 48px; height: 100%;
cursor: ew-resize; pointer-events: auto; touch-action: none;
transform: translateX(-50%);
}
.trimHandle::after {
content: ''; position: absolute; top: 10%; bottom: 10%; left: 50%;
transform: translateX(-50%); width: 3px; border-radius: 2px;
background: var(--orange); box-shadow: 0 0 6px rgba(255,138,42,0.5);
}
.trimHandle::before {
content: ''; position: absolute; top: 50%; left: 50%;
transform: translate(-50%,-50%); width: 10px; height: 24px;
border-radius: 5px; border: 1px solid rgba(255,138,42,0.5);
background: rgba(255,138,42,0.15);
}
.trimTimeInfo {
display: flex; align-items: center; justify-content: center;
gap: 12px; font-size: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.trimSeparator { color: var(--muted); }
.trimVadInfo { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.trimActions { display: flex; gap: 8px; flex-wrap: wrap; }
.trimActions button { flex: 1; min-width: 120px; }
.pill.trimBadge {
color: #89d4ff;
border-color: rgba(137,212,255,0.25);
background: rgba(137,212,255,0.08);
}
.trimBtn {
border-color: rgba(255,138,42,0.3);
background: rgba(255,138,42,0.1);
}
.trimBtn:hover {
border-color: rgba(255,138,42,0.5);
background: rgba(255,138,42,0.18);
}
@media (max-width: 720px) {
.wrap { padding: 18px 14px 30px; }
input[type="text"] { width: 100%; }
.fileItem { align-items: flex-start; flex-direction: column; }
.firmwareGrid { grid-template-columns: 1fr; }
.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;
}
.firmwareLogModal {
padding: 12px;
}
.firmwareLogDialog {
width: 100%;
height: 88vh;
padding: 14px;
}
.firmwareLogHeader {
flex-direction: column;
align-items: stretch;
}
.firmwareLogLine {
grid-template-columns: 1fr;
gap: 4px;
}
.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 flash ESPHome firmware from one local workspace.</p>
</div>
</div>
</div>
<div class="tabs">
<button id="tabTrainer" class="tabBtn active" type="button">Trainer</button>
<button id="tabFirmware" class="tabBtn" type="button">Firmware</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="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">Firmware Studio</div>
<h3>Prebuilt Tater Firmware Flasher</h3>
<p>Flash only for firmware updates. If this is a new satellite or it is not already on Tater firmware <code>3.0.3</code> or newer, do one USB flash first, then use this tab for fast OTA updates.</p>
<div class="firmwareSteps" aria-label="Firmware flashing steps">
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
<span class="firmwareStepChip"><b>2</b> Select target</span>
<span class="firmwareStepChip"><b>3</b> Flash OTA or USB</span>
</div>
</div>
<span id="firmwareStatus" class="pill">Flasher idle</span>
</div>
</div>
<div class="card runtimeWakeWordCard">
<div class="runtimeWakeWordHeader">
<div class="runtimeWakeWordTitle">
<span class="runtimeWakeWordBadge">3.0.3+</span>
<div>
<div class="firmwareKicker">Live Model Switching</div>
<h3>Tater firmware 3.0.3 or higher can swap wake words live</h3>
<p>No reflash is needed after training. Copy a trained wake word JSON URL below and paste it into your satellite's Home Assistant <code>microWakeWord Model URL</code> entity.</p>
</div>
</div>
<span class="pill ok">No reflash needed</span>
</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 class="firmwareLayout">
<section class="card firmwarePanel stack">
<div class="firmwarePanelHeader">
<div class="firmwarePanelTitle">
<span class="firmwareStepBadge">1</span>
<div>
<h3>Firmware Image</h3>
<p>Choose a prebuilt Tater firmware image from the shared firmware repo.</p>
</div>
</div>
</div>
<label class="field">
<strong>Firmware Family</strong>
<select id="firmwareTemplate">
<option value="">Loading firmware images...</option>
</select>
</label>
</section>
<section class="card firmwarePanel stack">
<div class="firmwarePanelHeader">
<div class="firmwarePanelTitle">
<span class="firmwareStepBadge">2</span>
<div>
<h3>Target Device</h3>
<p>Auto-detect a device or enter the OTA target manually.</p>
</div>
</div>
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
</div>
<div class="row">
<button id="refreshFirmwareBtn" type="button">Auto-detect ESPHome devices</button>
</div>
<div class="firmwareTargetGrid">
<label class="field">
<strong>Detected Device</strong>
<select id="firmwareDeviceSelect">
<option value="">No devices scanned yet</option>
</select>
</label>
<label class="field">
<strong>Device IP or Hostname</strong>
<input id="firmwareHost" type="text" placeholder="e.g. 10.4.20.139 or tatervpe.local" />
</label>
<label class="field">
<strong>OTA Port</strong>
<input id="firmwarePort" type="number" min="1" max="65535" value="3232" />
</label>
</div>
</section>
</div>
<div id="firmwareFields" class="firmwareFields stack" hidden></div>
<section class="card firmwareActionsPanel">
<div class="firmwarePanelTitle">
<span class="firmwareStepBadge">3</span>
<div>
<h3>Flash Firmware</h3>
<p>The console shows download, verification, OTA upload, USB flash, and reboot progress.</p>
</div>
</div>
<div class="row firmwareActions">
<button id="flashFirmwareBtn" class="primary" type="button" disabled>Flash OTA Update</button>
<button id="usbFirmwareBtn" type="button" disabled>Browser USB Flash</button>
<button id="saveFirmwareSettingsBtn" type="button">Save target</button>
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
</div>
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices older than Tater firmware 3.0.3. Chrome or Edge must be used on localhost or HTTPS. In the macOS app, open <code>http://127.0.0.1:8789</code> in Chrome or Edge; port <code>3232</code> is only for OTA updates after firmware is installed.</div>
</section>
</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="firmwareLogModal" class="firmwareLogModal" aria-hidden="true">
<div id="firmwareLogDialog" class="firmwareLogDialog" role="dialog" aria-modal="true" aria-labelledby="firmwareLogTitle">
<div class="firmwareLogHeader">
<div>
<h3 id="firmwareLogTitle" class="firmwareLogTitle">Prebuilt Firmware Flash</h3>
<p id="firmwareLogMeta" class="firmwareLogMeta">Firmware output will appear here.</p>
</div>
<button id="closeFirmwareLogBtn" type="button">Close</button>
</div>
<div id="firmwareLogStatus" class="firmwareLogStatus">Waiting for firmware output...</div>
<div id="firmwareLogConsole" class="firmwareLogConsole" role="log" aria-live="polite" aria-label="Firmware flash log">
<div class="firmwareLogEmpty">No firmware flash started yet.</div>
</div>
</div>
</div>
<div id="trimOverlay" class="trimOverlay" aria-hidden="true">
<div id="trimDialog" class="trimDialog" role="dialog" aria-modal="true">
<div class="trimHeader">
<div>
<h3 id="trimTitle" class="trimTitle">Trim Audio</h3>
<p id="trimHint" class="trimHint">Drag the handles to select a region, then save as a new sample.</p>
</div>
<button id="closeTrimBtn" type="button">Close</button>
</div>
<div class="trimCanvasWrap">
<canvas id="trimCanvas" class="trimCanvas"></canvas>
<div id="trimStartHandle" class="trimHandle" data-handle="start"></div>
<div id="trimEndHandle" class="trimHandle" data-handle="end"></div>
</div>
<div class="trimTimeInfo">
<span id="trimStartTime">0.00s</span>
<span class="trimSeparator">--</span>
<span id="trimEndTime">0.00s</span>
<span class="trimSeparator">|</span>
<span id="trimDuration">Duration: 0.00s</span>
</div>
<div id="trimVadInfo" class="trimVadInfo">
<span class="pill ok">VAD detected speech</span>
<span id="trimVadSegments" class="muted"></span>
</div>
<div class="trimActions">
<button id="trimPlayBtn" type="button">Play selection</button>
<button id="trimSelectFirstVadBtn" type="button">Select first VAD</button>
<button id="trimSaveBtn" class="primary" type="button">Save Trim</button>
<button id="trimCancelBtn" type="button">Cancel</button>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const uiState = {
session: null,
training: null,
availableLanguages: [],
selectedFiles: [],
captured: { items: [], captured_count: 0, negative_count: 0, personal_count: 0 },
samples: { personal: [], negative: [], personal_count: 0, negative_count: 0, activeBucket: "personal", pages: { personal: 0, negative: 0 } },
firmware: { devices: [], templates: [], wakeWords: [], flashing: null, logLines: [], activeTemplateKey: "" },
browserUsb: { ports: {}, esptoolJsModule: null, esptoolJsLoadPromise: null },
uploadBusy: false,
reviewBusy: false,
firmwareBusy: false,
trainingPoller: null,
firmwarePoller: null,
activeView: "trainer",
};
const SAMPLE_PAGE_SIZE = 50;
let firmwareProfileSaveTimer = null;
let firmwareProfileReloadTimer = null;
let wakeSoundPreviewAudio = null;
let wakeSoundPreviewButton = null;
// --- 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 = ["captured", "samples", "firmware"].includes(view) ? view : "trainer";
$("trainerView").hidden = uiState.activeView !== "trainer";
$("capturedView").hidden = uiState.activeView !== "captured";
$("samplesView").hidden = uiState.activeView !== "samples";
$("firmwareView").hidden = uiState.activeView !== "firmware";
$("tabTrainer").classList.toggle("active", uiState.activeView === "trainer");
$("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 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 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>`);
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>
<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);
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>
<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 firmwareLogTone(line) {
const text = String(line || "").trim();
const lower = text.toLowerCase();
const bracketMatch = text.match(/^\[[^\]]+\]\[([A-Z])\]/);
const token = bracketMatch?.[1] || "";
if (token === "E" || text.includes("✗") || lower.includes("traceback") || lower.includes("error") || lower.includes("failed") || lower.includes("crashed")) {
return "error";
}
if (token === "W" || text.includes("⚠") || lower.includes("warning") || lower.includes("warn")) {
return "warn";
}
if (token === "D" || token === "V" || lower.includes("[debug]")) {
return "debug";
}
return "info";
}
function firmwareLogLevel(line) {
const tone = firmwareLogTone(line);
if (tone === "error") return "ERROR";
if (tone === "warn") return "WARN";
if (tone === "debug") return "DEBUG";
return "INFO";
}
function renderFirmwareLogLines(lines, reset = true) {
const consoleEl = $("firmwareLogConsole");
const rows = Array.isArray(lines) ? lines.filter((line) => String(line || "").trim()) : [];
const shouldStick = reset || isNearBottom(consoleEl, 28);
if (reset) {
consoleEl.innerHTML = "";
}
if (!rows.length) {
consoleEl.innerHTML = `<div class="firmwareLogEmpty">Waiting for firmware flash output...</div>`;
} else {
consoleEl.innerHTML = rows.map((line) => {
const tone = firmwareLogTone(line);
return `
<div class="firmwareLogLine tone-${tone}">
<span class="firmwareLogLevel">${firmwareLogLevel(line)}</span>
<span class="firmwareLogMessage">${escapeHtml(line)}</span>
</div>
`;
}).join("");
}
if (shouldStick) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
}
function setFirmwareLogStatus(text) {
$("firmwareLogStatus").textContent = String(text || "Waiting for firmware output...").trim() || "Waiting for firmware output...";
}
function appendFirmwareLog(line, statusText = "") {
const text = String(line || "").trim();
if (!text) return;
uiState.firmware.logLines = [...(uiState.firmware.logLines || []), text].slice(-500);
renderFirmwareLogLines(uiState.firmware.logLines, true);
if (statusText) {
setFirmwareLogStatus(statusText);
}
}
function appendFirmwareLogs(lines, statusText = "") {
(Array.isArray(lines) ? lines : []).forEach((line) => appendFirmwareLog(line));
if (statusText) {
setFirmwareLogStatus(statusText);
}
}
function openFirmwareConsole(wobble = true, text = null, statusText = "") {
const template = selectedFirmwareTemplate();
const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim();
$("firmwareLogTitle").textContent = "Prebuilt Firmware Flash";
$("firmwareLogMeta").textContent = [
template?.label || template?.value || "Firmware",
host ? `${host}:${port || "3232"}` : "",
].filter(Boolean).join(" • ") || "Firmware output will appear here.";
if (text !== null) {
renderFirmwareLogLines(String(text).split("\n"), true);
} else {
renderFirmwareLogLines(uiState.firmware.logLines || [], true);
}
setFirmwareLogStatus(statusText || "Prebuilt firmware download, verification, and upload output appears here.");
const modal = $("firmwareLogModal");
const dialog = $("firmwareLogDialog");
modal.classList.add("active");
modal.setAttribute("aria-hidden", "false");
dialog.setAttribute("tabindex", "-1");
try {
dialog.focus({ preventScroll: true });
} catch (_) {
dialog.focus();
}
if (wobble) {
dialog.style.animation = "none";
void dialog.offsetWidth;
dialog.style.animation = "";
}
}
function closeFirmwareConsole() {
$("firmwareLogModal").classList.remove("active");
$("firmwareLogModal").setAttribute("aria-hidden", "true");
}
function waitForPaint() {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
}
function browserUsbPortLabel(port) {
if (!port || typeof port.getInfo !== "function") {
return "USB serial device";
}
const info = port.getInfo() || {};
const vendor = Number(info.usbVendorId || 0);
const product = Number(info.usbProductId || 0);
if (!vendor && !product) {
return "USB serial device";
}
const hex = (value) => value.toString(16).padStart(4, "0");
return `USB ${hex(vendor)}:${hex(product)}`;
}
async function browserUsbSelectPort(selector = "") {
if (!window.isSecureContext || !navigator.serial) {
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
}
const port = await navigator.serial.requestPort();
const key = String(selector || "default").trim() || "default";
uiState.browserUsb.ports[key] = port;
return port;
}
function normalizeEsptoolJsModule(module) {
if (module?.ESPLoader && module?.Transport) {
return module;
}
if (module?.default?.ESPLoader && module?.default?.Transport) {
return module.default;
}
return null;
}
async function ensureEsptoolJsLoaded() {
if (uiState.browserUsb.esptoolJsModule) {
return uiState.browserUsb.esptoolJsModule;
}
if (uiState.browserUsb.esptoolJsLoadPromise instanceof Promise) {
return uiState.browserUsb.esptoolJsLoadPromise;
}
const moduleUrls = [
"https://esm.sh/esptool-js@0.5.7?bundle",
"https://unpkg.com/esptool-js@0.5.7/bundle.js",
"https://cdn.jsdelivr.net/npm/esptool-js@0.5.7/bundle.js",
];
uiState.browserUsb.esptoolJsLoadPromise = (async () => {
const errors = [];
for (const moduleUrl of moduleUrls) {
try {
const module = normalizeEsptoolJsModule(await import(moduleUrl));
if (module) {
uiState.browserUsb.esptoolJsModule = module;
return module;
}
errors.push(`${moduleUrl}: missing ESPLoader/Transport exports`);
} catch (error) {
errors.push(`${moduleUrl}: ${String(error?.message || error || "load failed")}`);
}
}
throw new Error(`Could not load esptool-js. ${errors.join(" | ")}`);
})();
return uiState.browserUsb.esptoolJsLoadPromise;
}
async function fetchFirmwareBinary(binaryUrl) {
const response = await fetch(binaryUrl, { credentials: "same-origin" });
if (!response.ok) {
throw new Error(`Firmware download failed: HTTP ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
function uint8ArrayToBinaryString(bytes) {
const chunks = [];
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
}
return chunks.join("");
}
async function browserUsbSetSignals(port, signals, timeoutMs = 1200) {
if (!port || typeof port.setSignals !== "function") {
return false;
}
let timeoutId = 0;
try {
await Promise.race([
port.setSignals(signals),
new Promise((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error("Timed out setting USB serial control signals.")), Math.max(1, Number(timeoutMs) || 1));
}),
]);
return true;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async function browserUsbHardResetAfterFlash(transport, loader, port) {
try {
appendFirmwareLog("Resetting device after USB flash.");
if (transport && typeof transport.setRTS === "function") {
await transport.setRTS(true);
} else {
await browserUsbSetSignals(port, { dataTerminalReady: false, requestToSend: true, break: false });
}
await sleep(100);
if (loader && typeof loader.after === "function") {
await loader.after();
}
await sleep(1000);
return true;
} catch (error) {
appendFirmwareLog(`USB reset warning: ${String(error?.message || error)}`);
return false;
}
}
async function flashBrowserUsbPort(port, artifact) {
const module = await ensureEsptoolJsLoaded();
const { ESPLoader, Transport } = module;
if (!ESPLoader || !Transport) {
throw new Error("esptool-js did not expose ESPLoader/Transport.");
}
const binaryUrl = String(artifact?.binary_url || "").trim();
if (!binaryUrl) {
throw new Error("Browser flash binary URL is missing.");
}
const transport = new Transport(port);
const terminal = {
clean() {},
writeLine(data) {
const text = String(data || "").trim();
if (text) appendFirmwareLog(text);
},
write(data) {
const text = String(data || "").trim();
if (text) appendFirmwareLog(text);
},
};
const loader = new ESPLoader({
transport,
baudrate: 115200,
terminal,
debugLogging: false,
});
let lastProgress = -1;
try {
appendFirmwareLog(`Connecting to ${browserUsbPortLabel(port)}...`, "Connecting to selected USB device...");
const chipName = await loader.main();
appendFirmwareLog(`Connected to ${chipName || "ESP device"}.`);
setFirmwareLogStatus("Downloading prepared factory firmware...");
const firmwareData = await fetchFirmwareBinary(binaryUrl);
appendFirmwareLog(`Firmware downloaded (${firmwareData.byteLength} bytes).`);
const eraseAll = artifact?.erase_all !== false;
if (eraseAll) {
appendFirmwareLog("Erasing flash and writing factory image at offset 0x0.");
} else {
appendFirmwareLog("Writing factory image at offset 0x0.");
}
const flashOptions = {
fileArray: [{ data: firmwareData, address: 0 }],
flashMode: String(artifact?.flash_mode || "dio"),
flashFreq: String(artifact?.flash_freq || "40m"),
flashSize: String(artifact?.flash_size || "4MB"),
eraseAll,
compress: true,
reportProgress: (_fileIndex, written, total) => {
const pct = total > 0 ? Math.floor((written / total) * 100) : 0;
if (pct >= lastProgress + 5 || pct === 100) {
lastProgress = pct;
appendFirmwareLog(`USB flash progress: ${pct}% (${written}/${total} bytes)`, `Flashing over USB... ${pct}%`);
}
},
};
try {
await loader.writeFlash(flashOptions);
} catch (error) {
const message = String(error?.message || error || "");
if (!message.includes("charCodeAt")) {
throw error;
}
appendFirmwareLog("Retrying flash with binary-string firmware format.");
await loader.writeFlash({
...flashOptions,
eraseAll: false,
fileArray: [{ data: uint8ArrayToBinaryString(firmwareData), address: 0 }],
});
}
appendFirmwareLog("USB flash complete.");
const resetWorked = await browserUsbHardResetAfterFlash(transport, loader, port);
if (!resetWorked) {
appendFirmwareLog("If the device does not restart, unplug and reconnect USB once to leave bootloader mode.");
}
} finally {
try {
await transport.disconnect();
} catch (_error) {
// Ignore disconnect cleanup failures.
}
}
}
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 renderFirmwareDevices(devices, message) {
const list = Array.isArray(devices) ? devices : [];
uiState.firmware.devices = list;
if (!list.length) {
$("firmwareDeviceSelect").innerHTML = `<option value="">No devices detected</option>`;
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
return;
}
$("firmwareDeviceSelect").innerHTML = [
`<option value="">Choose detected device...</option>`,
...list.map((device, index) => {
const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`;
return `<option value="${index}">${escapeHtml(label)}</option>`;
}),
].join("");
setPill($("firmwareDetectStatus"), `${list.length} detected`, "ok");
if (!($("firmwareHost").value || "").trim()) {
$("firmwareDeviceSelect").value = "0";
applySelectedFirmwareDevice().catch((error) => {
setPill($("firmwareStatus"), "Device settings failed", "warn");
console.warn("Device settings load failed", error);
});
}
}
async function refreshFirmwareDevices() {
setPill($("firmwareDetectStatus"), "Scanning mDNS...", "warn");
const data = await api("/api/firmware/devices", { method: "GET" });
renderFirmwareDevices(data.devices, data.message);
syncButtons();
return data;
}
async function applySelectedFirmwareDevice() {
const indexText = $("firmwareDeviceSelect").value;
if (indexText === "") return;
const device = uiState.firmware.devices[Number(indexText)];
if (!device) return;
await flushFirmwareProfileSave();
$("firmwareHost").value = device.host || "";
$("firmwarePort").value = device.port || 3232;
setPill($("firmwareStatus"), "Loading device settings...", "warn");
const data = await refreshFirmwareTemplates();
if (!Array.isArray(data?.warnings) || !data.warnings.length) {
setPill($("firmwareStatus"), "Device settings loaded", "ok");
}
syncButtons();
}
function selectedFirmwareTemplate() {
const key = $("firmwareTemplate").value;
const match = (uiState.firmware.templates || []).find((item) => String(item.value || "") === key);
if (match) return match;
if (!key) return null;
const label = $("firmwareTemplate").selectedOptions?.[0]?.textContent || key;
return { value: key, label };
}
function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) {
if (!template) return;
if (template.target_host) {
$("firmwareHost").value = template.target_host;
}
if (template.target_port) {
$("firmwarePort").value = template.target_port;
}
}
function renderFirmwareTemplates(payload) {
const templates = Array.isArray(payload?.templates) ? payload.templates : [];
const previousTemplateKey = $("firmwareTemplate").value || uiState.firmware.activeTemplateKey || "";
uiState.firmware.templates = templates;
uiState.firmware.wakeWords = Array.isArray(payload?.wake_words) ? payload.wake_words : [];
renderRuntimeWakeWordLinks();
$("firmwareTemplate").innerHTML = templates.length
? templates.map((item) => {
const version = item.firmware_version ? ` ${item.firmware_version}` : "";
return `<option value="${escapeAttr(item.value)}">${escapeHtml((item.label || item.value) + version)}</option>`;
}).join("")
: `<option value="">No firmware images found</option>`;
if (previousTemplateKey && templates.some((item) => String(item.value || "") === previousTemplateKey)) {
$("firmwareTemplate").value = previousTemplateKey;
} else if (payload?.active_template_key) {
$("firmwareTemplate").value = payload.active_template_key;
}
uiState.firmware.activeTemplateKey = $("firmwareTemplate").value;
renderFirmwareFields();
applyFirmwareTemplateTarget();
}
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(),
}))
.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) => `
<div class="runtimeWakeWordItem">
<div>
<strong>${escapeHtml(item.label)}</strong>
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
</div>
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
</div>
`).join("");
}
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 renderFirmwareFields() {
resetWakeSoundPreview();
$("firmwareFields").innerHTML = "";
}
function renderFirmwareField(field) {
const key = String(field.key || "");
const label = String(field.label || key || "Value");
const type = String(field.type || "text");
const value = field.value ?? "";
const placeholder = field.placeholder ? ` placeholder="${escapeAttr(field.placeholder)}"` : "";
const description = field.description ? `<span class="muted">${escapeHtml(field.description)}</span>` : "";
const options = Array.isArray(field.options) ? field.options : [];
if (field.read_only) {
return `
<label class="field">
<strong>${escapeHtml(label)}</strong>
<span class="readOnlyValue">${escapeHtml(value || "(from YAML)")}</span>
${description}
</label>
`;
}
if (type === "wake_word_select") {
const optionHtml = options.length
? options.map((option) => {
const optionValue = String(option.key || "");
const selected = optionValue && optionValue === String(value || "") ? " selected" : "";
return `
<option value="${escapeAttr(optionValue)}"${selected}
data-wake-word-name="${escapeAttr(option.wake_word_name || optionValue)}"
data-wake-word-url="${escapeAttr(option.json_url || "")}">
${escapeHtml(option.label || optionValue)}
</option>
`;
}).join("")
: "";
return `
<label class="field">
<strong>${escapeHtml(label)}</strong>
<select data-firmware-field="${escapeAttr(key)}" data-wake-word-select ${options.length ? "" : "disabled"}>
<option value="">${escapeHtml(placeholder || "Choose a trained wake word...")}</option>
${optionHtml}
</select>
${description}
</label>
`;
}
if (type === "wake_sound_select") {
const optionHtml = options.length
? options.map((option) => {
const optionValue = String(option.value || "");
const selected = optionValue && optionValue === String(value || "") ? " selected" : "";
return `
<option value="${escapeAttr(optionValue)}"${selected}>
${escapeHtml(option.label || optionValue)}
</option>
`;
}).join("")
: "";
return `
<label class="field">
<strong>${escapeHtml(label)}</strong>
<select data-firmware-field="${escapeAttr(key)}" data-wake-sound-select ${options.length ? "" : "disabled"}>
${optionHtml || `<option value="__custom__">Custom URL</option>`}
</select>
${description}
</label>
`;
}
if (type === "checkbox") {
return `
<label class="field">
<strong>${escapeHtml(label)}</strong>
<span class="row">
<input data-firmware-field="${escapeAttr(key)}" type="checkbox" ${value ? "checked" : ""} style="width:auto;" />
<span class="muted">Enabled</span>
</span>
${description}
</label>
`;
}
return `
<label class="field">
<strong>${escapeHtml(label)}</strong>
<input data-firmware-field="${escapeAttr(key)}" type="${type === "password" ? "password" : "text"}" value="${escapeAttr(value)}"${placeholder} autocomplete="off" />
${description}
</label>
`;
}
function renderWakeSoundPreviewControl() {
return `
<div class="wakeSoundPreviewRow">
<button type="button" data-wake-sound-preview>Play Selected Sound</button>
<span class="wakeSoundPreviewStatus" data-wake-sound-preview-status></span>
</div>
`;
}
function syncRenderedWakeWordSelection() {
const select = document.querySelector("select[data-wake-word-select]");
if (select && select.value) {
applyWakeWordSelection(select, { silent: true });
}
}
function applyWakeWordSelection(select, options = {}) {
const option = select?.selectedOptions?.[0];
if (!option || !select.value) return;
const wakeWordName = option.dataset.wakeWordName || "";
const wakeWordUrl = option.dataset.wakeWordUrl || "";
const nameInput = document.querySelector('[data-firmware-field="wake_word_name"]');
const urlInput = document.querySelector('[data-firmware-field="wake_word_model_url"]');
if (nameInput && wakeWordName) {
nameInput.value = wakeWordName;
}
if (urlInput && wakeWordUrl) {
urlInput.value = wakeWordUrl;
}
if (!options.silent) {
setPill($("firmwareStatus"), "Wake word selected", "ok");
scheduleFirmwareProfileSave();
}
syncButtons();
}
function syncRenderedWakeSoundSelection({ fromPicker = false } = {}) {
const select = document.querySelector("select[data-wake-sound-select]");
const urlInput = document.querySelector('[data-firmware-field="wake_word_triggered_sound_file"]');
if (!(select instanceof HTMLSelectElement) || !(urlInput instanceof HTMLInputElement)) return;
if (fromPicker) {
const selectedUrl = String(select.value || "").trim();
if (selectedUrl && selectedUrl !== "__custom__") {
urlInput.value = selectedUrl;
}
return;
}
const currentUrl = String(urlInput.value || "").trim();
const optionValues = Array.from(select.options).map((option) => String(option.value || "").trim());
if (currentUrl && optionValues.includes(currentUrl)) {
select.value = currentUrl;
} else if (optionValues.includes("__custom__")) {
select.value = "__custom__";
}
}
function applyWakeSoundSelection(select, options = {}) {
syncRenderedWakeSoundSelection({ fromPicker: true });
if (!options.silent) {
setPill($("firmwareStatus"), "Wake sound selected", "ok");
scheduleFirmwareProfileSave();
}
syncButtons();
}
function resetWakeSoundPreview(message = "") {
if (wakeSoundPreviewAudio instanceof HTMLAudioElement) {
try {
wakeSoundPreviewAudio.pause();
wakeSoundPreviewAudio.currentTime = 0;
} catch (_error) {
// Browser cleanup can fail if the media element is already gone.
}
}
if (wakeSoundPreviewButton instanceof HTMLButtonElement && document.body.contains(wakeSoundPreviewButton)) {
wakeSoundPreviewButton.disabled = false;
wakeSoundPreviewButton.textContent = "Play Selected Sound";
const section = wakeSoundPreviewButton.closest(".firmwareSettingsSection");
const status = section?.querySelector?.("[data-wake-sound-preview-status]");
if (status instanceof HTMLElement) {
status.textContent = message;
}
}
wakeSoundPreviewAudio = null;
wakeSoundPreviewButton = null;
}
function getWakeSoundPreviewUrl() {
const urlInput = document.querySelector('[data-firmware-field="wake_word_triggered_sound_file"]');
const picker = document.querySelector("select[data-wake-sound-select]");
const urlValue = String(urlInput instanceof HTMLInputElement ? urlInput.value || "" : "").trim();
if (urlValue) return urlValue;
const pickerValue = String(picker instanceof HTMLSelectElement ? picker.value || "" : "").trim();
return pickerValue && pickerValue !== "__custom__" ? pickerValue : "";
}
async function handleWakeSoundPreview(button) {
const status = button.closest(".firmwareSettingsSection")?.querySelector?.("[data-wake-sound-preview-status]");
if (wakeSoundPreviewAudio instanceof HTMLAudioElement && wakeSoundPreviewButton === button && !wakeSoundPreviewAudio.paused) {
resetWakeSoundPreview("Stopped.");
return;
}
const url = getWakeSoundPreviewUrl();
if (!url) {
if (status instanceof HTMLElement) status.textContent = "Choose a wake sound or enter a URL first.";
return;
}
resetWakeSoundPreview();
const audio = new Audio(url);
audio.preload = "auto";
wakeSoundPreviewAudio = audio;
wakeSoundPreviewButton = button;
button.disabled = true;
button.textContent = "Loading...";
if (status instanceof HTMLElement) status.textContent = "Loading preview...";
const finish = (message) => {
if (wakeSoundPreviewAudio !== audio) return;
if (button instanceof HTMLButtonElement && document.body.contains(button)) {
button.disabled = false;
button.textContent = "Play Selected Sound";
}
if (status instanceof HTMLElement) status.textContent = message;
wakeSoundPreviewAudio = null;
wakeSoundPreviewButton = null;
};
audio.addEventListener("ended", () => finish("Finished."));
audio.addEventListener("error", () => finish("Could not play this wake sound."));
try {
await audio.play();
if (wakeSoundPreviewAudio === audio) {
button.disabled = false;
button.textContent = "Stop";
if (status instanceof HTMLElement) status.textContent = "Playing...";
}
} catch (error) {
finish("Playback blocked or unavailable.");
}
}
function firmwareTemplateQuery() {
const params = new URLSearchParams();
const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim();
if (host) params.set("target_host", host);
if (port) params.set("target_port", port);
const query = params.toString();
return query ? `?${query}` : "";
}
async function refreshFirmwareTemplates() {
const data = await api(`/api/firmware/templates${firmwareTemplateQuery()}`, { method: "GET" });
renderFirmwareTemplates(data);
if (Array.isArray(data.warnings) && data.warnings.length) {
setPill($("firmwareStatus"), "Template warning", "warn");
}
syncButtons();
return data;
}
function collectFirmwareValues() {
const values = {};
document.querySelectorAll("[data-firmware-field]").forEach((field) => {
const key = String(field.dataset.firmwareField || "");
if (!key) return;
if (field instanceof HTMLInputElement && field.type === "checkbox") {
values[key] = field.checked;
} else {
values[key] = field.value || "";
}
});
const wakeSoundChoice = String(values.wake_sound_catalog || "").trim();
if (wakeSoundChoice && wakeSoundChoice !== "__custom__") {
values.wake_word_triggered_sound_file = wakeSoundChoice;
}
return values;
}
async function saveFirmwareProfileNow({ templateKey = null, quiet = false } = {}) {
const key = String(templateKey || $("firmwareTemplate").value || "").trim();
if (!key) return null;
const values = collectFirmwareValues();
values.__target_host = ($("firmwareHost").value || "").trim();
values.__target_port = ($("firmwarePort").value || "3232").trim();
const result = await api("/api/firmware/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template_key: key, values }),
});
if (!quiet && !uiState.firmwareBusy) {
setPill($("firmwareStatus"), "Settings saved", "ok");
}
return result;
}
function scheduleFirmwareProfileSave() {
if (firmwareProfileSaveTimer) {
clearTimeout(firmwareProfileSaveTimer);
}
firmwareProfileSaveTimer = setTimeout(async () => {
firmwareProfileSaveTimer = null;
try {
await saveFirmwareProfileNow({ quiet: false });
} catch (error) {
if (!uiState.firmwareBusy) {
setPill($("firmwareStatus"), "Settings save failed", "warn");
}
}
}, 550);
}
function scheduleFirmwareProfileReload() {
if (firmwareProfileSaveTimer) {
clearTimeout(firmwareProfileSaveTimer);
firmwareProfileSaveTimer = null;
}
if (firmwareProfileReloadTimer) {
clearTimeout(firmwareProfileReloadTimer);
}
firmwareProfileReloadTimer = setTimeout(async () => {
firmwareProfileReloadTimer = null;
try {
await refreshFirmwareTemplates();
} catch (error) {
setPill($("firmwareStatus"), "Device settings failed", "warn");
}
}, 650);
}
function flushFirmwareProfileSave(templateKey = null) {
if (firmwareProfileSaveTimer) {
clearTimeout(firmwareProfileSaveTimer);
firmwareProfileSaveTimer = null;
}
if (firmwareProfileReloadTimer) {
clearTimeout(firmwareProfileReloadTimer);
firmwareProfileReloadTimer = null;
}
return saveFirmwareProfileNow({ templateKey, quiet: true }).catch(() => null);
}
async function startFirmwareFlash() {
const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim();
const template = selectedFirmwareTemplate();
if (!template) {
alert("Choose a firmware family first.");
return;
}
if (!host) {
alert("Enter the device IP or hostname first.");
return;
}
if (template.prebuilt_firmware && !template.prebuilt_firmware.available) {
alert(template.prebuilt_firmware.error || "No prebuilt OTA image is available for this firmware family.");
return;
}
const wakeSoundSelect = document.querySelector("select[data-wake-sound-select]");
if (wakeSoundSelect instanceof HTMLSelectElement) {
syncRenderedWakeSoundSelection({ fromPicker: true });
}
const ok = confirm(`Flash prebuilt ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nOnly continue if this device is already running Tater firmware 3.0.3 or newer. New devices need one USB flash first.`);
if (!ok) return;
uiState.firmwareBusy = true;
uiState.firmware.logLines = [
"===== Prebuilt Firmware Flash Console =====",
`→ Target: ${host}:${port || "3232"}`,
"→ Contacting trainer server to prepare the OTA image...",
];
setPill($("firmwareStatus"), "Starting firmware flash...", "warn");
openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Starting prebuilt firmware flash...");
syncButtons();
await flushFirmwareProfileSave();
await waitForPaint();
try {
const status = await api("/api/firmware/build_flash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_key: template.value,
host,
port: port || "3232",
values: collectFirmwareValues(),
}),
});
uiState.firmware.flashing = status;
uiState.firmware.logLines = (status.log_lines || []).length
? status.log_lines
: [...uiState.firmware.logLines, "✓ Flash session started. Waiting for firmware output..."];
openFirmwareConsole(false, uiState.firmware.logLines.join("\n") || "(waiting for flash output)", status.message || "Firmware session started.");
pollFirmwareFlash(status.session_id);
} catch (error) {
uiState.firmwareBusy = false;
setPill($("firmwareStatus"), "Flash failed to start", "err");
uiState.firmware.logLines = [String(error.message || error)];
openFirmwareConsole(false, uiState.firmware.logLines.join("\n"), "Firmware flash failed to start.");
syncButtons();
alert("Flash failed to start: " + error.message);
}
}
async function startUsbFirmwareFlash() {
const template = selectedFirmwareTemplate();
if (!template) {
alert("Choose a firmware family first.");
return;
}
const factory = template.prebuilt_firmware?.artifacts?.factory || {};
if (!factory.path) {
alert("No prebuilt USB factory image is available for this firmware family.");
return;
}
if (!window.isSecureContext || !navigator.serial) {
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
return;
}
const ok = confirm(`USB flash the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices older than Tater firmware 3.0.3. It erases flash and writes the factory image over USB.`);
if (!ok) return;
let port;
try {
port = await browserUsbSelectPort(template.value);
} catch (error) {
alert("USB device selection failed: " + error.message);
return;
}
uiState.firmwareBusy = true;
uiState.firmware.logLines = [
"===== Browser USB Firmware Flash Console =====",
`Firmware: ${template.label || template.value}`,
`USB device: ${browserUsbPortLabel(port)}`,
"Preparing verified factory image...",
];
setPill($("firmwareStatus"), "Preparing USB flash...", "warn");
openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Preparing browser USB flash...");
syncButtons();
await waitForPaint();
try {
const artifact = await api("/api/firmware/browser_flash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_key: template.value,
}),
});
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
await flashBrowserUsbPort(port, artifact);
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
setPill($("firmwareStatus"), "USB flash finished", "ok");
} catch (error) {
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
setPill($("firmwareStatus"), "USB flash failed", "err");
alert("Browser USB flash failed: " + error.message);
} finally {
uiState.firmwareBusy = false;
syncButtons();
}
}
async function pollFirmwareFlash(sessionId) {
if (uiState.firmwarePoller || !sessionId) return;
uiState.firmwarePoller = true;
try {
for (;;) {
const status = await api(`/api/firmware/flash_status/${encodeURIComponent(sessionId)}`, { method: "GET" });
uiState.firmware.flashing = status;
const lines = status.log_lines || [];
uiState.firmware.logLines = lines;
renderFirmwareLogLines(lines.length ? lines : ["(waiting for flash output)"], true);
if (status.running) {
setPill($("firmwareStatus"), status.message || "Firmware flash running", "warn");
setFirmwareLogStatus(status.message || "Firmware flash running.");
} else {
uiState.firmwareBusy = false;
if (status.exit_code === 0) {
setPill($("firmwareStatus"), "Firmware flashed successfully", "ok");
setFirmwareLogStatus("Firmware flashed successfully.");
} else {
setPill($("firmwareStatus"), `Firmware flash failed (${status.exit_code})`, "err");
setFirmwareLogStatus(`Firmware flash failed (${status.exit_code}).`);
}
syncButtons();
break;
}
syncButtons();
await new Promise((resolve) => setTimeout(resolve, 1200));
}
} catch (error) {
uiState.firmwareBusy = false;
setPill($("firmwareStatus"), "Flash status lost", "err");
uiState.firmware.logLines = [
...(uiState.firmware.logLines || []),
`✗ Flash status lost: ${error.message || error}`,
];
openFirmwareConsole(false, uiState.firmware.logLines.join("\n"), "Firmware status polling failed.");
syncButtons();
} finally {
uiState.firmwarePoller = null;
}
}
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);
const firmwareHost = ($("firmwareHost").value || "").trim();
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
const firmwareSelection = selectedFirmwareTemplate();
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
$("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;
$("refreshFirmwareBtn").disabled = uiState.firmwareBusy;
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
$("openFirmwareConsoleBtn").disabled = false;
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path;
}
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"));
$("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");
refreshFirmwareTemplates().catch((error) => {
setPill($("firmwareStatus"), "Firmware list failed", "err");
uiState.firmware.logLines = [`Firmware list load failed: ${error.message}`];
setConsoleLogAutoScroll($("trainLog"), uiState.firmware.logLines.join("\n"));
});
if (!uiState.firmware.devices.length) {
refreshFirmwareDevices().catch((error) => {
setPill($("firmwareDetectStatus"), "Scan failed", "err");
uiState.firmware.logLines = [`Device scan failed: ${error.message}`];
setConsoleLogAutoScroll($("trainLog"), uiState.firmware.logLines.join("\n"));
});
}
});
$("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"), "Model 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();
});
$("firmwareHost").addEventListener("input", () => {
syncButtons();
scheduleFirmwareProfileReload();
});
$("firmwarePort").addEventListener("input", () => {
syncButtons();
scheduleFirmwareProfileReload();
});
$("firmwareTemplate").addEventListener("change", () => {
flushFirmwareProfileSave(uiState.firmware.activeTemplateKey);
uiState.firmware.activeTemplateKey = $("firmwareTemplate").value;
renderFirmwareFields();
applyFirmwareTemplateTarget();
syncButtons();
});
$("firmwareFields").addEventListener("input", (event) => {
if (event.target?.matches?.('[data-firmware-field="wake_word_triggered_sound_file"]')) {
resetWakeSoundPreview();
syncRenderedWakeSoundSelection();
}
syncButtons();
scheduleFirmwareProfileSave();
});
$("firmwareFields").addEventListener("click", (event) => {
const button = event.target.closest("button[data-wake-sound-preview]");
if (!button) return;
handleWakeSoundPreview(button).catch(() => {
const status = button.closest(".firmwareSettingsSection")?.querySelector?.("[data-wake-sound-preview-status]");
if (status instanceof HTMLElement) {
status.textContent = "Preview failed.";
}
});
});
$("firmwareFields").addEventListener("change", (event) => {
const select = event.target.closest("select[data-wake-word-select]");
if (select) {
applyWakeWordSelection(select);
return;
}
const wakeSoundSelect = event.target.closest("select[data-wake-sound-select]");
if (wakeSoundSelect) {
resetWakeSoundPreview();
applyWakeSoundSelection(wakeSoundSelect);
return;
}
syncButtons();
scheduleFirmwareProfileSave();
});
$("firmwareDeviceSelect").addEventListener("change", () => {
applySelectedFirmwareDevice().catch((error) => {
setPill($("firmwareStatus"), "Device settings failed", "warn");
alert("Device settings failed: " + error.message);
});
});
$("refreshFirmwareBtn").addEventListener("click", async () => {
try {
await refreshFirmwareDevices();
} catch (error) {
setPill($("firmwareDetectStatus"), "Scan failed", "err");
alert("Device scan failed: " + error.message);
}
});
$("flashFirmwareBtn").addEventListener("click", startFirmwareFlash);
$("usbFirmwareBtn").addEventListener("click", startUsbFirmwareFlash);
$("saveFirmwareSettingsBtn").addEventListener("click", async () => {
const host = ($("firmwareHost").value || "").trim();
const template = ($("firmwareTemplate").value || "").trim();
if (!template) {
alert("Choose a firmware family first.");
return;
}
if (!host) {
alert("Enter the device IP or hostname first so settings can be saved for this device.");
return;
}
try {
setPill($("firmwareStatus"), "Saving device settings...", "warn");
await saveFirmwareProfileNow({ quiet: true });
setPill($("firmwareStatus"), "Target saved", "ok");
} catch (error) {
setPill($("firmwareStatus"), "Settings save failed", "err");
alert("Settings save failed: " + error.message);
} finally {
syncButtons();
}
});
$("cleanFirmwareBtn").addEventListener("click", async () => {
try {
setPill($("firmwareStatus"), "Clearing downloaded images...", "warn");
const result = await api("/api/firmware/clean", { method: "POST" });
setPill($("firmwareStatus"), result.message || "Downloaded images cleared", "ok");
} catch (error) {
setPill($("firmwareStatus"), "Clean failed", "err");
alert("Clean failed: " + error.message);
} finally {
syncButtons();
}
});
$("openFirmwareConsoleBtn").addEventListener("click", () => {
openFirmwareConsole(true, (uiState.firmware.logLines || []).join("\n") || "(no firmware flash started)", uiState.firmwareBusy ? "Firmware flash running." : "No active firmware flash.");
});
$("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();
});
$("closeFirmwareLogBtn").addEventListener("click", () => {
closeFirmwareConsole();
});
$("firmwareLogModal").addEventListener("click", (event) => {
if (event.target === $("firmwareLogModal")) {
closeFirmwareConsole();
}
});
$("consoleOverlay").addEventListener("click", (event) => {
if (event.target === $("consoleOverlay")) {
closeConsole();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeConsole();
closeFirmwareConsole();
}
});
$("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();
} catch (_) {}
try {
await refreshFirmwareTemplates();
} catch (error) {
setPill($("firmwareStatus"), "Firmware list failed", "err");
uiState.firmware.logLines = [`Firmware 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();
</script>
</body>
</html>