From 293318ad20d9730ec00c55920c4cdd30d2ccd70e Mon Sep 17 00:00:00 2001 From: MasterPhooey Date: Mon, 3 Aug 2026 07:25:58 -0500 Subject: [PATCH] Release NVIDIA WakeWord Trainer v24 --- VERSION | 2 +- WHATS_NEW.md | 5 +- frontend/src/TrainerApp.vue | 6 +++ frontend/src/trainer.css | 3 ++ frontend/src/types.ts | 2 + static/ui/trainer-ui.css | 2 +- static/ui/trainer-ui.js | 91 ++++++++++++++++++++++++------------- tests/test_auto_train.py | 50 ++++++++++++++++++++ tests/test_vue_ui.py | 9 ++++ trainer_server.py | 39 ++++++++++++++++ 10 files changed, 173 insertions(+), 36 deletions(-) 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.
+
ESP

ESPHome JSON

Use this URL as the model in an ESPHome micro_wake_word configuration.

+
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)