Release NVIDIA WakeWord Trainer v22

This commit is contained in:
MasterPhooey
2026-08-02 20:46:04 -05:00
parent 2b1320f1f3
commit 2a88090b85
37 changed files with 11685 additions and 3628 deletions

323
frontend/src/TrainerApp.vue Normal file
View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import AudioTrimModal from "./components/AudioTrimModal.vue";
import type { JsonRecord } from "./api";
import {
autoLinked, captureTone, claimTater, clearSamples, copyWakeWord, deleteManagedData, describeFormat,
disposeTrainer, ensureSupportedTtsMode, formatBytes, formatTimestamp, hasConsole, initializeTrainer,
isBusy, itemAudioUrl, negativeCount, notify, personalCount, previewPhrase, refreshAuto,
refreshCaptured, refreshManagedData, refreshSamples, refreshWakeWords, removeSample, revertSample, reviewCaptured,
runAutoAction, saveAuto, selectFiles, selectedSamples, startSession, startTraining, stopSession, sttEngines,
trainer, ttsRoute, unlinkTater, uploadSelectedFiles,
} from "./trainerStore";
import type { AudioItem, ManagedDataItem, SampleBucket, ViewName } from "./types";
const uploadInput = ref<HTMLInputElement | null>(null);
const consoleLog = ref<HTMLElement | null>(null);
const consoleFollowing = ref(true);
const linkUrl = ref("");
const linkCode = ref("");
const linkComplete = ref(false);
const mascotUrl = "/static/images/tater-wake-word-trainer.png";
const pageSize = 50;
const tabs: Array<{ id: ViewName; label: string; short: string }> = [
{ id: "trainer", label: "Trainer", short: "Train" },
{ id: "auto", label: "Auto Training", short: "Auto" },
{ id: "firmware", label: "Wake Words", short: "Words" },
{ id: "captured", label: "Captured Audio", short: "Inbox" },
{ id: "samples", label: "Samples", short: "Samples" },
{ id: "data", label: "Data", short: "Data" },
];
const pagedSamples = computed(() => {
const page = trainer.samplePage[trainer.sampleBucket];
return selectedSamples.value.slice(page * pageSize, (page + 1) * pageSize);
});
const samplePages = computed(() => Math.max(1, Math.ceil(selectedSamples.value.length / pageSize)));
const autoState = computed(() => trainer.auto.state || {});
const autoRuntime = computed(() => trainer.auto.runtime || {});
const autoAudit = computed(() => {
const state = autoState.value;
const rows: string[] = [];
if (state.last_review_result) rows.push(`Last review: ${String(state.last_review_result).replaceAll("_", " ")}`);
if (state.last_review_file) rows.push(String(state.last_review_file));
if (state.last_review_transcript) rows.push(`STT: “${state.last_review_transcript}`);
if (state.last_review_error) rows.push(`Error: ${state.last_review_error}`);
if (state.last_stt_engine) rows.push(`STT engine: ${String(state.last_stt_engine).replaceAll("_", " ")}`);
if (state.last_notify_at) rows.push(state.last_notify_error ? `Publish failed: ${state.last_notify_error}` : `Wake word published ${formatTimestamp(state.last_notify_at)}`);
return rows.join(" · ") || "No automatic review has run yet.";
});
const trainingStatus = computed(() => {
if (trainer.training.running) return { text: "Training running", tone: "warning" };
if (trainer.training.exit_code === 0) return { text: "Training finished", tone: "success" };
if (trainer.training.exit_code !== null) return { text: `Exit ${trainer.training.exit_code}`, tone: "error" };
return { text: "Not started", tone: "neutral" };
});
const autoStatus = computed(() => {
if (autoRuntime.value.review_running) return { text: `Transcribing ${autoRuntime.value.review_file || "wake"}`, tone: "warning" };
if (trainer.training.running && trainer.auto.config?.enabled) return { text: "Training running", tone: "warning" };
if (trainer.auto.config?.enabled) return { text: "Enabled", tone: "success" };
return { text: "Disabled", tone: "neutral" };
});
const consoleLines = computed(() => trainer.training.log_lines?.length ? trainer.training.log_lines : ["No training output yet."]);
const dataCategories = computed(() => {
const groups = new Map<string, ManagedDataItem[]>();
for (const item of trainer.managedData.items || []) {
const rows = groups.get(item.category) || [];
rows.push(item);
groups.set(item.category, rows);
}
return Array.from(groups, ([name, items]) => ({ name, items }));
});
watch(() => trainer.language, ensureSupportedTtsMode);
watch(() => trainer.toast.serial, () => window.setTimeout(() => { trainer.toast.message = ""; }, 4500));
watch(consoleLines, async () => {
if (!consoleFollowing.value) return;
await nextTick();
if (consoleFollowing.value && consoleLog.value) {
consoleLog.value.scrollTop = consoleLog.value.scrollHeight;
}
});
watch(() => trainer.consoleOpen, async (isOpen) => {
if (!isOpen) return;
consoleFollowing.value = true;
await nextTick();
scrollConsoleToBottom();
});
onMounted(() => {
void initializeTrainer();
document.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
disposeTrainer();
document.removeEventListener("keydown", onKeydown);
});
function onKeydown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
trainer.consoleOpen = false;
trainer.taterLinkOpen = false;
trainer.trimItem = null;
}
function onConsoleScroll(): void {
const element = consoleLog.value;
if (!element) return;
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
consoleFollowing.value = distanceFromBottom <= 32;
}
function scrollConsoleToBottom(): void {
const element = consoleLog.value;
if (!element) return;
consoleFollowing.value = true;
element.scrollTop = element.scrollHeight;
}
function changeView(view: ViewName): void {
trainer.activeView = view;
const run = view === "auto" ? refreshAuto(false)
: view === "captured" ? refreshCaptured()
: view === "samples" ? refreshSamples()
: view === "firmware" ? refreshWakeWords()
: view === "data" ? refreshManagedData()
: Promise.resolve();
void run.catch((error) => notify(error instanceof Error ? error.message : "Refresh failed.", "error"));
}
function setBucket(bucket: SampleBucket): void { trainer.sampleBucket = bucket; }
function openTrim(item: AudioItem, bucket: SampleBucket): void { trainer.trimBucket = bucket; trainer.trimItem = item; }
function openLink(): void {
linkUrl.value = trainer.autoForm.tater_url || "http://127.0.0.1:8501";
linkCode.value = "";
linkComplete.value = false;
trainer.taterLinkOpen = true;
void nextTick(() => (document.querySelector("#pairing-code") as HTMLInputElement | null)?.focus());
}
function formatLinkCode(): void {
const raw = linkCode.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
linkCode.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
}
async function submitLink(): Promise<void> {
if (!linkUrl.value.trim() || !linkCode.value.trim()) { notify("Tater address and pairing code are required.", "warning"); return; }
linkComplete.value = await claimTater(linkUrl.value, linkCode.value);
}
function metaRows(item: AudioItem): string[] {
const rows: string[] = [];
if (item.source_device) rows.push(String(item.source_device));
if (item.wake_word) rows.push(String(item.wake_word));
if (item.max_probability !== null && item.max_probability !== undefined) rows.push(`max ${item.max_probability}`);
if (item.average_probability !== null && item.average_probability !== undefined) rows.push(`avg ${item.average_probability}`);
if (item.detection_profile) rows.push(`profile ${String(item.detection_profile).replaceAll("_", " ")}`);
if (item.auto_review_status) rows.push(`auto ${String(item.auto_review_status).replaceAll("_", " ")}`);
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) rows.push(`VAD ${item.vad_max_probability}`);
return rows;
}
function sampleSubtitle(item: AudioItem): string {
const rows = [];
if (item.original_name && item.original_name !== item.saved_as) rows.push(`From ${item.original_name}`);
const timestamp = formatTimestamp(item.reviewed_at || item.received_at || item.created_at);
if (timestamp) rows.push(`Saved ${timestamp}`);
if (item.message) rows.push(String(item.message));
if (item.auto_negative) rows.push("Auto-reviewed false positive");
if (item.auto_positive) rows.push("Auto-promoted close miss");
return rows.join(" · ") || "Training sample";
}
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); }
function consoleTone(line: string): string {
const value = line.trim().toLowerCase();
if (/^(✓|✅)|success|finished/.test(value)) return "success";
if (/^(✗|❌)|error|failed|traceback/.test(value)) return "error";
if (/^(⚠|warning)/.test(value)) return "warning";
if (/^={4,}|^-----|^=====/.test(value)) return "heading";
return "";
}
</script>
<template>
<div class="app-shell">
<div class="ambient ambient-one" aria-hidden="true" /><div class="ambient ambient-two" aria-hidden="true" />
<header class="app-header">
<div class="brand"><div class="brand-mark" aria-hidden="true"><img :src="mascotUrl" alt="" /></div><div><span class="eyebrow">Tater tools</span><h1>Wake Word Studio</h1><p>Generate voices, curate real recordings, train, and publish.</p></div></div>
<div class="header-status"><span class="live-dot"><i />Local trainer</span><span v-if="trainer.session.safe_word" class="session-chip">{{ trainer.session.safe_word }} · {{ trainer.language }}</span></div>
</header>
<nav class="tabs" aria-label="Trainer areas">
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: trainer.activeView === tab.id }" @click="changeView(tab.id)"><span class="tab-full">{{ tab.label }}</span><span class="tab-short">{{ tab.short }}</span><b v-if="tab.id === 'captured' && trainer.captured.captured_count">{{ trainer.captured.captured_count }}</b></button>
</nav>
<main class="main-content">
<div v-if="!trainer.initialized" class="loading-panel"><span class="spinner" /><strong>Connecting to the local trainer</strong></div>
<template v-else>
<template v-if="trainer.activeView === 'trainer'">
<section class="hero training-hero">
<div><span class="eyebrow">Training studio</span><h2>Build a personal wake word</h2><p>Choose a multilingual voice route, check your real samples, then follow the model pipeline live.</p></div>
<div class="step-row"><span><b>1</b> Phrase</span><span><b>2</b> Samples</span><span><b>3</b> Train</span></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">1</div><div><h3>Phrase + voice</h3><p>The phrase and voice route lock while a session is active.</p></div><span class="pill" :class="trainer.session.safe_word ? 'success' : ''">{{ trainer.session.safe_word ? `Session · ${trainer.session.safe_word}` : "No session" }}</span></header>
<div class="form-grid phrase-form">
<label class="field wide"><span>Wake phrase</span><input v-model="trainer.phrase" type="text" placeholder='e.g. "hey tater"' :disabled="Boolean(trainer.session.safe_word) || isBusy('session')" @keyup.enter="startSession" /></label>
<label class="field"><span>Language</span><select v-model="trainer.language" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')"><option v-for="item in trainer.languages" :key="item.code" :value="item.code">{{ item.label }}</option></select><small>{{ ttsRoute }}</small></label>
<label class="field"><span>TTS source</span><select v-model="trainer.ttsMode" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')">
<option value="hybrid" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Four-provider ensemble · recommended</option>
<option value="modern" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.some((engine) => engine !== 'piper')">Modern only · no Piper</option>
<option value="piper" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Piper only · legacy</option>
</select><small>Models download once and stay cached.</small></label>
</div>
<div class="row form-actions"><button v-if="!trainer.session.safe_word" type="button" class="button primary" :disabled="isBusy('session') || !trainer.phrase.trim()" @click="startSession">{{ isBusy('session') ? "Starting…" : "Start session" }}</button><button v-else type="button" class="button danger" :disabled="isBusy('session')" @click="stopSession">{{ isBusy('session') ? "Stopping…" : (trainer.training.running ? "Stop session + training" : "Stop session") }}</button><button type="button" :disabled="!trainer.phrase.trim()" @click="previewPhrase">System preview</button></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Train wake word</h3><p>Personal positives and reviewed false-wake negatives are automatically included.</p></div><span class="pill" :class="trainingStatus.tone">{{ trainingStatus.text }}</span></header>
<div class="stats"><article><span>Positive samples</span><strong>{{ personalCount }}</strong></article><article><span>Negative samples</span><strong>{{ negativeCount }}</strong></article><article><span>Training format</span><strong class="format-value">16 kHz · mono · WAV</strong></article></div>
<div class="train-action"><button type="button" class="button primary large" :disabled="!trainer.session.safe_word || trainer.training.running || isBusy('training-start')" @click="startTraining">{{ trainer.training.running ? "Training in progress" : "Start training" }}</button></div>
<footer class="panel-footer"><span>Training opens the console automatically and continues if the window is closed.</span><button type="button" :disabled="!hasConsole" @click="trainer.consoleOpen = true">Open console</button></footer>
</section>
</template>
<template v-else-if="trainer.activeView === 'auto'">
<section class="hero auto-hero"><div><span class="eyebrow">False-positive loop</span><h2>Auto Training</h2><p>Transcribe captures, sort negatives, recover close misses, retrain on schedule, and publish through Tater.</p></div><span class="pill hero-pill" :class="autoStatus.tone">{{ autoStatus.text }}</span></section>
<section class="panel">
<header class="panel-head"><div class="number">1</div><div><h3>Review rules</h3><p>Conservative local STT keeps uncertain clips in the manual inbox.</p></div></header>
<div class="toggle-list">
<label><input v-model="trainer.autoForm.enabled" type="checkbox" /><span><strong>Enable Auto Training</strong><small>Queue eligible wake triggers for local transcription.</small></span></label>
<label><input v-model="trainer.autoForm.delete_confirmed_wakes" type="checkbox" /><span><strong>Delete confirmed good wakes</strong><small>Remove normal triggers when STT confirms the phrase.</small></span></label>
<label><input v-model="trainer.autoForm.promote_close_misses" type="checkbox" /><span><strong>Promote confirmed close misses</strong><small>Move verified close misses into positive samples.</small></span></label>
</div>
<div class="form-grid">
<label class="field"><span>Wake phrase</span><input v-model="trainer.autoForm.wake_phrase" type="text" /></label>
<label class="field"><span>STT language</span><input v-model="trainer.autoForm.language" type="text" /></label>
<label class="field wide"><span>STT engine</span><select v-model="trainer.autoForm.stt_engine"><option v-for="engine in sttEngines" :key="engine.id || engine.value" :value="engine.id || engine.value">{{ engine.label || engine.name || engine.id }}</option></select><small>{{ sttEngines.find((row) => (row.id || row.value) === trainer.autoForm.stt_engine)?.description || "Runs locally on this trainer." }}</small></label>
<label class="field"><span>Minimum transcript characters</span><input v-model.number="trainer.autoForm.minimum_transcript_chars" min="1" max="100" type="number" /></label>
</div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Training schedule</h3><p>A run starts only after enough newly reviewed negatives accumulate.</p></div></header>
<div class="form-grid">
<label class="field"><span>Run training</span><select v-model.number="trainer.autoForm.schedule_hours"><option :value="0">Manually only</option><option :value="6">Every 6 hours</option><option :value="12">Every 12 hours</option><option :value="24">Every day</option><option :value="48">Every 2 days</option><option :value="168">Every week</option></select></label>
<label class="field"><span>Minimum new negatives</span><input v-model.number="trainer.autoForm.minimum_new_negatives" min="1" max="10000" type="number" /></label>
</div>
<div class="stats"><article><span>Pending negatives</span><strong>{{ Number(autoState.pending_negative_count || 0) }}</strong></article><article><span>Next check</span><strong class="format-value">{{ autoState.next_run_at ? formatTimestamp(autoState.next_run_at) : "Manual" }}</strong></article><article><span>Last training</span><strong class="format-value">{{ autoState.last_train_finished_at ? formatTimestamp(autoState.last_train_finished_at) : "Never" }}</strong></article></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">3</div><div><h3>Publish to Tater</h3><p>Securely activate successful models across every connected satellite.</p></div></header>
<div class="form-grid"><label class="field wide"><span>Trainer public URL</span><input v-model="trainer.autoForm.advertised_base_url" type="text" placeholder="Auto-detect LAN address" /><small>{{ trainer.autoForm.advertised_base_url ? `Configured: ${trainer.autoForm.advertised_base_url}` : `Detected: ${trainer.auto.advertised_base_url || "unavailable"}` }}</small></label><label class="field wide"><span>Tater URL</span><input v-model="trainer.autoForm.tater_url" type="text" /></label></div>
<div class="link-row"><span class="pill" :class="autoLinked ? 'success' : 'warning'">{{ autoLinked ? `Linked${trainer.auto.trainer_link?.tater_name ? ` · ${trainer.auto.trainer_link.tater_name}` : ''}` : "Not linked" }}</span><button type="button" class="button primary" :disabled="isBusy('auto')" @click="openLink">{{ autoLinked ? "Relink Tater" : "Link Tater" }}</button><button v-if="autoLinked" type="button" class="button danger" :disabled="isBusy('auto')" @click="unlinkTater">Unlink</button></div>
<div class="toggle-list compact"><label><input v-model="trainer.autoForm.notify_satellites" type="checkbox" /><span><strong>Activate after successful training</strong><small>Tater applies the new word globally.</small></span></label></div>
</section>
<section class="panel action-panel"><div class="action-grid"><button type="button" class="button primary" :disabled="isBusy('auto')" @click="saveAuto">Save Auto Training</button><button type="button" :disabled="isBusy('auto')" @click="runAutoAction('review_now')">Review inbox now</button><button type="button" :disabled="isBusy('auto') || trainer.training.running" @click="runAutoAction('train_now')">Train now</button><button type="button" :disabled="isBusy('auto') || !autoLinked" @click="runAutoAction('notify_now')">Publish current word</button></div><p class="audit">{{ autoAudit }}</p></section>
</template>
<template v-else-if="trainer.activeView === 'captured'">
<section class="hero capture-hero"><div><span class="eyebrow">Capture review</span><h2>Captured Audio</h2><p>Listen to clips from your satellites and turn every real-world event into a better model.</p></div><span class="pill hero-pill" :class="trainer.captured.captured_count ? 'warning' : ''">{{ trainer.captured.captured_count ? `${trainer.captured.captured_count} waiting` : "Inbox idle" }}</span></section>
<section class="panel"><header class="panel-head"><div class="number">1</div><div><h3>Review queue</h3><p>Approve good phrases, keep false positives as negatives, or discard noise.</p></div><button type="button" :disabled="isBusy('captured')" @click="refreshCaptured()">{{ isBusy('captured') ? "Refreshing" : "Refresh inbox" }}</button></header><div class="stats"><article><span>Inbox</span><strong>{{ trainer.captured.captured_count }}</strong></article><article><span>Reviewed negatives</span><strong>{{ negativeCount }}</strong></article><article><span>Personal samples</span><strong>{{ personalCount }}</strong></article></div></section>
<section class="panel"><header class="panel-head"><div class="number">2</div><div><h3>Listen + sort</h3><p>Metadata remains visible so borderline detections are easy to understand.</p></div></header>
<div v-if="!trainer.captured.items?.length" class="empty-state">No captured audio yet. Clips sent by satellites will appear here.</div>
<div v-else class="audio-list"><article v-for="item in trainer.captured.items" :key="item.saved_as" class="audio-card">
<header><div><strong>{{ item.original_name || item.saved_as }}</strong><small>{{ formatTimestamp(item.captured_at || item.received_at) }} {{ item.message || "" }}</small></div><span class="pill" :class="captureTone(item).tone">{{ captureTone(item).label }}</span></header>
<div v-if="metaRows(item).length" class="meta-row"><span v-for="row in metaRows(item)" :key="row">{{ row }}</span></div>
<div v-if="item.transcript" class="transcript"><b>STT</b> {{ item.transcript }}</div><div v-if="item.auto_review_guided_transcript" class="transcript"><b>Guided wake check</b> {{ item.auto_review_guided_transcript }}</div>
<audio controls preload="none" :src="itemAudioUrl(item, 'captured')" />
<footer><span>{{ item.saved_as }} · {{ describeFormat(item.final_format) }}</span><div><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'approve_personal')">Add positive</button><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'mark_negative')">Mark negative</button><button type="button" class="button danger ghost" :disabled="isBusy('review')" @click="reviewCaptured(item, 'discard')">Discard</button></div></footer>
</article></div>
</section>
</template>
<template v-else-if="trainer.activeView === 'samples'">
<section class="hero samples-hero"><div><span class="eyebrow">Sample library</span><h2>Current Training Samples</h2><p>Audit positives and negatives, trim recordings precisely, and import seed audio.</p></div><span class="pill hero-pill">{{ personalCount + negativeCount }} total</span></section>
<section class="panel">
<header class="panel-head sample-head"><div class="number">1</div><div><h3>Saved samples</h3><p>Personal clips are positives. Negative clips are false wakes and hard negatives.</p></div><div class="segment-control"><button type="button" :class="{ active: trainer.sampleBucket === 'personal' }" @click="setBucket('personal')">Personal <b>{{ personalCount }}</b></button><button type="button" :class="{ active: trainer.sampleBucket === 'negative' }" @click="setBucket('negative')">Negative <b>{{ negativeCount }}</b></button></div></header>
<div class="row toolbar"><button type="button" :disabled="isBusy('samples')" @click="refreshSamples()">Refresh</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || personalCount === 0" @click="clearSamples('personal')">Clear positives</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || negativeCount === 0" @click="clearSamples('negative')">Clear negatives</button></div>
<div v-if="!selectedSamples.length" class="empty-state">No {{ trainer.sampleBucket }} samples saved yet.</div>
<div v-else class="audio-list compact-list"><article v-for="item in pagedSamples" :key="item.saved_as" class="audio-card">
<header><div><strong>{{ item.saved_as }}</strong><small>{{ sampleSubtitle(item) }}</small></div><div class="row"><span v-if="item.trimmed" class="pill warning">Trimmed</span><span class="pill" :class="trainer.sampleBucket === 'personal' ? 'success' : 'error'">{{ trainer.sampleBucket === "personal" ? "Positive" : "Negative" }}</span></div></header>
<audio controls preload="none" :src="itemAudioUrl(item, trainer.sampleBucket)" />
<footer><span>{{ describeFormat(item.final_format) }}</span><div><button type="button" @click="openTrim(item, trainer.sampleBucket)">Trim</button><button v-if="item.trimmed" type="button" @click="revertSample(item, trainer.sampleBucket)">Revert</button><button type="button" class="button danger ghost" :disabled="isBusy('review')" @click="removeSample(item, trainer.sampleBucket)">Remove</button></div></footer>
</article></div>
<div v-if="samplePages > 1" class="pagination"><button type="button" :disabled="trainer.samplePage[trainer.sampleBucket] === 0" @click="trainer.samplePage[trainer.sampleBucket]--">Previous</button><span>Page {{ trainer.samplePage[trainer.sampleBucket] + 1 }} of {{ samplePages }}</span><button type="button" :disabled="trainer.samplePage[trainer.sampleBucket] >= samplePages - 1" @click="trainer.samplePage[trainer.sampleBucket]++">Next</button></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Manual sample import</h3><p>Optional seed recordings are normalized to the trainers required WAV format.</p></div></header>
<label class="dropzone"><input ref="uploadInput" type="file" multiple accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus" @change="selectFiles" /><span><strong>Choose one or many audio files</strong><small>WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM</small></span><b>{{ trainer.selectedFiles.length ? `${trainer.selectedFiles.length} selected` : "Browse" }}</b></label>
<button type="button" class="button primary" :disabled="!trainer.session.safe_word || !trainer.selectedFiles.length || isBusy('upload')" @click="uploadSelectedFiles(uploadInput)">{{ isBusy('upload') ? "Uploading" : "Upload selected samples" }}</button>
<div class="progress-card"><div><strong>{{ trainer.uploadLabel }}</strong><span>{{ trainer.uploadProgress }}%</span></div><div class="progress-track"><i :style="{ width: `${trainer.uploadProgress}%` }" /></div><small>{{ trainer.uploadDetail }}</small></div>
</section>
</template>
<template v-else-if="trainer.activeView === 'data'">
<section class="hero data-hero"><div><span class="eyebrow">Local storage</span><h2>Data Management</h2><p>See exactly what the trainer has downloaded, generated, recorded, and produced.</p></div><span class="pill hero-pill">{{ formatBytes(trainer.managedData.total_size_bytes) }} total</span></section>
<section class="panel">
<header class="panel-head"><div class="number">i</div><div><h3>Trainer storage</h3><p>Deleting an item is permanent. Required downloads and generated caches will be rebuilt the next time training needs them.</p></div><button type="button" :disabled="isBusy('data') || isBusy('data-delete')" @click="refreshManagedData()">{{ isBusy('data') ? "Scanning" : "Refresh sizes" }}</button></header>
<div class="stats"><article><span>Space used</span><strong class="format-value">{{ formatBytes(trainer.managedData.total_size_bytes) }}</strong></article><article><span>Files</span><strong>{{ Number(trainer.managedData.total_file_count || 0).toLocaleString() }}</strong></article><article><span>Individual items</span><strong>{{ trainer.managedData.items.length }}</strong></article></div>
<p v-if="trainer.training.running" class="data-warning">Stop the active training session before deleting data.</p>
</section>
<section v-for="(group, groupIndex) in dataCategories" :key="group.name" class="panel data-panel">
<header class="panel-head"><div class="number">{{ groupIndex + 1 }}</div><div><h3>{{ group.name }}</h3><p>{{ group.items.length }} separately managed item{{ group.items.length === 1 ? "" : "s" }}</p></div></header>
<div class="data-list"><article v-for="item in group.items" :key="item.id" class="data-row" :class="{ empty: !item.file_count }">
<div class="data-copy"><div class="data-title"><strong>{{ item.label }}</strong><code>{{ item.location }}</code></div><small>{{ item.description }}</small><span v-if="item.rebuild_note" class="data-note">{{ item.rebuild_note }}</span></div>
<div class="data-usage"><strong>{{ formatBytes(item.size_bytes) }}</strong><span>{{ Number(item.file_count || 0).toLocaleString() }} file{{ item.file_count === 1 ? "" : "s" }}</span></div>
<button type="button" class="button danger ghost" :disabled="!item.file_count || trainer.training.running || isBusy('data') || isBusy('data-delete')" @click="deleteManagedData(item)">{{ isBusy('data-delete') ? "Please wait" : "Delete" }}</button>
</article></div>
</section>
<section v-if="!isBusy('data') && !trainer.managedData.items.length" class="panel empty-state">No managed trainer data was found.</section>
</template>
<template v-else-if="trainer.activeView === 'firmware'">
<section class="hero firmware-hero"><div><span class="eyebrow">Wake-word catalog</span><h2>Trained Wake Words</h2><p>Copy a local JSON package URL into Tater to switch every native satellite live.</p></div><span class="pill hero-pill" :class="trainer.wakeWords.length ? 'success' : 'warning'">{{ trainer.wakeWords.length ? `${trainer.wakeWords.length} trained` : "Catalog empty" }}</span></section>
<div class="native-notice"><strong>Tater Native</strong><span>These packages include model metadata and a direct model URL for live satellite updates.</span></div>
<section class="panel"><header class="panel-head"><div class="number">v1</div><div><h3>Published model URLs</h3><p>URLs stay local and are refreshed after each successful run.</p></div><button type="button" :disabled="isBusy('firmware')" @click="refreshWakeWords()">Refresh</button></header>
<div v-if="!trainer.wakeWords.length" class="empty-state">Train a wake word and its package will appear here.</div>
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="word.key || wordJsonUrl(word)"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordJsonUrl(word)" :href="wordJsonUrl(word)" target="_blank" rel="noreferrer">JSON · {{ wordJsonUrl(word) }}</a><span v-else class="muted">JSON package URL unavailable</span><a v-if="wordModelUrl(word)" :href="wordModelUrl(word)" target="_blank" rel="noreferrer">Model · {{ wordModelUrl(word) }}</a><div class="meta-row"><span v-if="word.language">{{ word.language }}</span><span v-if="word.trained_at">{{ formatTimestamp(word.trained_at) }}</span><span v-if="word.recall !== undefined">recall {{ word.recall }}</span></div></div><button type="button" :disabled="!wordJsonUrl(word)" @click="copyWakeWord(wordJsonUrl(word))">Copy URL</button></article></div>
</section>
</template>
</template>
</main>
<Teleport to="body">
<div v-if="trainer.consoleOpen" class="modal-backdrop console-backdrop" @click.self="trainer.consoleOpen = false">
<section class="modal console-modal" role="dialog" aria-modal="true" aria-label="Training console"><header class="modal-head"><div><span class="eyebrow">Live pipeline</span><h2>Training Console</h2><p>Closing this window does not interrupt training.</p></div><div class="row console-actions"><button v-if="!consoleFollowing" type="button" class="console-follow" @click="scrollConsoleToBottom">Jump to latest</button><span class="pill" :class="trainingStatus.tone">{{ trainingStatus.text }}</span><button type="button" @click="trainer.consoleOpen = false">Close</button></div></header><pre ref="consoleLog" class="console-log" @scroll.passive="onConsoleScroll"><span v-for="(line, index) in consoleLines" :key="`${index}-${line}`" :class="consoleTone(line)">{{ line }}</span></pre></section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="trainer.taterLinkOpen" class="modal-backdrop" @click.self="trainer.taterLinkOpen = false">
<section class="modal link-modal" role="dialog" aria-modal="true" aria-label="Link Tater"><header class="modal-head"><div><span class="eyebrow">Secure pairing</span><h2>{{ linkComplete ? "Tater linked" : "Link Tater" }}</h2><p>{{ linkComplete ? "This trainer can securely publish wake-word updates." : "Enter the short-lived code shown in Tater Voice Settings." }}</p></div><button type="button" @click="trainer.taterLinkOpen = false">Close</button></header>
<div v-if="linkComplete" class="link-success"><i>✓</i><strong>Successfully linked{{ trainer.auto.trainer_link?.tater_name ? ` to ${trainer.auto.trainer_link.tater_name}` : "" }}</strong><span>The private link key is stored locally and is never displayed.</span></div>
<div v-else class="stack"><label class="field"><span>Tater address</span><input v-model="linkUrl" type="text" /></label><label class="field"><span>Tater pairing code</span><input id="pairing-code" v-model="linkCode" class="pairing-code" maxlength="9" placeholder="ABCD-EFGH" autocomplete="off" @input="formatLinkCode" /></label><small>In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.</small><button type="button" class="button primary" :disabled="isBusy('link')" @click="submitLink">{{ isBusy('link') ? "Linking securely…" : "Link Tater" }}</button></div>
</section>
</div>
</Teleport>
<AudioTrimModal />
<Transition name="toast"><div v-if="trainer.toast.message" class="toast" :class="trainer.toast.tone" role="status">{{ trainer.toast.message }}</div></Transition>
</div>
</template>

