Release NVIDIA WakeWord Trainer v24

This commit is contained in:
MasterPhooey
2026-08-03 07:25:58 -05:00
parent 1f16f6f916
commit fee845b1de
10 changed files with 173 additions and 36 deletions

View File

@@ -1 +1 @@
23 24

View File

@@ -1,2 +1,3 @@
- Fixed NVIDIA v22 training runs remaining stuck immediately after Start Session instead of launching the training worker. - Added an ESPHome section to the Wake Words tab with a dedicated, copyable micro_wake_word JSON URL for every trained model.
- Corrected the worker-state handoff for both manual and automatic training, with regression coverage for the complete startup path. - Kept the full Tater Native package unchanged while serving a separate strict ESPHome v2 manifest that references the same TFLite model.
- Added regression coverage for ESPHome manifest generation and the shared Vue interface.

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();
@@ -301,6 +302,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

@@ -76,6 +76,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

@@ -4004,23 +4004,32 @@ var hc = {
}, Ju = ["href"], Yu = { }, Ju = ["href"], Yu = {
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 = { }, Xu = ["href"], Zu = { class: "meta-row" }, Qu = { key: 0 }, $u = { key: 1 }, ed = { key: 2 }, td = ["disabled", "onClick"], nd = { class: "panel compatibility-panel" }, rd = {
key: 0,
class: "empty-state"
}, id = {
key: 1,
class: "word-list"
}, ad = ["href"], od = {
key: 1,
class: "muted"
}, sd = ["disabled", "onClick"], cd = {
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 = { }, ld = { class: "modal-head" }, ud = { class: "row console-actions" }, dd = {
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 = { }, fd = { class: "modal-head" }, pd = {
key: 0, key: 0,
class: "link-success" class: "link-success"
}, cd = { }, md = {
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({ }, hd = { class: "field" }, gd = { class: "field" }, _d = ["disabled"], vd = "/static/images/tater-wake-word-trainer.png", yd = 50, bd = /* @__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 +4065,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 * yd, (e + 1) * yd);
}), 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 / yd))), 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 +4165,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[116] ||= 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[117] ||= G("div", {
class: "ambient ambient-two", class: "ambient ambient-two",
"aria-hidden": "true" "aria-hidden": "true"
}, null, -1), }, null, -1),
@@ -4175,7 +4187,7 @@ var hc = {
class: "brand-mark", class: "brand-mark",
"aria-hidden": "true" "aria-hidden": "true"
}, [G("img", { }, [G("img", {
src: fd, src: vd,
alt: "" alt: ""
})]), d[44] ||= G("div", null, [ })]), d[44] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Tater tools"), G("span", { class: "eyebrow" }, "Tater tools"),
@@ -4631,7 +4643,7 @@ var hc = {
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[107] ||= 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", Uu, [G("header", Wu, [
d[103] ||= G("div", { class: "number" }, "v1", -1), d[103] ||= 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[104] ||= G("div", null, [G("h3", null, "Published model URLs"), G("p", null, "URLs stay local and are refreshed after each successful run.")], -1),
@@ -4648,12 +4660,12 @@ var hc = {
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, Ju)) : (U(), W("span", Yu, "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, Xu)) : q("", !0),
G("div", Zu, [ G("div", Zu, [
e.language ? (U(), W("span", Qu, k(e.language), 1)) : q("", !0), e.language ? (U(), W("span", Qu, 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", $u, k(I(uc)(e.trained_at)), 1)) : q("", !0),
@@ -4663,17 +4675,32 @@ var hc = {
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, td)]))), 128))])) : (U(), W("div", Ku, "Train a wake word and its package will appear here."))]),
d[108] ||= 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", nd, [d[106] ||= 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", id, [(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, ad)) : (U(), W("span", od, "ESPHome package URL unavailable")),
d[105] ||= 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, sd)]))), 128))])) : (U(), W("div", rd, "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", cd, [G("header", ld, [d[109] ||= 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", ud, [
r.value ? q("", !0) : (U(), W("button", { r.value ? q("", !0) : (U(), W("button", {
key: 0, key: 0,
type: "button", type: "button",
@@ -4692,29 +4719,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", dd, [G("header", fd, [G("div", null, [
d[107] ||= G("span", { class: "eyebrow" }, "Secure pairing", -1), d[110] ||= 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", pd, [
d[108] ||= G("i", null, "✓", -1), d[111] ||= 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[112] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1)
])) : (U(), W("div", cd, [ ])) : (U(), W("div", md, [
G("label", ld, [d[110] ||= G("span", null, "Tater address", -1), R(G("input", { G("label", hd, [d[113] ||= 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", gd, [d[114] ||= 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 +4750,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[115] ||= 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, _d)
]))])])) : q("", !0)])), ]))])])) : q("", !0)])),
K(Ec), K(Ec),
K($a, { name: "toast" }, { K($a, { name: "toast" }, {
@@ -4742,7 +4769,7 @@ var hc = {
}) })
])); ]));
} }
}), hd = document.getElementById("trainer-app"); }), xd = document.getElementById("trainer-app");
if (!hd) throw Error("Missing #trainer-app mount point"); if (!xd) throw Error("Missing #trainer-app mount point");
ds(md).mount(hd); ds(bd).mount(xd);
//#endregion //#endregion

View File

@@ -583,6 +583,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

@@ -89,6 +89,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,
@@ -3960,6 +3981,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)