Files
microWakeWord-Trainer-Nvidi…/static/index.html
2026-04-14 22:55:49 -05:00

924 lines
30 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"],
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="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;
}
.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;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 50;
}
.consoleOverlay.open {
opacity: 1;
pointer-events: auto;
}
.consoleWindow {
width: min(1040px, calc(100vw - 36px));
height: min(76vh, 760px);
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 18px;
border-radius: 22px;
border: 1px solid rgba(255,255,255,0.12);
background:
linear-gradient(180deg, rgba(17, 20, 28, 0.78), rgba(8, 10, 16, 0.92)),
rgba(8, 10, 16, 0.75);
box-shadow: 0 28px 84px rgba(0,0,0,0.58);
backdrop-filter: blur(18px) saturate(1.12);
transform-origin: center;
}
.trainAction {
display: flex;
justify-content: center;
margin-top: 6px;
}
.trainAction .primary {
min-width: min(320px, 100%);
}
.trainFooter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
}
.consoleWindow.wobble {
animation: consoleWobble 560ms cubic-bezier(0.22, 0.85, 0.25, 1);
}
.consoleHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.consoleTitle {
margin: 0;
font-size: 18px;
}
.consoleHint {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
}
.consoleLog {
overflow: auto;
min-height: 0;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.08);
background:
linear-gradient(180deg, rgba(6, 9, 14, 0.9), rgba(4, 6, 10, 0.96)),
rgba(0,0,0,0.65);
padding: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
}
.consoleLine {
white-space: pre-wrap;
word-break: break-word;
color: #d8e2f1;
}
.consoleLine + .consoleLine {
margin-top: 2px;
}
.consoleMuted { color: #93a0b5; }
.consoleText { color: #d8e2f1; }
.consoleCmd { color: #89d4ff; }
.consoleWarn { color: #ffc46f; }
.consoleErr { color: #ff8f8f; }
.consoleOk { color: #6ee0af; }
.consoleSection { color: #ffd89b; font-weight: 600; letter-spacing: 0.02em; }
@keyframes consoleWobble {
0% { transform: translateY(24px) scale(0.93) rotate(-1.2deg); }
34% { transform: translateY(-6px) scale(1.02) rotate(0.85deg); }
58% { transform: translateY(2px) scale(0.99) rotate(-0.45deg); }
78% { transform: translateY(-1px) scale(1.01) rotate(0.2deg); }
100% { transform: translateY(0) scale(1) rotate(0deg); }
}
@media (max-width: 720px) {
.wrap { padding: 18px 14px 30px; }
input[type="text"] { width: 100%; }
.fileItem { align-items: flex-start; flex-direction: column; }
.consoleOverlay { padding: 12px; }
.consoleWindow {
width: 100%;
height: min(82vh, 760px);
padding: 14px;
}
.consoleHeader {
flex-direction: column;
align-items: stretch;
}
.trainFooter {
flex-direction: column;
align-items: stretch;
}
.trainAction .primary,
.trainFooter button {
width: 100%;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand">
<div class="logo"></div>
<div>
<h1>microWakeWord Personal Samples</h1>
<p>Start a session, upload your own recorded voice samples, and the app will validate or convert them into the training format used by the existing pipeline.</p>
</div>
</div>
</div>
<div class="card stack">
<div class="row">
<input id="phrase" type="text" placeholder='e.g. "tater totterson"' />
<button id="startSessionBtn" class="primary">Start session</button>
<button id="ttsBtn" disabled>Test TTS</button>
<span id="sessionPill" class="pill">No session</span>
</div>
<div class="row">
<label class="muted">Language
<select id="language">
<option value="en" selected>English (en)</option>
</select>
</label>
</div>
</div>
<div class="card stack">
<div class="row space">
<div>
<h3>Optional Personal Samples</h3>
<p>Personal samples are optional. You can train with TTS only, or upload your own audio here and it will be saved into <code>personal_samples/</code> as 16 kHz mono 16-bit PCM WAV.</p>
</div>
<span id="status" class="pill">Idle</span>
</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 already in the correct format are kept as-is.</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>
<button id="clearBtn" disabled>Clear personal samples</button>
</div>
<div class="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>
<div class="statGrid">
<div class="stat">
<span class="label">Uploaded</span>
<span class="value" id="uploadedCount">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 id="trainState" class="pill">Not started</span>
<button id="openConsoleBtn" disabled>Open console</button>
</div>
</div>
</div>
<div id="consoleOverlay" class="consoleOverlay" aria-hidden="true">
<div id="consoleWindow" class="consoleWindow" role="dialog" aria-modal="true" aria-labelledby="consoleTitle">
<div class="consoleHeader">
<div>
<h3 id="consoleTitle" class="consoleTitle">Training Console</h3>
<p 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>
<script>
const $ = (id) => document.getElementById(id);
const uiState = {
session: null,
training: null,
availableLanguages: [],
selectedFiles: [],
uploadBusy: false,
trainingPoller: null,
};
function setPill(el, text, cls) {
el.className = "pill " + (cls || "");
el.textContent = text;
}
async function api(path, opts) {
const res = await fetch(path, opts);
const ct = res.headers.get("content-type") || "";
const data = ct.includes("application/json") ? await res.json() : await res.text();
if (!res.ok) throw new Error(typeof data === "string" ? data : (data.error || JSON.stringify(data)));
return data;
}
function isNearBottom(el, px = 40) {
return (el.scrollHeight - el.scrollTop - el.clientHeight) <= px;
}
function setConsoleLogAutoScroll(el, text) {
const stick = isNearBottom(el);
el.innerHTML = renderConsoleHtml(text);
if (stick) el.scrollTop = el.scrollHeight;
}
function describeFormat(info) {
if (!info) return "unknown format";
if (!info.sample_rate) return (info.container || "unknown").toUpperCase();
const channels = info.channels === 1 ? "mono" : `${info.channels} ch`;
return `${info.sample_rate} Hz, ${channels}, ${info.sample_width_bits || "?"}-bit`;
}
function updateProgress(fraction, label, detail) {
const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
$("progressFill").style.width = `${(clamped * 100).toFixed(1)}%`;
$("progressPct").textContent = `${Math.round(clamped * 100)}%`;
$("progressLabel").textContent = label || "No upload in progress";
$("progressDetail").textContent = detail || "";
}
function escapeHtml(text) {
return String(text || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function consoleLineClass(line) {
const trimmed = String(line || "").trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return "consoleMuted";
if (trimmed.startsWith("→")) return "consoleCmd";
if (/^={3,}/.test(trimmed) || trimmed.includes("=====")) return "consoleSection";
if (trimmed.includes("✗") || lower.includes("traceback") || lower.includes("error") || lower.includes("failed") || lower.includes("crashed")) return "consoleErr";
if (trimmed.includes("✅") || trimmed.includes("✓") || lower.includes("finished") || lower.includes("success")) return "consoleOk";
if (trimmed.includes("⚠") || lower.includes("warning")) return "consoleWarn";
return "consoleText";
}
function renderConsoleHtml(text) {
const content = String(text || "(no training started)");
return content.split("\n").map((line) => {
const safe = line ? escapeHtml(line) : "&nbsp;";
return `<div class="consoleLine ${consoleLineClass(line)}">${safe}</div>`;
}).join("");
}
function openConsole(wobble = true) {
$("consoleOverlay").classList.add("open");
$("consoleOverlay").setAttribute("aria-hidden", "false");
if (wobble) {
const win = $("consoleWindow");
win.classList.remove("wobble");
void win.offsetWidth;
win.classList.add("wobble");
}
}
function closeConsole() {
$("consoleOverlay").classList.remove("open");
$("consoleOverlay").setAttribute("aria-hidden", "true");
}
function renderSelectedFiles() {
const files = uiState.selectedFiles;
$("selectedPill").textContent = files.length
? `${files.length} file${files.length === 1 ? "" : "s"} selected`
: "No files selected";
}
function 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.session?.takes_received || 0);
const hasConsole = Boolean(training.running || training.exit_code !== null || (training.log_lines || []).length);
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
$("clearBtn").disabled = sampleCount === 0 || uiState.uploadBusy;
$("openConsoleBtn").disabled = !hasConsole;
$("trainBtn").disabled = !hasSession || uiState.uploadBusy || Boolean(training.running);
$("startSessionBtn").disabled = uiState.uploadBusy;
}
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);
$("uploadedCount").textContent = String(uploaded);
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 refreshSession();
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 startTrainingWithPrompt() {
const session = await refreshSession();
const uploaded = Number(session?.takes_received || 0);
let allowNoPersonal = false;
if (uploaded === 0) {
const ok = confirm(
`No personal voice samples were uploaded 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);
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);
$("sampleFiles").addEventListener("change", () => {
uiState.selectedFiles = Array.from($("sampleFiles").files || []);
renderSelectedFiles();
syncButtons();
});
$("openConsoleBtn").addEventListener("click", () => {
openConsole(true);
});
$("closeConsoleBtn").addEventListener("click", () => {
closeConsole();
});
$("consoleOverlay").addEventListener("click", (event) => {
if (event.target === $("consoleOverlay")) {
closeConsole();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeConsole();
}
});
$("ttsBtn").addEventListener("click", () => {
const phrase = ($("phrase").value || "").trim();
if (!phrase) return;
const language = ($("language").value || "en").trim().toLowerCase();
const utterance = new SpeechSynthesisUtterance(phrase);
utterance.lang = language;
speechSynthesis.cancel();
speechSynthesis.speak(utterance);
});
$("startSessionBtn").addEventListener("click", async () => {
const phrase = ($("phrase").value || "").trim();
if (!phrase) {
alert("Enter a wake word phrase first.");
return;
}
const language = ($("language").value || "en").trim().toLowerCase();
try {
setPill($("sessionPill"), "Starting...", "warn");
const session = await api("/api/start_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phrase,
language,
}),
});
refreshSessionUI(session);
updateProgress(0, "No upload in progress", "Choose files and upload when you are ready.");
} catch (error) {
setPill($("sessionPill"), "Session failed", "err");
setPill($("status"), "Session failed", "err");
alert("Start session failed: " + error.message);
}
});
$("uploadBtn").addEventListener("click", uploadSelectedFiles);
$("clearBtn").addEventListener("click", async () => {
const count = Number(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 refreshSession();
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);
syncButtons();
setPill($("status"), "Train failed", "err");
setPill($("trainState"), "Start failed", "err");
alert("Train failed: " + error.message);
}
});
async function bootstrap() {
renderSelectedFiles();
updateProgress(0, "No upload in progress", "Choose files and upload when you are ready.");
try {
await refreshSession();
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);
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 (_) {}
}
bootstrap();
</script>
</body>
</html>