import { computed, reactive } from "vue"; import { getJson, postJson, putJson, request, type JsonRecord } from "./api"; import type { AccentOption, 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", english_accent: "mixed", 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(), phrase: "", language: "en", englishAccent: "mixed", ttsMode: "hybrid", languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[], englishAccents: [ { code: "mixed", label: "Mixed English" }, { code: "australian", label: "Australian" }, { code: "american", label: "American" }, { code: "british", label: "British" }, { code: "canadian", label: "Canadian" }, { code: "irish", label: "Irish" }, { code: "scottish", label: "Scottish" }, { code: "new_zealand", label: "New Zealand" }, { code: "indian", label: "Indian" }, { code: "south_african", label: "South African" }, ] as AccentOption[], 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(() => 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 = { 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(() => { 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 (Array.isArray(payload.available_english_accents) && payload.available_english_accents.length) { trainer.englishAccents = payload.available_english_accents; } if (payload.raw_phrase) trainer.phrase = payload.raw_phrase; if (payload.language) trainer.language = payload.language; if (payload.english_accent) trainer.englishAccent = payload.english_accent; if (payload.tts_mode) trainer.ttsMode = payload.tts_mode; if (payload.training) trainer.training = payload.training; } export async function refreshSession(): Promise { const payload = await getJson("/api/session"); applySession(payload); return payload; } export async function startSession(): Promise { if (!trainer.phrase.trim()) { notify("Enter a wake phrase first.", "warning"); return; } setBusy("session", true); try { const payload = await postJson("/api/start_session", { phrase: trainer.phrase.trim(), language: trainer.language, english_accent: trainer.englishAccent, 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 { 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("/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"; if (trainer.language !== "en" || trainer.ttsMode === "piper") trainer.englishAccent = "mixed"; } export async function refreshSamples(quiet = false): Promise { if (!quiet) setBusy("samples", true); try { const payload = await getJson("/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 { if (!quiet) setBusy("captured", true); try { const payload = await getJson("/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 { 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 { 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 { 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 { 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 { 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 { 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"; if (!trainer.autoForm.english_accent) trainer.autoForm.english_accent = trainer.session.english_accent || "mixed"; if (!String(trainer.autoForm.language).toLowerCase().startsWith("en")) trainer.autoForm.english_accent = "mixed"; } export async function refreshAuto(populate = false): Promise { const payload = await getJson("/api/auto_train"); applyAuto(payload, populate); return payload; } export async function saveAuto(): Promise { setBusy("auto", true); try { const payload = await putJson("/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 { setBusy("auto", true); try { const payload = await postJson("/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 { 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 { 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 { if (!quiet) setBusy("firmware", true); try { const payload = await getJson("/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 { setBusy("data", true); try { const payload = await getJson("/api/data"); trainer.managedData = { ...emptyManagedData(), ...payload }; return payload; } finally { setBusy("data", false); } } export async function deleteManagedData(item: ManagedDataItem): Promise { 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(`/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 { try { await navigator.clipboard.writeText(url); notify("Wake-word JSON URL copied."); } catch (error) { reportError(error, "Clipboard unavailable."); } } export async function startTraining(): Promise { 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("/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 { setBusy("bootstrap", true); try { await Promise.allSettled([ refreshSession(), refreshSamples(true), refreshCaptured(true), refreshAuto(true), refreshWakeWords(true), ]); ensureSupportedTtsMode(); try { const payload = await getJson("/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)}`; }