mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-06-12 20:10:19 -06:00
924 lines
30 KiB
HTML
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
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 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>
|