3 Commits
v23 ... v26

Author SHA1 Message Date
MasterPhooey
68c4227cb7 Release NVIDIA WakeWord Trainer v26 2026-08-04 06:25:09 -05:00
MasterPhooey
bd71567a4f Release NVIDIA WakeWord Trainer v25 2026-08-03 19:18:06 -05:00
MasterPhooey
293318ad20 Release NVIDIA WakeWord Trainer v24 2026-08-03 07:25:58 -05:00
13 changed files with 442 additions and 100 deletions

View File

@@ -1 +1 @@
23 26

View File

@@ -1,2 +1 @@
- Fixed NVIDIA v22 training runs remaining stuck immediately after Start Session instead of launching the training worker. - Restored MOSS-TTS-Nano generation with the current voice-cloning API so all four providers contribute to the final training corpus.
- Corrected the worker-state handoff for both manual and automatic training, with regression coverage for the complete startup path.

View File

@@ -16,6 +16,7 @@ import math
import os import os
import random import random
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import wave import wave
@@ -43,7 +44,7 @@ from tts_config import ( # noqa: E402
) )
GENERATOR_VERSION = "modern-tts-v15-four-provider-direct-corpus-safe-limits" GENERATOR_VERSION = "modern-tts-v16-four-provider-direct-corpus-safe-limits"
VOICE_BANK_VERSION = "modern-tts-voice-bank-v1-native-random-qualified-single-utterance" VOICE_BANK_VERSION = "modern-tts-voice-bank-v1-native-random-qualified-single-utterance"
COMPATIBLE_VOICE_BANK_VERSIONS = { COMPATIBLE_VOICE_BANK_VERSIONS = {
VOICE_BANK_VERSION, VOICE_BANK_VERSION,
@@ -65,6 +66,8 @@ DIRECT_CANDIDATE_FACTORS = {
ENGINE_MOSS: 1.25, ENGINE_MOSS: 1.25,
ENGINE_PIPER: 1.05, ENGINE_PIPER: 1.05,
} }
NORMALIZATION_TIMEOUT_SECONDS = 30.0
NORMALIZATION_PROGRESS_INTERVAL = 100
CARRIER_PROMPT_TEMPLATES = { CARRIER_PROMPT_TEMPLATES = {
"ar": "بصوت هادئ وطبيعي أقول {phrase} بوضوح، ثم أواصل الحديث بإيقاع ثابت.", "ar": "بصوت هادئ وطبيعي أقول {phrase} بوضوح، ثم أواصل الحديث بإيقاع ثابت.",
@@ -122,6 +125,38 @@ def run_with_batch_retry(
run(retry_command, env=env) run(retry_command, env=env)
def run_normalization_ffmpeg(command: list[str], timeout: float) -> int | None:
"""Run one conversion without allowing a stuck file read to block training."""
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
try:
return process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Do not use subprocess.run(timeout=...) here. On POSIX it performs an
# unbounded wait after killing the child, which can still freeze the
# trainer when a process is stuck in filesystem I/O.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except OSError:
try:
process.kill()
except ProcessLookupError:
pass
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
# The process may remain in uninterruptible I/O until the kernel
# releases it. The next candidate can still be processed safely.
pass
return None
def write_jsonl(path: Path, entries: list[dict]) -> None: def write_jsonl(path: Path, entries: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream: with path.open("w", encoding="utf-8") as stream:
@@ -1247,7 +1282,9 @@ class Generator:
def normalize(self, paths: list[Path], start_index: int, limit: int) -> list[Path]: def normalize(self, paths: list[Path], start_index: int, limit: int) -> list[Path]:
accepted = [] accepted = []
self.final_dir.mkdir(parents=True, exist_ok=True) self.final_dir.mkdir(parents=True, exist_ok=True)
for path in paths: candidate_count = len(paths)
log(f"→ Normalizing up to {limit} accepted clip(s) from {candidate_count} candidate(s)")
for processed, path in enumerate(paths, start=1):
if len(accepted) >= limit: if len(accepted) >= limit:
break break
final_path = self.final_dir / f"{start_index + len(accepted)}.wav" final_path = self.final_dir / f"{start_index + len(accepted)}.wav"
@@ -1258,6 +1295,7 @@ class Generator:
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"error", "error",
"-nostdin",
"-y", "-y",
"-i", "-i",
str(path), str(path),
@@ -1272,18 +1310,39 @@ class Generator:
"pcm_s16le", "pcm_s16le",
str(temp_path), str(temp_path),
] ]
try: return_code = run_normalization_ffmpeg(
subprocess.run(command, check=True) command,
except subprocess.CalledProcessError: timeout=NORMALIZATION_TIMEOUT_SECONDS,
)
converted = return_code == 0
if return_code is None:
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
continue log(
digest = hashlib.sha256(temp_path.read_bytes()).hexdigest() if temp_path.is_file() else "" f"⚠️ Normalization timed out after "
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes: f"{NORMALIZATION_TIMEOUT_SECONDS:g}s; skipping {path.name}"
temp_path.replace(final_path) )
self.accepted_hashes.add(digest) elif return_code != 0:
accepted.append(final_path)
else:
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
log(f"⚠️ ffmpeg rejected {path.name} (exit {return_code}); skipping it")
if converted:
digest = hashlib.sha256(temp_path.read_bytes()).hexdigest() if temp_path.is_file() else ""
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes:
temp_path.replace(final_path)
self.accepted_hashes.add(digest)
accepted.append(final_path)
else:
temp_path.unlink(missing_ok=True)
if (
processed % NORMALIZATION_PROGRESS_INTERVAL == 0
or processed == candidate_count
or len(accepted) >= limit
):
log(
f"Normalization progress: {len(accepted)}/{limit} accepted "
f"({processed}/{candidate_count} candidate(s) checked)"
)
return accepted return accepted
def generate(self) -> None: def generate(self) -> None:

View File

@@ -69,7 +69,6 @@ def main() -> int:
text=str(item["text"]), text=str(item["text"]),
output_audio_path=str(output_path), output_audio_path=str(output_path),
mode="voice_clone", mode="voice_clone",
prompt_text=str(item["ref_text"]),
prompt_audio_path=str(item["ref_audio"]), prompt_audio_path=str(item["ref_audio"]),
reference_audio_path=None, reference_audio_path=None,
text_tokenizer_path=None, text_tokenizer_path=None,

View File

@@ -162,6 +162,7 @@ function sampleSubtitle(item: AudioItem): string {
return rows.join(" · ") || "Training sample"; return rows.join(" · ") || "Training sample";
} }
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); } function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
function wordEsphomeJsonUrl(item: JsonRecord): string { return String(item.esphome_json_url || item.esphomeJsonUrl || ""); }
function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); } function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); }
function consoleTone(line: string): string { function consoleTone(line: string): string {
const value = line.trim().toLowerCase(); const value = line.trim().toLowerCase();
@@ -265,6 +266,7 @@ function consoleTone(line: string): string {
<div v-if="!selectedSamples.length" class="empty-state">No {{ trainer.sampleBucket }} samples saved yet.</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"> <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> <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>
<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, trainer.sampleBucket)" /> <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> <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> </article></div>
@@ -301,6 +303,11 @@ function consoleTone(line: string): string {
<div v-if="!trainer.wakeWords.length" class="empty-state">Train a wake word and its package will appear here.</div> <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> <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> </section>
<div class="native-notice esphome-notice"><strong>ESPHome</strong><span>Strict micro_wake_word manifest without Tater Native or calibration extensions.</span></div>
<section class="panel compatibility-panel"><header class="panel-head"><div class="number">ESP</div><div><h3>ESPHome JSON</h3><p>Use this URL as the model in an ESPHome micro_wake_word configuration.</p></div></header>
<div v-if="!trainer.wakeWords.length" class="empty-state">ESPHome links appear after a wake word is trained.</div>
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="`esphome-${word.key || wordEsphomeJsonUrl(word)}`"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordEsphomeJsonUrl(word)" :href="wordEsphomeJsonUrl(word)" target="_blank" rel="noreferrer">ESPHome JSON · {{ wordEsphomeJsonUrl(word) }}</a><span v-else class="muted">ESPHome package URL unavailable</span><div class="meta-row"><span>Schema v2</span><span>Same TFLite model</span></div></div><button type="button" :disabled="!wordEsphomeJsonUrl(word)" @click="copyWakeWord(wordEsphomeJsonUrl(word))">Copy ESPHome URL</button></article></div>
</section>
</template> </template>
</template> </template>
</main> </main>

View File

@@ -147,6 +147,9 @@ button:disabled { opacity: .43; cursor: not-allowed; }
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--orange), var(--violet)); transition: width .2s ease; } .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 { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; }
.native-notice strong { color: var(--green); } .native-notice strong { color: var(--green); }
.esphome-notice strong { color: var(--orange-2); }
.compatibility-panel { padding-top: 19px; }
.compatibility-panel .panel-head { margin-bottom: 15px; }
.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 { 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 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; } .word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; }

