diff --git a/VERSION b/VERSION
index 4099407..a45fd52 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-23
+24
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index 17bd332..2c94c07 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -1,2 +1,3 @@
-- Fixed NVIDIA v22 training runs remaining stuck immediately after Start Session instead of launching the training worker.
-- Corrected the worker-state handoff for both manual and automatic training, with regression coverage for the complete startup path.
+- Added an ESPHome section to the Wake Words tab with a dedicated, copyable micro_wake_word JSON URL for every trained model.
+- 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.
diff --git a/frontend/src/TrainerApp.vue b/frontend/src/TrainerApp.vue
index 2cff898..335c0f7 100644
--- a/frontend/src/TrainerApp.vue
+++ b/frontend/src/TrainerApp.vue
@@ -162,6 +162,7 @@ function sampleSubtitle(item: AudioItem): string {
return rows.join(" · ") || "Training sample";
}
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 consoleTone(line: string): string {
const value = line.trim().toLowerCase();
@@ -301,6 +302,11 @@ function consoleTone(line: string): string {
Train a wake word and its package will appear here.
+ ESPHomeStrict micro_wake_word manifest without Tater Native or calibration extensions.
+
+ ESPHome links appear after a wake word is trained.
+
+
diff --git a/frontend/src/trainer.css b/frontend/src/trainer.css
index 6f9f8fb..d1e0981 100644
--- a/frontend/src/trainer.css
+++ b/frontend/src/trainer.css
@@ -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; }
.native-notice { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; }
.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 > div { display: grid; min-width: 0; gap: 6px; }
.word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; }
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 476afce..625074b 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -76,6 +76,8 @@ export interface WakeWordItem extends JsonRecord {
url?: string;
json_url?: string;
jsonUrl?: string;
+ esphome_json_url?: string;
+ esphomeJsonUrl?: string;
model_url?: string;
modelUrl?: string;
}
diff --git a/static/ui/trainer-ui.css b/static/ui/trainer-ui.css
index 8858440..ee50976 100644
--- a/static/ui/trainer-ui.css
+++ b/static/ui/trainer-ui.css
@@ -1,2 +1,2 @@
-:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;color:#f3f1ee;font-synthesis:none;--bg:#0d0d0e;--surface:#1d1d1fe6;--surface-solid:#1c1c1e;--surface-2:#2b2b2ecc;--line:#ffffff1a;--line-strong:#ffffff2e;--text:#f3f1ee;--muted:#aaa6a0;--orange:#ff9134;--orange-2:#ffb267;--violet:#77736e;--blue:#a8a5a1;--green:#44dda5;--red:#ff6c7d;--yellow:#ffc561;--shadow:0 24px 70px #00000052;background:#0d0d0e;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{background:var(--bg);min-height:100%}body{background:radial-gradient(circle at 78% -10%,#ff913414,#0000 34%),linear-gradient(145deg,#121213,#0d0d0e 60%,#151413);min-width:320px;min-height:100vh;margin:0}button,input,select{font:inherit}button,.button{border:1px solid var(--line-strong);min-height:42px;color:var(--text);cursor:pointer;background:#303033db;border-radius:12px;padding:9px 16px;font-weight:700;transition:border-color .18s,transform .18s,background .18s,box-shadow .18s}button:hover:not(:disabled),.button:hover:not(:disabled){background:#3e3d3df0;border-color:#ff91348c;transform:translateY(-1px)}button:focus-visible,input:focus-visible,select:focus-visible{outline-offset:2px;outline:2px solid #ff9134e0}button:disabled{opacity:.43;cursor:not-allowed}.button.primary{color:#18100a;background:linear-gradient(135deg, var(--orange), #ffb45f);border-color:#ffad63;box-shadow:0 10px 28px #ff7e2330}.button.primary:hover:not(:disabled){background:linear-gradient(135deg,#ffa04c,#ffc078)}.button.danger{color:#fff;background:#ff4e6533;border-color:#ff6c7d8a}.button.ghost{background:0 0}.button.large{min-width:min(100%,360px);min-height:54px;font-size:16px}.app-shell{width:min(1180px,100% - 36px);margin:0 auto;padding:30px 0 80px;position:relative}.ambient{z-index:-1;filter:blur(95px);opacity:.16;pointer-events:none;border-radius:50%;width:380px;height:380px;position:fixed}.ambient-one{background:var(--violet);top:-160px;right:4vw}.ambient-two{background:var(--orange);bottom:-180px;left:-70px}.app-header{justify-content:space-between;align-items:center;gap:24px;margin-bottom:24px;display:flex}.brand{align-items:center;gap:16px;display:flex}.brand-mark{background:radial-gradient(circle at 50% 36%,#383330,#191819 72%);border:1px solid #ffa45266;border-radius:19px;flex:none;place-items:center;width:58px;height:58px;display:grid;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff1f,0 14px 36px #00000040}.brand-mark img{object-fit:contain;filter:drop-shadow(0 5px 9px #0000005c);width:56px;height:56px;display:block}.brand h1,.hero h2,.panel h3,.modal h2{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif}.brand h1{letter-spacing:-.035em;margin:2px 0 1px;font-size:clamp(22px,3vw,31px)}.brand p,.hero p,.panel p,.modal p{color:var(--muted);margin:0;line-height:1.55}.brand p{font-size:13px}.eyebrow{color:var(--orange-2);letter-spacing:.18em;text-transform:uppercase;font-size:10px;font-weight:800}.header-status{align-items:center;gap:10px;display:flex}.live-dot,.session-chip{border:1px solid var(--line);min-height:34px;color:var(--muted);background:#181819c7;border-radius:999px;align-items:center;padding:7px 11px;font-size:12px;font-weight:700;display:inline-flex}.live-dot i{background:var(--green);border-radius:50%;width:7px;height:7px;margin-right:7px;box-shadow:0 0 0 4px #44dda51a}.tabs{z-index:20;border:1px solid var(--line);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#141415e6;border-radius:16px;grid-template-columns:repeat(6,1fr);gap:5px;margin-bottom:18px;padding:6px;display:grid;position:sticky;top:12px;box-shadow:0 14px 36px #0003}.tabs button{min-height:42px;color:var(--muted);background:0 0;border-color:#0000;padding:8px;font-size:13px;position:relative}.tabs button.active{color:#fff;background:linear-gradient(135deg,#ff913438,#5c595638);border-color:#ff98416b;box-shadow:inset 0 1px #ffffff0d}.tabs button b{color:#23120b;background:var(--orange);border-radius:99px;place-items:center;min-width:18px;height:18px;margin-left:7px;padding:0 4px;font-size:10px;display:inline-grid}.tab-short{display:none}.main-content{gap:16px;display:grid}.hero,.panel,.native-notice{border:1px solid var(--line);background:var(--surface);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border-radius:22px}.hero{justify-content:space-between;align-items:flex-end;gap:30px;min-height:210px;padding:34px;display:flex;position:relative;overflow:hidden}.hero:after{content:"";background:radial-gradient(circle,#ff913438,#0000 67%);border-radius:50%;width:290px;height:290px;position:absolute;bottom:-95px;right:-45px}.auto-hero:after{background:radial-gradient(circle,#ff913429,#0000 67%)}.capture-hero:after{background:radial-gradient(circle,#beb8b11f,#0000 67%)}.firmware-hero:after{background:radial-gradient(circle,#ff913421,#0000 67%)}.hero>*{z-index:1;position:relative}.hero h2{letter-spacing:-.05em;max-width:760px;margin:8px 0;font-size:clamp(27px,5vw,48px);line-height:1.02}.hero p{max-width:720px;font-size:15px}.hero-pill{flex:none}.step-row{gap:7px;min-width:165px;display:grid}.step-row span{color:#d5d1cc;align-items:center;gap:8px;font-size:12px;font-weight:700;display:flex}.step-row b,.number{color:#26150b;background:linear-gradient(135deg, var(--orange), #ffc175);border-radius:11px;flex:none;place-items:center;width:32px;height:32px;font-size:12px;display:inline-grid}.panel{padding:26px}.panel-head{grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;margin-bottom:23px;display:grid}.panel-head h3{letter-spacing:-.025em;margin:0 0 3px;font-size:20px}.panel-head p{font-size:13px}.form-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.phrase-form{grid-template-columns:repeat(2,minmax(0,1fr))}.field{align-content:start;gap:7px;display:grid}.field>span{color:#ddd9d4;letter-spacing:.01em;font-size:12px;font-weight:800}.field.wide{grid-column:1/-1}.field input,.field select{border:1px solid var(--line-strong);width:100%;min-height:46px;color:var(--text);background:#0f0f10d1;border-radius:12px;padding:10px 13px}.field select{appearance:auto}.field input:disabled,.field select:disabled{opacity:1;cursor:not-allowed;color:#aaa7a3;-webkit-text-fill-color:#aaa7a3;background:#464544b8;border-color:#97938e38}.field small,.dropzone small,.progress-card small,.stack>small{color:var(--muted);font-size:11px;line-height:1.45}.row{align-items:center;gap:9px;display:flex}.row.space{justify-content:space-between}.form-actions{margin-top:16px}.stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;display:grid}.stats article{border:1px solid var(--line);background:#1212139e;border-radius:15px;gap:5px;min-height:105px;padding:17px;display:grid}.stats span{color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:700}.stats strong{align-self:end;font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:30px}.stats .format-value{font-size:15px;line-height:1.35}.train-action{place-items:center;padding:29px 0 19px;display:grid}.panel-footer{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:14px;padding-top:17px;font-size:12px;display:flex}.pill{border:1px solid var(--line-strong);color:#d1cdc8;white-space:nowrap;background:#302f2fbd;border-radius:999px;align-items:center;width:fit-content;min-height:29px;padding:5px 10px;font-size:11px;font-weight:800;display:inline-flex}.pill.success{color:#8bf2cc;background:#24a07621;border-color:#44dda559}.pill.warning{color:#ffd58a;background:#d6922421;border-color:#ffc5615c}.pill.error{color:#ffabb5;background:#dc445821;border-color:#ff6c7d5c}.toggle-list{gap:9px;margin-bottom:18px;display:grid}.toggle-list.compact{margin:15px 0 0}.toggle-list label{border:1px solid var(--line);cursor:pointer;background:#1212138f;border-radius:14px;align-items:flex-start;gap:12px;padding:13px;display:flex}.toggle-list input{width:18px;height:18px;accent-color:var(--orange);margin:2px 0 0}.toggle-list label>span{gap:3px;display:grid}.toggle-list small{color:var(--muted);line-height:1.45}.link-row{align-items:center;gap:10px;margin-top:15px;display:flex}.action-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;display:grid}.audit,.transcript{color:#d2cec9;background:#ff91340e;border:1px solid #ff913438;border-radius:13px;padding:13px;font-size:12px;line-height:1.55}.action-panel .audit{margin-top:15px}.audio-list,.word-list{gap:12px;display:grid}.audio-card{border:1px solid var(--line);background:#121213a3;border-radius:17px;gap:13px;padding:17px;display:grid}.audio-card header,.audio-card footer{justify-content:space-between;align-items:flex-start;gap:15px;display:flex}.audio-card header>div:first-child{gap:3px;min-width:0;display:grid}.audio-card header strong{overflow-wrap:anywhere}.audio-card small,.audio-card footer>span{color:var(--muted);font-size:11px;line-height:1.5}.audio-card audio{width:100%;height:42px}.audio-card footer{align-items:center}.audio-card footer>div{flex-wrap:wrap;justify-content:flex-end;gap:7px;display:flex}.audio-card footer button{min-height:36px;padding:6px 11px;font-size:11px}.meta-row{flex-wrap:wrap;gap:6px;display:flex}.meta-row span{border:1px solid var(--line);color:#bdb8b2;background:#323131ad;border-radius:99px;padding:4px 8px;font-size:10px}.empty-state{border:1px dashed var(--line-strong);min-height:130px;color:var(--muted);text-align:center;border-radius:15px;place-items:center;padding:24px;display:grid}.toolbar{flex-wrap:wrap;margin-bottom:14px}.segment-control{border:1px solid var(--line);background:#101011ad;border-radius:12px;gap:4px;padding:4px;display:flex}.segment-control button{background:0 0;border-color:#0000;min-height:34px;padding:5px 9px;font-size:11px}.segment-control button.active{background:#ff913424;border-color:#ff913447}.segment-control b{color:var(--orange-2);margin-left:4px}.pagination{color:var(--muted);justify-content:center;align-items:center;gap:12px;margin-top:16px;font-size:12px;display:flex}.dropzone{cursor:pointer;background:#ff91340d;border:1px dashed #ff913473;border-radius:16px;justify-content:space-between;align-items:center;gap:18px;min-height:100px;margin-bottom:14px;padding:19px;display:flex;position:relative}.dropzone input{opacity:0;cursor:pointer;position:absolute;inset:0}.dropzone span{gap:5px;display:grid}.dropzone>b{color:var(--orange-2);white-space:nowrap;background:#ff913426;border-radius:10px;padding:8px 12px;font-size:12px}.progress-card{border:1px solid var(--line);background:#121213a3;border-radius:14px;gap:9px;margin-top:15px;padding:14px;display:grid}.progress-card>div:first-child{justify-content:space-between;gap:10px;display:flex}.progress-card span{color:var(--orange-2);font-size:12px}.progress-track{background:#ffffff12;border-radius:99px;height:7px;overflow:hidden}.progress-track i{border-radius:inherit;background:linear-gradient(90deg, var(--orange), var(--violet));height:100%;transition:width .2s;display:block}.native-notice{color:var(--muted);align-items:center;gap:12px;padding:14px 18px;font-size:12px;display:flex}.native-notice strong{color:var(--green)}.word-list article{border:1px solid var(--line);background:#121213a3;border-radius:15px;justify-content:space-between;align-items:center;gap:20px;padding:16px;display:flex}.word-list article>div{gap:6px;min-width:0;display:grid}.word-list a{overflow-wrap:anywhere;color:var(--orange-2);font-size:11px;text-decoration:none}.data-hero:after{background:radial-gradient(circle,#b0aba426,#0000 67%)}.data-panel{padding-bottom:18px}.data-list{gap:9px;display:grid}.data-row{border:1px solid var(--line);background:#121213a3;border-radius:15px;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;padding:15px 16px;display:grid}.data-row.empty{background:#12121357}.data-copy{gap:6px;min-width:0;display:grid}.data-title{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.data-title strong{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:15px}.data-title code{overflow-wrap:anywhere;border:1px solid var(--line);color:#aaa6a0;background:#3736358c;border-radius:7px;padding:3px 7px;font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.data-copy small,.data-note,.data-usage span{color:var(--muted);font-size:11px;line-height:1.45}.data-note{color:#c7a57d}.data-usage{text-align:right;gap:4px;min-width:105px;display:grid}.data-usage strong{color:var(--orange-2);font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:16px}.data-row.empty .data-usage strong{color:#8c8883}.data-row>button{min-width:82px}.data-warning{background:#d6922417;border:1px solid #ffc5614d;border-radius:12px;padding:11px 13px;font-size:12px;color:#ffd58a!important;margin-top:14px!important}.loading-panel{min-height:400px;color:var(--muted);justify-content:center;align-items:center;gap:12px;display:flex}.spinner{border:2px solid #ffffff24;border-top-color:var(--orange);border-radius:50%;width:22px;height:22px;animation:.8s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.modal-backdrop{z-index:100;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#050506cc;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line-strong);background:#1c1c1e;border-radius:21px;width:min(680px,100%);max-height:calc(100vh - 40px);padding:23px;overflow:auto;box-shadow:0 36px 100px #0000008c}.modal-head{justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:18px;display:flex}.modal-head h2{margin:4px 0;font-size:24px}.console-modal{width:min(980px,100%)}.console-actions{flex-wrap:wrap;justify-content:flex-end}.console-follow{min-height:34px;color:var(--orange-2);background:#ff91341f;border-color:#ff91346b;padding:6px 11px;font-size:11px}.console-log{color:#cbc6c0;white-space:pre-wrap;background:#0b0b0c;border:1px solid #ff91342e;border-radius:14px;min-height:430px;max-height:calc(100vh - 190px);margin:0;padding:17px;font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;display:block;overflow:auto}.console-log span{min-height:1.65em;display:block}.console-log .success{color:#73e4b9}.console-log .error{color:#ff8290}.console-log .warning{color:#ffd079}.console-log .heading{color:var(--orange-2);font-weight:700}.stack{gap:14px;display:grid}.pairing-code{text-align:center;letter-spacing:.14em;text-transform:uppercase;font:700 28px/1 ui-rounded,SF Pro Rounded,system-ui,sans-serif}.link-success{text-align:center;place-items:center;gap:11px;padding:30px;display:grid}.link-success i{width:54px;height:54px;color:var(--green);background:#44dda51f;border:1px solid #44dda566;border-radius:50%;place-items:center;font-size:25px;font-style:normal;display:grid}.link-success span{color:var(--muted);font-size:12px}.trim-modal{width:min(820px,100%)}.waveform{border:1px solid var(--line);background:#0d0d0e;border-radius:14px;width:100%;height:210px;margin:16px 0}.range-grid{grid-template-columns:1fr 1fr;gap:13px;margin-bottom:13px;display:grid}.range-grid label{color:var(--muted);gap:7px;font-size:11px;display:grid}.range-grid input{width:100%;accent-color:var(--orange)}.modal-actions{justify-content:flex-end;margin-top:14px}.muted{color:var(--muted)}.toast{z-index:200;color:#eafff7;background:#144838f2;border:1px solid #44dda561;border-radius:13px;max-width:min(420px,100% - 44px);padding:13px 16px;font-size:13px;font-weight:700;position:fixed;bottom:22px;right:22px;box-shadow:0 18px 45px #0006}.toast.warning{background:#5d4116f7;border-color:#ffc56173}.toast.error{background:#5e1f2bf7;border-color:#ff6c7d73}.toast-enter-active,.toast-leave-active{transition:opacity .2s,transform .2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(10px)}@media (width<=920px){.tab-full{display:none}.tab-short{display:inline}}@media (width<=780px){.app-shell{width:min(100% - 22px,1180px);padding-top:17px}.app-header{align-items:flex-start}.header-status{display:none}.tabs{top:7px}.tab-full{display:none}.tab-short{display:inline}.hero{min-height:unset;align-items:flex-start;padding:24px}.step-row{display:none}.panel{padding:19px}.panel-head{grid-template-columns:auto minmax(0,1fr)}.panel-head>:last-child:not(:nth-child(2)){grid-column:1/-1}.form-grid,.phrase-form,.stats,.action-grid,.range-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.audio-card header,.audio-card footer,.word-list article,.panel-footer{flex-direction:column;align-items:stretch}.audio-card footer>div{justify-content:flex-start}.word-list article>button{width:100%}.sample-head .segment-control{grid-column:1/-1}.segment-control button{flex:1}.data-row{grid-template-columns:1fr auto}.data-copy{grid-column:1/-1}.data-usage{text-align:left}.data-row>button{min-width:96px}.modal-backdrop{padding:8px}.modal{max-height:calc(100vh - 16px);padding:17px}.modal-head{flex-direction:column}.console-actions{justify-content:flex-start}.console-log{min-height:55vh}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
+:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;color:#f3f1ee;font-synthesis:none;--bg:#0d0d0e;--surface:#1d1d1fe6;--surface-solid:#1c1c1e;--surface-2:#2b2b2ecc;--line:#ffffff1a;--line-strong:#ffffff2e;--text:#f3f1ee;--muted:#aaa6a0;--orange:#ff9134;--orange-2:#ffb267;--violet:#77736e;--blue:#a8a5a1;--green:#44dda5;--red:#ff6c7d;--yellow:#ffc561;--shadow:0 24px 70px #00000052;background:#0d0d0e;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{background:var(--bg);min-height:100%}body{background:radial-gradient(circle at 78% -10%,#ff913414,#0000 34%),linear-gradient(145deg,#121213,#0d0d0e 60%,#151413);min-width:320px;min-height:100vh;margin:0}button,input,select{font:inherit}button,.button{border:1px solid var(--line-strong);min-height:42px;color:var(--text);cursor:pointer;background:#303033db;border-radius:12px;padding:9px 16px;font-weight:700;transition:border-color .18s,transform .18s,background .18s,box-shadow .18s}button:hover:not(:disabled),.button:hover:not(:disabled){background:#3e3d3df0;border-color:#ff91348c;transform:translateY(-1px)}button:focus-visible,input:focus-visible,select:focus-visible{outline-offset:2px;outline:2px solid #ff9134e0}button:disabled{opacity:.43;cursor:not-allowed}.button.primary{color:#18100a;background:linear-gradient(135deg, var(--orange), #ffb45f);border-color:#ffad63;box-shadow:0 10px 28px #ff7e2330}.button.primary:hover:not(:disabled){background:linear-gradient(135deg,#ffa04c,#ffc078)}.button.danger{color:#fff;background:#ff4e6533;border-color:#ff6c7d8a}.button.ghost{background:0 0}.button.large{min-width:min(100%,360px);min-height:54px;font-size:16px}.app-shell{width:min(1180px,100% - 36px);margin:0 auto;padding:30px 0 80px;position:relative}.ambient{z-index:-1;filter:blur(95px);opacity:.16;pointer-events:none;border-radius:50%;width:380px;height:380px;position:fixed}.ambient-one{background:var(--violet);top:-160px;right:4vw}.ambient-two{background:var(--orange);bottom:-180px;left:-70px}.app-header{justify-content:space-between;align-items:center;gap:24px;margin-bottom:24px;display:flex}.brand{align-items:center;gap:16px;display:flex}.brand-mark{background:radial-gradient(circle at 50% 36%,#383330,#191819 72%);border:1px solid #ffa45266;border-radius:19px;flex:none;place-items:center;width:58px;height:58px;display:grid;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff1f,0 14px 36px #00000040}.brand-mark img{object-fit:contain;filter:drop-shadow(0 5px 9px #0000005c);width:56px;height:56px;display:block}.brand h1,.hero h2,.panel h3,.modal h2{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif}.brand h1{letter-spacing:-.035em;margin:2px 0 1px;font-size:clamp(22px,3vw,31px)}.brand p,.hero p,.panel p,.modal p{color:var(--muted);margin:0;line-height:1.55}.brand p{font-size:13px}.eyebrow{color:var(--orange-2);letter-spacing:.18em;text-transform:uppercase;font-size:10px;font-weight:800}.header-status{align-items:center;gap:10px;display:flex}.live-dot,.session-chip{border:1px solid var(--line);min-height:34px;color:var(--muted);background:#181819c7;border-radius:999px;align-items:center;padding:7px 11px;font-size:12px;font-weight:700;display:inline-flex}.live-dot i{background:var(--green);border-radius:50%;width:7px;height:7px;margin-right:7px;box-shadow:0 0 0 4px #44dda51a}.tabs{z-index:20;border:1px solid var(--line);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#141415e6;border-radius:16px;grid-template-columns:repeat(6,1fr);gap:5px;margin-bottom:18px;padding:6px;display:grid;position:sticky;top:12px;box-shadow:0 14px 36px #0003}.tabs button{min-height:42px;color:var(--muted);background:0 0;border-color:#0000;padding:8px;font-size:13px;position:relative}.tabs button.active{color:#fff;background:linear-gradient(135deg,#ff913438,#5c595638);border-color:#ff98416b;box-shadow:inset 0 1px #ffffff0d}.tabs button b{color:#23120b;background:var(--orange);border-radius:99px;place-items:center;min-width:18px;height:18px;margin-left:7px;padding:0 4px;font-size:10px;display:inline-grid}.tab-short{display:none}.main-content{gap:16px;display:grid}.hero,.panel,.native-notice{border:1px solid var(--line);background:var(--surface);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border-radius:22px}.hero{justify-content:space-between;align-items:flex-end;gap:30px;min-height:210px;padding:34px;display:flex;position:relative;overflow:hidden}.hero:after{content:"";background:radial-gradient(circle,#ff913438,#0000 67%);border-radius:50%;width:290px;height:290px;position:absolute;bottom:-95px;right:-45px}.auto-hero:after{background:radial-gradient(circle,#ff913429,#0000 67%)}.capture-hero:after{background:radial-gradient(circle,#beb8b11f,#0000 67%)}.firmware-hero:after{background:radial-gradient(circle,#ff913421,#0000 67%)}.hero>*{z-index:1;position:relative}.hero h2{letter-spacing:-.05em;max-width:760px;margin:8px 0;font-size:clamp(27px,5vw,48px);line-height:1.02}.hero p{max-width:720px;font-size:15px}.hero-pill{flex:none}.step-row{gap:7px;min-width:165px;display:grid}.step-row span{color:#d5d1cc;align-items:center;gap:8px;font-size:12px;font-weight:700;display:flex}.step-row b,.number{color:#26150b;background:linear-gradient(135deg, var(--orange), #ffc175);border-radius:11px;flex:none;place-items:center;width:32px;height:32px;font-size:12px;display:inline-grid}.panel{padding:26px}.panel-head{grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;margin-bottom:23px;display:grid}.panel-head h3{letter-spacing:-.025em;margin:0 0 3px;font-size:20px}.panel-head p{font-size:13px}.form-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.phrase-form{grid-template-columns:repeat(2,minmax(0,1fr))}.field{align-content:start;gap:7px;display:grid}.field>span{color:#ddd9d4;letter-spacing:.01em;font-size:12px;font-weight:800}.field.wide{grid-column:1/-1}.field input,.field select{border:1px solid var(--line-strong);width:100%;min-height:46px;color:var(--text);background:#0f0f10d1;border-radius:12px;padding:10px 13px}.field select{appearance:auto}.field input:disabled,.field select:disabled{opacity:1;cursor:not-allowed;color:#aaa7a3;-webkit-text-fill-color:#aaa7a3;background:#464544b8;border-color:#97938e38}.field small,.dropzone small,.progress-card small,.stack>small{color:var(--muted);font-size:11px;line-height:1.45}.row{align-items:center;gap:9px;display:flex}.row.space{justify-content:space-between}.form-actions{margin-top:16px}.stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;display:grid}.stats article{border:1px solid var(--line);background:#1212139e;border-radius:15px;gap:5px;min-height:105px;padding:17px;display:grid}.stats span{color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:700}.stats strong{align-self:end;font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:30px}.stats .format-value{font-size:15px;line-height:1.35}.train-action{place-items:center;padding:29px 0 19px;display:grid}.panel-footer{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:14px;padding-top:17px;font-size:12px;display:flex}.pill{border:1px solid var(--line-strong);color:#d1cdc8;white-space:nowrap;background:#302f2fbd;border-radius:999px;align-items:center;width:fit-content;min-height:29px;padding:5px 10px;font-size:11px;font-weight:800;display:inline-flex}.pill.success{color:#8bf2cc;background:#24a07621;border-color:#44dda559}.pill.warning{color:#ffd58a;background:#d6922421;border-color:#ffc5615c}.pill.error{color:#ffabb5;background:#dc445821;border-color:#ff6c7d5c}.toggle-list{gap:9px;margin-bottom:18px;display:grid}.toggle-list.compact{margin:15px 0 0}.toggle-list label{border:1px solid var(--line);cursor:pointer;background:#1212138f;border-radius:14px;align-items:flex-start;gap:12px;padding:13px;display:flex}.toggle-list input{width:18px;height:18px;accent-color:var(--orange);margin:2px 0 0}.toggle-list label>span{gap:3px;display:grid}.toggle-list small{color:var(--muted);line-height:1.45}.link-row{align-items:center;gap:10px;margin-top:15px;display:flex}.action-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;display:grid}.audit,.transcript{color:#d2cec9;background:#ff91340e;border:1px solid #ff913438;border-radius:13px;padding:13px;font-size:12px;line-height:1.55}.action-panel .audit{margin-top:15px}.audio-list,.word-list{gap:12px;display:grid}.audio-card{border:1px solid var(--line);background:#121213a3;border-radius:17px;gap:13px;padding:17px;display:grid}.audio-card header,.audio-card footer{justify-content:space-between;align-items:flex-start;gap:15px;display:flex}.audio-card header>div:first-child{gap:3px;min-width:0;display:grid}.audio-card header strong{overflow-wrap:anywhere}.audio-card small,.audio-card footer>span{color:var(--muted);font-size:11px;line-height:1.5}.audio-card audio{width:100%;height:42px}.audio-card footer{align-items:center}.audio-card footer>div{flex-wrap:wrap;justify-content:flex-end;gap:7px;display:flex}.audio-card footer button{min-height:36px;padding:6px 11px;font-size:11px}.meta-row{flex-wrap:wrap;gap:6px;display:flex}.meta-row span{border:1px solid var(--line);color:#bdb8b2;background:#323131ad;border-radius:99px;padding:4px 8px;font-size:10px}.empty-state{border:1px dashed var(--line-strong);min-height:130px;color:var(--muted);text-align:center;border-radius:15px;place-items:center;padding:24px;display:grid}.toolbar{flex-wrap:wrap;margin-bottom:14px}.segment-control{border:1px solid var(--line);background:#101011ad;border-radius:12px;gap:4px;padding:4px;display:flex}.segment-control button{background:0 0;border-color:#0000;min-height:34px;padding:5px 9px;font-size:11px}.segment-control button.active{background:#ff913424;border-color:#ff913447}.segment-control b{color:var(--orange-2);margin-left:4px}.pagination{color:var(--muted);justify-content:center;align-items:center;gap:12px;margin-top:16px;font-size:12px;display:flex}.dropzone{cursor:pointer;background:#ff91340d;border:1px dashed #ff913473;border-radius:16px;justify-content:space-between;align-items:center;gap:18px;min-height:100px;margin-bottom:14px;padding:19px;display:flex;position:relative}.dropzone input{opacity:0;cursor:pointer;position:absolute;inset:0}.dropzone span{gap:5px;display:grid}.dropzone>b{color:var(--orange-2);white-space:nowrap;background:#ff913426;border-radius:10px;padding:8px 12px;font-size:12px}.progress-card{border:1px solid var(--line);background:#121213a3;border-radius:14px;gap:9px;margin-top:15px;padding:14px;display:grid}.progress-card>div:first-child{justify-content:space-between;gap:10px;display:flex}.progress-card span{color:var(--orange-2);font-size:12px}.progress-track{background:#ffffff12;border-radius:99px;height:7px;overflow:hidden}.progress-track i{border-radius:inherit;background:linear-gradient(90deg, var(--orange), var(--violet));height:100%;transition:width .2s;display:block}.native-notice{color:var(--muted);align-items:center;gap:12px;padding:14px 18px;font-size:12px;display:flex}.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{border:1px solid var(--line);background:#121213a3;border-radius:15px;justify-content:space-between;align-items:center;gap:20px;padding:16px;display:flex}.word-list article>div{gap:6px;min-width:0;display:grid}.word-list a{overflow-wrap:anywhere;color:var(--orange-2);font-size:11px;text-decoration:none}.data-hero:after{background:radial-gradient(circle,#b0aba426,#0000 67%)}.data-panel{padding-bottom:18px}.data-list{gap:9px;display:grid}.data-row{border:1px solid var(--line);background:#121213a3;border-radius:15px;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;padding:15px 16px;display:grid}.data-row.empty{background:#12121357}.data-copy{gap:6px;min-width:0;display:grid}.data-title{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.data-title strong{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:15px}.data-title code{overflow-wrap:anywhere;border:1px solid var(--line);color:#aaa6a0;background:#3736358c;border-radius:7px;padding:3px 7px;font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.data-copy small,.data-note,.data-usage span{color:var(--muted);font-size:11px;line-height:1.45}.data-note{color:#c7a57d}.data-usage{text-align:right;gap:4px;min-width:105px;display:grid}.data-usage strong{color:var(--orange-2);font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:16px}.data-row.empty .data-usage strong{color:#8c8883}.data-row>button{min-width:82px}.data-warning{background:#d6922417;border:1px solid #ffc5614d;border-radius:12px;padding:11px 13px;font-size:12px;color:#ffd58a!important;margin-top:14px!important}.loading-panel{min-height:400px;color:var(--muted);justify-content:center;align-items:center;gap:12px;display:flex}.spinner{border:2px solid #ffffff24;border-top-color:var(--orange);border-radius:50%;width:22px;height:22px;animation:.8s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.modal-backdrop{z-index:100;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#050506cc;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line-strong);background:#1c1c1e;border-radius:21px;width:min(680px,100%);max-height:calc(100vh - 40px);padding:23px;overflow:auto;box-shadow:0 36px 100px #0000008c}.modal-head{justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:18px;display:flex}.modal-head h2{margin:4px 0;font-size:24px}.console-modal{width:min(980px,100%)}.console-actions{flex-wrap:wrap;justify-content:flex-end}.console-follow{min-height:34px;color:var(--orange-2);background:#ff91341f;border-color:#ff91346b;padding:6px 11px;font-size:11px}.console-log{color:#cbc6c0;white-space:pre-wrap;background:#0b0b0c;border:1px solid #ff91342e;border-radius:14px;min-height:430px;max-height:calc(100vh - 190px);margin:0;padding:17px;font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;display:block;overflow:auto}.console-log span{min-height:1.65em;display:block}.console-log .success{color:#73e4b9}.console-log .error{color:#ff8290}.console-log .warning{color:#ffd079}.console-log .heading{color:var(--orange-2);font-weight:700}.stack{gap:14px;display:grid}.pairing-code{text-align:center;letter-spacing:.14em;text-transform:uppercase;font:700 28px/1 ui-rounded,SF Pro Rounded,system-ui,sans-serif}.link-success{text-align:center;place-items:center;gap:11px;padding:30px;display:grid}.link-success i{width:54px;height:54px;color:var(--green);background:#44dda51f;border:1px solid #44dda566;border-radius:50%;place-items:center;font-size:25px;font-style:normal;display:grid}.link-success span{color:var(--muted);font-size:12px}.trim-modal{width:min(820px,100%)}.waveform{border:1px solid var(--line);background:#0d0d0e;border-radius:14px;width:100%;height:210px;margin:16px 0}.range-grid{grid-template-columns:1fr 1fr;gap:13px;margin-bottom:13px;display:grid}.range-grid label{color:var(--muted);gap:7px;font-size:11px;display:grid}.range-grid input{width:100%;accent-color:var(--orange)}.modal-actions{justify-content:flex-end;margin-top:14px}.muted{color:var(--muted)}.toast{z-index:200;color:#eafff7;background:#144838f2;border:1px solid #44dda561;border-radius:13px;max-width:min(420px,100% - 44px);padding:13px 16px;font-size:13px;font-weight:700;position:fixed;bottom:22px;right:22px;box-shadow:0 18px 45px #0006}.toast.warning{background:#5d4116f7;border-color:#ffc56173}.toast.error{background:#5e1f2bf7;border-color:#ff6c7d73}.toast-enter-active,.toast-leave-active{transition:opacity .2s,transform .2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(10px)}@media (width<=920px){.tab-full{display:none}.tab-short{display:inline}}@media (width<=780px){.app-shell{width:min(100% - 22px,1180px);padding-top:17px}.app-header{align-items:flex-start}.header-status{display:none}.tabs{top:7px}.tab-full{display:none}.tab-short{display:inline}.hero{min-height:unset;align-items:flex-start;padding:24px}.step-row{display:none}.panel{padding:19px}.panel-head{grid-template-columns:auto minmax(0,1fr)}.panel-head>:last-child:not(:nth-child(2)){grid-column:1/-1}.form-grid,.phrase-form,.stats,.action-grid,.range-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.audio-card header,.audio-card footer,.word-list article,.panel-footer{flex-direction:column;align-items:stretch}.audio-card footer>div{justify-content:flex-start}.word-list article>button{width:100%}.sample-head .segment-control{grid-column:1/-1}.segment-control button{flex:1}.data-row{grid-template-columns:1fr auto}.data-copy{grid-column:1/-1}.data-usage{text-align:left}.data-row>button{min-width:96px}.modal-backdrop{padding:8px}.modal{max-height:calc(100vh - 16px);padding:17px}.modal-head{flex-direction:column}.console-actions{justify-content:flex-start}.console-log{min-height:55vh}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
/*$vite$:1*/
\ No newline at end of file
diff --git a/static/ui/trainer-ui.js b/static/ui/trainer-ui.js
index 8073ddc..0234df3 100644
--- a/static/ui/trainer-ui.js
+++ b/static/ui/trainer-ui.js
@@ -4004,23 +4004,32 @@ var hc = {
}, Ju = ["href"], Yu = {
key: 1,
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",
role: "dialog",
"aria-modal": "true",
"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",
role: "dialog",
"aria-modal": "true",
"aria-label": "Link Tater"
-}, od = { class: "modal-head" }, sd = {
+}, fd = { class: "modal-head" }, pd = {
key: 0,
class: "link-success"
-}, cd = {
+}, md = {
key: 1,
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",
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 = [
@@ -4056,8 +4065,8 @@ var hc = {
}
], c = Y(() => {
let e = X.samplePage[X.sampleBucket];
- return As.value.slice(e * pd, (e + 1) * pd);
- }), l = Y(() => Math.max(1, Math.ceil(As.value.length / pd))), u = Y(() => X.auto.state || {}), d = Y(() => X.auto.runtime || {}), f = Y(() => {
+ return As.value.slice(e * yd, (e + 1) * yd);
+ }), 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 = [];
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 ? {
@@ -4156,18 +4165,21 @@ var hc = {
return String(e.json_url || e.url || e.jsonUrl || "");
}
function re(e) {
- return String(e.model_url || e.modelUrl || "");
+ return String(e.esphome_json_url || e.esphomeJsonUrl || "");
}
function E(e) {
+ return String(e.model_url || e.modelUrl || "");
+ }
+ function ie(e) {
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 (e, d) => (U(), W("div", Dc, [
- d[113] ||= G("div", {
+ d[116] ||= G("div", {
class: "ambient ambient-one",
"aria-hidden": "true"
}, null, -1),
- d[114] ||= G("div", {
+ d[117] ||= G("div", {
class: "ambient ambient-two",
"aria-hidden": "true"
}, null, -1),
@@ -4175,7 +4187,7 @@ var hc = {
class: "brand-mark",
"aria-hidden": "true"
}, [G("img", {
- src: fd,
+ src: vd,
alt: ""
})]), d[44] ||= G("div", null, [
G("span", { class: "eyebrow" }, "Tater tools"),
@@ -4631,7 +4643,7 @@ var hc = {
G("h2", null, "Trained Wake Words"),
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)]),
- 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, [
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),
@@ -4648,12 +4660,12 @@ var hc = {
target: "_blank",
rel: "noreferrer"
}, "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,
- href: re(e),
+ href: E(e),
target: "_blank",
rel: "noreferrer"
- }, "Model · " + k(re(e)), 9, Xu)) : q("", !0),
+ }, "Model · " + k(E(e)), 9, Xu)) : q("", !0),
G("div", Zu, [
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),
@@ -4663,17 +4675,32 @@ var hc = {
type: "button",
disabled: !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)]]))]),
(U(), ra(Jn, { to: "body" }, [I(X).consoleOpen ? (U(), W("div", {
key: 0,
class: "modal-backdrop console-backdrop",
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("h2", null, "Training Console"),
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", {
key: 0,
type: "button",
@@ -4692,29 +4719,29 @@ var hc = {
onScrollPassive: v
}, [(U(!0), W(V, null, Lr(h.value, (e, t) => (U(), W("span", {
key: `${t}-${e}`,
- class: O(E(e))
+ class: O(ie(e))
}, k(e), 3))), 128))], 544)])])) : q("", !0)])),
(U(), ra(Jn, { to: "body" }, [I(X).taterLinkOpen ? (U(), W("div", {
key: 0,
class: "modal-backdrop",
onClick: d[43] ||= as((e) => I(X).taterLinkOpen = !1, ["self"])
- }, [G("section", ad, [G("header", od, [G("div", null, [
- d[107] ||= G("span", { class: "eyebrow" }, "Secure pairing", -1),
+ }, [G("section", dd, [G("header", fd, [G("div", null, [
+ d[110] ||= G("span", { class: "eyebrow" }, "Secure pairing", -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("button", {
type: "button",
onClick: d[40] ||= (e) => I(X).taterLinkOpen = !1
- }, "Close")]), o.value ? (U(), W("div", sd, [
- d[108] ||= G("i", null, "✓", -1),
+ }, "Close")]), o.value ? (U(), W("div", pd, [
+ 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),
- d[109] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1)
- ])) : (U(), W("div", cd, [
- G("label", ld, [d[110] ||= G("span", null, "Tater address", -1), R(G("input", {
+ d[112] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1)
+ ])) : (U(), W("div", md, [
+ G("label", hd, [d[113] ||= G("span", null, "Tater address", -1), R(G("input", {
"onUpdate:modelValue": d[41] ||= (e) => i.value = e,
type: "text"
}, 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",
"onUpdate:modelValue": d[42] ||= (e) => a.value = e,
class: "pairing-code",
@@ -4723,13 +4750,13 @@ var hc = {
autocomplete: "off",
onInput: w
}, 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", {
type: "button",
class: "button primary",
disabled: I(Z)("link"),
onClick: ee
- }, k(I(Z)("link") ? "Linking securely…" : "Link Tater"), 9, dd)
+ }, k(I(Z)("link") ? "Linking securely…" : "Link Tater"), 9, _d)
]))])])) : q("", !0)])),
K(Ec),
K($a, { name: "toast" }, {
@@ -4742,7 +4769,7 @@ var hc = {
})
]));
}
-}), hd = document.getElementById("trainer-app");
-if (!hd) throw Error("Missing #trainer-app mount point");
-ds(md).mount(hd);
+}), xd = document.getElementById("trainer-app");
+if (!xd) throw Error("Missing #trainer-app mount point");
+ds(bd).mount(xd);
//#endregion
diff --git a/tests/test_auto_train.py b/tests/test_auto_train.py
index c2898b8..0f9d277 100644
--- a/tests/test_auto_train.py
+++ b/tests/test_auto_train.py
@@ -583,6 +583,56 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(len(rows), 1)
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]["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):
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
diff --git a/tests/test_vue_ui.py b/tests/test_vue_ui.py
index d578432..7e4952f 100644
--- a/tests/test_vue_ui.py
+++ b/tests/test_vue_ui.py
@@ -89,6 +89,15 @@ class VueTrainerUiTests(unittest.TestCase):
self.assertNotIn("copyWakeWord(word.url)", app)
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:
dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"]
for dockerfile in dockerfiles:
diff --git a/trainer_server.py b/trainer_server.py
index 71516e9..f5713be 100644
--- a/trainer_server.py
+++ b/trainer_server.py
@@ -581,6 +581,24 @@ def _metadata_int(value: Any) -> int | 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]]:
_sync_trained_wake_word_artifacts()
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"))
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
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)}"
if base:
json_url = f"{base}{json_url}"
+ esphome_json_url = f"{base}{esphome_json_url}"
model_url = f"{base}{model_url}"
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.
"url": json_url,
"json_url": json_url,
+ "esphome_json_url": esphome_json_url,
"model_url": model_url,
"json_file": json_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"}:
return JSONResponse({"ok": False, "error": "Unsupported wake word artifact."}, status_code=400)
_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
if not artifact_path.exists() or not artifact_path.is_file():
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)