Files
2026-05-19 15:49:57 -05:00

3805 lines
132 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;
}
.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>ESPHome Firmware Flasher</h3>
<p>Build a <code>microWakeWords</code> ESPHome firmware template and flash it to a sat over OTA. Auto-detect is best effort; if the device does not show up, enter its IP or hostname manually.</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> Review settings</span>
<span class="firmwareStepChip"><b>4</b> Build + flash</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 YAML</h3>
<p>Choose a Tater firmware YAML to build from the shared firmware repo.</p>
</div>
</div>
</div>
<label class="field">
<strong>YAML File</strong>
<select id="firmwareTemplate">
<option value="">Loading templates...</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>
<section class="card firmwarePanel stack">
<div class="firmwarePanelHeader">
<div class="firmwarePanelTitle">
<span class="firmwareStepBadge">3</span>
<div>
<h3>Device Settings</h3>
<p>Each build fetches the latest YAML, then applies the saved substitutions for this target device.</p>
</div>
</div>
<button id="saveFirmwareSettingsBtn" type="button">Save settings</button>
</div>
<div id="firmwareFields" class="firmwareFields stack">
<div class="emptyState">Firmware template settings will appear here.</div>
</div>
</section>
<section class="card firmwareActionsPanel">
<div class="firmwarePanelTitle">
<span class="firmwareStepBadge">4</span>
<div>
<h3>Build + Flash</h3>
<p>The ESPHome output opens in the console so you can follow build, upload, and reboot progress.</p>
</div>
</div>
<div class="row firmwareActions">
<button id="flashFirmwareBtn" class="primary" type="button" disabled>Build + Flash firmware</button>
<button id="cleanFirmwareBtn" type="button">Clean build files</button>
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
</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">Firmware Build + Flash</h3>
<p id="firmwareLogMeta" class="firmwareLogMeta">ESPHome 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 build and 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: "" },
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 ESPHome build 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 openFirmwareConsole(wobble = true, text = null, statusText = "") {
const template = selectedFirmwareTemplate();
const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim();
$("firmwareLogTitle").textContent = "Firmware Build + Flash";
$("firmwareLogMeta").textContent = [
template?.label || template?.value || "Firmware",
host ? `${host}:${port || "3232"}` : "",
].filter(Boolean).join(" • ") || "ESPHome output will appear here.";
if (text !== null) {
renderFirmwareLogLines(String(text).split("\n"), true);
} else {
renderFirmwareLogLines(uiState.firmware.logLines || [], true);
}
setFirmwareLogStatus(statusText || "ESPHome build, upload, and flash 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 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) => `<option value="${escapeAttr(item.value)}">${escapeHtml(item.label || item.value)}</option>`).join("")
: `<option value="">No firmware templates 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();
const template = selectedFirmwareTemplate();
const fields = Array.isArray(template?.fields) ? template.fields : [];
if (!fields.length) {
$("firmwareFields").innerHTML = `<div class="emptyState">No editable settings were found for this firmware template. You can continue with the selected template and target device.</div>`;
return;
}
const groups = new Map();
fields.forEach((field) => {
const section = String(field.section || "Firmware");
if (!groups.has(section)) groups.set(section, []);
groups.get(section).push(field);
});
const orderedGroups = Array.from(groups.entries());
const wakeSoundIndex = orderedGroups.findIndex(([section]) => section === "Wake Sound");
const microWakeWordIndex = orderedGroups.findIndex(([section]) => section === "Micro Wake Word");
if (wakeSoundIndex >= 0 && microWakeWordIndex >= 0 && wakeSoundIndex !== microWakeWordIndex + 1) {
const [wakeSoundGroup] = orderedGroups.splice(wakeSoundIndex, 1);
const targetIndex = orderedGroups.findIndex(([section]) => section === "Micro Wake Word");
orderedGroups.splice(targetIndex + 1, 0, wakeSoundGroup);
}
$("firmwareFields").innerHTML = orderedGroups.map(([section, rows]) => `
<section class="firmwareSettingsSection">
<div class="row space">
<h3 style="margin:0;">${escapeHtml(section)}</h3>
</div>
<div class="firmwareSettingsGrid">
${rows.map((field) => renderFirmwareField(field)).join("")}
${section === "Wake Sound" ? renderWakeSoundPreviewControl() : ""}
</div>
</section>
`).join("");
syncRenderedWakeWordSelection();
syncRenderedWakeSoundSelection();
}
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 template first.");
return;
}
if (!host) {
alert("Enter the device IP or hostname first.");
return;
}
const wakeSoundSelect = document.querySelector("select[data-wake-sound-select]");
if (wakeSoundSelect instanceof HTMLSelectElement) {
syncRenderedWakeSoundSelection({ fromPicker: true });
}
const ok = confirm(`Build and flash ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nMake sure this is the correct device before continuing.`);
if (!ok) return;
uiState.firmwareBusy = true;
uiState.firmware.logLines = [
"===== Firmware Build + Flash Console =====",
`→ Target: ${host}:${port || "3232"}`,
"→ Contacting trainer server to start the build...",
];
setPill($("firmwareStatus"), "Starting build + flash...", "warn");
openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Starting firmware build + 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, "✓ Build session started. Waiting for ESPHome 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 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 build + flash running", "warn");
setFirmwareLogStatus(status.message || "Firmware build + 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 build + flash failed (${status.exit_code})`, "err");
setFirmwareLogStatus(`Firmware build + 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();
$("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;
}
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"), "Templates failed", "err");
uiState.firmware.logLines = [`Template 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);
$("saveFirmwareSettingsBtn").addEventListener("click", async () => {
const host = ($("firmwareHost").value || "").trim();
const template = ($("firmwareTemplate").value || "").trim();
if (!template) {
alert("Choose a firmware template 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"), "Device settings 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"), "Cleaning build files...", "warn");
const result = await api("/api/firmware/clean", { method: "POST" });
setPill($("firmwareStatus"), result.message || "Build files cleaned", "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 build + 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"), "Templates failed", "err");
uiState.firmware.logLines = [`Template 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>