View File

@@ -31,6 +31,11 @@ export interface AudioItem extends JsonRecord {
original_name?: string; original_name?: string;
audio_url?: string; audio_url?: string;
final_format?: JsonRecord; final_format?: JsonRecord;
transcript?: string;
transcribed_at?: string;
auto_review_guided_transcript?: string;
auto_review_stt_engine?: string;
auto_review_stt_model?: string;
} }
export interface SamplesPayload extends JsonRecord { export interface SamplesPayload extends JsonRecord {
@@ -76,6 +81,8 @@ export interface WakeWordItem extends JsonRecord {
url?: string; url?: string;
json_url?: string; json_url?: string;
jsonUrl?: string; jsonUrl?: string;
esphome_json_url?: string;
esphomeJsonUrl?: string;
model_url?: string; model_url?: string;
modelUrl?: string; modelUrl?: string;
} }

File diff suppressed because one or more lines are too long

View File

@@ -3983,44 +3983,59 @@ var hc = {
}, du = { class: "row" }, fu = { }, du = { class: "row" }, fu = {
key: 0, key: 0,
class: "pill warning" class: "pill warning"
}, pu = ["src"], mu = ["onClick"], hu = ["onClick"], gu = ["disabled", "onClick"], _u = { }, pu = {
key: 0,
class: "transcript"
}, mu = {
key: 1,
class: "transcript"
}, hu = ["src"], gu = ["onClick"], _u = ["onClick"], vu = ["disabled", "onClick"], yu = {
key: 2, key: 2,
class: "pagination" class: "pagination"
}, vu = ["disabled"], yu = ["disabled"], bu = { class: "panel" }, xu = { class: "dropzone" }, Su = ["disabled"], Cu = { class: "progress-card" }, wu = { class: "progress-track" }, Tu = { class: "hero data-hero" }, Eu = { class: "pill hero-pill" }, Du = { class: "panel" }, Ou = { class: "panel-head" }, ku = ["disabled"], Au = { class: "stats" }, ju = { class: "format-value" }, Mu = { }, bu = ["disabled"], xu = ["disabled"], Su = { class: "panel" }, Cu = { class: "dropzone" }, wu = ["disabled"], Tu = { class: "progress-card" }, Eu = { class: "progress-track" }, Du = { class: "hero data-hero" }, Ou = { class: "pill hero-pill" }, ku = { class: "panel" }, Au = { class: "panel-head" }, ju = ["disabled"], Mu = { class: "stats" }, Nu = { class: "format-value" }, Pu = {
key: 0, key: 0,
class: "data-warning" class: "data-warning"
}, Nu = { class: "panel-head" }, Pu = { class: "number" }, Fu = { class: "data-list" }, Iu = { class: "data-copy" }, Lu = { class: "data-title" }, Ru = { }, Fu = { class: "panel-head" }, Iu = { class: "number" }, Lu = { class: "data-list" }, Ru = { class: "data-copy" }, zu = { class: "data-title" }, Bu = {
key: 0, key: 0,
class: "data-note" class: "data-note"
}, zu = { class: "data-usage" }, Bu = ["disabled", "onClick"], Vu = { }, Vu = { class: "data-usage" }, Hu = ["disabled", "onClick"], Uu = {
key: 0, key: 0,
class: "panel empty-state" class: "panel empty-state"
}, Hu = { class: "hero firmware-hero" }, Uu = { class: "panel" }, Wu = { class: "panel-head" }, Gu = ["disabled"], Ku = { }, Wu = { class: "hero firmware-hero" }, Gu = { class: "panel" }, Ku = { class: "panel-head" }, qu = ["disabled"], Ju = {
key: 0, key: 0,
class: "empty-state" class: "empty-state"
}, qu = { }, Yu = {
key: 1, key: 1,
class: "word-list" class: "word-list"
}, Ju = ["href"], Yu = { }, Xu = ["href"], Zu = {
key: 1, key: 1,
class: "muted" class: "muted"
}, Xu = ["href"], Zu = { class: "meta-row" }, Qu = { key: 0 }, $u = { key: 1 }, ed = { key: 2 }, td = ["disabled", "onClick"], nd = { }, Qu = ["href"], $u = { class: "meta-row" }, ed = { key: 0 }, td = { key: 1 }, nd = { key: 2 }, rd = ["disabled", "onClick"], id = { class: "panel compatibility-panel" }, ad = {
key: 0,
class: "empty-state"
}, od = {
key: 1,
class: "word-list"
}, sd = ["href"], cd = {
key: 1,
class: "muted"
}, ld = ["disabled", "onClick"], ud = {
class: "modal console-modal", class: "modal console-modal",
role: "dialog", role: "dialog",
"aria-modal": "true", "aria-modal": "true",
"aria-label": "Training console" "aria-label": "Training console"
}, rd = { class: "modal-head" }, id = { class: "row console-actions" }, ad = { }, dd = { class: "modal-head" }, fd = { class: "row console-actions" }, pd = {
class: "modal link-modal", class: "modal link-modal",
role: "dialog", role: "dialog",
"aria-modal": "true", "aria-modal": "true",
"aria-label": "Link Tater" "aria-label": "Link Tater"
}, od = { class: "modal-head" }, sd = { }, md = { class: "modal-head" }, hd = {
key: 0, key: 0,
class: "link-success" class: "link-success"
}, cd = { }, gd = {
key: 1, key: 1,
class: "stack" class: "stack"
}, ld = { class: "field" }, ud = { class: "field" }, dd = ["disabled"], fd = "/static/images/tater-wake-word-trainer.png", pd = 50, md = /* @__PURE__ */ fr({ }, _d = { class: "field" }, vd = { class: "field" }, yd = ["disabled"], bd = "/static/images/tater-wake-word-trainer.png", xd = 50, Sd = /* @__PURE__ */ fr({
__name: "TrainerApp", __name: "TrainerApp",
setup(e) { setup(e) {
let t = /* @__PURE__ */ F(null), n = /* @__PURE__ */ F(null), r = /* @__PURE__ */ F(!0), i = /* @__PURE__ */ F(""), a = /* @__PURE__ */ F(""), o = /* @__PURE__ */ F(!1), s = [ let t = /* @__PURE__ */ F(null), n = /* @__PURE__ */ F(null), r = /* @__PURE__ */ F(!0), i = /* @__PURE__ */ F(""), a = /* @__PURE__ */ F(""), o = /* @__PURE__ */ F(!1), s = [
@@ -4056,8 +4071,8 @@ var hc = {
} }
], c = Y(() => { ], c = Y(() => {
let e = X.samplePage[X.sampleBucket]; let e = X.samplePage[X.sampleBucket];
return As.value.slice(e * pd, (e + 1) * pd); return As.value.slice(e * xd, (e + 1) * xd);
}), l = Y(() => Math.max(1, Math.ceil(As.value.length / pd))), u = Y(() => X.auto.state || {}), d = Y(() => X.auto.runtime || {}), f = Y(() => { }), l = Y(() => Math.max(1, Math.ceil(As.value.length / xd))), u = Y(() => X.auto.state || {}), d = Y(() => X.auto.runtime || {}), f = Y(() => {
let e = u.value, t = []; let e = u.value, t = [];
return e.last_review_result && t.push(`Last review: ${String(e.last_review_result).replaceAll("_", " ")}`), e.last_review_file && t.push(String(e.last_review_file)), e.last_review_transcript && t.push(`STT: “${e.last_review_transcript}`), e.last_review_error && t.push(`Error: ${e.last_review_error}`), e.last_stt_engine && t.push(`STT engine: ${String(e.last_stt_engine).replaceAll("_", " ")}`), e.last_notify_at && t.push(e.last_notify_error ? `Publish failed: ${e.last_notify_error}` : `Wake word published ${uc(e.last_notify_at)}`), t.join(" · ") || "No automatic review has run yet."; return e.last_review_result && t.push(`Last review: ${String(e.last_review_result).replaceAll("_", " ")}`), e.last_review_file && t.push(String(e.last_review_file)), e.last_review_transcript && t.push(`STT: “${e.last_review_transcript}`), e.last_review_error && t.push(`Error: ${e.last_review_error}`), e.last_stt_engine && t.push(`STT engine: ${String(e.last_stt_engine).replaceAll("_", " ")}`), e.last_notify_at && t.push(e.last_notify_error ? `Publish failed: ${e.last_notify_error}` : `Wake word published ${uc(e.last_notify_at)}`), t.join(" · ") || "No automatic review has run yet.";
}), p = Y(() => X.training.running ? { }), p = Y(() => X.training.running ? {
@@ -4156,18 +4171,21 @@ var hc = {
return String(e.json_url || e.url || e.jsonUrl || ""); return String(e.json_url || e.url || e.jsonUrl || "");
} }
function re(e) { function re(e) {
return String(e.model_url || e.modelUrl || ""); return String(e.esphome_json_url || e.esphomeJsonUrl || "");
} }
function E(e) { function E(e) {
return String(e.model_url || e.modelUrl || "");
}
function ie(e) {
let t = e.trim().toLowerCase(); let t = e.trim().toLowerCase();
return /^(✓|✅)|success|finished/.test(t) ? "success" : /^(✗|❌)|error|failed|traceback/.test(t) ? "error" : /^(⚠|warning)/.test(t) ? "warning" : /^={4,}|^-----|^=====/.test(t) ? "heading" : ""; return /^(✓|✅)|success|finished/.test(t) ? "success" : /^(✗|❌)|error|failed|traceback/.test(t) ? "error" : /^(⚠|warning)/.test(t) ? "warning" : /^={4,}|^-----|^=====/.test(t) ? "heading" : "";
} }
return (e, d) => (U(), W("div", Dc, [ return (e, d) => (U(), W("div", Dc, [
d[113] ||= G("div", { d[118] ||= G("div", {
class: "ambient ambient-one", class: "ambient ambient-one",
"aria-hidden": "true" "aria-hidden": "true"
}, null, -1), }, null, -1),
d[114] ||= G("div", { d[119] ||= G("div", {
class: "ambient ambient-two", class: "ambient ambient-two",
"aria-hidden": "true" "aria-hidden": "true"
}, null, -1), }, null, -1),
@@ -4175,7 +4193,7 @@ var hc = {
class: "brand-mark", class: "brand-mark",
"aria-hidden": "true" "aria-hidden": "true"
}, [G("img", { }, [G("img", {
src: fd, src: bd,
alt: "" alt: ""
})]), d[44] ||= G("div", null, [ })]), d[44] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Tater tools"), G("span", { class: "eyebrow" }, "Tater tools"),
@@ -4518,46 +4536,48 @@ var hc = {
class: "audio-card" class: "audio-card"
}, [ }, [
G("header", null, [G("div", null, [G("strong", null, k(e.saved_as), 1), G("small", null, k(ne(e)), 1)]), G("div", du, [e.trimmed ? (U(), W("span", fu, "Trimmed")) : q("", !0), G("span", { class: O(["pill", I(X).sampleBucket === "personal" ? "success" : "error"]) }, k(I(X).sampleBucket === "personal" ? "Positive" : "Negative"), 3)])]), G("header", null, [G("div", null, [G("strong", null, k(e.saved_as), 1), G("small", null, k(ne(e)), 1)]), G("div", du, [e.trimmed ? (U(), W("span", fu, "Trimmed")) : q("", !0), G("span", { class: O(["pill", I(X).sampleBucket === "personal" ? "success" : "error"]) }, k(I(X).sampleBucket === "personal" ? "Positive" : "Negative"), 3)])]),
e.transcript ? (U(), W("div", pu, [d[94] ||= G("b", null, "STT", -1), da(" " + k(e.transcript), 1)])) : q("", !0),
e.auto_review_guided_transcript ? (U(), W("div", mu, [d[95] ||= G("b", null, "Guided wake check", -1), da(" " + k(e.auto_review_guided_transcript), 1)])) : q("", !0),
G("audio", { G("audio", {
controls: "", controls: "",
preload: "none", preload: "none",
src: I(mc)(e, I(X).sampleBucket) src: I(mc)(e, I(X).sampleBucket)
}, null, 8, pu), }, null, 8, hu),
G("footer", null, [G("span", null, k(I(fc)(e.final_format)), 1), G("div", null, [ G("footer", null, [G("span", null, k(I(fc)(e.final_format)), 1), G("div", null, [
G("button", { G("button", {
type: "button", type: "button",
onClick: (t) => S(e, I(X).sampleBucket) onClick: (t) => S(e, I(X).sampleBucket)
}, "Trim", 8, mu), }, "Trim", 8, gu),
e.trimmed ? (U(), W("button", { e.trimmed ? (U(), W("button", {
key: 0, key: 0,
type: "button", type: "button",
onClick: (t) => I(Js)(e, I(X).sampleBucket) onClick: (t) => I(Js)(e, I(X).sampleBucket)
}, "Revert", 8, hu)) : q("", !0), }, "Revert", 8, _u)) : q("", !0),
G("button", { G("button", {
type: "button", type: "button",
class: "button danger ghost", class: "button danger ghost",
disabled: I(Z)("review"), disabled: I(Z)("review"),
onClick: (t) => I(qs)(e, I(X).sampleBucket) onClick: (t) => I(qs)(e, I(X).sampleBucket)
}, "Remove", 8, gu) }, "Remove", 8, vu)
])]) ])])
]))), 128))])) : (U(), W("div", lu, "No " + k(I(X).sampleBucket) + " samples saved yet.", 1)), ]))), 128))])) : (U(), W("div", lu, "No " + k(I(X).sampleBucket) + " samples saved yet.", 1)),
l.value > 1 ? (U(), W("div", _u, [ l.value > 1 ? (U(), W("div", yu, [
G("button", { G("button", {
type: "button", type: "button",
disabled: I(X).samplePage[I(X).sampleBucket] === 0, disabled: I(X).samplePage[I(X).sampleBucket] === 0,
onClick: d[32] ||= (e) => I(X).samplePage[I(X).sampleBucket]-- onClick: d[32] ||= (e) => I(X).samplePage[I(X).sampleBucket]--
}, "Previous", 8, vu), }, "Previous", 8, bu),
G("span", null, "Page " + k(I(X).samplePage[I(X).sampleBucket] + 1) + " of " + k(l.value), 1), G("span", null, "Page " + k(I(X).samplePage[I(X).sampleBucket] + 1) + " of " + k(l.value), 1),
G("button", { G("button", {
type: "button", type: "button",
disabled: I(X).samplePage[I(X).sampleBucket] >= l.value - 1, disabled: I(X).samplePage[I(X).sampleBucket] >= l.value - 1,
onClick: d[33] ||= (e) => I(X).samplePage[I(X).sampleBucket]++ onClick: d[33] ||= (e) => I(X).samplePage[I(X).sampleBucket]++
}, "Next", 8, yu) }, "Next", 8, xu)
])) : q("", !0) ])) : q("", !0)
]), ]),
G("section", bu, [ G("section", Su, [
d[95] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "2"), G("div", null, [G("h3", null, "Manual sample import"), G("p", null, "Optional seed recordings are normalized to the trainers required WAV format.")])], -1), d[97] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "2"), G("div", null, [G("h3", null, "Manual sample import"), G("p", null, "Optional seed recordings are normalized to the trainers required WAV format.")])], -1),
G("label", xu, [ G("label", Cu, [
G("input", { G("input", {
ref_key: "uploadInput", ref_key: "uploadInput",
ref: t, ref: t,
@@ -4566,7 +4586,7 @@ var hc = {
accept: "audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus", accept: "audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus",
onChange: d[34] ||= (...e) => I(Us) && I(Us)(...e) onChange: d[34] ||= (...e) => I(Us) && I(Us)(...e)
}, null, 544), }, null, 544),
d[94] ||= G("span", null, [G("strong", null, "Choose one or many audio files"), G("small", null, "WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM")], -1), d[96] ||= G("span", null, [G("strong", null, "Choose one or many audio files"), G("small", null, "WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM")], -1),
G("b", null, k(I(X).selectedFiles.length ? `${I(X).selectedFiles.length} selected` : "Browse"), 1) G("b", null, k(I(X).selectedFiles.length ? `${I(X).selectedFiles.length} selected` : "Browse"), 1)
]), ]),
G("button", { G("button", {
@@ -4574,106 +4594,121 @@ var hc = {
class: "button primary", class: "button primary",
disabled: !I(X).session.safe_word || !I(X).selectedFiles.length || I(Z)("upload"), disabled: !I(X).session.safe_word || !I(X).selectedFiles.length || I(Z)("upload"),
onClick: d[35] ||= (e) => I(Gs)(t.value) onClick: d[35] ||= (e) => I(Gs)(t.value)
}, k(I(Z)("upload") ? "Uploading…" : "Upload selected samples"), 9, Su), }, k(I(Z)("upload") ? "Uploading…" : "Upload selected samples"), 9, wu),
G("div", Cu, [ G("div", Tu, [
G("div", null, [G("strong", null, k(I(X).uploadLabel), 1), G("span", null, k(I(X).uploadProgress) + "%", 1)]), G("div", null, [G("strong", null, k(I(X).uploadLabel), 1), G("span", null, k(I(X).uploadProgress) + "%", 1)]),
G("div", wu, [G("i", { style: fe({ width: `${I(X).uploadProgress}%` }) }, null, 4)]), G("div", Eu, [G("i", { style: fe({ width: `${I(X).uploadProgress}%` }) }, null, 4)]),
G("small", null, k(I(X).uploadDetail), 1) G("small", null, k(I(X).uploadDetail), 1)
]) ])
]) ])
], 64)) : I(X).activeView === "data" ? (U(), W(V, { key: 4 }, [ ], 64)) : I(X).activeView === "data" ? (U(), W(V, { key: 4 }, [
G("section", Tu, [d[96] ||= G("div", null, [ G("section", Du, [d[98] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Local storage"), G("span", { class: "eyebrow" }, "Local storage"),
G("h2", null, "Data Management"), G("h2", null, "Data Management"),
G("p", null, "See exactly what the trainer has downloaded, generated, recorded, and produced.") G("p", null, "See exactly what the trainer has downloaded, generated, recorded, and produced.")
], -1), G("span", Eu, k(I(dc)(I(X).managedData.total_size_bytes)) + " total", 1)]), ], -1), G("span", Ou, k(I(dc)(I(X).managedData.total_size_bytes)) + " total", 1)]),
G("section", Du, [ G("section", ku, [
G("header", Ou, [ G("header", Au, [
d[97] ||= G("div", { class: "number" }, "i", -1), d[99] ||= G("div", { class: "number" }, "i", -1),
d[98] ||= G("div", null, [G("h3", null, "Trainer storage"), G("p", null, "Deleting an item is permanent. Required downloads and generated caches will be rebuilt the next time training needs them.")], -1), d[100] ||= G("div", null, [G("h3", null, "Trainer storage"), G("p", null, "Deleting an item is permanent. Required downloads and generated caches will be rebuilt the next time training needs them.")], -1),
G("button", { G("button", {
type: "button", type: "button",
disabled: I(Z)("data") || I(Z)("data-delete"), disabled: I(Z)("data") || I(Z)("data-delete"),
onClick: d[36] ||= (e) => I(rc)() onClick: d[36] ||= (e) => I(rc)()
}, k(I(Z)("data") ? "Scanning…" : "Refresh sizes"), 9, ku) }, k(I(Z)("data") ? "Scanning…" : "Refresh sizes"), 9, ju)
]), ]),
G("div", Au, [ G("div", Mu, [
G("article", null, [d[99] ||= G("span", null, "Space used", -1), G("strong", ju, k(I(dc)(I(X).managedData.total_size_bytes)), 1)]), G("article", null, [d[101] ||= G("span", null, "Space used", -1), G("strong", Nu, k(I(dc)(I(X).managedData.total_size_bytes)), 1)]),
G("article", null, [d[100] ||= G("span", null, "Files", -1), G("strong", null, k(Number(I(X).managedData.total_file_count || 0).toLocaleString()), 1)]), G("article", null, [d[102] ||= G("span", null, "Files", -1), G("strong", null, k(Number(I(X).managedData.total_file_count || 0).toLocaleString()), 1)]),
G("article", null, [d[101] ||= G("span", null, "Individual items", -1), G("strong", null, k(I(X).managedData.items.length), 1)]) G("article", null, [d[103] ||= G("span", null, "Individual items", -1), G("strong", null, k(I(X).managedData.items.length), 1)])
]), ]),
I(X).training.running ? (U(), W("p", Mu, "Stop the active training session before deleting data.")) : q("", !0) I(X).training.running ? (U(), W("p", Pu, "Stop the active training session before deleting data.")) : q("", !0)
]), ]),
(U(!0), W(V, null, Lr(g.value, (e, t) => (U(), W("section", { (U(!0), W(V, null, Lr(g.value, (e, t) => (U(), W("section", {
key: e.name, key: e.name,
class: "panel data-panel" class: "panel data-panel"
}, [G("header", Nu, [G("div", Pu, k(t + 1), 1), G("div", null, [G("h3", null, k(e.name), 1), G("p", null, k(e.items.length) + " separately managed item" + k(e.items.length === 1 ? "" : "s"), 1)])]), G("div", Fu, [(U(!0), W(V, null, Lr(e.items, (e) => (U(), W("article", { }, [G("header", Fu, [G("div", Iu, k(t + 1), 1), G("div", null, [G("h3", null, k(e.name), 1), G("p", null, k(e.items.length) + " separately managed item" + k(e.items.length === 1 ? "" : "s"), 1)])]), G("div", Lu, [(U(!0), W(V, null, Lr(e.items, (e) => (U(), W("article", {
key: e.id, key: e.id,
class: O(["data-row", { empty: !e.file_count }]) class: O(["data-row", { empty: !e.file_count }])
}, [ }, [
G("div", Iu, [ G("div", Ru, [
G("div", Lu, [G("strong", null, k(e.label), 1), G("code", null, k(e.location), 1)]), G("div", zu, [G("strong", null, k(e.label), 1), G("code", null, k(e.location), 1)]),
G("small", null, k(e.description), 1), G("small", null, k(e.description), 1),
e.rebuild_note ? (U(), W("span", Ru, k(e.rebuild_note), 1)) : q("", !0) e.rebuild_note ? (U(), W("span", Bu, k(e.rebuild_note), 1)) : q("", !0)
]), ]),
G("div", zu, [G("strong", null, k(I(dc)(e.size_bytes)), 1), G("span", null, k(Number(e.file_count || 0).toLocaleString()) + " file" + k(e.file_count === 1 ? "" : "s"), 1)]), G("div", Vu, [G("strong", null, k(I(dc)(e.size_bytes)), 1), G("span", null, k(Number(e.file_count || 0).toLocaleString()) + " file" + k(e.file_count === 1 ? "" : "s"), 1)]),
G("button", { G("button", {
type: "button", type: "button",
class: "button danger ghost", class: "button danger ghost",
disabled: !e.file_count || I(X).training.running || I(Z)("data") || I(Z)("data-delete"), disabled: !e.file_count || I(X).training.running || I(Z)("data") || I(Z)("data-delete"),
onClick: (t) => I(ic)(e) onClick: (t) => I(ic)(e)
}, k(I(Z)("data-delete") ? "Please wait…" : "Delete"), 9, Bu) }, k(I(Z)("data-delete") ? "Please wait…" : "Delete"), 9, Hu)
], 2))), 128))])]))), 128)), ], 2))), 128))])]))), 128)),
!I(Z)("data") && !I(X).managedData.items.length ? (U(), W("section", Vu, "No managed trainer data was found.")) : q("", !0) !I(Z)("data") && !I(X).managedData.items.length ? (U(), W("section", Uu, "No managed trainer data was found.")) : q("", !0)
], 64)) : I(X).activeView === "firmware" ? (U(), W(V, { key: 5 }, [ ], 64)) : I(X).activeView === "firmware" ? (U(), W(V, { key: 5 }, [
G("section", Hu, [d[102] ||= G("div", null, [ G("section", Wu, [d[104] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Wake-word catalog"), G("span", { class: "eyebrow" }, "Wake-word catalog"),
G("h2", null, "Trained Wake Words"), G("h2", null, "Trained Wake Words"),
G("p", null, "Copy a local JSON package URL into Tater to switch every native satellite live.") G("p", null, "Copy a local JSON package URL into Tater to switch every native satellite live.")
], -1), G("span", { class: O(["pill hero-pill", I(X).wakeWords.length ? "success" : "warning"]) }, k(I(X).wakeWords.length ? `${I(X).wakeWords.length} trained` : "Catalog empty"), 3)]), ], -1), G("span", { class: O(["pill hero-pill", I(X).wakeWords.length ? "success" : "warning"]) }, k(I(X).wakeWords.length ? `${I(X).wakeWords.length} trained` : "Catalog empty"), 3)]),
d[105] ||= G("div", { class: "native-notice" }, [G("strong", null, "Tater Native"), G("span", null, "These packages include model metadata and a direct model URL for live satellite updates.")], -1), d[109] ||= G("div", { class: "native-notice" }, [G("strong", null, "Tater Native"), G("span", null, "These packages include model metadata and a direct model URL for live satellite updates.")], -1),
G("section", Uu, [G("header", Wu, [ G("section", Gu, [G("header", Ku, [
d[103] ||= G("div", { class: "number" }, "v1", -1), d[105] ||= G("div", { class: "number" }, "v1", -1),
d[104] ||= G("div", null, [G("h3", null, "Published model URLs"), G("p", null, "URLs stay local and are refreshed after each successful run.")], -1), d[106] ||= G("div", null, [G("h3", null, "Published model URLs"), G("p", null, "URLs stay local and are refreshed after each successful run.")], -1),
G("button", { G("button", {
type: "button", type: "button",
disabled: I(Z)("firmware"), disabled: I(Z)("firmware"),
onClick: d[37] ||= (e) => I(nc)() onClick: d[37] ||= (e) => I(nc)()
}, "Refresh", 8, Gu) }, "Refresh", 8, qu)
]), I(X).wakeWords.length ? (U(), W("div", qu, [(U(!0), W(V, null, Lr(I(X).wakeWords, (e) => (U(), W("article", { key: e.key || T(e) }, [G("div", null, [ ]), I(X).wakeWords.length ? (U(), W("div", Yu, [(U(!0), W(V, null, Lr(I(X).wakeWords, (e) => (U(), W("article", { key: e.key || T(e) }, [G("div", null, [
G("strong", null, k(e.label || e.name || "Trained wake word"), 1), G("strong", null, k(e.label || e.name || "Trained wake word"), 1),
T(e) ? (U(), W("a", { T(e) ? (U(), W("a", {
key: 0, key: 0,
href: T(e), href: T(e),
target: "_blank", target: "_blank",
rel: "noreferrer" rel: "noreferrer"
}, "JSON · " + k(T(e)), 9, Ju)) : (U(), W("span", Yu, "JSON package URL unavailable")), }, "JSON · " + k(T(e)), 9, Xu)) : (U(), W("span", Zu, "JSON package URL unavailable")),
re(e) ? (U(), W("a", { E(e) ? (U(), W("a", {
key: 2, key: 2,
href: re(e), href: E(e),
target: "_blank", target: "_blank",
rel: "noreferrer" rel: "noreferrer"
}, "Model · " + k(re(e)), 9, Xu)) : q("", !0), }, "Model · " + k(E(e)), 9, Qu)) : q("", !0),
G("div", Zu, [ G("div", $u, [
e.language ? (U(), W("span", Qu, k(e.language), 1)) : q("", !0), e.language ? (U(), W("span", ed, k(e.language), 1)) : q("", !0),
e.trained_at ? (U(), W("span", $u, k(I(uc)(e.trained_at)), 1)) : q("", !0), e.trained_at ? (U(), W("span", td, k(I(uc)(e.trained_at)), 1)) : q("", !0),
e.recall === void 0 ? q("", !0) : (U(), W("span", ed, "recall " + k(e.recall), 1)) e.recall === void 0 ? q("", !0) : (U(), W("span", nd, "recall " + k(e.recall), 1))
]) ])
]), G("button", { ]), G("button", {
type: "button", type: "button",
disabled: !T(e), disabled: !T(e),
onClick: (t) => I(ac)(T(e)) onClick: (t) => I(ac)(T(e))
}, "Copy URL", 8, td)]))), 128))])) : (U(), W("div", Ku, "Train a wake word and its package will appear here."))]) }, "Copy URL", 8, rd)]))), 128))])) : (U(), W("div", Ju, "Train a wake word and its package will appear here."))]),
d[110] ||= G("div", { class: "native-notice esphome-notice" }, [G("strong", null, "ESPHome"), G("span", null, "Strict micro_wake_word manifest without Tater Native or calibration extensions.")], -1),
G("section", id, [d[108] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "ESP"), G("div", null, [G("h3", null, "ESPHome JSON"), G("p", null, "Use this URL as the model in an ESPHome micro_wake_word configuration.")])], -1), I(X).wakeWords.length ? (U(), W("div", od, [(U(!0), W(V, null, Lr(I(X).wakeWords, (e) => (U(), W("article", { key: `esphome-${e.key || re(e)}` }, [G("div", null, [
G("strong", null, k(e.label || e.name || "Trained wake word"), 1),
re(e) ? (U(), W("a", {
key: 0,
href: re(e),
target: "_blank",
rel: "noreferrer"
}, "ESPHome JSON · " + k(re(e)), 9, sd)) : (U(), W("span", cd, "ESPHome package URL unavailable")),
d[107] ||= G("div", { class: "meta-row" }, [G("span", null, "Schema v2"), G("span", null, "Same TFLite model")], -1)
]), G("button", {
type: "button",
disabled: !re(e),
onClick: (t) => I(ac)(re(e))
}, "Copy ESPHome URL", 8, ld)]))), 128))])) : (U(), W("div", ad, "ESPHome links appear after a wake word is trained."))])
], 64)) : q("", !0)], 64)) : (U(), W("div", Lc, [...d[46] ||= [G("span", { class: "spinner" }, null, -1), G("strong", null, "Connecting to the local trainer…", -1)]]))]), ], 64)) : q("", !0)], 64)) : (U(), W("div", Lc, [...d[46] ||= [G("span", { class: "spinner" }, null, -1), G("strong", null, "Connecting to the local trainer…", -1)]]))]),
(U(), ra(Jn, { to: "body" }, [I(X).consoleOpen ? (U(), W("div", { (U(), ra(Jn, { to: "body" }, [I(X).consoleOpen ? (U(), W("div", {
key: 0, key: 0,
class: "modal-backdrop console-backdrop", class: "modal-backdrop console-backdrop",
onClick: d[39] ||= as((e) => I(X).consoleOpen = !1, ["self"]) onClick: d[39] ||= as((e) => I(X).consoleOpen = !1, ["self"])
}, [G("section", nd, [G("header", rd, [d[106] ||= G("div", null, [ }, [G("section", ud, [G("header", dd, [d[111] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Live pipeline"), G("span", { class: "eyebrow" }, "Live pipeline"),
G("h2", null, "Training Console"), G("h2", null, "Training Console"),
G("p", null, "Closing this window does not interrupt training.") G("p", null, "Closing this window does not interrupt training.")
], -1), G("div", id, [ ], -1), G("div", fd, [
r.value ? q("", !0) : (U(), W("button", { r.value ? q("", !0) : (U(), W("button", {
key: 0, key: 0,
type: "button", type: "button",
@@ -4692,29 +4727,29 @@ var hc = {
onScrollPassive: v onScrollPassive: v
}, [(U(!0), W(V, null, Lr(h.value, (e, t) => (U(), W("span", { }, [(U(!0), W(V, null, Lr(h.value, (e, t) => (U(), W("span", {
key: `${t}-${e}`, key: `${t}-${e}`,
class: O(E(e)) class: O(ie(e))
}, k(e), 3))), 128))], 544)])])) : q("", !0)])), }, k(e), 3))), 128))], 544)])])) : q("", !0)])),
(U(), ra(Jn, { to: "body" }, [I(X).taterLinkOpen ? (U(), W("div", { (U(), ra(Jn, { to: "body" }, [I(X).taterLinkOpen ? (U(), W("div", {
key: 0, key: 0,
class: "modal-backdrop", class: "modal-backdrop",
onClick: d[43] ||= as((e) => I(X).taterLinkOpen = !1, ["self"]) onClick: d[43] ||= as((e) => I(X).taterLinkOpen = !1, ["self"])
}, [G("section", ad, [G("header", od, [G("div", null, [ }, [G("section", pd, [G("header", md, [G("div", null, [
d[107] ||= G("span", { class: "eyebrow" }, "Secure pairing", -1), d[112] ||= G("span", { class: "eyebrow" }, "Secure pairing", -1),
G("h2", null, k(o.value ? "Tater linked" : "Link Tater"), 1), G("h2", null, k(o.value ? "Tater linked" : "Link Tater"), 1),
G("p", null, k(o.value ? "This trainer can securely publish wake-word updates." : "Enter the short-lived code shown in Tater Voice Settings."), 1) G("p", null, k(o.value ? "This trainer can securely publish wake-word updates." : "Enter the short-lived code shown in Tater Voice Settings."), 1)
]), G("button", { ]), G("button", {
type: "button", type: "button",
onClick: d[40] ||= (e) => I(X).taterLinkOpen = !1 onClick: d[40] ||= (e) => I(X).taterLinkOpen = !1
}, "Close")]), o.value ? (U(), W("div", sd, [ }, "Close")]), o.value ? (U(), W("div", hd, [
d[108] ||= G("i", null, "✓", -1), d[113] ||= G("i", null, "✓", -1),
G("strong", null, "Successfully linked" + k(I(X).auto.trainer_link?.tater_name ? ` to ${I(X).auto.trainer_link.tater_name}` : ""), 1), G("strong", null, "Successfully linked" + k(I(X).auto.trainer_link?.tater_name ? ` to ${I(X).auto.trainer_link.tater_name}` : ""), 1),
d[109] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1) d[114] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1)
])) : (U(), W("div", cd, [ ])) : (U(), W("div", gd, [
G("label", ld, [d[110] ||= G("span", null, "Tater address", -1), R(G("input", { G("label", _d, [d[115] ||= G("span", null, "Tater address", -1), R(G("input", {
"onUpdate:modelValue": d[41] ||= (e) => i.value = e, "onUpdate:modelValue": d[41] ||= (e) => i.value = e,
type: "text" type: "text"
}, null, 512), [[Xo, i.value]])]), }, null, 512), [[Xo, i.value]])]),
G("label", ud, [d[111] ||= G("span", null, "Tater pairing code", -1), R(G("input", { G("label", vd, [d[116] ||= G("span", null, "Tater pairing code", -1), R(G("input", {
id: "pairing-code", id: "pairing-code",
"onUpdate:modelValue": d[42] ||= (e) => a.value = e, "onUpdate:modelValue": d[42] ||= (e) => a.value = e,
class: "pairing-code", class: "pairing-code",
@@ -4723,13 +4758,13 @@ var hc = {
autocomplete: "off", autocomplete: "off",
onInput: w onInput: w
}, null, 544), [[Xo, a.value]])]), }, null, 544), [[Xo, a.value]])]),
d[112] ||= G("small", null, "In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.", -1), d[117] ||= G("small", null, "In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.", -1),
G("button", { G("button", {
type: "button", type: "button",
class: "button primary", class: "button primary",
disabled: I(Z)("link"), disabled: I(Z)("link"),
onClick: ee onClick: ee
}, k(I(Z)("link") ? "Linking securely…" : "Link Tater"), 9, dd) }, k(I(Z)("link") ? "Linking securely…" : "Link Tater"), 9, yd)
]))])])) : q("", !0)])), ]))])])) : q("", !0)])),
K(Ec), K(Ec),
K($a, { name: "toast" }, { K($a, { name: "toast" }, {
@@ -4742,7 +4777,7 @@ var hc = {
}) })
])); ]));
} }
}), hd = document.getElementById("trainer-app"); }), Cd = document.getElementById("trainer-app");
if (!hd) throw Error("Missing #trainer-app mount point"); if (!Cd) throw Error("Missing #trainer-app mount point");
ds(md).mount(hd); ds(Sd).mount(Cd);
//#endregion //#endregion

