Files
microWakeWord-Trainer-Nvidi…/frontend/src/trainerStore.ts
2026-08-10 07:06:44 -05:00

604 lines
23 KiB
TypeScript

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<string>(),
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<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 (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<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,
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<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";
if (trainer.language !== "en" || trainer.ttsMode === "piper") trainer.englishAccent = "mixed";
}
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";
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<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)}`;
}