mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-06-12 20:10:19 -06:00
3805 lines
132 KiB
HTML
3805 lines
132 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>microWakeWord Personal Samples</title>
|
||
<style>
|
||
:root{
|
||
--bg: #070709;
|
||
--panel: rgba(18, 18, 22, 0.8);
|
||
--panel2: rgba(24, 24, 30, 0.88);
|
||
--text: #ececf1;
|
||
--muted: #a9a9b3;
|
||
--line: rgba(255,255,255,0.1);
|
||
--orange: #ff8a2a;
|
||
--orange2:#ffc07f;
|
||
--ok:#39d4a0;
|
||
--warn:#ffb020;
|
||
--err:#ff5757;
|
||
--shadow: 0 20px 54px rgba(0,0,0,0.45);
|
||
--radius: 18px;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
margin: 0;
|
||
color: var(--text);
|
||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background:
|
||
radial-gradient(900px 500px at 12% 6%, rgba(255, 138, 42, 0.12), transparent 55%),
|
||
radial-gradient(720px 420px at 82% 14%, rgba(255, 192, 127, 0.1), transparent 60%),
|
||
radial-gradient(820px 620px at 50% 100%, rgba(255, 138, 42, 0.06), transparent 55%),
|
||
linear-gradient(180deg, #050506 0%, #0b0b11 100%);
|
||
}
|
||
|
||
.wrap { max-width: 980px; margin: 0 auto; padding: 28px 18px 42px; }
|
||
.topbar { display:flex; align-items:center; justify-content:space-between; gap: 12px; margin-bottom: 14px; }
|
||
.brand { display:flex; align-items:center; gap:12px; }
|
||
.logo {
|
||
width: 42px; height: 42px; border-radius: 14px;
|
||
border: 1px solid rgba(255,138,42,0.3);
|
||
background:
|
||
radial-gradient(circle at 30% 30%, rgba(255,176,102,0.52), rgba(255,138,42,0.24) 44%, rgba(0,0,0,0) 72%),
|
||
linear-gradient(180deg, rgba(255,138,42,0.22), rgba(255,138,42,0.06));
|
||
box-shadow: 0 12px 30px rgba(255,138,42,0.1);
|
||
}
|
||
|
||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0.2px; }
|
||
h3 { margin: 0 0 10px; font-size: 18px; }
|
||
p { margin: 0 0 14px; color: var(--muted); line-height: 1.5; }
|
||
|
||
.card {
|
||
border: 1px solid var(--line);
|
||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||
border-radius: var(--radius);
|
||
padding: 18px;
|
||
margin-top: 14px;
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||
.space { justify-content: space-between; }
|
||
.stack { display: grid; gap: 12px; }
|
||
.muted { color: var(--muted); }
|
||
|
||
input[type="text"],
|
||
input[type="number"],
|
||
input[type="password"],
|
||
select {
|
||
padding: 11px 12px;
|
||
font-size: 15px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
background: rgba(0,0,0,0.35);
|
||
color: var(--text);
|
||
outline: none;
|
||
}
|
||
input[type="text"] { width: 420px; max-width: 100%; }
|
||
input[type="password"] { width: 260px; max-width: 100%; }
|
||
input[type="number"] { width: 132px; }
|
||
input::placeholder { color: rgba(236,236,241,0.36); }
|
||
|
||
button {
|
||
padding: 10px 14px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.14);
|
||
background: rgba(255,255,255,0.06);
|
||
color: var(--text);
|
||
transition: transform 0.04s ease, border-color .15s ease, background .15s ease;
|
||
}
|
||
button:hover { border-color: rgba(255,138,42,0.36); background: rgba(255,255,255,0.08); }
|
||
button:active { transform: translateY(1px); }
|
||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
|
||
.primary {
|
||
border-color: rgba(255,138,42,0.42);
|
||
background: linear-gradient(180deg, rgba(255,138,42,0.24), rgba(255,138,42,0.12));
|
||
}
|
||
|
||
.pill {
|
||
display:inline-block;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.pill.ok { color: var(--ok); border-color: rgba(57,212,160,0.25); background: rgba(57,212,160,0.08); }
|
||
.pill.warn { color: var(--warn); border-color: rgba(255,176,32,0.25); background: rgba(255,176,32,0.08); }
|
||
.pill.err { color: var(--err); border-color: rgba(255,87,87,0.25); background: rgba(255,87,87,0.08); }
|
||
|
||
.statGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.stat {
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.04);
|
||
}
|
||
.stat .label {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
margin-bottom: 6px;
|
||
}
|
||
.stat .value { font-size: 24px; font-weight: 700; }
|
||
|
||
.dropzone {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 18px;
|
||
border-radius: 16px;
|
||
border: 1px dashed rgba(255,192,127,0.32);
|
||
background:
|
||
linear-gradient(180deg, rgba(255,138,42,0.08), rgba(255,138,42,0.04)),
|
||
rgba(255,255,255,0.02);
|
||
}
|
||
|
||
input[type="file"] {
|
||
width: 100%;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.progressShell {
|
||
width: 100%;
|
||
height: 16px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(255,255,255,0.08);
|
||
}
|
||
.progressFill {
|
||
height: 100%;
|
||
width: 0%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, rgba(255,138,42,0.88), rgba(255,192,127,0.94));
|
||
box-shadow: inset 0 0 18px rgba(255,255,255,0.18);
|
||
transition: width 0.18s ease;
|
||
}
|
||
|
||
.fileList {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
.fileItem {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.03);
|
||
}
|
||
.fileMeta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 6px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.field strong {
|
||
color: var(--text);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.field input,
|
||
.field select {
|
||
width: 100%;
|
||
}
|
||
|
||
.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 the VoicePE or Sat1 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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
return escapeHtml(text).replaceAll('"', """);
|
||
}
|
||
|
||
function formatTimestamp(value) {
|
||
if (!value) return "";
|
||
const parsed = new Date(value);
|
||
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
|
||
}
|
||
|
||
function 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) : " ";
|
||
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>
|