View File

@@ -315,6 +315,10 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(metadata["transcript"], "turn on the kitchen lights") self.assertEqual(metadata["transcript"], "turn on the kitchen lights")
self.assertEqual(metadata["auto_review_stt_engine"], "faster_whisper") self.assertEqual(metadata["auto_review_stt_engine"], "faster_whisper")
self.assertEqual(metadata["auto_review_stt_model"], "small.en") self.assertEqual(metadata["auto_review_stt_model"], "small.en")
sample_item = trainer._sample_item_from_path(negatives[0], "negative")
self.assertEqual(sample_item["transcript"], "turn on the kitchen lights")
self.assertEqual(sample_item["auto_review_stt_engine"], "faster_whisper")
self.assertEqual(sample_item["auto_review_stt_model"], "small.en")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 1) self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 1)
def test_matching_phrase_stays_in_manual_review_inbox(self): def test_matching_phrase_stays_in_manual_review_inbox(self):
@@ -467,9 +471,30 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(metadata["auto_positive"]) self.assertTrue(metadata["auto_positive"])
self.assertEqual(metadata["review_status"], "auto_approved_personal") self.assertEqual(metadata["review_status"], "auto_approved_personal")
self.assertEqual(metadata["transcript"], "hey tater") self.assertEqual(metadata["transcript"], "hey tater")
sample_item = trainer._sample_item_from_path(positives[0], "personal")
self.assertEqual(sample_item["transcript"], "hey tater")
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav"))) self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0) self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
def test_guided_stt_remains_visible_after_positive_auto_sort(self):
self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with (
patch.object(trainer, "_transcribe_capture", return_value="Hey, haters."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hey Tater",
),
):
trainer._auto_review_capture("wake.wav")
positives = list(trainer.PERSONAL_DIR.glob("*.wav"))
self.assertEqual(len(positives), 1)
sample_item = trainer._sample_item_from_path(positives[0], "personal")
self.assertEqual(sample_item["transcript"], "Hey, haters.")
self.assertEqual(sample_item["auto_review_guided_transcript"], "Hey Tater")
def test_close_miss_without_phrase_stays_in_inbox(self): def test_close_miss_without_phrase_stays_in_inbox(self):
audio_path = self.add_capture(event_type="close_miss") audio_path = self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
@@ -583,6 +608,56 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["url"], rows[0]["json_url"]) self.assertEqual(rows[0]["url"], rows[0]["json_url"])
self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json")) self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json"))
self.assertTrue(
rows[0]["esphome_json_url"].endswith(
"/api/trained_wake_words/hey_tater.esphome.json"
)
)
def test_esphome_manifest_route_removes_tater_extensions(self):
with tempfile.TemporaryDirectory() as directory:
trained_dir = Path(directory)
(trained_dir / "hey_tater.tflite").write_bytes(b"model")
metadata = {
"type": "micro",
"wake_word": "hey tater",
"label": "Hey Tater",
"author": "Tater Totterson",
"website": "https://example.com",
"model": "hey_tater.tflite",
"trained_languages": ["en"],
"version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": {
"probability_cutoff": 0.97,
"sliding_window_size": 5,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0",
},
"tater_native": {"format_version": 1},
"calibration": {"recall": 0.99},
}
(trained_dir / "hey_tater.json").write_text(
json.dumps(metadata),
encoding="utf-8",
)
with (
patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir),
patch.object(trainer, "_sync_trained_wake_word_artifacts"),
):
response = trainer.trained_wake_word_artifact(
"hey_tater.esphome.json"
)
payload = json.loads(response.body)
self.assertEqual(set(payload), set(trainer.ESPHOME_MANIFEST_KEYS))
self.assertEqual(payload["micro"], metadata["micro"])
self.assertNotIn("label", payload)
self.assertNotIn("tater_native", payload)
self.assertNotIn("calibration", payload)
def test_tater_notification_fails_when_trained_word_is_missing(self): def test_tater_notification_fails_when_trained_word_is_missing(self):
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token" trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ast
import importlib.util import importlib.util
import json import json
import math import math
import shutil import shutil
import signal
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
@@ -69,6 +71,22 @@ class ModernTtsTests(unittest.TestCase):
["--position_temperature", "5.0", "--class_temperature", "0.0"], ["--position_temperature", "5.0", "--class_temperature", "0.0"],
) )
def test_moss_voice_clone_uses_audio_without_disallowed_prompt_text(self) -> None:
worker_path = REPO_ROOT / "cli" / "tts_moss_worker.py"
tree = ast.parse(worker_path.read_text(encoding="utf-8"))
inference_calls = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "inference"
]
self.assertEqual(len(inference_calls), 1)
keywords = {keyword.arg for keyword in inference_calls[0].keywords}
self.assertIn("prompt_audio_path", keywords)
self.assertNotIn("prompt_text", keywords)
def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None: def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir) data_dir = Path(temp_dir)
@@ -586,6 +604,87 @@ class ModernTtsTests(unittest.TestCase):
self.assertTrue((output_dir / ".generation_manifest.json").is_file()) self.assertTrue((output_dir / ".generation_manifest.json").is_file())
self.assertTrue(instance.cache_hit()) self.assertTrue(instance.cache_hit())
def test_normalization_times_out_bad_clip_and_continues(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
output_dir = data_dir / "work" / "wake_word_samples"
args = argparse.Namespace(
phrase="hey tater",
language="en",
tts_mode="modern",
samples=2,
batch_size=1,
voice_count=2,
data_dir=data_dir,
output_dir=output_dir,
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
raw_dir = instance.raw_dir / "qwen3"
raw_dir.mkdir(parents=True)
paths = [raw_dir / "bad.wav", raw_dir / "good.wav"]
for path in paths:
write_tone(path)
instance.speed_by_path[path.resolve()] = 1.0
calls = []
class FakeProcess:
def __init__(self, *, pid, timed_out):
self.pid = pid
self.timed_out = timed_out
self.wait_calls = []
def wait(self, timeout=None):
self.wait_calls.append(timeout)
if self.timed_out and len(self.wait_calls) == 1:
raise subprocess.TimeoutExpired(
calls[0][0],
generator_module.NORMALIZATION_TIMEOUT_SECONDS,
)
return -signal.SIGKILL if self.timed_out else 0
def kill(self):
return None
processes = []
def fake_ffmpeg(command, **kwargs):
calls.append((command, kwargs))
temp_path = Path(command[-1])
if len(calls) == 1:
temp_path.touch()
process = FakeProcess(pid=12345, timed_out=True)
else:
write_tone(temp_path)
process = FakeProcess(pid=12346, timed_out=False)
processes.append(process)
return process
messages = []
with (
patch.object(generator_module.subprocess, "Popen", side_effect=fake_ffmpeg),
patch.object(generator_module.os, "killpg") as killpg,
patch.object(generator_module, "log", side_effect=messages.append),
):
accepted = instance.normalize(paths, 0, 2)
self.assertEqual(len(calls), 2)
self.assertEqual(len(accepted), 1)
self.assertTrue((instance.final_dir / "0.wav").is_file())
self.assertFalse((instance.final_dir / "0.tmp.wav").exists())
self.assertIn("-nostdin", calls[0][0])
self.assertIs(calls[0][1]["stdin"], subprocess.DEVNULL)
self.assertTrue(calls[0][1]["start_new_session"])
self.assertEqual(
processes[0].wait_calls,
[generator_module.NORMALIZATION_TIMEOUT_SECONDS, 2.0],
)
killpg.assert_called_once_with(12345, signal.SIGKILL)
self.assertTrue(any("timed out" in message for message in messages))
self.assertTrue(any("1/2 accepted" in message for message in messages))
def test_docker_and_ui_are_wired_for_modern_tts(self) -> None: def test_docker_and_ui_are_wired_for_modern_tts(self) -> None:
for dockerfile in ("dockerfile", "dockerfile.blackwell"): for dockerfile in ("dockerfile", "dockerfile.blackwell"):
source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8") source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8")

View File

@@ -80,6 +80,14 @@ class VueTrainerUiTests(unittest.TestCase):
self.assertIn('@scroll.passive="onConsoleScroll"', app) self.assertIn('@scroll.passive="onConsoleScroll"', app)
self.assertIn("Jump to latest", app) self.assertIn("Jump to latest", app)
def test_saved_positive_and_negative_cards_keep_stt_results_visible(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
self.assertEqual(app.count('v-if="item.transcript" class="transcript"'), 2)
self.assertEqual(app.count('v-if="item.auto_review_guided_transcript"'), 2)
self.assertIn("auto_review_guided_transcript?: string", types)
def test_wake_word_card_uses_explicit_json_catalog_url(self) -> None: def test_wake_word_card_uses_explicit_json_catalog_url(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
@@ -89,6 +97,15 @@ class VueTrainerUiTests(unittest.TestCase):
self.assertNotIn("copyWakeWord(word.url)", app) self.assertNotIn("copyWakeWord(word.url)", app)
self.assertIn("json_url?: string", types) self.assertIn("json_url?: string", types)
def test_wake_words_tab_exposes_esphome_manifest_urls(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
self.assertIn("ESPHome JSON", app)
self.assertIn("wordEsphomeJsonUrl", app)
self.assertIn("Copy ESPHome URL", app)
self.assertIn("esphome_json_url?: string", types)
def test_runtime_packaging_uses_bundle_without_node(self) -> None: def test_runtime_packaging_uses_bundle_without_node(self) -> None:
dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"] dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"]
for dockerfile in dockerfiles: for dockerfile in dockerfiles:

View File

@@ -581,6 +581,24 @@ def _metadata_int(value: Any) -> int | None:
return None return None
ESPHOME_MANIFEST_SUFFIX = ".esphome.json"
ESPHOME_MANIFEST_KEYS = (
"type",
"wake_word",
"author",
"website",
"model",
"trained_languages",
"version",
"micro",
)
def _esphome_manifest(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Return only fields accepted by ESPHome's micro_wake_word v2 schema."""
return {key: metadata[key] for key in ESPHOME_MANIFEST_KEYS if key in metadata}
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]: def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
_sync_trained_wake_word_artifacts() _sync_trained_wake_word_artifacts()
base = str(base_url or "").rstrip("/") base = str(base_url or "").rstrip("/")
@@ -619,9 +637,11 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
recall = _metadata_float(calibration.get("recall")) recall = _metadata_float(calibration.get("recall"))
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour")) false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
json_url = f"/api/trained_wake_words/{quote(json_path.name)}" json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
esphome_json_url = f"/api/trained_wake_words/{quote(safe + ESPHOME_MANIFEST_SUFFIX)}"
model_url = f"/api/trained_wake_words/{quote(model_path.name)}" model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
if base: if base:
json_url = f"{base}{json_url}" json_url = f"{base}{json_url}"
esphome_json_url = f"{base}{esphome_json_url}"
model_url = f"{base}{model_url}" model_url = f"{base}{model_url}"
rows.append( rows.append(
@@ -634,6 +654,7 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
# New consumers should prefer the explicit `json_url` field. # New consumers should prefer the explicit `json_url` field.
"url": json_url, "url": json_url,
"json_url": json_url, "json_url": json_url,
"esphome_json_url": esphome_json_url,
"model_url": model_url, "model_url": model_url,
"json_file": json_path.name, "json_file": json_path.name,
"model_file": model_path.name, "model_file": model_path.name,
@@ -2690,6 +2711,9 @@ def _sample_item_from_path(audio_path: Path, bucket: str) -> Dict[str, Any]:
"message": meta.get("message") or "", "message": meta.get("message") or "",
"transcript": meta.get("transcript") or "", "transcript": meta.get("transcript") or "",
"transcribed_at": meta.get("transcribed_at") or "", "transcribed_at": meta.get("transcribed_at") or "",
"auto_review_guided_transcript": meta.get("auto_review_guided_transcript") or "",
"auto_review_stt_engine": meta.get("auto_review_stt_engine") or "",
"auto_review_stt_model": meta.get("auto_review_stt_model") or "",
"auto_negative": bool(meta.get("auto_negative")), "auto_negative": bool(meta.get("auto_negative")),
"auto_positive": bool(meta.get("auto_positive")), "auto_positive": bool(meta.get("auto_positive")),
"auto_review_reason": meta.get("auto_review_reason") or "", "auto_review_reason": meta.get("auto_review_reason") or "",
@@ -3960,6 +3984,24 @@ def trained_wake_word_artifact(filename: str):
if not safe_filename or Path(safe_filename).suffix.lower() not in {".json", ".tflite"}: if not safe_filename or Path(safe_filename).suffix.lower() not in {".json", ".tflite"}:
return JSONResponse({"ok": False, "error": "Unsupported wake word artifact."}, status_code=400) return JSONResponse({"ok": False, "error": "Unsupported wake word artifact."}, status_code=400)
_sync_trained_wake_word_artifacts() _sync_trained_wake_word_artifacts()
if safe_filename.endswith(ESPHOME_MANIFEST_SUFFIX):
source_stem = safe_filename[: -len(ESPHOME_MANIFEST_SUFFIX)]
source_path = TRAINED_WAKE_WORDS_DIR / f"{source_stem}.json"
if not source_stem or not source_path.is_file():
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)
try:
metadata = json.loads(source_path.read_text(encoding="utf-8"))
except Exception:
return JSONResponse({"ok": False, "error": "Wake word package is invalid."}, status_code=422)
if not isinstance(metadata, dict):
return JSONResponse({"ok": False, "error": "Wake word package is invalid."}, status_code=422)
model_name = Path(str(metadata.get("model") or f"{source_stem}.tflite")).name
if not (TRAINED_WAKE_WORDS_DIR / model_name).is_file():
return JSONResponse({"ok": False, "error": "Wake word model not found."}, status_code=404)
return JSONResponse(
_esphome_manifest(metadata),
headers={"Cache-Control": "no-store, max-age=0"},
)
artifact_path = TRAINED_WAKE_WORDS_DIR / safe_filename artifact_path = TRAINED_WAKE_WORDS_DIR / safe_filename
if not artifact_path.exists() or not artifact_path.is_file(): if not artifact_path.exists() or not artifact_path.is_file():
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404) return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)