43
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,43 @@
export type JsonRecord = Record<string, any>;
export async function request<T = JsonRecord>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
credentials: "same-origin",
...options,
headers: {
Accept: "application/json",
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
const message = typeof body === "object" && body
? body.error || body.detail || body.message
: body;
throw new Error(String(message || `Request failed (${response.status})`));
}
return body as T;
}
export function getJson<T = JsonRecord>(path: string): Promise<T> {
return request<T>(path);
}
export function postJson<T = JsonRecord>(path: string, body: unknown = {}): Promise<T> {
return request<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export function putJson<T = JsonRecord>(path: string, body: unknown): Promise<T> {
return request<T>(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
import { request, type JsonRecord } from "../api";
import { notify, refreshSamples, trainer } from "../trainerStore";
const canvas = ref<HTMLCanvasElement | null>(null);
const audioBuffer = ref<AudioBuffer | null>(null);
const duration = ref(0);
const start = ref(0);
const end = ref(0);
const vadSegments = ref<Array<{ start: number; end: number }>>([]);
const loading = ref(false);
const saving = ref(false);
watch(() => trainer.trimItem, async (item) => {
if (!item) {
audioBuffer.value = null;
return;
}
loading.value = true;
try {
const url = `/api/audio/${encodeURIComponent(trainer.trimBucket)}/${encodeURIComponent(item.saved_as)}`;
const response = await fetch(url);
if (!response.ok) throw new Error("Audio could not be loaded.");
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
const context = new AudioContextCtor() as AudioContext;
audioBuffer.value = await context.decodeAudioData(await response.arrayBuffer());
duration.value = audioBuffer.value.duration;
start.value = 0;
end.value = duration.value;
await context.close();
try {
const vad = await request<JsonRecord>(`/api/samples/${encodeURIComponent(trainer.trimBucket)}/${encodeURIComponent(item.saved_as)}/vad`, { method: "POST" });
vadSegments.value = Array.isArray(vad.segments) ? vad.segments : [];
if (vadSegments.value.length) {
start.value = Math.max(0, Number(vadSegments.value[0].start || 0));
end.value = Math.min(duration.value, Number(vadSegments.value[0].end || duration.value));
}
} catch {
vadSegments.value = [];
}
await nextTick();
draw();
} catch (error) {
notify(error instanceof Error ? error.message : "Audio could not be loaded.", "error");
close();
} finally {
loading.value = false;
}
}, { immediate: true });
watch([start, end], () => draw());
function close(): void {
trainer.trimItem = null;
audioBuffer.value = null;
vadSegments.value = [];
}
function selectFirstVad(): void {
const segment = vadSegments.value[0];
if (!segment) return;
start.value = Number(segment.start);
end.value = Number(segment.end);
}
function draw(): void {
const target = canvas.value;
const buffer = audioBuffer.value;
if (!target || !buffer || !duration.value) return;
const rect = target.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const dpr = window.devicePixelRatio || 1;
target.width = Math.round(rect.width * dpr);
target.height = Math.round(rect.height * dpr);
const context = target.getContext("2d");
if (!context) return;
context.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
const middle = height / 2;
const samples = buffer.getChannelData(0);
const step = Math.max(1, Math.floor(samples.length / width));
context.clearRect(0, 0, width, height);
context.strokeStyle = "rgba(222, 218, 212, .24)";
context.lineWidth = 1;
context.beginPath();
for (let x = 0; x < width; x += 1) {
let minimum = 1;
let maximum = -1;
for (let offset = 0; offset < step; offset += 1) {
const value = samples[Math.floor(x) * step + offset] || 0;
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
}
context.moveTo(x, middle + minimum * middle * 0.84);
context.lineTo(x, middle + maximum * middle * 0.84);
}
context.stroke();
const from = (start.value / duration.value) * width;
const to = (end.value / duration.value) * width;
context.fillStyle = "rgba(8, 8, 9, .66)";
context.fillRect(0, 0, from, height);
context.fillRect(to, 0, width - to, height);
context.fillStyle = "rgba(255, 145, 52, .12)";
context.fillRect(from, 0, to - from, height);
context.strokeStyle = "#ff9134";
context.lineWidth = 2;
for (const x of [from, to]) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
context.strokeStyle = "rgba(68, 225, 165, .55)";
for (const segment of vadSegments.value) {
const x = (segment.start / duration.value) * width;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
}
function playSelection(): void {
const buffer = audioBuffer.value;
if (!buffer) return;
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
const context = new AudioContextCtor() as AudioContext;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
source.start(0, start.value, Math.max(0.01, end.value - start.value));
source.onended = () => void context.close();
}
async function wavBlob(): Promise<Blob> {
const buffer = audioBuffer.value;
if (!buffer) throw new Error("Audio is not loaded.");
const startSample = Math.floor(start.value * buffer.sampleRate);
const endSample = Math.min(Math.floor(end.value * buffer.sampleRate), buffer.length);
const targetRate = 16000;
let pcm: Float32Array;
if (buffer.sampleRate === targetRate) {
pcm = buffer.getChannelData(0).slice(startSample, endSample);
} else {
const frames = Math.max(1, Math.floor((endSample - startSample) * targetRate / buffer.sampleRate));
const offline = new OfflineAudioContext(1, frames, targetRate);
const source = offline.createBufferSource();
source.buffer = buffer;
source.connect(offline.destination);
source.start(0, start.value, end.value - start.value);
pcm = (await offline.startRendering()).getChannelData(0);
}
const output = new ArrayBuffer(44 + pcm.length * 2);
const view = new DataView(output);
view.setUint32(0, 0x52494646, false);
view.setUint32(4, 36 + pcm.length * 2, 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, pcm.length * 2, true);
for (let index = 0; index < pcm.length; index += 1) {
view.setInt16(44 + index * 2, Math.max(-32768, Math.min(32767, Math.round(pcm[index] * 32767))), true);
}
return new Blob([output], { type: "audio/wav" });
}
async function save(): Promise<void> {
const item = trainer.trimItem;
if (!item) return;
saving.value = true;
try {
const form = new FormData();
form.append("file", await wavBlob(), "trimmed.wav");
form.append("bucket", trainer.trimBucket);
form.append("source_file", item.saved_as);
form.append("start_time", start.value.toFixed(3));
form.append("end_time", end.value.toFixed(3));
const result = await request<JsonRecord>("/api/samples/trim", { method: "POST", body: form });
close();
await refreshSamples(true);
notify(result.message || "Trimmed sample saved.");
} catch (error) {
notify(error instanceof Error ? error.message : "Trim failed.", "error");
} finally {
saving.value = false;
}
}
function redraw(): void {
if (trainer.trimItem) draw();
}
window.addEventListener("resize", redraw);
onBeforeUnmount(() => window.removeEventListener("resize", redraw));
</script>
<template>
<Teleport to="body">
<div v-if="trainer.trimItem" class="modal-backdrop" @click.self="close">
<section class="modal trim-modal" role="dialog" aria-modal="true" aria-label="Trim audio">
<header class="modal-head">
<div><span class="eyebrow">Audio editor</span><h2>Trim {{ trainer.trimItem.saved_as }}</h2></div>
<button type="button" class="button ghost" @click="close">Close</button>
</header>
<p class="muted">Keep the spoken wake phrase and remove excess silence or noise. VAD markers appear in green.</p>
<div v-if="loading" class="empty-state">Loading waveform</div>
<template v-else>
<canvas ref="canvas" class="waveform" />
<div class="range-grid">
<label><span>Start · {{ start.toFixed(2) }}s</span><input v-model.number="start" type="range" min="0" :max="Math.max(0, end - .01)" step=".01" /></label>
<label><span>End · {{ end.toFixed(2) }}s</span><input v-model.number="end" type="range" :min="Math.min(duration, start + .01)" :max="duration" step=".01" /></label>
</div>
<div class="row space">
<span class="pill">Selection {{ Math.max(0, end - start).toFixed(2) }}s</span>
<span v-if="vadSegments.length" class="pill success">{{ vadSegments.length }} speech segment{{ vadSegments.length === 1 ? "" : "s" }}</span>
</div>
<div class="row modal-actions">
<button type="button" @click="playSelection">Play selection</button>
<button v-if="vadSegments.length" type="button" @click="selectFirstVad">Select first VAD</button>
<button type="button" class="button primary" :disabled="saving" @click="save">{{ saving ? "Saving" : "Save trim" }}</button>
</div>
</template>
</section>
</div>
</Teleport>
</template>

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import TrainerApp from "./TrainerApp.vue";
import "./trainer.css";
const root = document.getElementById("trainer-app");
if (!root) {
throw new Error("Missing #trainer-app mount point");
}
createApp(TrainerApp).mount(root);

217
frontend/src/trainer.css Normal file
View File

@@ -0,0 +1,217 @@
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3f1ee;
background: #0d0d0e;
font-synthesis: none;
--bg: #0d0d0e;
--surface: rgba(29, 29, 31, .9);
--surface-solid: #1c1c1e;
--surface-2: rgba(43, 43, 46, .8);
--line: rgba(255, 255, 255, .1);
--line-strong: rgba(255, 255, 255, .18);
--text: #f3f1ee;
--muted: #aaa6a0;
--orange: #ff9134;
--orange-2: #ffb267;
--violet: #77736e;
--blue: #a8a5a1;
--green: #44dda5;
--red: #ff6c7d;
--yellow: #ffc561;
--shadow: 0 24px 70px rgba(0, 0, 0, .32);
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 78% -10%, rgba(255, 145, 52, .08), transparent 34%), linear-gradient(145deg, #121213, #0d0d0e 60%, #151413); }
button, input, select { font: inherit; }
button, .button {
min-height: 42px; padding: 9px 16px; border: 1px solid var(--line-strong); border-radius: 12px;
color: var(--text); background: rgba(48, 48, 51, .86); font-weight: 700; cursor: pointer;
transition: border-color .18s ease, transform .18s ease, background .18s ease, box-shadow .18s ease;
}
button:hover:not(:disabled), .button:hover:not(:disabled) { transform: translateY(-1px); border-color: rgba(255, 145, 52, .55); background: rgba(62, 61, 61, .94); }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid rgba(255, 145, 52, .88); outline-offset: 2px; }
button:disabled { opacity: .43; cursor: not-allowed; }
.button.primary { color: #18100a; border-color: #ffad63; background: linear-gradient(135deg, var(--orange), #ffb45f); box-shadow: 0 10px 28px rgba(255, 126, 35, .19); }
.button.primary:hover:not(:disabled) { background: linear-gradient(135deg, #ffa04c, #ffc078); }
.button.danger { border-color: rgba(255, 108, 125, .54); color: #fff; background: rgba(255, 78, 101, .2); }
.button.ghost { background: transparent; }
.button.large { min-width: min(100%, 360px); min-height: 54px; font-size: 16px; }
.app-shell { position: relative; width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 30px 0 80px; }
.ambient { position: fixed; z-index: -1; width: 380px; height: 380px; border-radius: 50%; filter: blur(95px); opacity: .16; pointer-events: none; }
.ambient-one { top: -160px; right: 4vw; background: var(--violet); }
.ambient-two { bottom: -180px; left: -70px; background: var(--orange); }
.app-header { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 24px; }
.brand { display: flex; align-items: center; gap: 16px; }
.brand-mark { position: relative; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; width: 58px; height: 58px; border: 1px solid rgba(255, 164, 82, .4); border-radius: 19px; background: radial-gradient(circle at 50% 36%, #383330, #191819 72%); box-shadow: inset 0 1px rgba(255,255,255,.12), 0 14px 36px rgba(0,0,0,.25); }
.brand-mark img { display: block; width: 56px; height: 56px; object-fit: contain; filter: drop-shadow(0 5px 9px rgba(0, 0, 0, .36)); }
.brand h1, .hero h2, .panel h3, .modal h2 { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; }
.brand h1 { margin: 2px 0 1px; font-size: clamp(22px, 3vw, 31px); letter-spacing: -.035em; }
.brand p, .hero p, .panel p, .modal p { margin: 0; color: var(--muted); line-height: 1.55; }
.brand p { font-size: 13px; }
.eyebrow { color: var(--orange-2); font-size: 10px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
.header-status { display: flex; align-items: center; gap: 10px; }
.live-dot, .session-chip { display: inline-flex; align-items: center; min-height: 34px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(24, 24, 25, .78); font-size: 12px; font-weight: 700; }
.live-dot i { width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(68,221,165,.1); }
.tabs { position: sticky; top: 12px; z-index: 20; display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; padding: 6px; margin-bottom: 18px; border: 1px solid var(--line); border-radius: 16px; background: rgba(20, 20, 21, .9); box-shadow: 0 14px 36px rgba(0,0,0,.2); backdrop-filter: blur(18px); }
.tabs button { position: relative; min-height: 42px; padding: 8px; border-color: transparent; color: var(--muted); background: transparent; font-size: 13px; }
.tabs button.active { color: #fff; border-color: rgba(255, 152, 65, .42); background: linear-gradient(135deg, rgba(255,145,52,.22), rgba(92,89,86,.22)); box-shadow: inset 0 1px rgba(255,255,255,.05); }
.tabs button b { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 7px; padding: 0 4px; border-radius: 99px; color: #23120b; background: var(--orange); font-size: 10px; }
.tab-short { display: none; }
.main-content { display: grid; gap: 16px; }
.hero, .panel, .native-notice { border: 1px solid var(--line); border-radius: 22px; background: var(--surface); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 30px; min-height: 210px; padding: 34px; }
.hero::after { content: ""; position: absolute; right: -45px; bottom: -95px; width: 290px; height: 290px; border-radius: 50%; background: radial-gradient(circle, rgba(255,145,52,.22), transparent 67%); }
.auto-hero::after { background: radial-gradient(circle, rgba(255,145,52,.16), transparent 67%); }
.capture-hero::after { background: radial-gradient(circle, rgba(190,184,177,.12), transparent 67%); }
.firmware-hero::after { background: radial-gradient(circle, rgba(255,145,52,.13), transparent 67%); }
.hero > * { position: relative; z-index: 1; }
.hero h2 { max-width: 760px; margin: 8px 0; font-size: clamp(27px, 5vw, 48px); line-height: 1.02; letter-spacing: -.05em; }
.hero p { max-width: 720px; font-size: 15px; }
.hero-pill { flex: 0 0 auto; }
.step-row { display: grid; gap: 7px; min-width: 165px; }
.step-row span { display: flex; align-items: center; gap: 8px; color: #d5d1cc; font-size: 12px; font-weight: 700; }
.step-row b, .number { display: inline-grid; place-items: center; flex: 0 0 auto; width: 32px; height: 32px; border-radius: 11px; color: #26150b; background: linear-gradient(135deg, var(--orange), #ffc175); font-size: 12px; }
.panel { padding: 26px; }
.panel-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 23px; }
.panel-head h3 { margin: 0 0 3px; font-size: 20px; letter-spacing: -.025em; }
.panel-head p { font-size: 13px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.phrase-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: grid; align-content: start; gap: 7px; }
.field > span { color: #ddd9d4; font-size: 12px; font-weight: 800; letter-spacing: .01em; }
.field.wide { grid-column: 1 / -1; }
.field input, .field select { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text); background: rgba(15, 15, 16, .82); }
.field select { appearance: auto; }
.field input:disabled, .field select:disabled { opacity: 1; cursor: not-allowed; color: #aaa7a3; border-color: rgba(151, 147, 142, .22); background: rgba(70, 69, 68, .72); -webkit-text-fill-color: #aaa7a3; }
.field small, .dropzone small, .progress-card small, .stack > small { color: var(--muted); font-size: 11px; line-height: 1.45; }
.row { display: flex; align-items: center; gap: 9px; }
.row.space { justify-content: space-between; }
.form-actions { margin-top: 16px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.stats article { display: grid; gap: 5px; min-height: 105px; padding: 17px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .62); }
.stats span { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.stats strong { align-self: end; font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 30px; }
.stats .format-value { font-size: 15px; line-height: 1.35; }
.train-action { display: grid; place-items: center; padding: 29px 0 19px; }
.panel-footer { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding-top: 17px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.pill { display: inline-flex; align-items: center; width: fit-content; min-height: 29px; padding: 5px 10px; border: 1px solid var(--line-strong); border-radius: 999px; color: #d1cdc8; background: rgba(48, 47, 47, .74); font-size: 11px; font-weight: 800; white-space: nowrap; }
.pill.success { color: #8bf2cc; border-color: rgba(68,221,165,.35); background: rgba(36, 160, 118, .13); }
.pill.warning { color: #ffd58a; border-color: rgba(255,197,97,.36); background: rgba(214, 146, 36, .13); }
.pill.error { color: #ffabb5; border-color: rgba(255,108,125,.36); background: rgba(220, 68, 88, .13); }
.toggle-list { display: grid; gap: 9px; margin-bottom: 18px; }
.toggle-list.compact { margin: 15px 0 0; }
.toggle-list label { display: flex; align-items: flex-start; gap: 12px; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18, 18, 19, .56); cursor: pointer; }
.toggle-list input { width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--orange); }
.toggle-list label > span { display: grid; gap: 3px; }
.toggle-list small { color: var(--muted); line-height: 1.45; }
.link-row { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
.action-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; }
.audit, .transcript { padding: 13px; border: 1px solid rgba(255,145,52,.22); border-radius: 13px; color: #d2cec9; background: rgba(255, 145, 52, .055); font-size: 12px; line-height: 1.55; }
.action-panel .audit { margin-top: 15px; }
.audio-list, .word-list { display: grid; gap: 12px; }
.audio-card { display: grid; gap: 13px; padding: 17px; border: 1px solid var(--line); border-radius: 17px; background: rgba(18, 18, 19, .64); }
.audio-card header, .audio-card footer { display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; }
.audio-card header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.audio-card header strong { overflow-wrap: anywhere; }
.audio-card small, .audio-card footer > span { color: var(--muted); font-size: 11px; line-height: 1.5; }
.audio-card audio { width: 100%; height: 42px; }
.audio-card footer { align-items: center; }
.audio-card footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.audio-card footer button { min-height: 36px; padding: 6px 11px; font-size: 11px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 6px; }
.meta-row span { padding: 4px 8px; border: 1px solid var(--line); border-radius: 99px; color: #bdb8b2; background: rgba(50,49,49,.68); font-size: 10px; }
.empty-state { display: grid; place-items: center; min-height: 130px; padding: 24px; border: 1px dashed var(--line-strong); border-radius: 15px; color: var(--muted); text-align: center; }
.toolbar { flex-wrap: wrap; margin-bottom: 14px; }
.segment-control { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: rgba(16,16,17,.68); }
.segment-control button { min-height: 34px; padding: 5px 9px; border-color: transparent; background: transparent; font-size: 11px; }
.segment-control button.active { border-color: rgba(255,145,52,.28); background: rgba(255,145,52,.14); }
.segment-control b { margin-left: 4px; color: var(--orange-2); }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; color: var(--muted); font-size: 12px; }
.dropzone { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 18px; min-height: 100px; margin-bottom: 14px; padding: 19px; border: 1px dashed rgba(255,145,52,.45); border-radius: 16px; background: rgba(255,145,52,.05); cursor: pointer; }
.dropzone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.dropzone span { display: grid; gap: 5px; }
.dropzone > b { padding: 8px 12px; border-radius: 10px; background: rgba(255,145,52,.15); color: var(--orange-2); font-size: 12px; white-space: nowrap; }
.progress-card { display: grid; gap: 9px; margin-top: 15px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18,18,19,.64); }
.progress-card > div:first-child { display: flex; justify-content: space-between; gap: 10px; }
.progress-card span { color: var(--orange-2); font-size: 12px; }
.progress-track { overflow: hidden; height: 7px; border-radius: 99px; background: rgba(255,255,255,.07); }
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--orange), var(--violet)); transition: width .2s ease; }
.native-notice { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; }
.native-notice strong { color: var(--green); }
.word-list article { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18,18,19,.64); }
.word-list article > div { display: grid; min-width: 0; gap: 6px; }
.word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; }
.data-hero::after { background: radial-gradient(circle, rgba(176, 171, 164, .15), transparent 67%); }
.data-panel { padding-bottom: 18px; }
.data-list { display: grid; gap: 9px; }
.data-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 18px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .64); }
.data-row.empty { background: rgba(18, 18, 19, .34); }
.data-copy { display: grid; min-width: 0; gap: 6px; }
.data-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.data-title strong { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 15px; }
.data-title code { overflow-wrap: anywhere; padding: 3px 7px; border: 1px solid var(--line); border-radius: 7px; color: #aaa6a0; background: rgba(55, 54, 53, .55); font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
.data-copy small, .data-note, .data-usage span { color: var(--muted); font-size: 11px; line-height: 1.45; }
.data-note { color: #c7a57d; }
.data-usage { display: grid; min-width: 105px; gap: 4px; text-align: right; }
.data-usage strong { color: var(--orange-2); font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 16px; }
.data-row.empty .data-usage strong { color: #8c8883; }
.data-row > button { min-width: 82px; }
.data-warning { margin-top: 14px !important; padding: 11px 13px; border: 1px solid rgba(255, 197, 97, .3); border-radius: 12px; color: #ffd58a !important; background: rgba(214, 146, 36, .09); font-size: 12px; }
.loading-panel { display: flex; justify-content: center; align-items: center; gap: 12px; min-height: 400px; color: var(--muted); }
.spinner { width: 22px; height: 22px; border: 2px solid rgba(255,255,255,.14); border-top-color: var(--orange); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(5, 5, 6, .8); backdrop-filter: blur(12px); }
.modal { overflow: auto; width: min(680px, 100%); max-height: calc(100vh - 40px); padding: 23px; border: 1px solid var(--line-strong); border-radius: 21px; background: #1c1c1e; box-shadow: 0 36px 100px rgba(0,0,0,.55); }
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 18px; }
.modal-head h2 { margin: 4px 0; font-size: 24px; }
.console-modal { width: min(980px, 100%); }
.console-actions { flex-wrap: wrap; justify-content: flex-end; }
.console-follow { min-height: 34px; padding: 6px 11px; border-color: rgba(255,145,52,.42); color: var(--orange-2); background: rgba(255,145,52,.12); font-size: 11px; }
.console-log { overflow: auto; display: block; min-height: 430px; max-height: calc(100vh - 190px); margin: 0; padding: 17px; border: 1px solid rgba(255,145,52,.18); border-radius: 14px; color: #cbc6c0; background: #0b0b0c; font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
.console-log span { display: block; min-height: 1.65em; }
.console-log .success { color: #73e4b9; }.console-log .error { color: #ff8290; }.console-log .warning { color: #ffd079; }.console-log .heading { color: var(--orange-2); font-weight: 700; }
.stack { display: grid; gap: 14px; }
.pairing-code { text-align: center; font: 700 28px/1 ui-rounded, "SF Pro Rounded", system-ui, sans-serif; letter-spacing: .14em; text-transform: uppercase; }
.link-success { display: grid; place-items: center; gap: 11px; padding: 30px; text-align: center; }
.link-success i { display: grid; place-items: center; width: 54px; height: 54px; border: 1px solid rgba(68,221,165,.4); border-radius: 50%; color: var(--green); background: rgba(68,221,165,.12); font-size: 25px; font-style: normal; }
.link-success span { color: var(--muted); font-size: 12px; }
.trim-modal { width: min(820px, 100%); }
.waveform { width: 100%; height: 210px; margin: 16px 0; border: 1px solid var(--line); border-radius: 14px; background: #0d0d0e; }
.range-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-bottom: 13px; }
.range-grid label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }
.range-grid input { width: 100%; accent-color: var(--orange); }
.modal-actions { justify-content: flex-end; margin-top: 14px; }
.muted { color: var(--muted); }
.toast { position: fixed; z-index: 200; right: 22px; bottom: 22px; max-width: min(420px, calc(100% - 44px)); padding: 13px 16px; border: 1px solid rgba(68,221,165,.38); border-radius: 13px; color: #eafff7; background: rgba(20, 72, 56, .95); box-shadow: 0 18px 45px rgba(0,0,0,.4); font-size: 13px; font-weight: 700; }
.toast.warning { border-color: rgba(255,197,97,.45); background: rgba(93, 65, 22, .97); }.toast.error { border-color: rgba(255,108,125,.45); background: rgba(94, 31, 43, .97); }
.toast-enter-active, .toast-leave-active { transition: opacity .2s ease, transform .2s ease; }.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 920px) { .tab-full { display: none; }.tab-short { display: inline; } }
@media (max-width: 780px) {
.app-shell { width: min(100% - 22px, 1180px); padding-top: 17px; }
.app-header { align-items: flex-start; }.header-status { display: none; }
.tabs { top: 7px; }.tab-full { display: none; }.tab-short { display: inline; }
.hero { align-items: flex-start; min-height: unset; padding: 24px; }.step-row { display: none; }
.panel { padding: 19px; }.panel-head { grid-template-columns: auto minmax(0, 1fr); }.panel-head > :last-child:not(:nth-child(2)) { grid-column: 1 / -1; }
.form-grid, .phrase-form, .stats, .action-grid, .range-grid { grid-template-columns: 1fr; }.field.wide { grid-column: auto; }
.audio-card header, .audio-card footer, .word-list article, .panel-footer { flex-direction: column; align-items: stretch; }
.audio-card footer > div { justify-content: flex-start; }.word-list article > button { width: 100%; }
.sample-head .segment-control { grid-column: 1 / -1; }.segment-control button { flex: 1; }
.data-row { grid-template-columns: 1fr auto; }.data-copy { grid-column: 1 / -1; }.data-usage { text-align: left; }.data-row > button { min-width: 96px; }
.modal-backdrop { padding: 8px; }.modal { max-height: calc(100vh - 16px); padding: 17px; }.modal-head { flex-direction: column; }.console-actions { justify-content: flex-start; }.console-log { min-height: 55vh; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } }

View File

@@ -0,0 +1,580 @@
import { computed, reactive } from "vue";
import { getJson, postJson, putJson, request, type JsonRecord } from "./api";
import type {
AudioItem,
AutoTrainForm,
AutoTrainPayload,
CapturedPayload,
LanguageOption,
ManagedDataItem,
ManagedDataPayload,
SampleBucket,
SamplesPayload,
SessionPayload,
ToastState,
TrainingState,
ViewName,
WakeWordItem,
} from "./types";
const emptyTraining = (): TrainingState => ({ running: false, exit_code: null, log_lines: [] });
const emptySamples = (): SamplesPayload => ({ personal: [], negative: [], personal_count: 0, negative_count: 0 });
const emptyCaptured = (): CapturedPayload => ({ items: [], captured_count: 0, personal_count: 0, negative_count: 0 });
const emptyManagedData = (): ManagedDataPayload => ({ items: [], total_size_bytes: 0, total_file_count: 0 });
const defaultAutoForm = (): AutoTrainForm => ({
enabled: false,
wake_phrase: "",
language: "en",
stt_engine: "faster_whisper",
minimum_transcript_chars: 2,
delete_confirmed_wakes: false,
promote_close_misses: false,
schedule_hours: 24,
minimum_new_negatives: 3,
advertised_base_url: "",
tater_url: "http://127.0.0.1:8501",
notify_satellites: true,
});
export const trainer = reactive({
activeView: "trainer" as ViewName,
initialized: false,
busy: new Set<string>(),
phrase: "",
language: "en",
ttsMode: "hybrid",
languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[],
session: {} as SessionPayload,
samples: emptySamples(),
captured: emptyCaptured(),
training: emptyTraining(),
auto: {} as AutoTrainPayload,
autoForm: defaultAutoForm(),
wakeWords: [] as WakeWordItem[],
managedData: emptyManagedData(),
selectedFiles: [] as File[],
sampleBucket: "personal" as SampleBucket,
samplePage: { personal: 0, negative: 0 },
uploadProgress: 0,
uploadLabel: "No upload in progress",
uploadDetail: "Choose files and upload when you are ready.",
consoleOpen: false,
taterLinkOpen: false,
trimItem: null as AudioItem | null,
trimBucket: "personal" as SampleBucket,
toast: { message: "", tone: "success", serial: 0 } as ToastState,
});
let autoTimer = 0;
let trainingTimer = 0;
export const personalCount = computed(() => Number(trainer.samples.personal_count ?? trainer.session.takes_received ?? 0));
export const negativeCount = computed(() => Number(trainer.samples.negative_count ?? trainer.captured.negative_count ?? 0));
export const currentLanguage = computed<LanguageOption>(() =>
trainer.languages.find((item) => item.code === trainer.language) || trainer.languages[0],
);
export const ttsRoute = computed(() => {
const engines = currentLanguage.value?.engines?.length ? currentLanguage.value.engines : ["omnivoice"];
const selected = trainer.ttsMode === "piper"
? engines.filter((engine) => engine === "piper")
: trainer.ttsMode === "hybrid"
? engines
: engines.filter((engine) => engine !== "piper");
const labels: Record<string, string> = { omnivoice: "OmniVoice", qwen3: "Qwen3", moss: "MOSS", piper: "Piper" };
const quality = trainer.ttsMode === "piper" ? "Legacy" : titleCase(currentLanguage.value?.quality || "experimental");
return `${selected.map((engine) => labels[engine] || engine).join(" + ") || "Unavailable"} · ${quality}`;
});
export const hasConsole = computed(() => Boolean(
trainer.training.running || trainer.training.exit_code !== null || trainer.training.log_lines?.length,
));
export const selectedSamples = computed(() => trainer.samples[trainer.sampleBucket] || []);
export const autoLinked = computed(() => Boolean(trainer.auto.trainer_link?.linked));
export const sttEngines = computed<JsonRecord[]>(() => {
const rows = trainer.auto.stt_engines;
return Array.isArray(rows) && rows.length
? rows
: [{ id: "faster_whisper", label: "Faster Whisper" }, { id: "parakeet_onnx", label: "Parakeet ONNX" }];
});
function titleCase(value: unknown): string {
return String(value || "").replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
export function isBusy(name?: string): boolean {
return name ? trainer.busy.has(name) : trainer.busy.size > 0;
}
function setBusy(name: string, active: boolean): void {
if (active) trainer.busy.add(name);
else trainer.busy.delete(name);
}
export function notify(message: unknown, tone: ToastState["tone"] = "success"): void {
trainer.toast = { message: String(message || ""), tone, serial: trainer.toast.serial + 1 };
}
function reportError(error: unknown, fallback: string): void {
notify(error instanceof Error ? error.message : fallback, "error");
}
function applySession(payload: SessionPayload): void {
trainer.session = payload || {};
if (Array.isArray(payload.available_languages) && payload.available_languages.length) {
trainer.languages = payload.available_languages;
}
if (payload.raw_phrase) trainer.phrase = payload.raw_phrase;
if (payload.language) trainer.language = payload.language;
if (payload.tts_mode) trainer.ttsMode = payload.tts_mode;
if (payload.training) trainer.training = payload.training;
}
export async function refreshSession(): Promise<SessionPayload> {
const payload = await getJson<SessionPayload>("/api/session");
applySession(payload);
return payload;
}
export async function startSession(): Promise<void> {
if (!trainer.phrase.trim()) {
notify("Enter a wake phrase first.", "warning");
return;
}
setBusy("session", true);
try {
const payload = await postJson<SessionPayload>("/api/start_session", {
phrase: trainer.phrase.trim(),
language: trainer.language,
tts_mode: trainer.ttsMode,
});
applySession(payload);
notify(`Session ${payload.safe_word || "started"} is ready.`);
} catch (error) {
reportError(error, "Session failed to start.");
} finally {
setBusy("session", false);
}
}
export async function stopSession(): Promise<void> {
const wasTraining = Boolean(trainer.training.running);
if (wasTraining && !window.confirm("Training is running. Stop training cleanly and end this session?")) {
return;
}
setBusy("session", true);
if (trainingTimer) {
window.clearInterval(trainingTimer);
trainingTimer = 0;
}
try {
const payload = await postJson<SessionPayload>("/api/stop_session");
applySession(payload);
notify(wasTraining ? "Training stopped cleanly and the session ended." : "Session ended. You can edit the wake phrase now.");
} catch (error) {
if (wasTraining) beginTrainingPoll();
reportError(error, "Session could not be stopped.");
} finally {
setBusy("session", false);
}
}
export function previewPhrase(): void {
if (!trainer.phrase.trim() || !("speechSynthesis" in window)) return;
const utterance = new SpeechSynthesisUtterance(trainer.phrase.trim());
utterance.lang = trainer.language;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
export function ensureSupportedTtsMode(): void {
const engines = currentLanguage.value?.engines || [];
const modern = engines.some((engine) => engine !== "piper");
const piper = engines.includes("piper");
if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper";
if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper";
if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern";
}
export async function refreshSamples(quiet = false): Promise<SamplesPayload> {
if (!quiet) setBusy("samples", true);
try {
const payload = await getJson<SamplesPayload>("/api/samples");
trainer.samples = { ...emptySamples(), ...payload };
for (const bucket of ["personal", "negative"] as const) {
const lastPage = Math.max(0, Math.ceil((trainer.samples[bucket]?.length || 0) / 50) - 1);
trainer.samplePage[bucket] = Math.min(trainer.samplePage[bucket], lastPage);
}
return payload;
} finally {
if (!quiet) setBusy("samples", false);
}
}
export async function refreshCaptured(quiet = false): Promise<CapturedPayload> {
if (!quiet) setBusy("captured", true);
try {
const payload = await getJson<CapturedPayload>("/api/captured_audio");
trainer.captured = { ...emptyCaptured(), ...payload };
return payload;
} finally {
if (!quiet) setBusy("captured", false);
}
}
export function selectFiles(event: Event): void {
const input = event.target as HTMLInputElement;
trainer.selectedFiles = Array.from(input.files || []);
}
function uploadOne(file: File, index: number, total: number): Promise<JsonRecord> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const data = new FormData();
data.append("file", file, file.name);
xhr.open("POST", "/api/upload_personal_sample");
xhr.responseType = "json";
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
trainer.uploadProgress = Math.round(((index + event.loaded / event.total) / total) * 100);
trainer.uploadLabel = `Uploading ${file.name} (${index + 1}/${total})`;
trainer.uploadDetail = "Sending and normalizing the recording.";
};
xhr.onload = () => {
const body = xhr.response || {};
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error || `Upload failed for ${file.name}`));
};
xhr.onerror = () => reject(new Error(`Upload failed for ${file.name}`));
xhr.send(data);
});
}
export async function uploadSelectedFiles(input?: HTMLInputElement | null): Promise<void> {
if (!trainer.session.safe_word) {
notify("Start a trainer session before uploading samples.", "warning");
return;
}
if (!trainer.selectedFiles.length) return;
setBusy("upload", true);
try {
const files = [...trainer.selectedFiles];
for (let index = 0; index < files.length; index += 1) await uploadOne(files[index], index, files.length);
trainer.uploadProgress = 100;
trainer.uploadLabel = "Upload complete";
trainer.uploadDetail = `${files.length} sample${files.length === 1 ? "" : "s"} saved in the required training format.`;
trainer.selectedFiles = [];
if (input) input.value = "";
await Promise.all([refreshSession(), refreshSamples(true)]);
notify("Personal samples uploaded.");
} catch (error) {
trainer.uploadProgress = 0;
reportError(error, "Sample upload failed.");
} finally {
setBusy("upload", false);
}
}
export async function reviewCaptured(item: AudioItem, action: "approve_personal" | "mark_negative" | "discard"): Promise<void> {
if (action === "discard" && !window.confirm(`Discard ${item.saved_as} from the captured-audio inbox?`)) return;
setBusy("review", true);
try {
await postJson(`/api/captured_audio/${encodeURIComponent(item.saved_as)}/${action}`);
await Promise.all([refreshSession(), refreshCaptured(true), refreshSamples(true)]);
notify(action === "approve_personal" ? "Clip added to personal samples." : action === "mark_negative" ? "Clip marked negative." : "Clip discarded.");
} catch (error) {
reportError(error, "Review action failed.");
} finally {
setBusy("review", false);
}
}
export async function removeSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
if (!window.confirm(`Remove ${item.saved_as} from ${bucket} samples?`)) return;
setBusy("review", true);
try {
await request(`/api/samples/${bucket}/${encodeURIComponent(item.saved_as)}`, { method: "DELETE" });
await refreshSamples(true);
notify("Sample removed.");
} catch (error) {
reportError(error, "Sample removal failed.");
} finally {
setBusy("review", false);
}
}
export async function revertSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
if (!window.confirm(`Revert ${item.saved_as} to its pre-trim version?`)) return;
const form = new FormData();
form.append("bucket", bucket);
form.append("file_name", item.saved_as);
setBusy("review", true);
try {
await request("/api/samples/revert", { method: "POST", body: form });
await refreshSamples(true);
notify("Original sample restored.");
} catch (error) {
reportError(error, "Sample revert failed.");
} finally {
setBusy("review", false);
}
}
export async function clearSamples(bucket: SampleBucket): Promise<void> {
const count = bucket === "personal" ? personalCount.value : negativeCount.value;
if (!count || !window.confirm(`Clear ${count} ${bucket} sample${count === 1 ? "" : "s"}?`)) return;
setBusy("review", true);
try {
await postJson(bucket === "personal" ? "/api/reset_recordings" : "/api/reset_negative_samples");
await Promise.all([refreshSession(), refreshSamples(true), refreshCaptured(true)]);
notify(`${titleCase(bucket)} samples cleared.`);
} catch (error) {
reportError(error, "Samples could not be cleared.");
} finally {
setBusy("review", false);
}
}
function applyAuto(payload: AutoTrainPayload, populate: boolean): void {
trainer.auto = payload || {};
if (!populate) return;
trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) };
if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || "";
if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en";
}
export async function refreshAuto(populate = false): Promise<AutoTrainPayload> {
const payload = await getJson<AutoTrainPayload>("/api/auto_train");
applyAuto(payload, populate);
return payload;
}
export async function saveAuto(): Promise<void> {
setBusy("auto", true);
try {
const payload = await putJson<AutoTrainPayload>("/api/auto_train", trainer.autoForm);
applyAuto(payload, true);
notify(payload.config?.enabled ? "Auto Training saved and enabled." : "Auto Training saved.");
} catch (error) {
reportError(error, "Auto Training settings failed to save.");
} finally {
setBusy("auto", false);
}
}
export async function runAutoAction(action: "review_now" | "train_now" | "notify_now"): Promise<void> {
setBusy("auto", true);
try {
const payload = await postJson<AutoTrainPayload>("/api/auto_train/action", { action });
applyAuto(payload, false);
if (action === "train_now") {
trainer.consoleOpen = true;
beginTrainingPoll();
}
notify(action === "review_now" ? `${Number(payload.queued || 0)} clips queued for review.` : action === "train_now" ? "Training started." : "Wake word published.");
} catch (error) {
reportError(error, "Auto Training action failed.");
} finally {
setBusy("auto", false);
}
}
export async function claimTater(taterUrl: string, pairingCode: string): Promise<boolean> {
setBusy("link", true);
try {
await postJson("/api/tater_link/claim", { tater_url: taterUrl.trim(), pairing_code: pairingCode.trim() });
trainer.autoForm.tater_url = taterUrl.trim();
await refreshAuto(false);
notify("Trainer linked securely to Tater.");
return true;
} catch (error) {
reportError(error, "Tater link failed.");
return false;
} finally {
setBusy("link", false);
}
}
export async function unlinkTater(): Promise<void> {
if (!window.confirm("Unlink this trainer from Tater?")) return;
setBusy("auto", true);
try {
await postJson("/api/tater_link/unlink");
await refreshAuto(false);
notify("Trainer unlinked from Tater.", "warning");
} catch (error) {
reportError(error, "Tater unlink failed.");
} finally {
setBusy("auto", false);
}
}
export async function refreshWakeWords(quiet = false): Promise<void> {
if (!quiet) setBusy("firmware", true);
try {
const payload = await getJson<JsonRecord>("/api/trained_wake_words/catalog");
trainer.wakeWords = Array.isArray(payload.wake_words) ? payload.wake_words : [];
} finally {
if (!quiet) setBusy("firmware", false);
}
}
export async function refreshManagedData(): Promise<ManagedDataPayload> {
setBusy("data", true);
try {
const payload = await getJson<ManagedDataPayload>("/api/data");
trainer.managedData = { ...emptyManagedData(), ...payload };
return payload;
} finally {
setBusy("data", false);
}
}
export async function deleteManagedData(item: ManagedDataItem): Promise<void> {
if (!item.file_count) return;
const details = `${formatBytes(item.size_bytes)} · ${Number(item.file_count).toLocaleString()} file${item.file_count === 1 ? "" : "s"}`;
const rebuild = item.rebuild_note ? `\n\n${item.rebuild_note}` : "";
if (!window.confirm(`Permanently delete ${item.label} (${details})?${rebuild}\n\nThis cannot be undone.`)) return;
setBusy("data-delete", true);
try {
const payload = await request<ManagedDataPayload>(`/api/data/${encodeURIComponent(item.id)}`, { method: "DELETE" });
trainer.managedData = { ...emptyManagedData(), ...payload };
await Promise.allSettled([
refreshSession(),
refreshSamples(true),
refreshCaptured(true),
refreshWakeWords(true),
]);
notify(`${item.label} deleted. ${formatBytes(item.size_bytes)} released.`);
} catch (error) {
reportError(error, `${item.label} could not be deleted.`);
} finally {
setBusy("data-delete", false);
}
}
export async function copyWakeWord(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url);
notify("Wake-word JSON URL copied.");
} catch (error) {
reportError(error, "Clipboard unavailable.");
}
}
export async function startTraining(): Promise<void> {
await Promise.all([refreshSession(), refreshSamples(true)]);
let allowNoPersonal = false;
if (!personalCount.value) {
allowNoPersonal = window.confirm("No positive samples are saved. Train anyway without personal voices?");
if (!allowNoPersonal) return;
}
setBusy("training-start", true);
trainer.training = { running: true, exit_code: null, log_lines: ["Waiting for training output…"] };
trainer.consoleOpen = true;
try {
await postJson("/api/train", { allow_no_personal: allowNoPersonal });
beginTrainingPoll();
} catch (error) {
trainer.training = { running: false, exit_code: 1, log_lines: [error instanceof Error ? error.message : String(error)] };
reportError(error, "Training could not start.");
} finally {
setBusy("training-start", false);
}
}
export function beginTrainingPoll(): void {
if (trainingTimer) return;
const poll = async () => {
try {
const payload = await getJson<JsonRecord>("/api/train_status");
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
if (!trainer.training.running) {
window.clearInterval(trainingTimer);
trainingTimer = 0;
await Promise.all([refreshSamples(true), refreshWakeWords(true)]);
notify(trainer.training.exit_code === 0 ? "Training finished successfully." : `Training ended with exit ${trainer.training.exit_code}.`, trainer.training.exit_code === 0 ? "success" : "error");
}
} catch {
// A temporary request failure should not stop the live poll.
}
};
void poll();
trainingTimer = window.setInterval(() => void poll(), 1500);
}
export async function initializeTrainer(): Promise<void> {
setBusy("bootstrap", true);
try {
await Promise.allSettled([
refreshSession(),
refreshSamples(true),
refreshCaptured(true),
refreshAuto(true),
refreshWakeWords(true),
]);
ensureSupportedTtsMode();
try {
const payload = await getJson<JsonRecord>("/api/train_status");
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
if (trainer.training.running) {
trainer.consoleOpen = true;
beginTrainingPoll();
}
} catch {
// Remaining panels can still function when status is temporarily unavailable.
}
autoTimer = window.setInterval(() => {
if (trainer.activeView === "auto" && !isBusy("auto")) void refreshAuto(false).catch(() => undefined);
}, 2500);
trainer.initialized = true;
} finally {
setBusy("bootstrap", false);
}
}
export function disposeTrainer(): void {
window.clearInterval(autoTimer);
window.clearInterval(trainingTimer);
autoTimer = 0;
trainingTimer = 0;
}
export function formatTimestamp(value: unknown): string {
if (!value) return "";
const parsed = new Date(String(value));
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
export function formatBytes(value: unknown): string {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`;
}
export function describeFormat(info: JsonRecord | undefined): string {
if (!info) return "16 kHz · mono · 16-bit WAV";
const rate = Number(info.sample_rate || info.sample_rate_hz || 16000);
const channels = Number(info.channels || 1) === 1 ? "mono" : `${info.channels} channels`;
const bits = Number(info.bits_per_sample || info.sample_width_bits || 16);
return `${Math.round(rate / 1000)} kHz · ${channels} · ${bits}-bit`;
}
export function captureTone(item: AudioItem): { label: string; tone: string } {
if (item.blocked_by_vad) return { label: "Blocked by VAD", tone: "warning" };
const type = String(item.event_type || "").toLowerCase();
if (type.includes("close")) return { label: item.capture_label || "Close miss", tone: "warning" };
if (type.includes("false")) return { label: item.capture_label || "False trigger", tone: "error" };
if (type.includes("wake") || type.includes("detect")) return { label: item.capture_label || "Wake trigger", tone: "success" };
return { label: item.capture_label || "Captured", tone: "neutral" };
}
export function itemAudioUrl(item: AudioItem, bucket: SampleBucket | "captured"): string {
return item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`;
}

105
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { JsonRecord } from "./api";
export type ViewName = "trainer" | "auto" | "firmware" | "captured" | "samples" | "data";
export type SampleBucket = "personal" | "negative";
export interface LanguageOption extends JsonRecord {
code: string;
label: string;
engines?: string[];
quality?: string;
}
export interface TrainingState extends JsonRecord {
running: boolean;
exit_code: number | null;
log_lines: string[];
}
export interface SessionPayload extends JsonRecord {
safe_word?: string;
raw_phrase?: string;
language?: string;
tts_mode?: string;
takes_received?: number;
available_languages?: LanguageOption[];
training?: TrainingState;
}
export interface AudioItem extends JsonRecord {
saved_as: string;
original_name?: string;
audio_url?: string;
final_format?: JsonRecord;
}
export interface SamplesPayload extends JsonRecord {
personal: AudioItem[];
negative: AudioItem[];
personal_count: number;
negative_count: number;
}
export interface CapturedPayload extends JsonRecord {
items: AudioItem[];
captured_count: number;
personal_count: number;
negative_count: number;
}
export interface AutoTrainForm extends JsonRecord {
enabled: boolean;
wake_phrase: string;
language: string;
stt_engine: string;
minimum_transcript_chars: number;
delete_confirmed_wakes: boolean;
promote_close_misses: boolean;
schedule_hours: number;
minimum_new_negatives: number;
advertised_base_url: string;
tater_url: string;
notify_satellites: boolean;
}
export interface AutoTrainPayload extends JsonRecord {
config?: Partial<AutoTrainForm>;
state?: JsonRecord;
runtime?: JsonRecord;
trainer_link?: JsonRecord;
advertised_base_url?: string;
}
export interface WakeWordItem extends JsonRecord {
key?: string;
label?: string;
url?: string;
json_url?: string;
jsonUrl?: string;
model_url?: string;
modelUrl?: string;
}
export interface ManagedDataItem extends JsonRecord {
id: string;
label: string;
category: string;
description: string;
location: string;
size_bytes: number;
file_count: number;
exists: boolean;
rebuild_note?: string;
}
export interface ManagedDataPayload extends JsonRecord {
items: ManagedDataItem[];
total_size_bytes: number;
total_file_count: number;
}
export interface ToastState {
message: string;
tone: "success" | "warning" | "error";
serial: number;
}