mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
Release NVIDIA WakeWord Trainer v22
This commit is contained in:
323
frontend/src/TrainerApp.vue
Normal file
323
frontend/src/TrainerApp.vue
Normal 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 trainer’s 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
43
frontend/src/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
235
frontend/src/components/AudioTrimModal.vue
Normal file
235
frontend/src/components/AudioTrimModal.vue
Normal 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
11
frontend/src/main.ts
Normal 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
217
frontend/src/trainer.css
Normal 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; } }
|
||||
580
frontend/src/trainerStore.ts
Normal file
580
frontend/src/trainerStore.ts
Normal 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
105
frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user