mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 16:05:34 -06:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd71567a4f | ||
|
|
293318ad20 |
@@ -1,2 +1,2 @@
|
|||||||
- Fixed NVIDIA v22 training runs remaining stuck immediately after Start Session instead of launching the training worker.
|
- Prevented unreadable audio files from freezing sample preparation by adding a bounded FFmpeg watchdog, safe cleanup, and visible normalization progress.
|
||||||
- Corrected the worker-state handoff for both manual and automatic training, with regression coverage for the complete startup path.
|
- Kept primary and guided STT results visible after captures are automatically sorted into positive or negative training samples.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,11 +1310,22 @@ 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(
|
||||||
|
f"⚠️ Normalization timed out after "
|
||||||
|
f"{NORMALIZATION_TIMEOUT_SECONDS:g}s; skipping {path.name}"
|
||||||
|
)
|
||||||
|
elif return_code != 0:
|
||||||
|
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 ""
|
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:
|
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes:
|
||||||
temp_path.replace(final_path)
|
temp_path.replace(final_path)
|
||||||
@@ -1284,6 +1333,16 @@ class Generator:
|
|||||||
accepted.append(final_path)
|
accepted.append(final_path)
|
||||||
else:
|
else:
|
||||||
temp_path.unlink(missing_ok=True)
|
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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 trainer’s 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 trainer’s 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -586,6 +587,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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user