42 Commits
v4 ... v23

Author SHA1 Message Date
MasterPhooey
1f16f6f916 Release NVIDIA WakeWord Trainer v23 2026-08-03 06:45:32 -05:00
MasterPhooey
2a88090b85 Release NVIDIA WakeWord Trainer v22 2026-08-02 20:46:04 -05:00
MasterPhooey
2b1320f1f3 Release NVIDIA WakeWord Trainer v21 2026-07-26 18:40:25 -05:00
MasterPhooey
19ee63a65b Release NVIDIA WakeWord Trainer v20 2026-07-26 11:55:35 -05:00
MasterPhooey
518df63161 Release NVIDIA WakeWord Trainer v19 2026-07-26 11:23:35 -05:00
MasterPhooey
6ee228e8d3 Release NVIDIA WakeWord Trainer v18 2026-07-26 09:07:22 -05:00
MasterPhooey
2eee70cb34 Release NVIDIA WakeWord Trainer v17 2026-07-25 17:23:31 -05:00
MasterPhooey
426e4ec83f Release NVIDIA WakeWord Trainer v16 2026-07-25 12:48:48 -05:00
MasterPhooey
c474deb8b5 Release NVIDIA WakeWord Trainer v15 2026-07-24 23:16:08 -05:00
MasterPhooey
931694b711 Release NVIDIA WakeWord Trainer v14 2026-07-19 09:53:42 -05:00
MasterPhooey
5554b2eb5e Release NVIDIA WakeWord Trainer v13 2026-07-17 20:38:18 -05:00
MasterPhooey
7d77f71dc3 Release NVIDIA WakeWord Trainer v12 2026-07-17 08:21:50 -05:00
MasterPhooey
3d341d0617 Release NVIDIA WakeWord Trainer v11 2026-07-12 12:02:36 -05:00
MasterPhooey
a1b22200e0 Point Docker trainer at native Tater firmware 2026-07-11 08:35:38 -05:00
MasterPhooey
89260f1f14 Add Nvidia Docker release notes 2026-06-27 09:39:47 -05:00
MasterPhooey
0140dfb56f Fix Docker image release tags 2026-06-27 09:36:59 -05:00
MasterPhooey
1fc7d80bae Add RTX 50 Blackwell image support 2026-06-27 07:46:53 -05:00
MasterPhooey
31a6388da4 Improve trainer capture metadata and USB flashing 2026-06-27 06:39:58 -05:00
MasterPhooey
85c2d6334b Show MIT RIR download progress 2026-06-18 23:25:30 -05:00
MasterPhooey
5f6f108c85 Use mirrored MIT impulse responses 2026-06-18 10:23:23 -05:00
MasterPhooey
bb5033c5fb Clarify browser USB firmware flashing 2026-06-15 07:06:23 -05:00
MasterPhooey
8a8f4a82d9 Add browser USB firmware flashing 2026-06-15 06:43:22 -05:00
Tater Totterson
ed120e91ab Merge pull request #52 from TaterTotterson/prebuilt-firmware-tagged-docker
Update prebuilt firmware flasher and tagged Docker releases
2026-06-15 07:14:35 -04:00
MasterPhooey
7d8ebd6637 Update prebuilt firmware flasher and tagged Docker releases 2026-06-15 06:08:49 -05:00
MasterPhooey
874f273d0b Bump ESPHome pin to 2026.5.1 2026-06-03 10:21:23 -05:00
MasterPhooey
04249f414d Add new ReSpeaker firmware flasher templates 2026-05-19 15:49:57 -05:00
MasterPhooey
6a0d60d569 Add live wake word URL card 2026-05-19 07:42:20 -05:00
MasterPhooey
8df17599c2 Update Tater repo logo 2026-05-16 09:44:35 -05:00
MasterPhooey
280e8f8de4 Update README logo 2026-05-16 07:59:22 -05:00
Tater Totterson
b582a6cade Update Docker image name in workflow 2026-05-16 01:03:11 -05:00
MasterPhooey
196ab8c0e7 Add VAD trimming and Docker publishing 2026-05-16 00:32:05 -05:00
MasterPhooey
134f607bef 2026.4.3 2026-05-03 09:31:02 -05:00
MasterPhooey
4a9e2f2cde 2026.4.3 2026-05-03 07:55:07 -05:00
MasterPhooey
7c246856df cache update 2026-05-02 09:27:11 -05:00
MasterPhooey
3705dabc09 sat1 cache fix 2026-05-01 21:34:17 -05:00
MasterPhooey
1dcf48209f wake sound 2026-05-01 18:31:13 -05:00
MasterPhooey
4f44bef8d5 build cache 2026-05-01 18:03:37 -05:00
MasterPhooey
98fa879db1 wake sound 2026-05-01 17:01:15 -05:00
MasterPhooey
dfac549430 wake sound 2026-05-01 16:49:57 -05:00
MasterPhooey
775a78326b firmware url fixes 2026-05-01 16:24:36 -05:00
MasterPhooey
429be4cc67 502 2026-04-25 12:48:06 -05:00
Tater Totterson
2e6179ec32 Enhance README with images and link
Added additional images and a link to the README for better presentation.
2026-04-25 10:05:57 -05:00
47 changed files with 15098 additions and 4069 deletions

144
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,144 @@
name: Publish Docker Images
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
packages: write
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: tatertotterson/microwakeword
jobs:
docker:
name: Docker image
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Validate tag matches trainer version
if: startsWith(github.ref, 'refs/tags/')
shell: bash
run: |
set -euo pipefail
version="$(tr -d '[:space:]' < VERSION)"
expected_tag="v${version#v}"
if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
echo "Tag ${GITHUB_REF_NAME} does not match trainer version ${expected_tag}." >&2
exit 1
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest
type=ref,event=tag
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker
- name: Docker metadata (Blackwell)
id: meta-blackwell
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=blackwell
type=ref,event=tag,suffix=-blackwell
- name: Build and push Blackwell image
uses: docker/build-push-action@v6
with:
context: .
file: dockerfile.blackwell
platforms: linux/amd64
push: true
tags: ${{ steps.meta-blackwell.outputs.tags }}
labels: ${{ steps.meta-blackwell.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker-blackwell
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker-blackwell
- name: Create release notes
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
title="microWakeWord Nvidia Trainer ${TAG_NAME}"
generated_notes="$(mktemp)"
release_notes="$(mktemp)"
test -s WHATS_NEW.md
gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${GITHUB_SHA}" \
--jq '.body' > "${generated_notes}"
{
echo "## What's New"
echo
cat WHATS_NEW.md
echo
echo
echo "## Docker Images"
echo
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:latest\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}-blackwell\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:blackwell\`"
echo
cat "${generated_notes}"
} > "${release_notes}"
if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
gh release edit "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
else
gh release create "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
fi

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
personal_samples/*
data/
.DS_Store
trim_history/
.DS_Store
frontend/node_modules/
__pycache__/
*.py[cod]

205
README.md
View File

@@ -1,9 +1,13 @@
<div align="center">
<h1>microWakeWord NVIDIA Docker Trainer UI</h1>
<img width="800" alt="microWakeWord NVIDIA trainer screenshot" src="https://github.com/user-attachments/assets/694f4cb7-e4d8-4e2b-80ec-b40fb41cbfff" />
<a href="https://taterassistant.com">
<img src="images/tater-repo-logo.png" alt="microWakeWord Trainer" width="460"/>
</a>
</div>
<h3 align="center">
<a href="https://taterassistant.com">taterassistant.com</a>
</h3>
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, device-captured samples, reviewed false-wake negatives, live training logs, and ESPHome firmware flashing.
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, modern multilingual TTS ensembles, device-captured samples, reviewed false-wake negatives, live training logs, and local wake-word links for Tater Native satellites.
Real samples come from device-captured wake audio, close misses, or manual uploads. Every saved sample is normalized to `16 kHz / mono / 16-bit PCM WAV` before training.
@@ -15,6 +19,27 @@ Real samples come from device-captured wake audio, close misses, or manual uploa
docker pull ghcr.io/tatertotterson/microwakeword:latest
```
Tagged releases also publish matching immutable image tags:
```bash
docker pull ghcr.io/tatertotterson/microwakeword:v17
```
The release tag must match `VERSION`. Update `WHATS_NEW.md` before tagging; the Docker workflow prepends it to GitHub's automatically generated release notes.
RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
Python 3.13 TensorFlow build for `sm_120`:
```bash
docker pull ghcr.io/tatertotterson/microwakeword:blackwell
docker pull ghcr.io/tatertotterson/microwakeword:v17-blackwell
```
Use the Blackwell image only for RTX 50-series cards. It includes the
community-built TensorFlow wheel from
[chivitiH/tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313),
which is unofficial and licensed CC BY-NC 4.0.
---
## Run The Container
@@ -28,14 +53,19 @@ docker run -d \
ghcr.io/tatertotterson/microwakeword:latest
```
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v17` when you want to pin a known release instead of tracking `latest`.
For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v17-blackwell`
in the same `docker run` command.
The flags:
- `--gpus all` enables GPU acceleration.
- `--network host` lets the container receive mDNS/zeroconf traffic for ESPHome auto-detect.
- `--network host` exposes the trainer server directly so satellites can send captured audio and load trained wake-word files.
- `-e REC_PORT=8789` sets the trainer web UI and captured-audio port. Change this value if `8789` is already in use.
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and generated wake-word artifacts.
Host networking is recommended for the Firmware tab's mDNS device discovery. Manual IP flashing and captured-audio uploads can still work without host networking if the trainer port is reachable, but auto-detect may not see devices from Docker bridge networking.
If you do not use host networking, publish the trainer port and make sure satellites can reach it from your LAN.
Open:
@@ -43,31 +73,49 @@ Open:
http://localhost:8789
```
If you change `REC_PORT`, open that port instead and use the same port in the ESPHome `Trainer App URL`.
If you change `REC_PORT`, open that port instead and use the same port in the satellite `Trainer App URL`.
---
## What The UI Does
- The entire interface is reactive Vue 3 + TypeScript, following the same typed component pattern as Tater's newer UI surfaces.
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
- `Captured Audio` reviews clips sent by ESPHome sats, including wake hits, close misses, and false wakes.
- `Auto Training` transcribes real wake triggers, promotes phrase-misses to hard negatives, schedules retraining, and refreshes Tater Native satellites.
- `Captured Audio` reviews clips sent by Tater Native or ESPHome sats, including wake hits, close misses, and false wakes.
- `Samples` plays, removes, clears, and manually imports personal or negative samples.
- `Firmware` builds the latest `microWakeWords` ESPHome YAMLs from GitHub and flashes VoicePE or Satellite1 over OTA.
- Popup consoles show colorized training and firmware logs while long-running jobs are active.
- `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater.
- Popup consoles show colorized training logs while long-running jobs are active.
The production bundle is committed under `static/ui`, so neither NVIDIA Docker image needs Node.js. To change the UI, edit `frontend/src` and rebuild it before building the image:
```bash
cd frontend
npm install
npm run build
```
`npm run build` type-checks every Vue component before writing the offline bundle copied into both the standard CUDA and Blackwell images.
---
## Captured Audio Workflow
To collect samples from a sat, flash it with the Tater firmware from [TaterTotterson/microWakeWords](https://github.com/TaterTotterson/microWakeWords). The `Firmware` tab can build and flash the VoicePE or Satellite1 YAMLs directly from that repo.
To collect samples from a sat, point its trainer feedback setting at this app. Tater Native satellites use the native settings popup in Tater. Older ESPHome satellites can still use their device entities.
After flashing, the device exposes ESPHome entities for capture setup:
For Tater Native satellites, enable trainer feedback in Tater:
- `Send Good Wakes To Trainer` toggles upload of confirmed wake-word triggers.
- `Send Close Misses To Trainer` toggles upload of near misses.
- `Trainer App URL` sets the trainer address, for example `http://trainer.local:8789` or `http://<trainer-ip>:8789`.
For older ESPHome firmware, the equivalent capture setup is exposed as device entities:
- `Capture Wake Audio` toggles upload of wake-word triggers.
- `Capture Close Misses` toggles upload of near misses.
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`.
ESPHome devices can send raw captured audio to:
Satellites send raw captured audio to:
```text
/api/upload_captured_audio_raw
@@ -128,11 +176,39 @@ Starting a new session does not clear samples. Use the clear buttons in `Samples
---
## Auto Training
`Auto Training` is an opt-in sample-review and retraining loop. It is disabled until you enter the exact wake phrase and enable it.
For each new wake-trigger clip sent to the trainer:
1. The selected local STT engine transcribes the audio.
2. If the transcript contains the configured wake phrase, the clip stays in `Captured Audio` for manual review by default.
3. If speech was transcribed but the wake phrase is absent, the clip moves to `/data/negative_samples/` as an auto-reviewed hard negative.
4. Empty transcripts, VAD-blocked captures, and captures for another wake word stay out of the automatic negative path.
Two optional cleanup rules are available:
- `Delete confirmed good wakes` removes normal wake-trigger clips after STT confirms the configured phrase.
- `Promote confirmed close misses` checks close misses that passed VAD and moves them to the personal positive samples only when STT confirms the configured phrase.
A close miss with an empty transcript or without the configured phrase stays in `Captured Audio`; it is never turned into a negative automatically. Saving Auto Training settings also scans existing eligible captures. Enabling close-miss promotion reviews previous unreviewed close misses, while enabling cleanup removes previously confirmed good wakes without transcribing them a second time.
Auto Training exposes only an engine selector. Faster Whisper is the recommended default and uses CUDA with `float16` when CTranslate2 can see an NVIDIA GPU, with a CPU `int8` fallback. The trainer manages `small.en` for English and `small` for other languages. Parakeet ONNX uses the managed INT8 `nemo-parakeet-tdt-0.6b-v3` model with CUDA and CPU fallback. Downloaded STT models are cached in `/data/auto_train_models/`.
Scheduled training runs only after the configured number of new automatic negatives has accumulated. A successful run securely publishes the trained wake-word name and JSON URL to the linked Tater instance. Tater saves it as the global satellite wake word and pushes the updated setting to every connected satellite, so no satellite firmware change is required.
The `Trainer public URL` must be reachable from the satellites. With the documented `--network host` command, the trainer can normally use the LAN address from the browser request or host network. If you open the UI as `http://localhost:8789`, enter a value such as `http://192.168.1.50:8789`, or start the container with `REC_PUBLIC_BASE_URL` set to that value. When using Docker bridge networking, always set this URL to the published host address; a container bridge address is not satellite-reachable.
The default Tater URL, `http://127.0.0.1:8501`, assumes the documented host networking. Change it to a container-reachable Tater address if you use another Docker network. Click `Link Tater` and enter the short-lived code shown in Tater Voice Settings; the resulting trainer-specific link credential is stored in `/data/auto_train_config.json` with owner-only permissions.
---
## Training Flow
1. Enter the wake phrase in `Trainer`.
2. Choose the language.
3. Optionally test pronunciation with `Test TTS`.
2. Choose the language and TTS source.
3. Optionally check browser pronunciation with `System preview`.
4. Review the positive and negative sample counts.
5. Click `Start training`.
6. Watch the popup training console.
@@ -141,20 +217,27 @@ Personal samples are optional. Training can run with zero personal samples after
Reviewed negative samples are converted into `/data/work/reviewed_negative_features/` and inserted into the training YAML as a hard-negative feature set when present.
On RTX 50-series / Blackwell GPUs, the Blackwell Docker image keeps sample generation and augmentation in the normal Python 3.12 trainer environment, then runs only the TensorFlow training/export stage in `/data/.venv-blackwell` with Python 3.13 and the Blackwell-native TensorFlow wheel.
---
## Language Support
The language picker is dynamic.
The language picker is built from OmniVoice's live catalog (currently more than 600 languages), with a bundled common-language fallback for offline startup. Languages covered by Qwen3-TTS and MOSS-TTS-Nano are automatically marked `Recommended`; OmniVoice-only languages are marked `Experimental` so lower-resource coverage is not presented as equal quality.
- `en` is always available.
- English keeps the existing dedicated generator model path.
- Non-English languages are discovered from the Piper voices catalog and any local Piper voice metadata.
- When a non-English language is selected, the trainer downloads all voices for that selected language only.
- Already-downloaded voices are reused.
- It does not download every language up front.
The selected code is sent directly to the supporting model. Model downloads happen only when a language is used, and the Hugging Face cache is persisted under `/data/.cache/huggingface`. The fetched language catalog is cached under `/data/.cache/omnivoice_languages.json`.
If the upstream Piper catalog is unavailable, already-installed local voices are used when available.
### TTS modes
- `Four-provider ensemble` is the default. It uses OmniVoice for every catalog language, adds Qwen3-TTS and MOSS-TTS-Nano where supported, and adds Piper when a compatible model exists.
- `Modern only` uses the multilingual providers without Piper.
- `Piper only` preserves the previous generator as an explicit legacy fallback.
Where a Piper voice is unavailable, the default route automatically continues with the modern providers.
Qwen, OmniVoice, and Piper now generate final corpus candidates directly instead of cloning a 128-profile bank. Qwen provides 18,750 balanced voice conditions before an instruction repeats, and Piper uses every speaker in its installed model. MOSS Nano is clone-only, so each MOSS take uses a different already-accepted direct take as its carrier rather than cycling a small bank.
Every generated file is normalized to `16 kHz / mono / 16-bit PCM WAV` and rejected if it contains static, broadband/high-frequency noise, silence, clipping, excessive duration/rambling, or an exact duplicate. A generation manifest records the planned and accepted provider counts and the applied safety limits.
---
@@ -162,29 +245,28 @@ If the upstream Piper catalog is unavailable, already-installed local voices are
The first training run downloads and prepares missing training assets into `/data`, including:
- Piper voices for the selected language
- isolated Python environments for each selected modern TTS engine
- selected TTS model weights and the direct generated corpus
- additional language-specific Piper voices only when hybrid or legacy Piper mode is selected
- negative datasets and background data
- the Python training environment
- generated samples and augmented feature caches
After those assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted.
The three modern engines deliberately use separate environments under `/data/tts-envs/`; their required PyTorch and Transformers versions conflict with one another and with the trainer environment. Model weights can require many gigabytes, so allow extra disk space and time on the first run. After the assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted.
---
## Firmware Flashing
## Trained Wake Words
The `Firmware` tab builds and flashes Tater firmware for supported ESPHome sats.
The `Wake Words` tab lists locally trained wake-word packages from `/data/trained_wake_words/`.
- Downloads the latest firmware YAML templates from `TaterTotterson/microWakeWords` on GitHub.
- Lets you choose `VoicePE` or `Satellite1`.
- Auto-detects ESPHome devices with mDNS when the container is running with host networking.
- Allows manual IP or hostname entry if discovery does not find the device.
- Saves firmware form values so you do not re-enter sounds and URLs every run.
- Lists locally trained wake words from `/data/trained_wake_words/` for easy model selection.
- Builds with ESPHome and flashes OTA.
- Streams ESPHome output in a colorized firmware console.
- Copy the JSON URL into the Tater Native satellite settings to switch wake words live.
- Links use the configured public trainer URL, a non-loopback browser host, or the detected LAN address instead of advertising `127.0.0.1` to satellites.
- Open the JSON or model links directly for quick inspection.
- The JSON includes the matching model path plus Tater tuning metadata.
- No firmware flashing happens from this trainer app anymore.
Firmware YAMLs are intentionally pulled from GitHub each time. There is no local fallback path in the trainer UI.
Use the main Tater app for satellite firmware updates and USB flashing.
---
@@ -197,14 +279,51 @@ Successful runs produce timestamped training output folders such as:
/data/output/<timestamp>-<wake_word>-<samples>-<steps>/<wake_word>.json
```
The trainer also syncs firmware-ready artifacts into:
The trainer also syncs Tater-ready wake-word artifacts into:
```text
/data/trained_wake_words/<wake_word>.tflite
/data/trained_wake_words/<wake_word>.json
```
The firmware tab uses `/data/trained_wake_words/` to populate the wake-word dropdown.
The `Wake Words` tab uses `/data/trained_wake_words/` to populate the local wake-word links.
The JSON keeps the standard microWakeWord fields for compatibility:
```json
{
"micro": {
"probability_cutoff": 0.97,
"sliding_window_size": 6
}
}
```
It also includes Tater Native metadata used by newer satellites and the Tater settings UI:
```json
{
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"tater_native": {
"format_version": 1,
"wake_threshold": 0.97,
"wake_sliding_window": 6,
"close_miss_threshold": 0.80,
"frontend": {
"name": "tflm_microfrontend",
"sample_rate": 16000,
"feature_duration_ms": 30,
"feature_step_ms": 10,
"feature_size": 40
}
}
}
```
Calibration metrics are included under `calibration` so false accepts/hour and recall can be surfaced in the UI.
Calibration evaluates thresholds from `0.95` through `1.00` with sliding windows of `5`, `6`, and `7`. Among candidates within 0.5 percentage points of the best recall, it prefers the lowest measured ambient false-accept rate. If calibration cannot complete, packaging uses the conservative `0.97` threshold and a window of `6`.
---
@@ -218,10 +337,11 @@ That removes:
- negative samples
- captured inbox clips
- downloaded Piper voices
- modern TTS environments, model weights, and completed direct-generated corpora
- cached datasets
- training environments
- trained models
- firmware build caches
- Auto Training settings, state, transcripts, and cached Faster Whisper models
---
@@ -229,9 +349,10 @@ That removes:
- Personal samples are optional.
- Negative samples are optional but useful for reducing false wakes.
- Auto Training is disabled by default and only classifies actual wake triggers automatically.
- The UI server is `trainer_server.py`.
- The launcher is `run.sh`.
- Firmware capture settings live on the ESPHome device and can be toggled from the device entities after flashing.
- Trainer capture settings live in Tater for Tater Native satellites, and on device entities for older ESPHome satellites.
---
@@ -241,3 +362,7 @@ Built on top of:
- [microWakeWord](https://github.com/kahrendt/microWakeWord)
- [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator)
- [OmniVoice](https://github.com/k2-fsa/OmniVoice)
- [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)
- [MOSS-TTS-Nano](https://github.com/OpenMOSS/MOSS-TTS-Nano)
- [tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313) for the optional RTX 50-series / Blackwell image

1
VERSION Normal file
View File

@@ -0,0 +1 @@
23

2
WHATS_NEW.md Normal file
View File

@@ -0,0 +1,2 @@
- 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.

View File

@@ -9,24 +9,22 @@ import math
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Sequence
from typing import Any, Iterable, Sequence
import numpy as np
import yaml
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
DEFAULT_WINDOW_SIZES = [3, 4, 5, 6, 7]
DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "1.0"))
DEFAULT_WINDOW_SIZES = [5, 6, 7]
DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "0.25"))
DEFAULT_COOLDOWN_SLICES = int(os.environ.get("MWW_CALIBRATION_COOLDOWN_SLICES", "25"))
DEFAULT_POSITIVE_SKIP_SLICES = int(
os.environ.get("MWW_CALIBRATION_POSITIVE_SKIP_SLICES", "25")
)
DEFAULT_CUTOFF_STEP = float(os.environ.get("MWW_CALIBRATION_CUTOFF_STEP", "0.01"))
DEFAULT_CUTOFF_MIN = float(os.environ.get("MWW_CALIBRATION_CUTOFF_MIN", "0.00"))
DEFAULT_CUTOFF_MIN = float(os.environ.get("MWW_CALIBRATION_CUTOFF_MIN", "0.95"))
DEFAULT_CUTOFF_MAX = float(os.environ.get("MWW_CALIBRATION_CUTOFF_MAX", "1.00"))
DEFAULT_RECALL_MARGIN = float(os.environ.get("MWW_CALIBRATION_RECALL_MARGIN", "0.005"))
PREFERRED_WINDOW_SIZE = 6
def parse_args() -> argparse.Namespace:
@@ -65,6 +63,15 @@ def parse_args() -> argparse.Namespace:
default=DEFAULT_TARGET_FAPH,
help="Target ambient false accepts per hour for the selected operating point.",
)
parser.add_argument(
"--recall-margin",
type=float,
default=DEFAULT_RECALL_MARGIN,
help=(
"Maximum recall loss allowed when preferring a candidate with fewer "
"ambient false accepts (0.005 means 0.5 percentage points)."
),
)
parser.add_argument(
"--cooldown-slices",
type=int,
@@ -159,7 +166,13 @@ def _compute_false_accepts_per_hour(
def _select_best_candidate(
candidates: list[dict[str, float]],
target_faph: float,
recall_margin: float = DEFAULT_RECALL_MARGIN,
) -> tuple[dict[str, float], float]:
if not candidates:
raise ValueError("at least one calibration candidate is required")
if recall_margin < 0:
raise ValueError("recall margin must be >= 0")
fallback_limits = [
target_faph,
max(target_faph * 2.0, target_faph + 0.5),
@@ -172,13 +185,27 @@ def _select_best_candidate(
return index
return len(fallback_limits)
# Stay in the strictest false-accept tier that has a viable candidate. Within
# that tier, keep candidates close to the best recall, then spend the allowed
# recall margin on the lowest measured false-accept rate.
best_tier = min(tier(candidate) for candidate in candidates)
tier_candidates = [
candidate for candidate in candidates if tier(candidate) == best_tier
]
best_recall = max(candidate["recall"] for candidate in tier_candidates)
recall_floor = best_recall - recall_margin
viable_candidates = [
candidate
for candidate in tier_candidates
if candidate["recall"] >= recall_floor - 1e-12
]
best = min(
candidates,
viable_candidates,
key=lambda candidate: (
tier(candidate),
-candidate["recall"],
candidate["false_accepts_per_hour"],
abs(candidate["sliding_window_size"] - 5),
-candidate["recall"],
abs(candidate["sliding_window_size"] - PREFERRED_WINDOW_SIZE),
-candidate["probability_cutoff"],
),
)
@@ -195,7 +222,7 @@ def _load_config(config_path: Path) -> dict:
def _load_eval_sets(
handler: FeatureHandler,
handler: Any,
config: dict,
) -> tuple[str, str, list[np.ndarray], list[np.ndarray]]:
for positive_mode, ambient_mode in (
@@ -228,7 +255,7 @@ def _load_eval_sets(
def _predict_tracks(
model: Model,
model: Any,
tracks: Sequence[np.ndarray],
label: str,
) -> list[np.ndarray]:
@@ -244,8 +271,13 @@ def _predict_tracks(
def main() -> int:
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
args = parse_args()
window_sizes = _parse_window_sizes(args.window_sizes)
if args.recall_margin < 0 or args.recall_margin > 1:
raise ValueError("recall-margin must be between 0 and 1")
if args.cutoff_step <= 0:
raise ValueError("cutoff-step must be > 0")
if args.cutoff_max < args.cutoff_min:
@@ -276,6 +308,10 @@ def main() -> int:
f"→ Evaluating window sizes {window_sizes} with target <= "
f"{args.target_faph:.2f} false accepts/hour"
)
print(
f"→ Favoring lower false accepts within "
f"{args.recall_margin:.2%} of the best recall"
)
config = _load_config(config_path)
config["flags"] = config.get("flags", {})
@@ -338,7 +374,11 @@ def main() -> int:
candidates.append(candidate)
window_candidates.append(candidate)
best_window, _ = _select_best_candidate(window_candidates, args.target_faph)
best_window, _ = _select_best_candidate(
window_candidates,
args.target_faph,
args.recall_margin,
)
best_by_window.append(best_window)
print(
" window={window}: cutoff={cutoff:.2f}; recall={recall:.2%}; "
@@ -350,7 +390,11 @@ def main() -> int:
)
)
best, selected_limit = _select_best_candidate(candidates, args.target_faph)
best, selected_limit = _select_best_candidate(
candidates,
args.target_faph,
args.recall_margin,
)
if best["false_accepts_per_hour"] > args.target_faph + 1e-9:
print(
"⚠️ No candidate met the target false accepts/hour budget; "
@@ -390,6 +434,8 @@ def main() -> int:
"cutoff_min": round(float(cutoffs[0]), 4),
"cutoff_max": round(float(cutoffs[-1]), 4),
"cutoff_step": float(args.cutoff_step),
"recall_margin": float(args.recall_margin),
"preferred_window_size": PREFERRED_WINDOW_SIZE,
},
"per_window_best": best_by_window,
"generated_at": datetime.now(timezone.utc).isoformat(),

112
cli/setup_blackwell_venv Executable file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
set -euo pipefail
PROGDIR="$(dirname "$(realpath "$0")")"
ROOTDIR="$(dirname "${PROGDIR}")"
KNOWN_ARGS=( data-dir force python )
source "${PROGDIR}/shell.functions"
if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then
echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2
HELP=true
fi
if [ "${HELP}" == "true" ] ; then
cat <<EOF >&2
Usage: setup_blackwell_venv [ --data-dir=/data ] [ --force ] [ --python=python3.13 ]
Creates /data/.venv-blackwell for RTX 50 / Blackwell TensorFlow training.
Sample generation and augmentation continue to use /data/.venv.
Environment overrides:
MWW_BLACKWELL_TF_WHEEL_URL: TensorFlow Blackwell wheel URL.
EOF
exit 1
fi
[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath "${DATA_DIR}")"
[ -d "${DATA_DIR}" ] || {
echo "Data directory '${DATA_DIR}' doesn't exist." >&2
exit 1
}
PYTHON="${PYTHON:-python3.13}"
VENV="${DATA_DIR}/.venv-blackwell"
MARKER="${VENV}/.mww-blackwell-venv"
TF_WHEEL_URL="${MWW_BLACKWELL_TF_WHEEL_URL:-https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl}"
if ! command -v "${PYTHON}" >/dev/null 2>&1 ; then
echo "Python 3.13 is required for the Blackwell TensorFlow wheel. Missing: ${PYTHON}" >&2
exit 1
fi
if [ "${FORCE:-false}" != "true" ] && [ -x "${VENV}/bin/python" ] && [ -f "${MARKER}" ] ; then
echo " Blackwell TensorFlow venv found (skipping setup_blackwell_venv)"
exit 0
fi
echo "===== Setting up Blackwell TensorFlow environment ${VENV} ====="
rm -rf "${VENV}" || :
"${PYTHON}" -m venv --upgrade-deps "${VENV}"
source "${VENV}/bin/activate"
export PIP_PROGRESS_BAR=off
export PIP_NO_COLOR=1
export PIP_QUIET=0
pip_install() {
if $VERBOSE ; then
pip install "$@" || return 1
else
{ pip install "$@" || return 1 ; } | stdbuf -i0 -o0 tr -d '[:print:]' | stdbuf -i0 -o0 tr '\n' '.'
fi
echo
}
echo " ===== Installing Blackwell TensorFlow wheel ====="
pip_install --upgrade pip setuptools wheel
pip_install "${TF_WHEEL_URL}"
echo " ===== Installing microWakeWord training dependencies ====="
pip_install \
audiomentations \
audio_metadata \
datasets \
mmap_ninja \
pymicro-features \
pyyaml \
webrtcvad-wheels \
ai-edge-litert \
numpy-minmax \
numpy-rms \
absl-py \
"numpy==2.3.5"
echo " ===== Checking microwakeword ====="
MWW="${DATA_DIR}/tools/microWakeWord"
if [ ! -d "${MWW}" ] || [ -n "$(git -C "${MWW}" status --porcelain 2>/dev/null || true)" ] ; then
rm -rf "${MWW}" || :
mkdir -p "${DATA_DIR}/tools"
echo " Cloning micro-wake-word to ${DATA_DIR}/tools"
git clone https://github.com/TaterTotterson/micro-wake-word "${MWW}" &>/dev/null
fi
echo " Installing microwakeword into Blackwell venv"
pip_install --no-deps -e "${MWW}"
echo " ===== Testing Blackwell TensorFlow environment ====="
"${VENV}/bin/python" - <<'PY'
import tensorflow as tf
from ai_edge_litert.interpreter import Interpreter
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
print("TensorFlow:", tf.__version__)
print("CUDA build:", tf.test.is_built_with_cuda())
print("GPU:", tf.config.list_physical_devices("GPU"))
print("microWakeWord Blackwell imports available")
PY
touch "${MARKER}"
echo "Blackwell TensorFlow environment ready: ${VENV}"

View File

@@ -25,9 +25,9 @@ fi
mkdir -p "${DATA_DIR}/training_datasets/downloads" || :
cd "${DATA_DIR}/training_datasets"
AUDIO_URL="https://mcdermottlab.mit.edu/Reverb/IRMAudio/Audio.zip"
AUDIO_ZIPFILE="MIT_RIR_Audio.zip"
AUDIO_ZIP="./downloads/${AUDIO_ZIPFILE}"
HF_RIR_REPO_ID="TaterTotterson/MIT_environmental_impulse_responses"
HF_RIR_API_URL="https://huggingface.co/api/datasets/${HF_RIR_REPO_ID}"
HF_RIR_SOURCE_KEY="hf_mit_environmental_impulse_responses"
AUDIO_DIR="./mit_rirs"
mkdir -p "${AUDIO_DIR}" || :
AUDIO16K_DIR="./mit_rirs_16k"
@@ -35,10 +35,92 @@ mkdir -p "${AUDIO16K_DIR}" || :
AUDIO_FILECOUNT="./downloads/mit_rir_filecount"
AUDIO_IN_GLOB="*.wav"
declare -A filecounts=( [${AUDIO_ZIPFILE}]=0 )
declare -A filecounts=( [${HF_RIR_SOURCE_KEY}]=0 )
get_filecounts filecounts "${AUDIO_FILECOUNT}"
echo "===== Checking MIT_RIR ====="
echo "===== Checking MIT environmental RIRs ====="
download_hf_mit_rirs() {
source ${DATA_DIR}/.venv/bin/activate
python - "${HF_RIR_REPO_ID}" "${HF_RIR_API_URL}" "${AUDIO_DIR}" <<-'EOF'
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
repo_id = sys.argv[1]
api_url = sys.argv[2]
audio_dir = Path(sys.argv[3])
audio_dir.mkdir(parents=True, exist_ok=True)
request = urllib.request.Request(api_url, headers={"User-Agent": "WakeWordTrainer/1.0"})
with urllib.request.urlopen(request, timeout=30) as response:
metadata = json.loads(response.read().decode("utf-8"))
files = sorted(
sibling.get("rfilename", "")
for sibling in metadata.get("siblings", [])
if str(sibling.get("rfilename", "")).startswith("16khz/")
and str(sibling.get("rfilename", "")).lower().endswith(".wav")
)
if not files:
raise SystemExit("Hugging Face MIT RIR dataset did not list any 16khz WAV files")
print(f" Found {len(files)} MIT environmental RIR files on Hugging Face mirror", flush=True)
downloaded = 0
skipped = 0
def download_file(url: str, target: Path, rel: str):
tmp = target.with_suffix(target.suffix + ".incomplete")
for attempt in range(1, 4):
try:
if tmp.exists():
tmp.unlink()
with urllib.request.urlopen(url, timeout=30) as response:
with tmp.open("wb") as out:
while True:
chunk = response.read(1024 * 64)
if not chunk:
break
out.write(chunk)
if not tmp.exists() or tmp.stat().st_size == 0:
raise RuntimeError("empty download")
tmp.replace(target)
return
except Exception as exc:
if tmp.exists():
tmp.unlink()
if attempt == 3:
raise RuntimeError(f"download failed for {rel}: {exc}") from exc
print(f" Retry {attempt}/2 for {rel}: {exc}", flush=True)
time.sleep(2 * attempt)
total = len(files)
for idx, rel in enumerate(files, start=1):
target = audio_dir / rel
if target.exists() and target.stat().st_size > 0:
skipped += 1
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" MIT RIR download progress: {idx}/{total} files ({downloaded} downloaded, {skipped} reused)", flush=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
encoded = urllib.parse.quote(rel, safe="/")
url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{encoded}"
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" Downloading MIT RIR {idx}/{total}: {rel}", flush=True)
download_file(url, target, rel)
if not target.exists() or target.stat().st_size == 0:
raise SystemExit(f"download failed for {rel}")
downloaded += 1
if idx == 1 or idx % 25 == 0 or idx == total:
print(f" MIT RIR download progress: {idx}/{total} files ({downloaded} downloaded, {skipped} reused)", flush=True)
print(f" Hugging Face MIT environmental RIR download complete ({downloaded} downloaded, {skipped} reused)", flush=True)
print(f" MIT environmental RIR files available: {len(files)}", flush=True)
EOF
}
converter() {
source ${DATA_DIR}/.venv/bin/activate
@@ -58,9 +140,9 @@ rir_out = Path(sys.argv[2])
waves = list(rir_in.rglob("*.wav"))
try:
print(" MIT RIR normalizing to 16k…")
print(" MIT environmental RIR normalizing to 16k…")
# Normalize to 16k mono
for p in tqdm(waves, desc=" MIT_RIR (resample 16k mono)"):
for p in tqdm(waves, desc=" MIT environmental RIR (resample 16k mono)"):
outfile = Path(rir_out / p.name)
if outfile.exists():
continue
@@ -70,14 +152,14 @@ try:
if sr != 16000:
a, _ = librosa.load(p, sr=16000, mono=True)
write_wav(outfile, a, 16000)
print(" MIT RIR normalization complete")
print(" MIT environmental RIR normalization complete")
except Exception as e2:
print(f" MIT RIR fallback failed: {e2}")
print(f" MIT environmental RIR preparation failed: {e2}")
raise
EOF
}
expected_filecount=${filecounts[${AUDIO_ZIPFILE}]}
expected_filecount=${filecounts[${HF_RIR_SOURCE_KEY}]}
actual_filecount=$(find "${AUDIO16K_DIR}" -name '*.wav' 2>/dev/null | wc -l) || :
write_filecount=false
@@ -85,24 +167,16 @@ if [ "${actual_filecount}" -ne 0 ] && [ "${actual_filecount}" -eq "${expected_fi
echo " Existing ${AUDIO16K_DIR} valid"
else
actual_filecount=$(find "${AUDIO_DIR}" -name "${AUDIO_IN_GLOB}" 2>/dev/null | wc -l) || :
if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
if [ ! -f "${AUDIO_ZIP}" ] ; then
echo " Downloading ${AUDIO_ZIPFILE}"
curl -sfL "${AUDIO_URL}" -o "${AUDIO_ZIP}"
fi
if [ "${actual_filecount}" -eq 0 ] || [ "${expected_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
rm -rf "${AUDIO_DIR}" || :
echo " Unzipping ${AUDIO_ZIPFILE}"
unzip -u -q -d "${AUDIO_DIR}" "${AUDIO_ZIP}"
fi
if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then
echo " Cleaning up ${AUDIO_ZIPFILE}"
rm -rf "${AUDIO_ZIP}"
mkdir -p "${AUDIO_DIR}" || :
echo " Downloading MIT environmental impulse responses from Hugging Face mirror"
download_hf_mit_rirs
fi
converter
actual_filecount=$(find "${AUDIO16K_DIR}" -name "*.wav" 2>/dev/null | wc -l) || :
filecounts[${AUDIO_ZIPFILE}]="${actual_filecount}"
filecounts[${HF_RIR_SOURCE_KEY}]="${actual_filecount}"
write_filecount=true
fi
@@ -110,15 +184,10 @@ if ${write_filecount} ; then
write_filecounts filecounts "${AUDIO_FILECOUNT}"
fi
if "${CLEANUP_ARCHIVES}" && [ -f "${AUDIO_ZIP}" ] ; then
echo " Cleaning up ${AUDIO_ZIPFILE}"
rm -rf "${AUDIO_ZIP}"
fi
if "${CLEANUP_INTERMEDIATE_FILES}" && [ -d "${AUDIO_DIR}" ]; then
echo " Cleaning up ${AUDIO_DIR}"
rm -rf "${AUDIO_DIR}"
fi
echo " MIT_RIR complete"
echo " MIT environmental RIRs complete"
exit 0

113
cli/setup_modern_tts_envs Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
set -euo pipefail
PROGPATH="$(realpath "$0")"
PROGDIR="$(dirname "${PROGPATH}")"
KNOWN_ARGS=( data-dir engine gpu no-gpu )
# shellcheck source=/dev/null
source "${PROGDIR}/shell.functions"
ENGINE="${ENGINE:-${POSITIONAL_ARGS[0]:-}}"
case "${ENGINE}" in
omnivoice|qwen3|moss) ;;
*)
echo "Usage: setup_modern_tts_envs --engine=<omnivoice|qwen3|moss> [--data-dir=/data]" >&2
exit 2
;;
esac
PYTHON_BIN="${MWW_TTS_PYTHON:-python3.12}"
command -v "${PYTHON_BIN}" >/dev/null 2>&1 || PYTHON_BIN=python3
if [ -z "${GPU:-}" ] ; then
GPU=false
if [ -c /dev/nvidiactl ] || { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1 ; } ; then
GPU=true
fi
fi
TTS_ROOT="${DATA_DIR}/tts-envs"
VENV="${TTS_ROOT}/${ENGINE}"
STACK_VERSION="modern-tts-v1"
MARKER="${VENV}/.stack-version"
mkdir -p "${TTS_ROOT}" "${DATA_DIR}/.cache/huggingface"
case "${ENGINE}" in
omnivoice)
TORCH_VERSION="2.8.0"
TORCHAUDIO_VERSION="2.8.0"
PACKAGE_SPEC="git+https://github.com/k2-fsa/OmniVoice.git@28bc0889d92110491d726a9c79f26a895db5a074"
IMPORT_NAME="omnivoice"
STACK_ID="${STACK_VERSION}:omnivoice-28bc088:torch-${TORCH_VERSION}"
;;
qwen3)
TORCH_VERSION="2.9.1"
TORCHAUDIO_VERSION="2.9.1"
PACKAGE_SPEC="qwen-tts==0.1.1"
IMPORT_NAME="qwen_tts"
STACK_ID="${STACK_VERSION}:qwen-tts-0.1.1:torch-${TORCH_VERSION}"
;;
moss)
TORCH_VERSION="2.7.0"
TORCHAUDIO_VERSION="2.7.0"
PACKAGE_SPEC="git+https://github.com/OpenMOSS/MOSS-TTS-Nano.git@cc7bdf19c7639c0870dab22045a33b442760f6be"
IMPORT_NAME="moss_tts_nano"
STACK_ID="${STACK_VERSION}:moss-cc7bdf1:torch-${TORCH_VERSION}"
;;
esac
environment_ready() {
[ -x "${VENV}/bin/python" ] || return 1
[ -f "${MARKER}" ] || return 1
[ "$(cat "${MARKER}")" = "${STACK_ID}" ] || return 1
"${VENV}/bin/python" - "${IMPORT_NAME}" "${GPU}" <<'PY' >/dev/null 2>&1
import importlib
import sys
import torch
importlib.import_module(sys.argv[1])
expect_cuda = sys.argv[2].lower() == "true"
if expect_cuda and not torch.cuda.is_available():
raise SystemExit("NVIDIA GPU was detected but this environment cannot use CUDA")
if torch.cuda.is_available():
torch.zeros(1, device="cuda")
PY
}
if environment_ready ; then
echo "✅ Reusing ${ENGINE} TTS environment: ${VENV}"
exit 0
fi
echo "===== Preparing isolated ${ENGINE} TTS environment ====="
rm -rf "${VENV}"
"${PYTHON_BIN}" -m venv "${VENV}"
PY="${VENV}/bin/python"
"${PY}" -m pip install -U pip setuptools wheel
if ${GPU} ; then
TORCH_INDEX="${MWW_TTS_TORCH_INDEX:-https://download.pytorch.org/whl/cu128}"
echo "→ Installing CUDA torch ${TORCH_VERSION} from ${TORCH_INDEX}"
"${PY}" -m pip install \
"torch==${TORCH_VERSION}" \
"torchaudio==${TORCHAUDIO_VERSION}" \
--index-url "${TORCH_INDEX}"
else
echo "→ Installing CPU torch ${TORCH_VERSION}"
"${PY}" -m pip install \
"torch==${TORCH_VERSION}" \
"torchaudio==${TORCHAUDIO_VERSION}"
fi
echo "→ Installing ${PACKAGE_SPEC}"
"${PY}" -m pip install "${PACKAGE_SPEC}" "huggingface_hub[hf_xet]"
printf '%s\n' "${STACK_ID}" > "${MARKER}"
if ! environment_ready ; then
echo "❌ ${ENGINE} environment failed its import/CUDA check." >&2
exit 1
fi
echo "✅ ${ENGINE} TTS environment ready: ${VENV}"

View File

@@ -103,7 +103,8 @@ else
if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
if [ ! -f "${AUDIO_ZIP}" ] ; then
echo " Downloading ${AUDIO_ZIPFILE}"
curl -sfL "${AUDIO_URL}" -o "${AUDIO_ZIP}"
curl -fL --progress-bar "${AUDIO_URL}" -o "${AUDIO_ZIP}" \
2> >(tr '\r' '\n' >&2)
fi
rm -rf "${AUDIO_DIR}" || :

View File

@@ -12,6 +12,8 @@ DEFAULT_SAMPLES=50000
DEFAULT_BATCH_SIZE=100
DEFAULT_TRAINING_STEPS=40000
DEFAULT_LANGUAGE=en
DEFAULT_TTS_MODE=hybrid
DEFAULT_TTS_VOICE_COUNT=128
[ -f "${DATA_DIR}/.defaults.env" ] && source "${DATA_DIR}/.defaults.env" || :
@@ -19,6 +21,8 @@ DEFAULT_LANGUAGE=en
: "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}"
: "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}"
: "${LANGUAGE:=${DEFAULT_LANGUAGE}}"
: "${TTS_MODE:=${DEFAULT_TTS_MODE}}"
: "${TTS_VOICE_COUNT:=${DEFAULT_TTS_VOICE_COUNT}}"
: "${CLEANUP_WORK_DIR:=false}"
: "${CLEANUP_ARCHIVES:=false}"
: "${CLEANUP_INTERMEDIATE_FILES:=false}"

1469
cli/tts_generate_samples.py Executable file

File diff suppressed because it is too large Load Diff

99
cli/tts_moss_worker.py Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Persistent-process MOSS-TTS-Nano voice-cloning worker."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
from transformers import AutoModelForCausalLM
from moss_tts_nano.defaults import (
DEFAULT_AUDIO_TOKENIZER_PATH,
DEFAULT_CHECKPOINT_PATH,
)
MOSS_AUDIO_TOKENIZER_TYPE = "moss-audio-tokenizer-nano"
def read_jsonl(path: Path) -> list[dict]:
entries = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
entries.append(json.loads(line))
return entries
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input-jsonl", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--checkpoint", default=str(DEFAULT_CHECKPOINT_PATH))
parser.add_argument(
"--audio-tokenizer",
default=str(DEFAULT_AUDIO_TOKENIZER_PATH),
)
args = parser.parse_args()
entries = read_jsonl(args.input_jsonl)
if not entries:
return 0
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if device.type == "cuda":
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
else:
dtype = torch.float32
model = AutoModelForCausalLM.from_pretrained(
args.checkpoint,
trust_remote_code=True,
)
model.to(device=device, dtype=dtype)
if hasattr(model, "_set_attention_implementation"):
model._set_attention_implementation("sdpa")
model.eval()
args.output_dir.mkdir(parents=True, exist_ok=True)
for index, item in enumerate(entries, start=1):
seed = int(item.get("seed", index))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
output_path = args.output_dir / f"{item['id']}.wav"
model.inference(
text=str(item["text"]),
output_audio_path=str(output_path),
mode="voice_clone",
prompt_text=str(item["ref_text"]),
prompt_audio_path=str(item["ref_audio"]),
reference_audio_path=None,
text_tokenizer_path=None,
audio_tokenizer_type=MOSS_AUDIO_TOKENIZER_TYPE,
audio_tokenizer_pretrained_name_or_path=args.audio_tokenizer,
device=device,
nq=None,
max_new_frames=64,
voice_clone_max_text_tokens=32,
voice_clone_max_memory_per_sample_gb=1.0,
do_sample=True,
use_kv_cache=True,
text_temperature=1.0,
text_top_p=1.0,
text_top_k=50,
audio_temperature=0.7,
audio_top_p=0.9,
audio_top_k=25,
audio_repetition_penalty=1.3,
)
if index % 10 == 0 or index == len(entries):
print(f"MOSS generated {index}/{len(entries)}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

151
cli/tts_qwen_worker.py Executable file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Persistent-process Qwen3-TTS worker used by the sample orchestrator."""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from pathlib import Path
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
VOICE_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
VOICE_CLONE_MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
def read_jsonl(path: Path) -> list[dict]:
entries = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
entries.append(json.loads(line))
return entries
def chunks(values: list, size: int):
for index in range(0, len(values), size):
yield values[index : index + size]
def runtime() -> tuple[str, torch.dtype]:
if torch.cuda.is_available():
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
return "cuda:0", dtype
return "cpu", torch.float32
def load_model(model_id: str) -> Qwen3TTSModel:
device, dtype = runtime()
return Qwen3TTSModel.from_pretrained(
model_id,
device_map=device,
dtype=dtype,
attn_implementation="sdpa",
)
def build_bank(entries: list[dict], output_dir: Path, batch_size: int) -> None:
model = load_model(VOICE_DESIGN_MODEL)
output_dir.mkdir(parents=True, exist_ok=True)
for batch in chunks(entries, max(1, batch_size)):
seed = int(batch[0].get("seed", 0))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
wavs, sample_rate = model.generate_voice_design(
text=[str(item["text"]) for item in batch],
language=[str(item["language_name"]) for item in batch],
instruct=[str(item["instruct"]) for item in batch],
)
for item, wav in zip(batch, wavs):
sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate)
def generate_direct(entries: list[dict], output_dir: Path, batch_size: int) -> None:
"""Create every final corpus candidate with a fresh voice design."""
model = load_model(VOICE_DESIGN_MODEL)
output_dir.mkdir(parents=True, exist_ok=True)
completed = 0
for batch in chunks(entries, max(1, batch_size)):
seed = int(batch[0].get("seed", completed + 1))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
wavs, sample_rate = model.generate_voice_design(
text=[str(item["text"]) for item in batch],
language=[str(item["language_name"]) for item in batch],
instruct=[str(item["instruct"]) for item in batch],
# Qwen emits 12 acoustic frames per second. Four seconds is a hard
# wake-phrase ceiling and prevents decoder rambling.
max_new_tokens=48,
temperature=0.8,
top_k=50,
top_p=0.9,
repetition_penalty=1.12,
)
for item, wav in zip(batch, wavs):
sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate)
completed += 1
if completed % 25 == 0 or completed == len(entries):
print(f"Qwen direct generation created {completed}/{len(entries)}", flush=True)
def generate(entries: list[dict], output_dir: Path, batch_size: int) -> None:
model = load_model(VOICE_CLONE_MODEL)
output_dir.mkdir(parents=True, exist_ok=True)
grouped: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
for item in entries:
key = (
str(item["ref_audio"]),
str(item["ref_text"]),
str(item["language_name"]),
)
grouped[key].append(item)
for (ref_audio, ref_text, language_name), group in grouped.items():
prompt = model.create_voice_clone_prompt(
ref_audio=ref_audio,
ref_text=ref_text,
x_vector_only_mode=False,
)
for batch in chunks(group, max(1, batch_size)):
seed = int(batch[0].get("seed", 0))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
wavs, sample_rate = model.generate_voice_clone(
text=[str(item["text"]) for item in batch],
language=[language_name] * len(batch),
voice_clone_prompt=prompt,
)
for item, wav in zip(batch, wavs):
sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=("bank", "direct", "generate"), required=True)
parser.add_argument("--input-jsonl", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--batch-size", type=int, default=4)
args = parser.parse_args()
entries = read_jsonl(args.input_jsonl)
if not entries:
return 0
if args.mode == "bank":
build_bank(entries, args.output_dir, args.batch_size)
elif args.mode == "direct":
generate_direct(entries, args.output_dir, args.batch_size)
else:
generate(entries, args.output_dir, args.batch_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())

374
cli/tts_reference_qa.py Normal file
View File

@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Batch semantic and speech-presence QA for synthetic voice references."""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
import wave
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
MIN_PHRASE_SIMILARITY = 0.68
MIN_SPEECH_RATIO = 0.20
ACOUSTIC_LIMITS = {
"omnivoice": {
"minimum_speech_ratio": 0.25,
"maximum_spectral_flatness": 0.18,
"maximum_high_frequency_ratio": 0.30,
"maximum_zero_crossing_rate": 0.28,
},
"qwen3": {
"minimum_speech_ratio": 0.15,
"maximum_spectral_flatness": 0.25,
"maximum_high_frequency_ratio": 0.35,
"maximum_zero_crossing_rate": 0.32,
"vad_bypass_flatness": 0.11,
},
"moss": {
"minimum_speech_ratio": 0.20,
"maximum_spectral_flatness": 0.22,
"maximum_high_frequency_ratio": 0.32,
"maximum_zero_crossing_rate": 0.30,
"vad_bypass_flatness": 0.10,
},
"piper": {
"minimum_speech_ratio": 0.18,
"maximum_spectral_flatness": 0.22,
"maximum_high_frequency_ratio": 0.32,
"maximum_zero_crossing_rate": 0.30,
"vad_bypass_flatness": 0.10,
},
}
def normalize_text(value: Any) -> str:
text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ")
text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE)
return re.sub(r"\s+", " ", text).strip()
def phrase_similarity(transcript: Any, expected_phrase: Any) -> float:
transcript_words = normalize_text(transcript).split()
phrase_words = normalize_text(expected_phrase).split()
if not transcript_words or not phrase_words:
return 0.0
phrase_token = "".join(phrase_words)
best_score = 0.0
minimum_words = max(1, len(phrase_words) - 1)
maximum_words = min(len(transcript_words), len(phrase_words) + 1)
for word_count in range(minimum_words, maximum_words + 1):
for start in range(0, len(transcript_words) - word_count + 1):
candidate = "".join(transcript_words[start : start + word_count])
best_score = max(best_score, SequenceMatcher(None, candidate, phrase_token).ratio())
return best_score
def transcript_matches_phrase(transcript: Any, expected_phrase: Any) -> bool:
transcript_words = normalize_text(transcript).split()
phrase_words = normalize_text(expected_phrase).split()
if not transcript_words or not phrase_words:
return False
transcript_token = "".join(transcript_words)
phrase_token = "".join(phrase_words)
complete_phrase = transcript_token.count(phrase_token) == 1
has_full_word_shape = len(transcript_words) >= len(phrase_words)
has_single_utterance_shape = len(transcript_words) <= len(phrase_words) + 1
repeats_expected_word = any(
transcript_words.count(word) > phrase_words.count(word)
for word in set(phrase_words)
)
return has_single_utterance_shape and not repeats_expected_word and (
complete_phrase
or (
has_full_word_shape
and phrase_similarity(transcript, expected_phrase) >= MIN_PHRASE_SIMILARITY
)
)
def semantic_rejection_reason(
transcript: Any,
expected_phrase: Any,
detected_speech_ratio: float,
) -> str:
"""Distinguish obvious decoder collapse from an uncertain ASR mismatch."""
transcript_words = normalize_text(transcript).split()
phrase_words = normalize_text(expected_phrase).split()
transcript_token = "".join(transcript_words)
phrase_token = "".join(phrase_words)
if phrase_token and transcript_token.count(phrase_token) > 1:
return "repeated_phrase"
if any(
transcript_words.count(word) > phrase_words.count(word)
for word in set(phrase_words)
):
return "repeated_phrase"
if not transcript_token:
return "no_speech_detected" if detected_speech_ratio < MIN_SPEECH_RATIO else "decoder_collapse"
# OmniVoice's failed diffusion samples commonly become one sustained
# vowel/hum (Whisper renders these as "ehhhh", "aaaa", or "hmm").
if len(transcript_token) >= 3 and set(transcript_token) <= set("aeiouhmy"):
return "decoder_collapse"
return "phrase_mismatch"
def read_resampled_audio(path: Path):
import numpy as np
with wave.open(str(path), "rb") as stream:
channels = stream.getnchannels()
sample_width = stream.getsampwidth()
sample_rate = stream.getframerate()
frames = stream.getnframes()
raw = stream.readframes(frames)
if channels < 1 or sample_width != 2 or sample_rate <= 0 or not raw:
raise ValueError("expected PCM16 WAV audio")
audio = np.frombuffer(raw, dtype="<i2").astype(np.float32)
if channels > 1:
audio = audio.reshape(-1, channels).mean(axis=1)
audio /= 32768.0
if sample_rate != 16000:
output_length = max(1, round(len(audio) * 16000 / sample_rate))
source_positions = np.arange(len(audio), dtype=np.float64)
target_positions = np.arange(output_length, dtype=np.float64) * (sample_rate / 16000)
audio = np.interp(target_positions, source_positions, audio).astype(np.float32)
return audio
def speech_ratio(path: Path, vad_model) -> float:
import torch
from silero_vad import get_speech_timestamps
audio = read_resampled_audio(path)
timestamps = get_speech_timestamps(
torch.from_numpy(audio),
vad_model,
sampling_rate=16000,
threshold=0.5,
)
speech_samples = sum(item["end"] - item["start"] for item in timestamps)
return speech_samples / max(1, len(audio))
def acoustic_metrics(path: Path) -> dict[str, float]:
"""Return inexpensive measurements that separate speech from static."""
import numpy as np
audio = read_resampled_audio(path)
if not len(audio):
raise ValueError("empty audio")
centered = audio - float(np.mean(audio))
peak = float(np.max(np.abs(centered)))
rms = float(np.sqrt(np.mean(np.square(centered))))
clipped_ratio = float(np.mean(np.abs(audio) >= 0.999))
zero_crossing_rate = float(np.mean(centered[:-1] * centered[1:] < 0)) if len(centered) > 1 else 1.0
frame_size = 512
hop = 256
spectra = []
window = np.hanning(frame_size).astype(np.float32)
padded = np.pad(centered, (0, max(0, frame_size - len(centered))))
for start in range(0, max(1, len(padded) - frame_size + 1), hop):
frame = padded[start : start + frame_size]
if len(frame) < frame_size:
frame = np.pad(frame, (0, frame_size - len(frame)))
if float(np.sqrt(np.mean(np.square(frame)))) < 0.001:
continue
spectra.append(np.square(np.abs(np.fft.rfft(frame * window))))
if spectra:
power = np.mean(np.stack(spectra), axis=0) + 1e-12
useful = power[3:]
spectral_flatness = float(np.exp(np.mean(np.log(useful))) / np.mean(useful))
frequencies = np.fft.rfftfreq(frame_size, 1.0 / 16000.0)
high_frequency_ratio = float(
np.sum(power[frequencies >= 4000.0]) / max(1e-12, np.sum(power[frequencies >= 80.0]))
)
else:
spectral_flatness = 1.0
high_frequency_ratio = 1.0
return {
"duration": len(audio) / 16000.0,
"rms": rms,
"peak": peak,
"clipped_ratio": clipped_ratio,
"dc_offset": abs(float(np.mean(audio))),
"spectral_flatness": spectral_flatness,
"high_frequency_ratio": high_frequency_ratio,
"zero_crossing_rate": zero_crossing_rate,
}
def acoustic_rejection_reason(
metrics: dict[str, float],
detected_speech_ratio: float,
profile: str,
minimum_duration: float,
maximum_duration: float,
) -> str:
limits = ACOUSTIC_LIMITS[profile]
if metrics["duration"] < minimum_duration:
return "too_short"
if metrics["duration"] > maximum_duration:
return "too_long_or_rambling"
if metrics["rms"] < 0.004:
return "too_quiet"
if metrics["rms"] > 0.55 or metrics["clipped_ratio"] > 0.01:
return "clipped_or_overdriven"
if metrics["dc_offset"] > 0.05:
return "dc_offset"
if metrics["spectral_flatness"] > limits["maximum_spectral_flatness"]:
return "static_or_broadband_noise"
if metrics["high_frequency_ratio"] > limits["maximum_high_frequency_ratio"]:
return "high_frequency_noise"
if metrics["zero_crossing_rate"] > limits["maximum_zero_crossing_rate"]:
return "noise_like_waveform"
vad_bypass = limits.get("vad_bypass_flatness", -1.0)
if detected_speech_ratio < limits["minimum_speech_ratio"] and metrics["spectral_flatness"] > vad_bypass:
return "no_speech_detected"
return "accepted"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--input-jsonl", type=Path, required=True)
parser.add_argument("--output-jsonl", type=Path, required=True)
parser.add_argument("--phrase", required=True)
parser.add_argument("--language", required=True)
parser.add_argument("--download-root", type=Path, required=True)
parser.add_argument(
"--speech-only",
action="store_true",
help="Use VAD only; intended for fast corpus-wide decoder-collapse filtering.",
)
parser.add_argument(
"--profile",
choices=tuple(ACOUSTIC_LIMITS),
help="Apply strict provider-specific corpus safety limits.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
entries = [
json.loads(line)
for line in args.input_jsonl.read_text(encoding="utf-8").splitlines()
if line.strip()
]
from silero_vad import load_silero_vad
vad_model = load_silero_vad(onnx=True)
language = args.language.strip().lower().split("_", 1)[0]
try:
from faster_whisper.tokenizer import _LANGUAGE_CODES
semantic_checked = not args.speech_only and language in set(_LANGUAGE_CODES)
except Exception:
semantic_checked = False
whisper_model = None
if semantic_checked:
import ctranslate2
from faster_whisper import WhisperModel
device = "cuda" if int(ctranslate2.get_cuda_device_count()) > 0 else "cpu"
compute_type = "float16" if device == "cuda" else "int8"
model_name = "small.en" if language == "en" else "small"
args.download_root.mkdir(parents=True, exist_ok=True)
whisper_model = WhisperModel(
model_name,
device=device,
compute_type=compute_type,
download_root=str(args.download_root),
)
results = []
for entry in entries:
path = Path(entry["path"])
try:
detected_speech_ratio = speech_ratio(path, vad_model)
metrics = acoustic_metrics(path)
except Exception as error:
results.append(
{
"id": entry["id"],
"accepted": False,
"reason": f"speech_detection_failed: {error}",
"transcript": "",
"similarity": 0.0,
"speech_ratio": 0.0,
"semantic_checked": semantic_checked,
}
)
continue
acoustic_reason = "accepted"
if args.profile:
acoustic_reason = acoustic_rejection_reason(
metrics,
detected_speech_ratio,
args.profile,
float(entry.get("minimum_duration", 0.25)),
float(entry.get("maximum_duration", 5.0)),
)
transcript = ""
similarity = 0.0
if acoustic_reason != "accepted":
accepted = False
reason = acoustic_reason
elif whisper_model is not None:
segments, _info = whisper_model.transcribe(
str(path),
language=language,
beam_size=1,
condition_on_previous_text=False,
)
transcript = re.sub(
r"\s+",
" ",
" ".join(str(segment.text or "").strip() for segment in segments),
).strip()
similarity = phrase_similarity(transcript, args.phrase)
accepted = transcript_matches_phrase(transcript, args.phrase)
reason = (
"accepted"
if accepted
else semantic_rejection_reason(transcript, args.phrase, detected_speech_ratio)
)
else:
accepted = True if args.profile else detected_speech_ratio >= MIN_SPEECH_RATIO
reason = "accepted" if accepted else "no_speech_detected"
results.append(
{
"id": entry["id"],
"accepted": accepted,
"reason": reason,
"transcript": transcript,
"similarity": round(similarity, 4),
"speech_ratio": round(detected_speech_ratio, 4),
"acoustic_metrics": {key: round(value, 6) for key, value in metrics.items()},
"semantic_checked": semantic_checked,
}
)
args.output_jsonl.parent.mkdir(parents=True, exist_ok=True)
with args.output_jsonl.open("w", encoding="utf-8") as stream:
for result in results:
stream.write(json.dumps(result, ensure_ascii=False) + "\n")
accepted_count = sum(bool(result["accepted"]) for result in results)
print(f"Reference QA accepted {accepted_count}/{len(results)} clip(s)", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,12 +1,13 @@
#!/bin/bash
set -e
set -euo pipefail
PROGPATH=$(realpath "$0")
PROGDIR=$(dirname "${PROGPATH}")
PROGPATH="$(realpath "$0")"
PROGDIR="$(dirname "${PROGPATH}")"
KNOWN_ARGS=( samples batch-size data-dir language )
KNOWN_ARGS=( samples batch-size data-dir language tts-mode tts-voice-count )
# shellcheck source=/dev/null
source "${PROGDIR}/shell.functions"
WAKE_WORD="${POSITIONAL_ARGS[0]}"
WAKE_WORD="${POSITIONAL_ARGS[0]:-}"
if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then
echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2
@@ -16,147 +17,48 @@ fi
if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then
cat <<EOF >&2
Usage: $0 [ --samples=<samples> ] [ --batch-size=<batch_size> ]
[ --language=<lang> ] <wake_word>
--samples: The number of samples to generate for the wake word.
Default: ${DEFAULT_SAMPLES}
--batch-size: How many samples should be generated at a time. The more
samples, the more memory is needed.
Default: ${DEFAULT_BATCH_SIZE}
--language: Language for TTS voice selection.
"en" uses the multi-speaker LibriTTS-R generator.
Other languages (e.g. "nl") use single-speaker ONNX
voices and cycle between them for variety.
Default: ${DEFAULT_LANGUAGE}
<wake_word> The word to generate samples for.
Required.
[ --language=<lang> ] [ --tts-mode=<modern|hybrid|piper> ]
[ --tts-voice-count=<voices> ] <wake_word>
--samples: Number of samples to generate. Default: ${DEFAULT_SAMPLES}
--batch-size: Generation batch size. Default: ${DEFAULT_BATCH_SIZE}
--language: TTS language code. Default: ${DEFAULT_LANGUAGE}
--tts-mode: modern, hybrid, or piper. Default: ${DEFAULT_TTS_MODE}
--tts-voice-count: Deprecated compatibility option; direct generation ignores it.
<wake_word> Required phrase to synthesize.
EOF
exit 1
fi
# shellcheck source=/dev/null
source "${DATA_DIR}/.venv/bin/activate"
case "${TTS_MODE}" in
modern|hybrid|piper) ;;
*)
echo "ERROR: --tts-mode must be modern, hybrid, or piper." >&2
exit 2
;;
esac
WORK_DIR="${DATA_DIR}/work"
mkdir -p "${WORK_DIR}" || :
cd "${WORK_DIR}"
PSG="${DATA_DIR}/tools/piper-sample-generator"
MODELS_DIR="${PSG}/models"
VOICES_DIR="${PSG}/voices"
SAMPLES_DIR="${WORK_DIR}/wake_word_samples"
mkdir -p "${SAMPLES_DIR}" || :
# ---------------------------------------------------------------------------
# Build the --model argument(s) based on language
# ---------------------------------------------------------------------------
declare -a MODEL_ARGS=()
MODEL_TAG=""
if [ "${LANGUAGE}" == "en" ] ; then
# English: use the multi-speaker LibriTTS-R generator (.pt)
MODEL_NAME="en_US-libritts_r-medium.pt"
MODEL_FILE="${MODELS_DIR}/${MODEL_NAME}"
if [ ! -f "${MODEL_FILE}" ] ; then
echo "ERROR: English model ${MODEL_FILE} not found. Run setup_python_venv first." >&2
exit 1
fi
MODEL_ARGS=( --model "${MODEL_FILE}" )
MODEL_TAG="${MODEL_NAME}"
else
# Non-English: find all ONNX voices matching the language prefix
# e.g. LANGUAGE=nl matches nl_NL-pim-medium.onnx, nl_BE-nathalie-medium.onnx, etc.
shopt -s nullglob
voice_files=( "${VOICES_DIR}/${LANGUAGE}"_*.onnx )
shopt -u nullglob
if [ ${#voice_files[@]} -eq 0 ] ; then
echo "ERROR: No ONNX voice files found for language '${LANGUAGE}' in ${VOICES_DIR}/" >&2
echo " Expected files matching: ${LANGUAGE}_*.onnx" >&2
echo " Run setup_python_venv to download voice models." >&2
exit 1
fi
echo " Using ${#voice_files[@]} voice(s) for language '${LANGUAGE}':"
MODEL_TAG="${LANGUAGE}"
for vf in "${voice_files[@]}" ; do
vname="$(basename "${vf}")"
echo " - ${vname}"
MODEL_ARGS+=( --model "${vf}" )
MODEL_TAG="${MODEL_TAG}+${vname}"
done
fi
REGENERATE=false
if [ "${SAMPLES}" -eq 1 ] ; then
echo "===== Generating ${SAMPLES} sample of '${WAKE_WORD}' (language=${LANGUAGE}) ====="
wake_word_filename="${WAKE_WORD//[ \`~\!@#\$%^&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}"
mkdir -p "${WORK_DIR}/test_sample" || :
"${PSG}/generate_samples.py" "${WAKE_WORD}" \
"${MODEL_ARGS[@]}" \
--max-samples ${SAMPLES} \
--batch-size ${BATCH_SIZE} \
--output-dir "${WORK_DIR}/test_sample" \
--max-speakers 100 2>&1 | sed -r -e "s/(DEBUG|INFO):__main__:/ /g"
mv "${WORK_DIR}/test_sample/0.wav" "${WORK_DIR}/test_sample/${wake_word_filename}.wav"
echo "Sample available at ${WORK_DIR}/test_sample/${wake_word_filename}.wav"
echo "Play it from your host."
exit 0
fi
grep -q "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" "${WORK_DIR}/last_wake_word" &>/dev/null || REGENERATE=true
# Double check that the number of existing samples matches SAMPLES
existing_samples=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l)
[ "${existing_samples}" -eq "${SAMPLES}" ] || REGENERATE=true
mkdir -p "${WORK_DIR}"
START_TS=$EPOCHSECONDS
echo "===== Generating ${SAMPLES} wake-word samples (language=${LANGUAGE}, tts=${TTS_MODE}) ====="
if ! ${REGENERATE} ; then
echo "Sample generation not required"
echo
exit 0
fi
python3 "${PROGDIR}/tts_generate_samples.py" "${WAKE_WORD}" \
--samples="${SAMPLES}" \
--batch-size="${BATCH_SIZE}" \
--language="${LANGUAGE}" \
--tts-mode="${TTS_MODE}" \
--voice-count="${TTS_VOICE_COUNT}" \
--data-dir="${DATA_DIR}" \
--output-dir="${SAMPLES_DIR}"
echo -e "\n===== Generating ${SAMPLES} wake word samples in batches of ${BATCH_SIZE} (language=${LANGUAGE}) ====="
export TF_CPP_MIN_LOG_LEVEL=9
export TF_FORCE_GPU_ALLOW_GROWTH=true
export TF_GPU_ALLOCATOR=cuda_malloc_async
export TF_XLA_FLAGS="--tf_xla_auto_jit=0"
export NVIDIA_TF32_OVERRIDE=1
export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512
export GLOG_minloglevel=2
export GRPC_VERBOSITY=ERROR
echo " Generating samples"
rm -rf "${SAMPLES_DIR}" || :
mkdir -p "${SAMPLES_DIR}" || :
python "${PROGDIR}/run_generator_with_progress.py" \
--generator "${PSG}/generate_samples.py" \
--output-dir "${SAMPLES_DIR}" \
--max-samples ${SAMPLES} \
-- \
"${WAKE_WORD}" \
"${MODEL_ARGS[@]}" \
--max-samples ${SAMPLES} \
--batch-size ${BATCH_SIZE} \
--output-dir "${SAMPLES_DIR}"
generated_files=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l)
generated_files=$(find "${SAMPLES_DIR}" -maxdepth 1 -name '*.wav' | wc -l)
if [ "${generated_files}" -ne "${SAMPLES}" ] ; then
echo "ERROR: only generated ${generated_files} files" >&2
echo "ERROR: only generated ${generated_files} of ${SAMPLES} files" >&2
exit 1
fi
echo "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" > "${WORK_DIR}/last_wake_word"
echo
END_TS=$EPOCHSECONDS
print_elapsed_time "${START_TS}" "${END_TS}" "Generated ${SAMPLES} wake word samples."
exit 0

View File

@@ -84,6 +84,21 @@ if [ "${IS_BLACKWELL}" = "true" ]; then
echo " Using GPU compatibility retries; CPU fallback is ${ALLOW_CPU_FALLBACK} (override with MWW_ALLOW_CPU_FALLBACK=true|false)."
fi
BLACKWELL_TF_MODE="${MWW_BLACKWELL_TF:-auto}"
BLACKWELL_TF_REQUIRED="false"
BLACKWELL_TF_ACTIVE="false"
case "${BLACKWELL_TF_MODE,,}" in
1|true|yes|on|required)
BLACKWELL_TF_REQUIRED="true"
;;
0|false|no|off|disabled)
BLACKWELL_TF_MODE="disabled"
;;
*)
BLACKWELL_TF_MODE="auto"
;;
esac
# Enable driver-side PTX JIT fallback when ptxas/nvlink are unavailable.
if [ -z "${XLA_FLAGS:-}" ]; then
export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found"
@@ -238,6 +253,32 @@ fi
echo " Wrote training_parameters.yaml"
rm -rf "${WORK_DIR}/trained_models/wakeword"
if [ "${IS_BLACKWELL}" = "true" ] && [ "${BLACKWELL_TF_MODE}" != "disabled" ]; then
BLACKWELL_SETUP="${PROGDIR}/setup_blackwell_venv"
BLACKWELL_PYTHON="${DATA_DIR}/.venv-blackwell/bin/python"
if [ -x "${BLACKWELL_SETUP}" ] && command -v python3.13 >/dev/null 2>&1; then
echo "↪️ Preparing Blackwell-native TensorFlow training environment."
if "${BLACKWELL_SETUP}" --data-dir="${DATA_DIR}"; then
PYTHON_BIN="${BLACKWELL_PYTHON}"
BLACKWELL_TF_ACTIVE="true"
echo "✅ Blackwell TensorFlow training enabled: ${PYTHON_BIN}"
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow setup failed and MWW_BLACKWELL_TF is required." >&2
exit 1
fi
echo "⚠️ Blackwell TensorFlow setup failed; continuing with compatibility retries."
fi
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow was required, but python3.13/setup_blackwell_venv is unavailable." >&2
exit 1
fi
echo " Blackwell TensorFlow image support not available; continuing with compatibility retries."
fi
fi
wake_word_filename="$(
echo "${WAKE_WORD}" \
| tr '[:upper:]' '[:lower:]' \
@@ -261,11 +302,11 @@ TRAIN_ARGS=(
--test_tflite_streaming_quantized 1
--use_weights best_weights
mixednet
--pointwise_filters "64,64,64,64"
--pointwise_filters "128,128,128,128"
--repeat_in_block "1,1,1,1"
--mixconv_kernel_sizes "[5], [7,11], [9,15], [23]"
--residual_connection "0,0,0,0"
--first_conv_filters 32
--first_conv_filters 64
--first_conv_kernel_size 5
--stride 2
)
@@ -345,6 +386,7 @@ fi
TRAINING_DONE="false"
echo "🏋️ Starting model training and TFLite export (this is the longest stage)…"
echo "🧠 Model quality: high_accuracy_plus"
if run_attempt "Attempt 1/3: GPU training (default runtime profile)" ; then
echo "✅ Training complete (GPU path)."
TRAINING_DONE="true"
@@ -425,7 +467,12 @@ echo "🎯 Calibrating detector settings for on-device use…"
if "${PYTHON_BIN:-python}" "${PROGDIR}/calibrate_detector.py" \
--training-config "${WORK_DIR}/trained_models/wakeword/training_config.yaml" \
--model "${source_path}" \
--output "${calibration_path}"; then
--output "${calibration_path}" \
--target-faph "${MWW_CALIBRATION_TARGET_FAPH:-0.25}" \
--recall-margin "${MWW_CALIBRATION_RECALL_MARGIN:-0.005}" \
--window-sizes "${MWW_CALIBRATION_WINDOW_SIZES:-5,6,7}" \
--cutoff-min "${MWW_CALIBRATION_CUTOFF_MIN:-0.95}" \
--cutoff-max "${MWW_CALIBRATION_CUTOFF_MAX:-1.00}"; then
echo "✅ Detector calibration complete."
else
echo "⚠️ Detector calibration failed; packaging with default detector settings."
@@ -455,7 +502,9 @@ json_path = Path(os.environ["JSON_PATH"])
calibration_path = Path(os.environ.get("CALIBRATION_PATH", ""))
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
probability_cutoff = 0.97
sliding_window_size = 5
sliding_window_size = 6
strict_min_close_miss_threshold = 0.68
calibration = {}
if calibration_path.exists():
try:
@@ -469,21 +518,63 @@ if calibration_path.exists():
except Exception as exc:
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.")
probability_cutoff = round(probability_cutoff, 3)
sliding_window_size = max(1, min(10, int(sliding_window_size)))
selected_metrics = calibration.get("selected_metrics") if isinstance(calibration.get("selected_metrics"), dict) else {}
evaluation = calibration.get("evaluation") if isinstance(calibration.get("evaluation"), dict) else {}
close_miss_threshold = max(
0.01,
min(0.99, round(max(strict_min_close_miss_threshold, probability_cutoff - 0.17), 3)),
)
meta = {
"type": "micro",
"wake_word": os.environ["WAKE_WORD_TITLE"],
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
"author": "Tater Totterson",
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
"model": os.environ["TFLITE_FILENAME"],
"trained_languages": [language],
"version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": {
"probability_cutoff": round(probability_cutoff, 2),
"probability_cutoff": probability_cutoff,
"sliding_window_size": sliding_window_size,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0",
},
"tater_native": {
"format_version": 1,
"wake_threshold": probability_cutoff,
"wake_sliding_window": sliding_window_size,
"close_miss_threshold": close_miss_threshold,
"frontend": {
"name": "tflm_microfrontend",
"sample_rate": 16000,
"feature_duration_ms": 30,
"feature_step_ms": 10,
"feature_size": 40,
"input_feature_frames": 2,
"lower_band_limit": 125.0,
"upper_band_limit": 7500.0,
},
"recommended_for": ["tater-native-satellite", "voice-pe"],
},
"calibration": {
"target_false_accepts_per_hour": calibration.get("target_false_accepts_per_hour"),
"selected_false_accepts_per_hour_limit": calibration.get("selected_false_accepts_per_hour_limit"),
"recall": selected_metrics.get("recall"),
"false_accepts_per_hour": selected_metrics.get("false_accepts_per_hour"),
"ambient_hours": selected_metrics.get("ambient_hours"),
"positive_dataset": evaluation.get("positive_dataset"),
"ambient_dataset": evaluation.get("ambient_dataset"),
"positive_tracks": evaluation.get("positive_tracks"),
"ambient_tracks": evaluation.get("ambient_tracks"),
"generated_at": calibration.get("generated_at"),
},
}
json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
PY

View File

@@ -6,7 +6,8 @@ ENV DEBIAN_FRONTEND=noninteractive
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.12 python3.12-venv python3.12-dev python3-pip python-is-python3 \
git wget curl unzip patch ca-certificates nano less \
git wget curl unzip patch ninja-build build-essential cmake pkg-config \
ca-certificates nano less libgomp1 ffmpeg sox libsox-fmt-all libsndfile1 espeak-ng \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data
@@ -27,14 +28,16 @@ COPY --chown=root:root --chmod=0755 \
requirements.txt \
/root/mww-scripts/
COPY --chown=root:root --chmod=0644 tts_config.py /root/mww-scripts/tts_config.py
# CLI folder
COPY --chown=root:root cli/ /root/mww-scripts/cli/
# Make all CLI scripts executable (avoids "Permission denied")
RUN chmod -R a+x /root/mww-scripts/cli
# Static UI for trainer
COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html
# Prebuilt Vue/TypeScript UI (Node.js is not required at runtime)
COPY --chown=root:root static/ /root/mww-scripts/static/
# trainer server
CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"]

57
dockerfile.blackwell Normal file
View File

@@ -0,0 +1,57 @@
# RTX 50 / Blackwell image
FROM nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV CUDA_HOME=/usr/local/cuda
ENV PATH=/usr/local/cuda/bin:${PATH}
ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH}
ENV MWW_BLACKWELL_IMAGE=1
ENV MWW_BLACKWELL_TF=auto
ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl
# System deps. Python 3.12 remains the main trainer/runtime venv, while
# Python 3.13 is used only for the Blackwell TensorFlow training step.
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git wget unzip patch \
ninja-build build-essential cmake pkg-config nano less libgomp1 \
ffmpeg sox libsox-fmt-all libsndfile1 espeak-ng \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3.12 python3.12-venv python3.12-dev \
python3.13 python3.13-venv python3.13-dev \
python3-pip python-is-python3 \
&& ldconfig \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data
# Trainer UI port
EXPOSE 8789
# Script root
WORKDIR /root/mww-scripts
# Bash environment
COPY --chown=root:root --chmod=0755 .bashrc /root/
# Root-level entrypoints
COPY --chown=root:root --chmod=0755 \
train_wake_word \
run.sh \
trainer_server.py \
requirements.txt \
/root/mww-scripts/
COPY --chown=root:root --chmod=0644 tts_config.py /root/mww-scripts/tts_config.py
# CLI folder
COPY --chown=root:root cli/ /root/mww-scripts/cli/
# Make all CLI scripts executable (avoids "Permission denied")
RUN chmod -R a+x /root/mww-scripts/cli
# Prebuilt Vue/TypeScript UI (Node.js is not required at runtime)
COPY --chown=root:root static/ /root/mww-scripts/static/
# trainer server
CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"]

1199
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "microwakeword-trainer-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "3.5.40"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"typescript": "5.9.3",
"vite": "8.2.0",
"vue-tsc": "3.3.9"
}
}

323
frontend/src/TrainerApp.vue Normal file
View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import AudioTrimModal from "./components/AudioTrimModal.vue";
import type { JsonRecord } from "./api";
import {
autoLinked, captureTone, claimTater, clearSamples, copyWakeWord, deleteManagedData, describeFormat,
disposeTrainer, ensureSupportedTtsMode, formatBytes, formatTimestamp, hasConsole, initializeTrainer,
isBusy, itemAudioUrl, negativeCount, notify, personalCount, previewPhrase, refreshAuto,
refreshCaptured, refreshManagedData, refreshSamples, refreshWakeWords, removeSample, revertSample, reviewCaptured,
runAutoAction, saveAuto, selectFiles, selectedSamples, startSession, startTraining, stopSession, sttEngines,
trainer, ttsRoute, unlinkTater, uploadSelectedFiles,
} from "./trainerStore";
import type { AudioItem, ManagedDataItem, SampleBucket, ViewName } from "./types";
const uploadInput = ref<HTMLInputElement | null>(null);
const consoleLog = ref<HTMLElement | null>(null);
const consoleFollowing = ref(true);
const linkUrl = ref("");
const linkCode = ref("");
const linkComplete = ref(false);
const mascotUrl = "/static/images/tater-wake-word-trainer.png";
const pageSize = 50;
const tabs: Array<{ id: ViewName; label: string; short: string }> = [
{ id: "trainer", label: "Trainer", short: "Train" },
{ id: "auto", label: "Auto Training", short: "Auto" },
{ id: "firmware", label: "Wake Words", short: "Words" },
{ id: "captured", label: "Captured Audio", short: "Inbox" },
{ id: "samples", label: "Samples", short: "Samples" },
{ id: "data", label: "Data", short: "Data" },
];
const pagedSamples = computed(() => {
const page = trainer.samplePage[trainer.sampleBucket];
return selectedSamples.value.slice(page * pageSize, (page + 1) * pageSize);
});
const samplePages = computed(() => Math.max(1, Math.ceil(selectedSamples.value.length / pageSize)));
const autoState = computed(() => trainer.auto.state || {});
const autoRuntime = computed(() => trainer.auto.runtime || {});
const autoAudit = computed(() => {
const state = autoState.value;
const rows: string[] = [];
if (state.last_review_result) rows.push(`Last review: ${String(state.last_review_result).replaceAll("_", " ")}`);
if (state.last_review_file) rows.push(String(state.last_review_file));
if (state.last_review_transcript) rows.push(`STT: “${state.last_review_transcript}`);
if (state.last_review_error) rows.push(`Error: ${state.last_review_error}`);
if (state.last_stt_engine) rows.push(`STT engine: ${String(state.last_stt_engine).replaceAll("_", " ")}`);
if (state.last_notify_at) rows.push(state.last_notify_error ? `Publish failed: ${state.last_notify_error}` : `Wake word published ${formatTimestamp(state.last_notify_at)}`);
return rows.join(" · ") || "No automatic review has run yet.";
});
const trainingStatus = computed(() => {
if (trainer.training.running) return { text: "Training running", tone: "warning" };
if (trainer.training.exit_code === 0) return { text: "Training finished", tone: "success" };
if (trainer.training.exit_code !== null) return { text: `Exit ${trainer.training.exit_code}`, tone: "error" };
return { text: "Not started", tone: "neutral" };
});
const autoStatus = computed(() => {
if (autoRuntime.value.review_running) return { text: `Transcribing ${autoRuntime.value.review_file || "wake"}`, tone: "warning" };
if (trainer.training.running && trainer.auto.config?.enabled) return { text: "Training running", tone: "warning" };
if (trainer.auto.config?.enabled) return { text: "Enabled", tone: "success" };
return { text: "Disabled", tone: "neutral" };
});
const consoleLines = computed(() => trainer.training.log_lines?.length ? trainer.training.log_lines : ["No training output yet."]);
const dataCategories = computed(() => {
const groups = new Map<string, ManagedDataItem[]>();
for (const item of trainer.managedData.items || []) {
const rows = groups.get(item.category) || [];
rows.push(item);
groups.set(item.category, rows);
}
return Array.from(groups, ([name, items]) => ({ name, items }));
});
watch(() => trainer.language, ensureSupportedTtsMode);
watch(() => trainer.toast.serial, () => window.setTimeout(() => { trainer.toast.message = ""; }, 4500));
watch(consoleLines, async () => {
if (!consoleFollowing.value) return;
await nextTick();
if (consoleFollowing.value && consoleLog.value) {
consoleLog.value.scrollTop = consoleLog.value.scrollHeight;
}
});
watch(() => trainer.consoleOpen, async (isOpen) => {
if (!isOpen) return;
consoleFollowing.value = true;
await nextTick();
scrollConsoleToBottom();
});
onMounted(() => {
void initializeTrainer();
document.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
disposeTrainer();
document.removeEventListener("keydown", onKeydown);
});
function onKeydown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
trainer.consoleOpen = false;
trainer.taterLinkOpen = false;
trainer.trimItem = null;
}
function onConsoleScroll(): void {
const element = consoleLog.value;
if (!element) return;
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
consoleFollowing.value = distanceFromBottom <= 32;
}
function scrollConsoleToBottom(): void {
const element = consoleLog.value;
if (!element) return;
consoleFollowing.value = true;
element.scrollTop = element.scrollHeight;
}
function changeView(view: ViewName): void {
trainer.activeView = view;
const run = view === "auto" ? refreshAuto(false)
: view === "captured" ? refreshCaptured()
: view === "samples" ? refreshSamples()
: view === "firmware" ? refreshWakeWords()
: view === "data" ? refreshManagedData()
: Promise.resolve();
void run.catch((error) => notify(error instanceof Error ? error.message : "Refresh failed.", "error"));
}
function setBucket(bucket: SampleBucket): void { trainer.sampleBucket = bucket; }
function openTrim(item: AudioItem, bucket: SampleBucket): void { trainer.trimBucket = bucket; trainer.trimItem = item; }
function openLink(): void {
linkUrl.value = trainer.autoForm.tater_url || "http://127.0.0.1:8501";
linkCode.value = "";
linkComplete.value = false;
trainer.taterLinkOpen = true;
void nextTick(() => (document.querySelector("#pairing-code") as HTMLInputElement | null)?.focus());
}
function formatLinkCode(): void {
const raw = linkCode.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
linkCode.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
}
async function submitLink(): Promise<void> {
if (!linkUrl.value.trim() || !linkCode.value.trim()) { notify("Tater address and pairing code are required.", "warning"); return; }
linkComplete.value = await claimTater(linkUrl.value, linkCode.value);
}
function metaRows(item: AudioItem): string[] {
const rows: string[] = [];
if (item.source_device) rows.push(String(item.source_device));
if (item.wake_word) rows.push(String(item.wake_word));
if (item.max_probability !== null && item.max_probability !== undefined) rows.push(`max ${item.max_probability}`);
if (item.average_probability !== null && item.average_probability !== undefined) rows.push(`avg ${item.average_probability}`);
if (item.detection_profile) rows.push(`profile ${String(item.detection_profile).replaceAll("_", " ")}`);
if (item.auto_review_status) rows.push(`auto ${String(item.auto_review_status).replaceAll("_", " ")}`);
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) rows.push(`VAD ${item.vad_max_probability}`);
return rows;
}
function sampleSubtitle(item: AudioItem): string {
const rows = [];
if (item.original_name && item.original_name !== item.saved_as) rows.push(`From ${item.original_name}`);
const timestamp = formatTimestamp(item.reviewed_at || item.received_at || item.created_at);
if (timestamp) rows.push(`Saved ${timestamp}`);
if (item.message) rows.push(String(item.message));
if (item.auto_negative) rows.push("Auto-reviewed false positive");
if (item.auto_positive) rows.push("Auto-promoted close miss");
return rows.join(" · ") || "Training sample";
}
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); }
function consoleTone(line: string): string {
const value = line.trim().toLowerCase();
if (/^(✓|✅)|success|finished/.test(value)) return "success";
if (/^(✗|❌)|error|failed|traceback/.test(value)) return "error";
if (/^(⚠|warning)/.test(value)) return "warning";
if (/^={4,}|^-----|^=====/.test(value)) return "heading";
return "";
}
</script>
<template>
<div class="app-shell">
<div class="ambient ambient-one" aria-hidden="true" /><div class="ambient ambient-two" aria-hidden="true" />
<header class="app-header">
<div class="brand"><div class="brand-mark" aria-hidden="true"><img :src="mascotUrl" alt="" /></div><div><span class="eyebrow">Tater tools</span><h1>Wake Word Studio</h1><p>Generate voices, curate real recordings, train, and publish.</p></div></div>
<div class="header-status"><span class="live-dot"><i />Local trainer</span><span v-if="trainer.session.safe_word" class="session-chip">{{ trainer.session.safe_word }} · {{ trainer.language }}</span></div>
</header>
<nav class="tabs" aria-label="Trainer areas">
<button v-for="tab in tabs" :key="tab.id" type="button" :class="{ active: trainer.activeView === tab.id }" @click="changeView(tab.id)"><span class="tab-full">{{ tab.label }}</span><span class="tab-short">{{ tab.short }}</span><b v-if="tab.id === 'captured' && trainer.captured.captured_count">{{ trainer.captured.captured_count }}</b></button>
</nav>
<main class="main-content">
<div v-if="!trainer.initialized" class="loading-panel"><span class="spinner" /><strong>Connecting to the local trainer</strong></div>
<template v-else>
<template v-if="trainer.activeView === 'trainer'">
<section class="hero training-hero">
<div><span class="eyebrow">Training studio</span><h2>Build a personal wake word</h2><p>Choose a multilingual voice route, check your real samples, then follow the model pipeline live.</p></div>
<div class="step-row"><span><b>1</b> Phrase</span><span><b>2</b> Samples</span><span><b>3</b> Train</span></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">1</div><div><h3>Phrase + voice</h3><p>The phrase and voice route lock while a session is active.</p></div><span class="pill" :class="trainer.session.safe_word ? 'success' : ''">{{ trainer.session.safe_word ? `Session · ${trainer.session.safe_word}` : "No session" }}</span></header>
<div class="form-grid phrase-form">
<label class="field wide"><span>Wake phrase</span><input v-model="trainer.phrase" type="text" placeholder='e.g. "hey tater"' :disabled="Boolean(trainer.session.safe_word) || isBusy('session')" @keyup.enter="startSession" /></label>
<label class="field"><span>Language</span><select v-model="trainer.language" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')"><option v-for="item in trainer.languages" :key="item.code" :value="item.code">{{ item.label }}</option></select><small>{{ ttsRoute }}</small></label>
<label class="field"><span>TTS source</span><select v-model="trainer.ttsMode" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')">
<option value="hybrid" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Four-provider ensemble · recommended</option>
<option value="modern" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.some((engine) => engine !== 'piper')">Modern only · no Piper</option>
<option value="piper" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Piper only · legacy</option>
</select><small>Models download once and stay cached.</small></label>
</div>
<div class="row form-actions"><button v-if="!trainer.session.safe_word" type="button" class="button primary" :disabled="isBusy('session') || !trainer.phrase.trim()" @click="startSession">{{ isBusy('session') ? "Starting…" : "Start session" }}</button><button v-else type="button" class="button danger" :disabled="isBusy('session')" @click="stopSession">{{ isBusy('session') ? "Stopping…" : (trainer.training.running ? "Stop session + training" : "Stop session") }}</button><button type="button" :disabled="!trainer.phrase.trim()" @click="previewPhrase">System preview</button></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Train wake word</h3><p>Personal positives and reviewed false-wake negatives are automatically included.</p></div><span class="pill" :class="trainingStatus.tone">{{ trainingStatus.text }}</span></header>
<div class="stats"><article><span>Positive samples</span><strong>{{ personalCount }}</strong></article><article><span>Negative samples</span><strong>{{ negativeCount }}</strong></article><article><span>Training format</span><strong class="format-value">16 kHz · mono · WAV</strong></article></div>
<div class="train-action"><button type="button" class="button primary large" :disabled="!trainer.session.safe_word || trainer.training.running || isBusy('training-start')" @click="startTraining">{{ trainer.training.running ? "Training in progress" : "Start training" }}</button></div>
<footer class="panel-footer"><span>Training opens the console automatically and continues if the window is closed.</span><button type="button" :disabled="!hasConsole" @click="trainer.consoleOpen = true">Open console</button></footer>
</section>
</template>
<template v-else-if="trainer.activeView === 'auto'">
<section class="hero auto-hero"><div><span class="eyebrow">False-positive loop</span><h2>Auto Training</h2><p>Transcribe captures, sort negatives, recover close misses, retrain on schedule, and publish through Tater.</p></div><span class="pill hero-pill" :class="autoStatus.tone">{{ autoStatus.text }}</span></section>
<section class="panel">
<header class="panel-head"><div class="number">1</div><div><h3>Review rules</h3><p>Conservative local STT keeps uncertain clips in the manual inbox.</p></div></header>
<div class="toggle-list">
<label><input v-model="trainer.autoForm.enabled" type="checkbox" /><span><strong>Enable Auto Training</strong><small>Queue eligible wake triggers for local transcription.</small></span></label>
<label><input v-model="trainer.autoForm.delete_confirmed_wakes" type="checkbox" /><span><strong>Delete confirmed good wakes</strong><small>Remove normal triggers when STT confirms the phrase.</small></span></label>
<label><input v-model="trainer.autoForm.promote_close_misses" type="checkbox" /><span><strong>Promote confirmed close misses</strong><small>Move verified close misses into positive samples.</small></span></label>
</div>
<div class="form-grid">
<label class="field"><span>Wake phrase</span><input v-model="trainer.autoForm.wake_phrase" type="text" /></label>
<label class="field"><span>STT language</span><input v-model="trainer.autoForm.language" type="text" /></label>
<label class="field wide"><span>STT engine</span><select v-model="trainer.autoForm.stt_engine"><option v-for="engine in sttEngines" :key="engine.id || engine.value" :value="engine.id || engine.value">{{ engine.label || engine.name || engine.id }}</option></select><small>{{ sttEngines.find((row) => (row.id || row.value) === trainer.autoForm.stt_engine)?.description || "Runs locally on this trainer." }}</small></label>
<label class="field"><span>Minimum transcript characters</span><input v-model.number="trainer.autoForm.minimum_transcript_chars" min="1" max="100" type="number" /></label>
</div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Training schedule</h3><p>A run starts only after enough newly reviewed negatives accumulate.</p></div></header>
<div class="form-grid">
<label class="field"><span>Run training</span><select v-model.number="trainer.autoForm.schedule_hours"><option :value="0">Manually only</option><option :value="6">Every 6 hours</option><option :value="12">Every 12 hours</option><option :value="24">Every day</option><option :value="48">Every 2 days</option><option :value="168">Every week</option></select></label>
<label class="field"><span>Minimum new negatives</span><input v-model.number="trainer.autoForm.minimum_new_negatives" min="1" max="10000" type="number" /></label>
</div>
<div class="stats"><article><span>Pending negatives</span><strong>{{ Number(autoState.pending_negative_count || 0) }}</strong></article><article><span>Next check</span><strong class="format-value">{{ autoState.next_run_at ? formatTimestamp(autoState.next_run_at) : "Manual" }}</strong></article><article><span>Last training</span><strong class="format-value">{{ autoState.last_train_finished_at ? formatTimestamp(autoState.last_train_finished_at) : "Never" }}</strong></article></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">3</div><div><h3>Publish to Tater</h3><p>Securely activate successful models across every connected satellite.</p></div></header>
<div class="form-grid"><label class="field wide"><span>Trainer public URL</span><input v-model="trainer.autoForm.advertised_base_url" type="text" placeholder="Auto-detect LAN address" /><small>{{ trainer.autoForm.advertised_base_url ? `Configured: ${trainer.autoForm.advertised_base_url}` : `Detected: ${trainer.auto.advertised_base_url || "unavailable"}` }}</small></label><label class="field wide"><span>Tater URL</span><input v-model="trainer.autoForm.tater_url" type="text" /></label></div>
<div class="link-row"><span class="pill" :class="autoLinked ? 'success' : 'warning'">{{ autoLinked ? `Linked${trainer.auto.trainer_link?.tater_name ? ` · ${trainer.auto.trainer_link.tater_name}` : ''}` : "Not linked" }}</span><button type="button" class="button primary" :disabled="isBusy('auto')" @click="openLink">{{ autoLinked ? "Relink Tater" : "Link Tater" }}</button><button v-if="autoLinked" type="button" class="button danger" :disabled="isBusy('auto')" @click="unlinkTater">Unlink</button></div>
<div class="toggle-list compact"><label><input v-model="trainer.autoForm.notify_satellites" type="checkbox" /><span><strong>Activate after successful training</strong><small>Tater applies the new word globally.</small></span></label></div>
</section>
<section class="panel action-panel"><div class="action-grid"><button type="button" class="button primary" :disabled="isBusy('auto')" @click="saveAuto">Save Auto Training</button><button type="button" :disabled="isBusy('auto')" @click="runAutoAction('review_now')">Review inbox now</button><button type="button" :disabled="isBusy('auto') || trainer.training.running" @click="runAutoAction('train_now')">Train now</button><button type="button" :disabled="isBusy('auto') || !autoLinked" @click="runAutoAction('notify_now')">Publish current word</button></div><p class="audit">{{ autoAudit }}</p></section>
</template>
<template v-else-if="trainer.activeView === 'captured'">
<section class="hero capture-hero"><div><span class="eyebrow">Capture review</span><h2>Captured Audio</h2><p>Listen to clips from your satellites and turn every real-world event into a better model.</p></div><span class="pill hero-pill" :class="trainer.captured.captured_count ? 'warning' : ''">{{ trainer.captured.captured_count ? `${trainer.captured.captured_count} waiting` : "Inbox idle" }}</span></section>
<section class="panel"><header class="panel-head"><div class="number">1</div><div><h3>Review queue</h3><p>Approve good phrases, keep false positives as negatives, or discard noise.</p></div><button type="button" :disabled="isBusy('captured')" @click="refreshCaptured()">{{ isBusy('captured') ? "Refreshing" : "Refresh inbox" }}</button></header><div class="stats"><article><span>Inbox</span><strong>{{ trainer.captured.captured_count }}</strong></article><article><span>Reviewed negatives</span><strong>{{ negativeCount }}</strong></article><article><span>Personal samples</span><strong>{{ personalCount }}</strong></article></div></section>
<section class="panel"><header class="panel-head"><div class="number">2</div><div><h3>Listen + sort</h3><p>Metadata remains visible so borderline detections are easy to understand.</p></div></header>
<div v-if="!trainer.captured.items?.length" class="empty-state">No captured audio yet. Clips sent by satellites will appear here.</div>
<div v-else class="audio-list"><article v-for="item in trainer.captured.items" :key="item.saved_as" class="audio-card">
<header><div><strong>{{ item.original_name || item.saved_as }}</strong><small>{{ formatTimestamp(item.captured_at || item.received_at) }} {{ item.message || "" }}</small></div><span class="pill" :class="captureTone(item).tone">{{ captureTone(item).label }}</span></header>
<div v-if="metaRows(item).length" class="meta-row"><span v-for="row in metaRows(item)" :key="row">{{ row }}</span></div>
<div v-if="item.transcript" class="transcript"><b>STT</b> {{ item.transcript }}</div><div v-if="item.auto_review_guided_transcript" class="transcript"><b>Guided wake check</b> {{ item.auto_review_guided_transcript }}</div>
<audio controls preload="none" :src="itemAudioUrl(item, 'captured')" />
<footer><span>{{ item.saved_as }} · {{ describeFormat(item.final_format) }}</span><div><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'approve_personal')">Add positive</button><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'mark_negative')">Mark negative</button><button type="button" class="button danger ghost" :disabled="isBusy('review')" @click="reviewCaptured(item, 'discard')">Discard</button></div></footer>
</article></div>
</section>
</template>
<template v-else-if="trainer.activeView === 'samples'">
<section class="hero samples-hero"><div><span class="eyebrow">Sample library</span><h2>Current Training Samples</h2><p>Audit positives and negatives, trim recordings precisely, and import seed audio.</p></div><span class="pill hero-pill">{{ personalCount + negativeCount }} total</span></section>
<section class="panel">
<header class="panel-head sample-head"><div class="number">1</div><div><h3>Saved samples</h3><p>Personal clips are positives. Negative clips are false wakes and hard negatives.</p></div><div class="segment-control"><button type="button" :class="{ active: trainer.sampleBucket === 'personal' }" @click="setBucket('personal')">Personal <b>{{ personalCount }}</b></button><button type="button" :class="{ active: trainer.sampleBucket === 'negative' }" @click="setBucket('negative')">Negative <b>{{ negativeCount }}</b></button></div></header>
<div class="row toolbar"><button type="button" :disabled="isBusy('samples')" @click="refreshSamples()">Refresh</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || personalCount === 0" @click="clearSamples('personal')">Clear positives</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || negativeCount === 0" @click="clearSamples('negative')">Clear negatives</button></div>
<div v-if="!selectedSamples.length" class="empty-state">No {{ trainer.sampleBucket }} samples saved yet.</div>
<div v-else class="audio-list compact-list"><article v-for="item in pagedSamples" :key="item.saved_as" class="audio-card">
<header><div><strong>{{ item.saved_as }}</strong><small>{{ sampleSubtitle(item) }}</small></div><div class="row"><span v-if="item.trimmed" class="pill warning">Trimmed</span><span class="pill" :class="trainer.sampleBucket === 'personal' ? 'success' : 'error'">{{ trainer.sampleBucket === "personal" ? "Positive" : "Negative" }}</span></div></header>
<audio controls preload="none" :src="itemAudioUrl(item, trainer.sampleBucket)" />
<footer><span>{{ describeFormat(item.final_format) }}</span><div><button type="button" @click="openTrim(item, trainer.sampleBucket)">Trim</button><button v-if="item.trimmed" type="button" @click="revertSample(item, trainer.sampleBucket)">Revert</button><button type="button" class="button danger ghost" :disabled="isBusy('review')" @click="removeSample(item, trainer.sampleBucket)">Remove</button></div></footer>
</article></div>
<div v-if="samplePages > 1" class="pagination"><button type="button" :disabled="trainer.samplePage[trainer.sampleBucket] === 0" @click="trainer.samplePage[trainer.sampleBucket]--">Previous</button><span>Page {{ trainer.samplePage[trainer.sampleBucket] + 1 }} of {{ samplePages }}</span><button type="button" :disabled="trainer.samplePage[trainer.sampleBucket] >= samplePages - 1" @click="trainer.samplePage[trainer.sampleBucket]++">Next</button></div>
</section>
<section class="panel">
<header class="panel-head"><div class="number">2</div><div><h3>Manual sample import</h3><p>Optional seed recordings are normalized to the trainers required WAV format.</p></div></header>
<label class="dropzone"><input ref="uploadInput" type="file" multiple accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus" @change="selectFiles" /><span><strong>Choose one or many audio files</strong><small>WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM</small></span><b>{{ trainer.selectedFiles.length ? `${trainer.selectedFiles.length} selected` : "Browse" }}</b></label>
<button type="button" class="button primary" :disabled="!trainer.session.safe_word || !trainer.selectedFiles.length || isBusy('upload')" @click="uploadSelectedFiles(uploadInput)">{{ isBusy('upload') ? "Uploading" : "Upload selected samples" }}</button>
<div class="progress-card"><div><strong>{{ trainer.uploadLabel }}</strong><span>{{ trainer.uploadProgress }}%</span></div><div class="progress-track"><i :style="{ width: `${trainer.uploadProgress}%` }" /></div><small>{{ trainer.uploadDetail }}</small></div>
</section>
</template>
<template v-else-if="trainer.activeView === 'data'">
<section class="hero data-hero"><div><span class="eyebrow">Local storage</span><h2>Data Management</h2><p>See exactly what the trainer has downloaded, generated, recorded, and produced.</p></div><span class="pill hero-pill">{{ formatBytes(trainer.managedData.total_size_bytes) }} total</span></section>
<section class="panel">
<header class="panel-head"><div class="number">i</div><div><h3>Trainer storage</h3><p>Deleting an item is permanent. Required downloads and generated caches will be rebuilt the next time training needs them.</p></div><button type="button" :disabled="isBusy('data') || isBusy('data-delete')" @click="refreshManagedData()">{{ isBusy('data') ? "Scanning" : "Refresh sizes" }}</button></header>
<div class="stats"><article><span>Space used</span><strong class="format-value">{{ formatBytes(trainer.managedData.total_size_bytes) }}</strong></article><article><span>Files</span><strong>{{ Number(trainer.managedData.total_file_count || 0).toLocaleString() }}</strong></article><article><span>Individual items</span><strong>{{ trainer.managedData.items.length }}</strong></article></div>
<p v-if="trainer.training.running" class="data-warning">Stop the active training session before deleting data.</p>
</section>
<section v-for="(group, groupIndex) in dataCategories" :key="group.name" class="panel data-panel">
<header class="panel-head"><div class="number">{{ groupIndex + 1 }}</div><div><h3>{{ group.name }}</h3><p>{{ group.items.length }} separately managed item{{ group.items.length === 1 ? "" : "s" }}</p></div></header>
<div class="data-list"><article v-for="item in group.items" :key="item.id" class="data-row" :class="{ empty: !item.file_count }">
<div class="data-copy"><div class="data-title"><strong>{{ item.label }}</strong><code>{{ item.location }}</code></div><small>{{ item.description }}</small><span v-if="item.rebuild_note" class="data-note">{{ item.rebuild_note }}</span></div>
<div class="data-usage"><strong>{{ formatBytes(item.size_bytes) }}</strong><span>{{ Number(item.file_count || 0).toLocaleString() }} file{{ item.file_count === 1 ? "" : "s" }}</span></div>
<button type="button" class="button danger ghost" :disabled="!item.file_count || trainer.training.running || isBusy('data') || isBusy('data-delete')" @click="deleteManagedData(item)">{{ isBusy('data-delete') ? "Please wait" : "Delete" }}</button>
</article></div>
</section>
<section v-if="!isBusy('data') && !trainer.managedData.items.length" class="panel empty-state">No managed trainer data was found.</section>
</template>
<template v-else-if="trainer.activeView === 'firmware'">
<section class="hero firmware-hero"><div><span class="eyebrow">Wake-word catalog</span><h2>Trained Wake Words</h2><p>Copy a local JSON package URL into Tater to switch every native satellite live.</p></div><span class="pill hero-pill" :class="trainer.wakeWords.length ? 'success' : 'warning'">{{ trainer.wakeWords.length ? `${trainer.wakeWords.length} trained` : "Catalog empty" }}</span></section>
<div class="native-notice"><strong>Tater Native</strong><span>These packages include model metadata and a direct model URL for live satellite updates.</span></div>
<section class="panel"><header class="panel-head"><div class="number">v1</div><div><h3>Published model URLs</h3><p>URLs stay local and are refreshed after each successful run.</p></div><button type="button" :disabled="isBusy('firmware')" @click="refreshWakeWords()">Refresh</button></header>
<div v-if="!trainer.wakeWords.length" class="empty-state">Train a wake word and its package will appear here.</div>
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="word.key || wordJsonUrl(word)"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordJsonUrl(word)" :href="wordJsonUrl(word)" target="_blank" rel="noreferrer">JSON · {{ wordJsonUrl(word) }}</a><span v-else class="muted">JSON package URL unavailable</span><a v-if="wordModelUrl(word)" :href="wordModelUrl(word)" target="_blank" rel="noreferrer">Model · {{ wordModelUrl(word) }}</a><div class="meta-row"><span v-if="word.language">{{ word.language }}</span><span v-if="word.trained_at">{{ formatTimestamp(word.trained_at) }}</span><span v-if="word.recall !== undefined">recall {{ word.recall }}</span></div></div><button type="button" :disabled="!wordJsonUrl(word)" @click="copyWakeWord(wordJsonUrl(word))">Copy URL</button></article></div>
</section>
</template>
</template>
</main>
<Teleport to="body">
<div v-if="trainer.consoleOpen" class="modal-backdrop console-backdrop" @click.self="trainer.consoleOpen = false">
<section class="modal console-modal" role="dialog" aria-modal="true" aria-label="Training console"><header class="modal-head"><div><span class="eyebrow">Live pipeline</span><h2>Training Console</h2><p>Closing this window does not interrupt training.</p></div><div class="row console-actions"><button v-if="!consoleFollowing" type="button" class="console-follow" @click="scrollConsoleToBottom">Jump to latest</button><span class="pill" :class="trainingStatus.tone">{{ trainingStatus.text }}</span><button type="button" @click="trainer.consoleOpen = false">Close</button></div></header><pre ref="consoleLog" class="console-log" @scroll.passive="onConsoleScroll"><span v-for="(line, index) in consoleLines" :key="`${index}-${line}`" :class="consoleTone(line)">{{ line }}</span></pre></section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="trainer.taterLinkOpen" class="modal-backdrop" @click.self="trainer.taterLinkOpen = false">
<section class="modal link-modal" role="dialog" aria-modal="true" aria-label="Link Tater"><header class="modal-head"><div><span class="eyebrow">Secure pairing</span><h2>{{ linkComplete ? "Tater linked" : "Link Tater" }}</h2><p>{{ linkComplete ? "This trainer can securely publish wake-word updates." : "Enter the short-lived code shown in Tater Voice Settings." }}</p></div><button type="button" @click="trainer.taterLinkOpen = false">Close</button></header>
<div v-if="linkComplete" class="link-success"><i>✓</i><strong>Successfully linked{{ trainer.auto.trainer_link?.tater_name ? ` to ${trainer.auto.trainer_link.tater_name}` : "" }}</strong><span>The private link key is stored locally and is never displayed.</span></div>
<div v-else class="stack"><label class="field"><span>Tater address</span><input v-model="linkUrl" type="text" /></label><label class="field"><span>Tater pairing code</span><input id="pairing-code" v-model="linkCode" class="pairing-code" maxlength="9" placeholder="ABCD-EFGH" autocomplete="off" @input="formatLinkCode" /></label><small>In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.</small><button type="button" class="button primary" :disabled="isBusy('link')" @click="submitLink">{{ isBusy('link') ? "Linking securely…" : "Link Tater" }}</button></div>
</section>
</div>
</Teleport>
<AudioTrimModal />
<Transition name="toast"><div v-if="trainer.toast.message" class="toast" :class="trainer.toast.tone" role="status">{{ trainer.toast.message }}</div></Transition>
</div>
</template>

43
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,43 @@
export type JsonRecord = Record<string, any>;
export async function request<T = JsonRecord>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
credentials: "same-origin",
...options,
headers: {
Accept: "application/json",
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
const body = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
const message = typeof body === "object" && body
? body.error || body.detail || body.message
: body;
throw new Error(String(message || `Request failed (${response.status})`));
}
return body as T;
}
export function getJson<T = JsonRecord>(path: string): Promise<T> {
return request<T>(path);
}
export function postJson<T = JsonRecord>(path: string, body: unknown = {}): Promise<T> {
return request<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export function putJson<T = JsonRecord>(path: string, body: unknown): Promise<T> {
return request<T>(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
import { request, type JsonRecord } from "../api";
import { notify, refreshSamples, trainer } from "../trainerStore";
const canvas = ref<HTMLCanvasElement | null>(null);
const audioBuffer = ref<AudioBuffer | null>(null);
const duration = ref(0);
const start = ref(0);
const end = ref(0);
const vadSegments = ref<Array<{ start: number; end: number }>>([]);
const loading = ref(false);
const saving = ref(false);
watch(() => trainer.trimItem, async (item) => {
if (!item) {
audioBuffer.value = null;
return;
}
loading.value = true;
try {
const url = `/api/audio/${encodeURIComponent(trainer.trimBucket)}/${encodeURIComponent(item.saved_as)}`;
const response = await fetch(url);
if (!response.ok) throw new Error("Audio could not be loaded.");
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
const context = new AudioContextCtor() as AudioContext;
audioBuffer.value = await context.decodeAudioData(await response.arrayBuffer());
duration.value = audioBuffer.value.duration;
start.value = 0;
end.value = duration.value;
await context.close();
try {
const vad = await request<JsonRecord>(`/api/samples/${encodeURIComponent(trainer.trimBucket)}/${encodeURIComponent(item.saved_as)}/vad`, { method: "POST" });
vadSegments.value = Array.isArray(vad.segments) ? vad.segments : [];
if (vadSegments.value.length) {
start.value = Math.max(0, Number(vadSegments.value[0].start || 0));
end.value = Math.min(duration.value, Number(vadSegments.value[0].end || duration.value));
}
} catch {
vadSegments.value = [];
}
await nextTick();
draw();
} catch (error) {
notify(error instanceof Error ? error.message : "Audio could not be loaded.", "error");
close();
} finally {
loading.value = false;
}
}, { immediate: true });
watch([start, end], () => draw());
function close(): void {
trainer.trimItem = null;
audioBuffer.value = null;
vadSegments.value = [];
}
function selectFirstVad(): void {
const segment = vadSegments.value[0];
if (!segment) return;
start.value = Number(segment.start);
end.value = Number(segment.end);
}
function draw(): void {
const target = canvas.value;
const buffer = audioBuffer.value;
if (!target || !buffer || !duration.value) return;
const rect = target.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const dpr = window.devicePixelRatio || 1;
target.width = Math.round(rect.width * dpr);
target.height = Math.round(rect.height * dpr);
const context = target.getContext("2d");
if (!context) return;
context.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
const middle = height / 2;
const samples = buffer.getChannelData(0);
const step = Math.max(1, Math.floor(samples.length / width));
context.clearRect(0, 0, width, height);
context.strokeStyle = "rgba(222, 218, 212, .24)";
context.lineWidth = 1;
context.beginPath();
for (let x = 0; x < width; x += 1) {
let minimum = 1;
let maximum = -1;
for (let offset = 0; offset < step; offset += 1) {
const value = samples[Math.floor(x) * step + offset] || 0;
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
}
context.moveTo(x, middle + minimum * middle * 0.84);
context.lineTo(x, middle + maximum * middle * 0.84);
}
context.stroke();
const from = (start.value / duration.value) * width;
const to = (end.value / duration.value) * width;
context.fillStyle = "rgba(8, 8, 9, .66)";
context.fillRect(0, 0, from, height);
context.fillRect(to, 0, width - to, height);
context.fillStyle = "rgba(255, 145, 52, .12)";
context.fillRect(from, 0, to - from, height);
context.strokeStyle = "#ff9134";
context.lineWidth = 2;
for (const x of [from, to]) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
context.strokeStyle = "rgba(68, 225, 165, .55)";
for (const segment of vadSegments.value) {
const x = (segment.start / duration.value) * width;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
}
function playSelection(): void {
const buffer = audioBuffer.value;
if (!buffer) return;
const AudioContextCtor = window.AudioContext || (window as any).webkitAudioContext;
const context = new AudioContextCtor() as AudioContext;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
source.start(0, start.value, Math.max(0.01, end.value - start.value));
source.onended = () => void context.close();
}
async function wavBlob(): Promise<Blob> {
const buffer = audioBuffer.value;
if (!buffer) throw new Error("Audio is not loaded.");
const startSample = Math.floor(start.value * buffer.sampleRate);
const endSample = Math.min(Math.floor(end.value * buffer.sampleRate), buffer.length);
const targetRate = 16000;
let pcm: Float32Array;
if (buffer.sampleRate === targetRate) {
pcm = buffer.getChannelData(0).slice(startSample, endSample);
} else {
const frames = Math.max(1, Math.floor((endSample - startSample) * targetRate / buffer.sampleRate));
const offline = new OfflineAudioContext(1, frames, targetRate);
const source = offline.createBufferSource();
source.buffer = buffer;
source.connect(offline.destination);
source.start(0, start.value, end.value - start.value);
pcm = (await offline.startRendering()).getChannelData(0);
}
const output = new ArrayBuffer(44 + pcm.length * 2);
const view = new DataView(output);
view.setUint32(0, 0x52494646, false);
view.setUint32(4, 36 + pcm.length * 2, true);
view.setUint32(8, 0x57415645, false);
view.setUint32(12, 0x666d7420, false);
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, targetRate, true);
view.setUint32(28, targetRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
view.setUint32(36, 0x64617461, false);
view.setUint32(40, pcm.length * 2, true);
for (let index = 0; index < pcm.length; index += 1) {
view.setInt16(44 + index * 2, Math.max(-32768, Math.min(32767, Math.round(pcm[index] * 32767))), true);
}
return new Blob([output], { type: "audio/wav" });
}
async function save(): Promise<void> {
const item = trainer.trimItem;
if (!item) return;
saving.value = true;
try {
const form = new FormData();
form.append("file", await wavBlob(), "trimmed.wav");
form.append("bucket", trainer.trimBucket);
form.append("source_file", item.saved_as);
form.append("start_time", start.value.toFixed(3));
form.append("end_time", end.value.toFixed(3));
const result = await request<JsonRecord>("/api/samples/trim", { method: "POST", body: form });
close();
await refreshSamples(true);
notify(result.message || "Trimmed sample saved.");
} catch (error) {
notify(error instanceof Error ? error.message : "Trim failed.", "error");
} finally {
saving.value = false;
}
}
function redraw(): void {
if (trainer.trimItem) draw();
}
window.addEventListener("resize", redraw);
onBeforeUnmount(() => window.removeEventListener("resize", redraw));
</script>
<template>
<Teleport to="body">
<div v-if="trainer.trimItem" class="modal-backdrop" @click.self="close">
<section class="modal trim-modal" role="dialog" aria-modal="true" aria-label="Trim audio">
<header class="modal-head">
<div><span class="eyebrow">Audio editor</span><h2>Trim {{ trainer.trimItem.saved_as }}</h2></div>
<button type="button" class="button ghost" @click="close">Close</button>
</header>
<p class="muted">Keep the spoken wake phrase and remove excess silence or noise. VAD markers appear in green.</p>
<div v-if="loading" class="empty-state">Loading waveform</div>
<template v-else>
<canvas ref="canvas" class="waveform" />
<div class="range-grid">
<label><span>Start · {{ start.toFixed(2) }}s</span><input v-model.number="start" type="range" min="0" :max="Math.max(0, end - .01)" step=".01" /></label>
<label><span>End · {{ end.toFixed(2) }}s</span><input v-model.number="end" type="range" :min="Math.min(duration, start + .01)" :max="duration" step=".01" /></label>
</div>
<div class="row space">
<span class="pill">Selection {{ Math.max(0, end - start).toFixed(2) }}s</span>
<span v-if="vadSegments.length" class="pill success">{{ vadSegments.length }} speech segment{{ vadSegments.length === 1 ? "" : "s" }}</span>
</div>
<div class="row modal-actions">
<button type="button" @click="playSelection">Play selection</button>
<button v-if="vadSegments.length" type="button" @click="selectFirstVad">Select first VAD</button>
<button type="button" class="button primary" :disabled="saving" @click="save">{{ saving ? "Saving" : "Save trim" }}</button>
</div>
</template>
</section>
</div>
</Teleport>
</template>

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import TrainerApp from "./TrainerApp.vue";
import "./trainer.css";
const root = document.getElementById("trainer-app");
if (!root) {
throw new Error("Missing #trainer-app mount point");
}
createApp(TrainerApp).mount(root);

217
frontend/src/trainer.css Normal file
View File

@@ -0,0 +1,217 @@
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3f1ee;
background: #0d0d0e;
font-synthesis: none;
--bg: #0d0d0e;
--surface: rgba(29, 29, 31, .9);
--surface-solid: #1c1c1e;
--surface-2: rgba(43, 43, 46, .8);
--line: rgba(255, 255, 255, .1);
--line-strong: rgba(255, 255, 255, .18);
--text: #f3f1ee;
--muted: #aaa6a0;
--orange: #ff9134;
--orange-2: #ffb267;
--violet: #77736e;
--blue: #a8a5a1;
--green: #44dda5;
--red: #ff6c7d;
--yellow: #ffc561;
--shadow: 0 24px 70px rgba(0, 0, 0, .32);
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 78% -10%, rgba(255, 145, 52, .08), transparent 34%), linear-gradient(145deg, #121213, #0d0d0e 60%, #151413); }
button, input, select { font: inherit; }
button, .button {
min-height: 42px; padding: 9px 16px; border: 1px solid var(--line-strong); border-radius: 12px;
color: var(--text); background: rgba(48, 48, 51, .86); font-weight: 700; cursor: pointer;
transition: border-color .18s ease, transform .18s ease, background .18s ease, box-shadow .18s ease;
}
button:hover:not(:disabled), .button:hover:not(:disabled) { transform: translateY(-1px); border-color: rgba(255, 145, 52, .55); background: rgba(62, 61, 61, .94); }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid rgba(255, 145, 52, .88); outline-offset: 2px; }
button:disabled { opacity: .43; cursor: not-allowed; }
.button.primary { color: #18100a; border-color: #ffad63; background: linear-gradient(135deg, var(--orange), #ffb45f); box-shadow: 0 10px 28px rgba(255, 126, 35, .19); }
.button.primary:hover:not(:disabled) { background: linear-gradient(135deg, #ffa04c, #ffc078); }
.button.danger { border-color: rgba(255, 108, 125, .54); color: #fff; background: rgba(255, 78, 101, .2); }
.button.ghost { background: transparent; }
.button.large { min-width: min(100%, 360px); min-height: 54px; font-size: 16px; }
.app-shell { position: relative; width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 30px 0 80px; }
.ambient { position: fixed; z-index: -1; width: 380px; height: 380px; border-radius: 50%; filter: blur(95px); opacity: .16; pointer-events: none; }
.ambient-one { top: -160px; right: 4vw; background: var(--violet); }
.ambient-two { bottom: -180px; left: -70px; background: var(--orange); }
.app-header { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 24px; }
.brand { display: flex; align-items: center; gap: 16px; }
.brand-mark { position: relative; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; width: 58px; height: 58px; border: 1px solid rgba(255, 164, 82, .4); border-radius: 19px; background: radial-gradient(circle at 50% 36%, #383330, #191819 72%); box-shadow: inset 0 1px rgba(255,255,255,.12), 0 14px 36px rgba(0,0,0,.25); }
.brand-mark img { display: block; width: 56px; height: 56px; object-fit: contain; filter: drop-shadow(0 5px 9px rgba(0, 0, 0, .36)); }
.brand h1, .hero h2, .panel h3, .modal h2 { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; }
.brand h1 { margin: 2px 0 1px; font-size: clamp(22px, 3vw, 31px); letter-spacing: -.035em; }
.brand p, .hero p, .panel p, .modal p { margin: 0; color: var(--muted); line-height: 1.55; }
.brand p { font-size: 13px; }
.eyebrow { color: var(--orange-2); font-size: 10px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
.header-status { display: flex; align-items: center; gap: 10px; }
.live-dot, .session-chip { display: inline-flex; align-items: center; min-height: 34px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(24, 24, 25, .78); font-size: 12px; font-weight: 700; }
.live-dot i { width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(68,221,165,.1); }
.tabs { position: sticky; top: 12px; z-index: 20; display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; padding: 6px; margin-bottom: 18px; border: 1px solid var(--line); border-radius: 16px; background: rgba(20, 20, 21, .9); box-shadow: 0 14px 36px rgba(0,0,0,.2); backdrop-filter: blur(18px); }
.tabs button { position: relative; min-height: 42px; padding: 8px; border-color: transparent; color: var(--muted); background: transparent; font-size: 13px; }
.tabs button.active { color: #fff; border-color: rgba(255, 152, 65, .42); background: linear-gradient(135deg, rgba(255,145,52,.22), rgba(92,89,86,.22)); box-shadow: inset 0 1px rgba(255,255,255,.05); }
.tabs button b { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 7px; padding: 0 4px; border-radius: 99px; color: #23120b; background: var(--orange); font-size: 10px; }
.tab-short { display: none; }
.main-content { display: grid; gap: 16px; }
.hero, .panel, .native-notice { border: 1px solid var(--line); border-radius: 22px; background: var(--surface); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 30px; min-height: 210px; padding: 34px; }
.hero::after { content: ""; position: absolute; right: -45px; bottom: -95px; width: 290px; height: 290px; border-radius: 50%; background: radial-gradient(circle, rgba(255,145,52,.22), transparent 67%); }
.auto-hero::after { background: radial-gradient(circle, rgba(255,145,52,.16), transparent 67%); }
.capture-hero::after { background: radial-gradient(circle, rgba(190,184,177,.12), transparent 67%); }
.firmware-hero::after { background: radial-gradient(circle, rgba(255,145,52,.13), transparent 67%); }
.hero > * { position: relative; z-index: 1; }
.hero h2 { max-width: 760px; margin: 8px 0; font-size: clamp(27px, 5vw, 48px); line-height: 1.02; letter-spacing: -.05em; }
.hero p { max-width: 720px; font-size: 15px; }
.hero-pill { flex: 0 0 auto; }
.step-row { display: grid; gap: 7px; min-width: 165px; }
.step-row span { display: flex; align-items: center; gap: 8px; color: #d5d1cc; font-size: 12px; font-weight: 700; }
.step-row b, .number { display: inline-grid; place-items: center; flex: 0 0 auto; width: 32px; height: 32px; border-radius: 11px; color: #26150b; background: linear-gradient(135deg, var(--orange), #ffc175); font-size: 12px; }
.panel { padding: 26px; }
.panel-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 23px; }
.panel-head h3 { margin: 0 0 3px; font-size: 20px; letter-spacing: -.025em; }
.panel-head p { font-size: 13px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.phrase-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: grid; align-content: start; gap: 7px; }
.field > span { color: #ddd9d4; font-size: 12px; font-weight: 800; letter-spacing: .01em; }
.field.wide { grid-column: 1 / -1; }
.field input, .field select { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text); background: rgba(15, 15, 16, .82); }
.field select { appearance: auto; }
.field input:disabled, .field select:disabled { opacity: 1; cursor: not-allowed; color: #aaa7a3; border-color: rgba(151, 147, 142, .22); background: rgba(70, 69, 68, .72); -webkit-text-fill-color: #aaa7a3; }
.field small, .dropzone small, .progress-card small, .stack > small { color: var(--muted); font-size: 11px; line-height: 1.45; }
.row { display: flex; align-items: center; gap: 9px; }
.row.space { justify-content: space-between; }
.form-actions { margin-top: 16px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.stats article { display: grid; gap: 5px; min-height: 105px; padding: 17px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .62); }
.stats span { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.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 { display: grid; place-items: center; padding: 29px 0 19px; }
.panel-footer { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding-top: 17px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.pill { display: inline-flex; align-items: center; width: fit-content; min-height: 29px; padding: 5px 10px; border: 1px solid var(--line-strong); border-radius: 999px; color: #d1cdc8; background: rgba(48, 47, 47, .74); font-size: 11px; font-weight: 800; white-space: nowrap; }
.pill.success { color: #8bf2cc; border-color: rgba(68,221,165,.35); background: rgba(36, 160, 118, .13); }
.pill.warning { color: #ffd58a; border-color: rgba(255,197,97,.36); background: rgba(214, 146, 36, .13); }
.pill.error { color: #ffabb5; border-color: rgba(255,108,125,.36); background: rgba(220, 68, 88, .13); }
.toggle-list { display: grid; gap: 9px; margin-bottom: 18px; }
.toggle-list.compact { margin: 15px 0 0; }
.toggle-list label { display: flex; align-items: flex-start; gap: 12px; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18, 18, 19, .56); cursor: pointer; }
.toggle-list input { width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--orange); }
.toggle-list label > span { display: grid; gap: 3px; }
.toggle-list small { color: var(--muted); line-height: 1.45; }
.link-row { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
.action-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; }
.audit, .transcript { padding: 13px; border: 1px solid rgba(255,145,52,.22); border-radius: 13px; color: #d2cec9; background: rgba(255, 145, 52, .055); font-size: 12px; line-height: 1.55; }
.action-panel .audit { margin-top: 15px; }
.audio-list, .word-list { display: grid; gap: 12px; }
.audio-card { display: grid; gap: 13px; padding: 17px; border: 1px solid var(--line); border-radius: 17px; background: rgba(18, 18, 19, .64); }
.audio-card header, .audio-card footer { display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; }
.audio-card header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.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 { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.audio-card footer button { min-height: 36px; padding: 6px 11px; font-size: 11px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 6px; }
.meta-row span { padding: 4px 8px; border: 1px solid var(--line); border-radius: 99px; color: #bdb8b2; background: rgba(50,49,49,.68); font-size: 10px; }
.empty-state { display: grid; place-items: center; min-height: 130px; padding: 24px; border: 1px dashed var(--line-strong); border-radius: 15px; color: var(--muted); text-align: center; }
.toolbar { flex-wrap: wrap; margin-bottom: 14px; }
.segment-control { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: rgba(16,16,17,.68); }
.segment-control button { min-height: 34px; padding: 5px 9px; border-color: transparent; background: transparent; font-size: 11px; }
.segment-control button.active { border-color: rgba(255,145,52,.28); background: rgba(255,145,52,.14); }
.segment-control b { margin-left: 4px; color: var(--orange-2); }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; color: var(--muted); font-size: 12px; }
.dropzone { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 18px; min-height: 100px; margin-bottom: 14px; padding: 19px; border: 1px dashed rgba(255,145,52,.45); border-radius: 16px; background: rgba(255,145,52,.05); cursor: pointer; }
.dropzone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.dropzone span { display: grid; gap: 5px; }
.dropzone > b { padding: 8px 12px; border-radius: 10px; background: rgba(255,145,52,.15); color: var(--orange-2); font-size: 12px; white-space: nowrap; }
.progress-card { display: grid; gap: 9px; margin-top: 15px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18,18,19,.64); }
.progress-card > div:first-child { display: flex; justify-content: space-between; gap: 10px; }
.progress-card span { color: var(--orange-2); font-size: 12px; }
.progress-track { overflow: hidden; height: 7px; border-radius: 99px; background: rgba(255,255,255,.07); }
.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); }
.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; }
.data-hero::after { background: radial-gradient(circle, rgba(176, 171, 164, .15), transparent 67%); }
.data-panel { padding-bottom: 18px; }
.data-list { display: grid; gap: 9px; }
.data-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 18px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .64); }
.data-row.empty { background: rgba(18, 18, 19, .34); }
.data-copy { display: grid; min-width: 0; gap: 6px; }
.data-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.data-title strong { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 15px; }
.data-title code { overflow-wrap: anywhere; padding: 3px 7px; border: 1px solid var(--line); border-radius: 7px; color: #aaa6a0; background: rgba(55, 54, 53, .55); 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 { display: grid; min-width: 105px; gap: 4px; text-align: right; }
.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 { margin-top: 14px !important; padding: 11px 13px; border: 1px solid rgba(255, 197, 97, .3); border-radius: 12px; color: #ffd58a !important; background: rgba(214, 146, 36, .09); font-size: 12px; }
.loading-panel { display: flex; justify-content: center; align-items: center; gap: 12px; min-height: 400px; color: var(--muted); }
.spinner { width: 22px; height: 22px; border: 2px solid rgba(255,255,255,.14); border-top-color: var(--orange); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(5, 5, 6, .8); backdrop-filter: blur(12px); }
.modal { overflow: auto; width: min(680px, 100%); max-height: calc(100vh - 40px); padding: 23px; border: 1px solid var(--line-strong); border-radius: 21px; background: #1c1c1e; box-shadow: 0 36px 100px rgba(0,0,0,.55); }
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 18px; }
.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; padding: 6px 11px; border-color: rgba(255,145,52,.42); color: var(--orange-2); background: rgba(255,145,52,.12); font-size: 11px; }
.console-log { overflow: auto; display: block; min-height: 430px; max-height: calc(100vh - 190px); margin: 0; padding: 17px; border: 1px solid rgba(255,145,52,.18); border-radius: 14px; color: #cbc6c0; background: #0b0b0c; font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
.console-log span { display: block; min-height: 1.65em; }
.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 { display: grid; gap: 14px; }
.pairing-code { text-align: center; font: 700 28px/1 ui-rounded, "SF Pro Rounded", system-ui, sans-serif; letter-spacing: .14em; text-transform: uppercase; }
.link-success { display: grid; place-items: center; gap: 11px; padding: 30px; text-align: center; }
.link-success i { display: grid; place-items: center; width: 54px; height: 54px; border: 1px solid rgba(68,221,165,.4); border-radius: 50%; color: var(--green); background: rgba(68,221,165,.12); font-size: 25px; font-style: normal; }
.link-success span { color: var(--muted); font-size: 12px; }
.trim-modal { width: min(820px, 100%); }
.waveform { width: 100%; height: 210px; margin: 16px 0; border: 1px solid var(--line); border-radius: 14px; background: #0d0d0e; }
.range-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-bottom: 13px; }
.range-grid label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }
.range-grid input { width: 100%; accent-color: var(--orange); }
.modal-actions { justify-content: flex-end; margin-top: 14px; }
.muted { color: var(--muted); }
.toast { position: fixed; z-index: 200; right: 22px; bottom: 22px; max-width: min(420px, calc(100% - 44px)); padding: 13px 16px; border: 1px solid rgba(68,221,165,.38); border-radius: 13px; color: #eafff7; background: rgba(20, 72, 56, .95); box-shadow: 0 18px 45px rgba(0,0,0,.4); font-size: 13px; font-weight: 700; }
.toast.warning { border-color: rgba(255,197,97,.45); background: rgba(93, 65, 22, .97); }.toast.error { border-color: rgba(255,108,125,.45); background: rgba(94, 31, 43, .97); }
.toast-enter-active, .toast-leave-active { transition: opacity .2s ease, transform .2s ease; }.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 920px) { .tab-full { display: none; }.tab-short { display: inline; } }
@media (max-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 { align-items: flex-start; min-height: unset; 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; animation-duration: .01ms !important; transition-duration: .01ms !important; } }

View File

@@ -0,0 +1,580 @@
import { computed, reactive } from "vue";
import { getJson, postJson, putJson, request, type JsonRecord } from "./api";
import type {
AudioItem,
AutoTrainForm,
AutoTrainPayload,
CapturedPayload,
LanguageOption,
ManagedDataItem,
ManagedDataPayload,
SampleBucket,
SamplesPayload,
SessionPayload,
ToastState,
TrainingState,
ViewName,
WakeWordItem,
} from "./types";
const emptyTraining = (): TrainingState => ({ running: false, exit_code: null, log_lines: [] });
const emptySamples = (): SamplesPayload => ({ personal: [], negative: [], personal_count: 0, negative_count: 0 });
const emptyCaptured = (): CapturedPayload => ({ items: [], captured_count: 0, personal_count: 0, negative_count: 0 });
const emptyManagedData = (): ManagedDataPayload => ({ items: [], total_size_bytes: 0, total_file_count: 0 });
const defaultAutoForm = (): AutoTrainForm => ({
enabled: false,
wake_phrase: "",
language: "en",
stt_engine: "faster_whisper",
minimum_transcript_chars: 2,
delete_confirmed_wakes: false,
promote_close_misses: false,
schedule_hours: 24,
minimum_new_negatives: 3,
advertised_base_url: "",
tater_url: "http://127.0.0.1:8501",
notify_satellites: true,
});
export const trainer = reactive({
activeView: "trainer" as ViewName,
initialized: false,
busy: new Set<string>(),
phrase: "",
language: "en",
ttsMode: "hybrid",
languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[],
session: {} as SessionPayload,
samples: emptySamples(),
captured: emptyCaptured(),
training: emptyTraining(),
auto: {} as AutoTrainPayload,
autoForm: defaultAutoForm(),
wakeWords: [] as WakeWordItem[],
managedData: emptyManagedData(),
selectedFiles: [] as File[],
sampleBucket: "personal" as SampleBucket,
samplePage: { personal: 0, negative: 0 },
uploadProgress: 0,
uploadLabel: "No upload in progress",
uploadDetail: "Choose files and upload when you are ready.",
consoleOpen: false,
taterLinkOpen: false,
trimItem: null as AudioItem | null,
trimBucket: "personal" as SampleBucket,
toast: { message: "", tone: "success", serial: 0 } as ToastState,
});
let autoTimer = 0;
let trainingTimer = 0;
export const personalCount = computed(() => Number(trainer.samples.personal_count ?? trainer.session.takes_received ?? 0));
export const negativeCount = computed(() => Number(trainer.samples.negative_count ?? trainer.captured.negative_count ?? 0));
export const currentLanguage = computed<LanguageOption>(() =>
trainer.languages.find((item) => item.code === trainer.language) || trainer.languages[0],
);
export const ttsRoute = computed(() => {
const engines = currentLanguage.value?.engines?.length ? currentLanguage.value.engines : ["omnivoice"];
const selected = trainer.ttsMode === "piper"
? engines.filter((engine) => engine === "piper")
: trainer.ttsMode === "hybrid"
? engines
: engines.filter((engine) => engine !== "piper");
const labels: Record<string, string> = { omnivoice: "OmniVoice", qwen3: "Qwen3", moss: "MOSS", piper: "Piper" };
const quality = trainer.ttsMode === "piper" ? "Legacy" : titleCase(currentLanguage.value?.quality || "experimental");
return `${selected.map((engine) => labels[engine] || engine).join(" + ") || "Unavailable"} · ${quality}`;
});
export const hasConsole = computed(() => Boolean(
trainer.training.running || trainer.training.exit_code !== null || trainer.training.log_lines?.length,
));
export const selectedSamples = computed(() => trainer.samples[trainer.sampleBucket] || []);
export const autoLinked = computed(() => Boolean(trainer.auto.trainer_link?.linked));
export const sttEngines = computed<JsonRecord[]>(() => {
const rows = trainer.auto.stt_engines;
return Array.isArray(rows) && rows.length
? rows
: [{ id: "faster_whisper", label: "Faster Whisper" }, { id: "parakeet_onnx", label: "Parakeet ONNX" }];
});
function titleCase(value: unknown): string {
return String(value || "").replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
export function isBusy(name?: string): boolean {
return name ? trainer.busy.has(name) : trainer.busy.size > 0;
}
function setBusy(name: string, active: boolean): void {
if (active) trainer.busy.add(name);
else trainer.busy.delete(name);
}
export function notify(message: unknown, tone: ToastState["tone"] = "success"): void {
trainer.toast = { message: String(message || ""), tone, serial: trainer.toast.serial + 1 };
}
function reportError(error: unknown, fallback: string): void {
notify(error instanceof Error ? error.message : fallback, "error");
}
function applySession(payload: SessionPayload): void {
trainer.session = payload || {};
if (Array.isArray(payload.available_languages) && payload.available_languages.length) {
trainer.languages = payload.available_languages;
}
if (payload.raw_phrase) trainer.phrase = payload.raw_phrase;
if (payload.language) trainer.language = payload.language;
if (payload.tts_mode) trainer.ttsMode = payload.tts_mode;
if (payload.training) trainer.training = payload.training;
}
export async function refreshSession(): Promise<SessionPayload> {
const payload = await getJson<SessionPayload>("/api/session");
applySession(payload);
return payload;
}
export async function startSession(): Promise<void> {
if (!trainer.phrase.trim()) {
notify("Enter a wake phrase first.", "warning");
return;
}
setBusy("session", true);
try {
const payload = await postJson<SessionPayload>("/api/start_session", {
phrase: trainer.phrase.trim(),
language: trainer.language,
tts_mode: trainer.ttsMode,
});
applySession(payload);
notify(`Session ${payload.safe_word || "started"} is ready.`);
} catch (error) {
reportError(error, "Session failed to start.");
} finally {
setBusy("session", false);
}
}
export async function stopSession(): Promise<void> {
const wasTraining = Boolean(trainer.training.running);
if (wasTraining && !window.confirm("Training is running. Stop training cleanly and end this session?")) {
return;
}
setBusy("session", true);
if (trainingTimer) {
window.clearInterval(trainingTimer);
trainingTimer = 0;
}
try {
const payload = await postJson<SessionPayload>("/api/stop_session");
applySession(payload);
notify(wasTraining ? "Training stopped cleanly and the session ended." : "Session ended. You can edit the wake phrase now.");
} catch (error) {
if (wasTraining) beginTrainingPoll();
reportError(error, "Session could not be stopped.");
} finally {
setBusy("session", false);
}
}
export function previewPhrase(): void {
if (!trainer.phrase.trim() || !("speechSynthesis" in window)) return;
const utterance = new SpeechSynthesisUtterance(trainer.phrase.trim());
utterance.lang = trainer.language;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
export function ensureSupportedTtsMode(): void {
const engines = currentLanguage.value?.engines || [];
const modern = engines.some((engine) => engine !== "piper");
const piper = engines.includes("piper");
if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper";
if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper";
if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern";
}
export async function refreshSamples(quiet = false): Promise<SamplesPayload> {
if (!quiet) setBusy("samples", true);
try {
const payload = await getJson<SamplesPayload>("/api/samples");
trainer.samples = { ...emptySamples(), ...payload };
for (const bucket of ["personal", "negative"] as const) {
const lastPage = Math.max(0, Math.ceil((trainer.samples[bucket]?.length || 0) / 50) - 1);
trainer.samplePage[bucket] = Math.min(trainer.samplePage[bucket], lastPage);
}
return payload;
} finally {
if (!quiet) setBusy("samples", false);
}
}
export async function refreshCaptured(quiet = false): Promise<CapturedPayload> {
if (!quiet) setBusy("captured", true);
try {
const payload = await getJson<CapturedPayload>("/api/captured_audio");
trainer.captured = { ...emptyCaptured(), ...payload };
return payload;
} finally {
if (!quiet) setBusy("captured", false);
}
}
export function selectFiles(event: Event): void {
const input = event.target as HTMLInputElement;
trainer.selectedFiles = Array.from(input.files || []);
}
function uploadOne(file: File, index: number, total: number): Promise<JsonRecord> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const data = new FormData();
data.append("file", file, file.name);
xhr.open("POST", "/api/upload_personal_sample");
xhr.responseType = "json";
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
trainer.uploadProgress = Math.round(((index + event.loaded / event.total) / total) * 100);
trainer.uploadLabel = `Uploading ${file.name} (${index + 1}/${total})`;
trainer.uploadDetail = "Sending and normalizing the recording.";
};
xhr.onload = () => {
const body = xhr.response || {};
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error || `Upload failed for ${file.name}`));
};
xhr.onerror = () => reject(new Error(`Upload failed for ${file.name}`));
xhr.send(data);
});
}
export async function uploadSelectedFiles(input?: HTMLInputElement | null): Promise<void> {
if (!trainer.session.safe_word) {
notify("Start a trainer session before uploading samples.", "warning");
return;
}
if (!trainer.selectedFiles.length) return;
setBusy("upload", true);
try {
const files = [...trainer.selectedFiles];
for (let index = 0; index < files.length; index += 1) await uploadOne(files[index], index, files.length);
trainer.uploadProgress = 100;
trainer.uploadLabel = "Upload complete";
trainer.uploadDetail = `${files.length} sample${files.length === 1 ? "" : "s"} saved in the required training format.`;
trainer.selectedFiles = [];
if (input) input.value = "";
await Promise.all([refreshSession(), refreshSamples(true)]);
notify("Personal samples uploaded.");
} catch (error) {
trainer.uploadProgress = 0;
reportError(error, "Sample upload failed.");
} finally {
setBusy("upload", false);
}
}
export async function reviewCaptured(item: AudioItem, action: "approve_personal" | "mark_negative" | "discard"): Promise<void> {
if (action === "discard" && !window.confirm(`Discard ${item.saved_as} from the captured-audio inbox?`)) return;
setBusy("review", true);
try {
await postJson(`/api/captured_audio/${encodeURIComponent(item.saved_as)}/${action}`);
await Promise.all([refreshSession(), refreshCaptured(true), refreshSamples(true)]);
notify(action === "approve_personal" ? "Clip added to personal samples." : action === "mark_negative" ? "Clip marked negative." : "Clip discarded.");
} catch (error) {
reportError(error, "Review action failed.");
} finally {
setBusy("review", false);
}
}
export async function removeSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
if (!window.confirm(`Remove ${item.saved_as} from ${bucket} samples?`)) return;
setBusy("review", true);
try {
await request(`/api/samples/${bucket}/${encodeURIComponent(item.saved_as)}`, { method: "DELETE" });
await refreshSamples(true);
notify("Sample removed.");
} catch (error) {
reportError(error, "Sample removal failed.");
} finally {
setBusy("review", false);
}
}
export async function revertSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
if (!window.confirm(`Revert ${item.saved_as} to its pre-trim version?`)) return;
const form = new FormData();
form.append("bucket", bucket);
form.append("file_name", item.saved_as);
setBusy("review", true);
try {
await request("/api/samples/revert", { method: "POST", body: form });
await refreshSamples(true);
notify("Original sample restored.");
} catch (error) {
reportError(error, "Sample revert failed.");
} finally {
setBusy("review", false);
}
}
export async function clearSamples(bucket: SampleBucket): Promise<void> {
const count = bucket === "personal" ? personalCount.value : negativeCount.value;
if (!count || !window.confirm(`Clear ${count} ${bucket} sample${count === 1 ? "" : "s"}?`)) return;
setBusy("review", true);
try {
await postJson(bucket === "personal" ? "/api/reset_recordings" : "/api/reset_negative_samples");
await Promise.all([refreshSession(), refreshSamples(true), refreshCaptured(true)]);
notify(`${titleCase(bucket)} samples cleared.`);
} catch (error) {
reportError(error, "Samples could not be cleared.");
} finally {
setBusy("review", false);
}
}
function applyAuto(payload: AutoTrainPayload, populate: boolean): void {
trainer.auto = payload || {};
if (!populate) return;
trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) };
if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || "";
if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en";
}
export async function refreshAuto(populate = false): Promise<AutoTrainPayload> {
const payload = await getJson<AutoTrainPayload>("/api/auto_train");
applyAuto(payload, populate);
return payload;
}
export async function saveAuto(): Promise<void> {
setBusy("auto", true);
try {
const payload = await putJson<AutoTrainPayload>("/api/auto_train", trainer.autoForm);
applyAuto(payload, true);
notify(payload.config?.enabled ? "Auto Training saved and enabled." : "Auto Training saved.");
} catch (error) {
reportError(error, "Auto Training settings failed to save.");
} finally {
setBusy("auto", false);
}
}
export async function runAutoAction(action: "review_now" | "train_now" | "notify_now"): Promise<void> {
setBusy("auto", true);
try {
const payload = await postJson<AutoTrainPayload>("/api/auto_train/action", { action });
applyAuto(payload, false);
if (action === "train_now") {
trainer.consoleOpen = true;
beginTrainingPoll();
}
notify(action === "review_now" ? `${Number(payload.queued || 0)} clips queued for review.` : action === "train_now" ? "Training started." : "Wake word published.");
} catch (error) {
reportError(error, "Auto Training action failed.");
} finally {
setBusy("auto", false);
}
}
export async function claimTater(taterUrl: string, pairingCode: string): Promise<boolean> {
setBusy("link", true);
try {
await postJson("/api/tater_link/claim", { tater_url: taterUrl.trim(), pairing_code: pairingCode.trim() });
trainer.autoForm.tater_url = taterUrl.trim();
await refreshAuto(false);
notify("Trainer linked securely to Tater.");
return true;
} catch (error) {
reportError(error, "Tater link failed.");
return false;
} finally {
setBusy("link", false);
}
}
export async function unlinkTater(): Promise<void> {
if (!window.confirm("Unlink this trainer from Tater?")) return;
setBusy("auto", true);
try {
await postJson("/api/tater_link/unlink");
await refreshAuto(false);
notify("Trainer unlinked from Tater.", "warning");
} catch (error) {
reportError(error, "Tater unlink failed.");
} finally {
setBusy("auto", false);
}
}
export async function refreshWakeWords(quiet = false): Promise<void> {
if (!quiet) setBusy("firmware", true);
try {
const payload = await getJson<JsonRecord>("/api/trained_wake_words/catalog");
trainer.wakeWords = Array.isArray(payload.wake_words) ? payload.wake_words : [];
} finally {
if (!quiet) setBusy("firmware", false);
}
}
export async function refreshManagedData(): Promise<ManagedDataPayload> {
setBusy("data", true);
try {
const payload = await getJson<ManagedDataPayload>("/api/data");
trainer.managedData = { ...emptyManagedData(), ...payload };
return payload;
} finally {
setBusy("data", false);
}
}
export async function deleteManagedData(item: ManagedDataItem): Promise<void> {
if (!item.file_count) return;
const details = `${formatBytes(item.size_bytes)} · ${Number(item.file_count).toLocaleString()} file${item.file_count === 1 ? "" : "s"}`;
const rebuild = item.rebuild_note ? `\n\n${item.rebuild_note}` : "";
if (!window.confirm(`Permanently delete ${item.label} (${details})?${rebuild}\n\nThis cannot be undone.`)) return;
setBusy("data-delete", true);
try {
const payload = await request<ManagedDataPayload>(`/api/data/${encodeURIComponent(item.id)}`, { method: "DELETE" });
trainer.managedData = { ...emptyManagedData(), ...payload };
await Promise.allSettled([
refreshSession(),
refreshSamples(true),
refreshCaptured(true),
refreshWakeWords(true),
]);
notify(`${item.label} deleted. ${formatBytes(item.size_bytes)} released.`);
} catch (error) {
reportError(error, `${item.label} could not be deleted.`);
} finally {
setBusy("data-delete", false);
}
}
export async function copyWakeWord(url: string): Promise<void> {
try {
await navigator.clipboard.writeText(url);
notify("Wake-word JSON URL copied.");
} catch (error) {
reportError(error, "Clipboard unavailable.");
}
}
export async function startTraining(): Promise<void> {
await Promise.all([refreshSession(), refreshSamples(true)]);
let allowNoPersonal = false;
if (!personalCount.value) {
allowNoPersonal = window.confirm("No positive samples are saved. Train anyway without personal voices?");
if (!allowNoPersonal) return;
}
setBusy("training-start", true);
trainer.training = { running: true, exit_code: null, log_lines: ["Waiting for training output…"] };
trainer.consoleOpen = true;
try {
await postJson("/api/train", { allow_no_personal: allowNoPersonal });
beginTrainingPoll();
} catch (error) {
trainer.training = { running: false, exit_code: 1, log_lines: [error instanceof Error ? error.message : String(error)] };
reportError(error, "Training could not start.");
} finally {
setBusy("training-start", false);
}
}
export function beginTrainingPoll(): void {
if (trainingTimer) return;
const poll = async () => {
try {
const payload = await getJson<JsonRecord>("/api/train_status");
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
if (!trainer.training.running) {
window.clearInterval(trainingTimer);
trainingTimer = 0;
await Promise.all([refreshSamples(true), refreshWakeWords(true)]);
notify(trainer.training.exit_code === 0 ? "Training finished successfully." : `Training ended with exit ${trainer.training.exit_code}.`, trainer.training.exit_code === 0 ? "success" : "error");
}
} catch {
// A temporary request failure should not stop the live poll.
}
};
void poll();
trainingTimer = window.setInterval(() => void poll(), 1500);
}
export async function initializeTrainer(): Promise<void> {
setBusy("bootstrap", true);
try {
await Promise.allSettled([
refreshSession(),
refreshSamples(true),
refreshCaptured(true),
refreshAuto(true),
refreshWakeWords(true),
]);
ensureSupportedTtsMode();
try {
const payload = await getJson<JsonRecord>("/api/train_status");
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
if (trainer.training.running) {
trainer.consoleOpen = true;
beginTrainingPoll();
}
} catch {
// Remaining panels can still function when status is temporarily unavailable.
}
autoTimer = window.setInterval(() => {
if (trainer.activeView === "auto" && !isBusy("auto")) void refreshAuto(false).catch(() => undefined);
}, 2500);
trainer.initialized = true;
} finally {
setBusy("bootstrap", false);
}
}
export function disposeTrainer(): void {
window.clearInterval(autoTimer);
window.clearInterval(trainingTimer);
autoTimer = 0;
trainingTimer = 0;
}
export function formatTimestamp(value: unknown): string {
if (!value) return "";
const parsed = new Date(String(value));
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
export function formatBytes(value: unknown): string {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`;
}
export function describeFormat(info: JsonRecord | undefined): string {
if (!info) return "16 kHz · mono · 16-bit WAV";
const rate = Number(info.sample_rate || info.sample_rate_hz || 16000);
const channels = Number(info.channels || 1) === 1 ? "mono" : `${info.channels} channels`;
const bits = Number(info.bits_per_sample || info.sample_width_bits || 16);
return `${Math.round(rate / 1000)} kHz · ${channels} · ${bits}-bit`;
}
export function captureTone(item: AudioItem): { label: string; tone: string } {
if (item.blocked_by_vad) return { label: "Blocked by VAD", tone: "warning" };
const type = String(item.event_type || "").toLowerCase();
if (type.includes("close")) return { label: item.capture_label || "Close miss", tone: "warning" };
if (type.includes("false")) return { label: item.capture_label || "False trigger", tone: "error" };
if (type.includes("wake") || type.includes("detect")) return { label: item.capture_label || "Wake trigger", tone: "success" };
return { label: item.capture_label || "Captured", tone: "neutral" };
}
export function itemAudioUrl(item: AudioItem, bucket: SampleBucket | "captured"): string {
return item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`;
}

105
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { JsonRecord } from "./api";
export type ViewName = "trainer" | "auto" | "firmware" | "captured" | "samples" | "data";
export type SampleBucket = "personal" | "negative";
export interface LanguageOption extends JsonRecord {
code: string;
label: string;
engines?: string[];
quality?: string;
}
export interface TrainingState extends JsonRecord {
running: boolean;
exit_code: number | null;
log_lines: string[];
}
export interface SessionPayload extends JsonRecord {
safe_word?: string;
raw_phrase?: string;
language?: string;
tts_mode?: string;
takes_received?: number;
available_languages?: LanguageOption[];
training?: TrainingState;
}
export interface AudioItem extends JsonRecord {
saved_as: string;
original_name?: string;
audio_url?: string;
final_format?: JsonRecord;
}
export interface SamplesPayload extends JsonRecord {
personal: AudioItem[];
negative: AudioItem[];
personal_count: number;
negative_count: number;
}
export interface CapturedPayload extends JsonRecord {
items: AudioItem[];
captured_count: number;
personal_count: number;
negative_count: number;
}
export interface AutoTrainForm extends JsonRecord {
enabled: boolean;
wake_phrase: string;
language: string;
stt_engine: string;
minimum_transcript_chars: number;
delete_confirmed_wakes: boolean;
promote_close_misses: boolean;
schedule_hours: number;
minimum_new_negatives: number;
advertised_base_url: string;
tater_url: string;
notify_satellites: boolean;
}
export interface AutoTrainPayload extends JsonRecord {
config?: Partial<AutoTrainForm>;
state?: JsonRecord;
runtime?: JsonRecord;
trainer_link?: JsonRecord;
advertised_base_url?: string;
}
export interface WakeWordItem extends JsonRecord {
key?: string;
label?: string;
url?: string;
json_url?: string;
jsonUrl?: string;
model_url?: string;
modelUrl?: string;
}
export interface ManagedDataItem extends JsonRecord {
id: string;
label: string;
category: string;
description: string;
location: string;
size_bytes: number;
file_count: number;
exists: boolean;
rebuild_note?: string;
}
export interface ManagedDataPayload extends JsonRecord {
items: ManagedDataItem[];
total_size_bytes: number;
total_file_count: number;
}
export interface ToastState {
message: string;
tone: "success" | "warning" | "error";
serial: number;
}

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

26
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "node:path";
export default defineConfig({
plugins: [vue()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
outDir: resolve(import.meta.dirname, "../static/ui"),
emptyOutDir: true,
lib: {
entry: resolve(import.meta.dirname, "src/main.ts"),
formats: ["es"],
fileName: () => "trainer-ui.js",
},
cssCodeSplit: false,
rollupOptions: {
output: {
assetFileNames: (assetInfo) =>
assetInfo.name?.endsWith(".css") ? "trainer-ui.css" : "[name][extname]",
},
},
},
});

BIN
images/tater-repo-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

116
run.sh
View File

@@ -17,7 +17,6 @@ PIN_FILE="${VENV_DIR}/.pinned_installed"
FASTAPI_VERSION="${REC_FASTAPI_VERSION:-0.115.6}"
UVICORN_VERSION="${REC_UVICORN_VERSION:-0.30.6}"
PY_MULTIPART_VERSION="${REC_PY_MULTIPART_VERSION:-0.0.9}"
ESPHOME_VERSION="${REC_ESPHOME_VERSION:-2026.4.0}"
echo "microWakeWord Trainer UI (Docker)"
echo "-> ROOTDIR: ${ROOTDIR}"
@@ -26,6 +25,21 @@ echo "-> URL: http://localhost:${PORT}/"
mkdir -p "${DATA_DIR}"
install_ui_deps() {
${PIP} install \
"fastapi==${FASTAPI_VERSION}" \
"uvicorn[standard]==${UVICORN_VERSION}" \
"python-multipart==${PY_MULTIPART_VERSION}" \
"silero-vad>=5.0.0" \
"numpy>=1.24.0" \
"faster-whisper>=1.0.0" \
"onnx-asr[hub]>=0.12.0" \
"nvidia-cublas-cu12" \
"nvidia-cudnn-cu12==9.*"
${PIP} uninstall -y onnxruntime
${PIP} install "onnxruntime-gpu[cuda,cudnn]<1.27"
}
# -----------------------------
# Trainer UI venv (separate)
# -----------------------------
@@ -40,32 +54,100 @@ source "${VENV_DIR}/bin/activate"
if [[ ! -f "${PIN_FILE}" ]]; then
echo "Installing pinned trainer UI deps"
${PIP} install -U pip setuptools wheel
${PIP} install \
"fastapi==${FASTAPI_VERSION}" \
"uvicorn[standard]==${UVICORN_VERSION}" \
"python-multipart==${PY_MULTIPART_VERSION}" \
"esphome==${ESPHOME_VERSION}"
install_ui_deps
touch "${PIN_FILE}"
else
echo "Reusing existing trainer UI venv (no upgrades)"
if ! "${PY}" - "${ESPHOME_VERSION}" <<'PY' >/dev/null 2>&1
import importlib.metadata
if ! "${PY}" - "${FASTAPI_VERSION}" "${UVICORN_VERSION}" "${PY_MULTIPART_VERSION}" <<'PY' >/dev/null 2>&1
import importlib.metadata as md
import sys
expected = sys.argv[1]
installed = importlib.metadata.version("esphome")
raise SystemExit(0 if installed == expected else 1)
fastapi_version, uvicorn_version, multipart_version = sys.argv[1:4]
def version_tuple(value):
parts = []
for token in str(value).replace("-", ".").split("."):
if token.isdigit():
parts.append(int(token))
else:
digits = "".join(ch for ch in token if ch.isdigit())
if digits:
parts.append(int(digits))
break
return tuple(parts)
exact = {
"fastapi": fastapi_version,
"uvicorn": uvicorn_version,
"python-multipart": multipart_version,
}
minimum = {
"silero-vad": "5.0.0",
"numpy": "1.24.0",
"faster-whisper": "1.0.0",
"onnx-asr": "0.12.0",
"nvidia-cudnn-cu12": "9.0.0",
}
present = (
"torch",
"nvidia-cublas-cu12",
"onnxruntime-gpu",
)
for package, expected in exact.items():
if md.version(package) != expected:
raise SystemExit(1)
for package, minimum_version in minimum.items():
if version_tuple(md.version(package)) < version_tuple(minimum_version):
raise SystemExit(1)
for package in present:
md.version(package)
import onnxruntime as ort
if "CUDAExecutionProvider" not in ort.get_available_providers():
raise SystemExit(1)
PY
then
echo "Firmware tab dependencies missing or stale; installing ESPHome firmware dependencies"
${PIP} install \
"fastapi==${FASTAPI_VERSION}" \
"uvicorn[standard]==${UVICORN_VERSION}" \
"python-multipart==${PY_MULTIPART_VERSION}" \
"esphome==${ESPHOME_VERSION}"
echo "UI dependencies missing or stale; installing recorder dependencies"
install_ui_deps
fi
fi
# Faster Whisper/CTranslate2 loads these CUDA libraries before Python starts.
# They live in the persistent UI venv so both Docker image variants can use GPU STT.
WHISPER_CUDA_LIBRARY_PATH="$("${PY}" - <<'PY'
from importlib.util import find_spec
from pathlib import Path
def package_directory(name):
try:
spec = find_spec(name)
except (ImportError, AttributeError, ValueError):
return ""
if spec is None:
return ""
for location in spec.submodule_search_locations or ():
if location:
return str(Path(location).resolve())
origin = spec.origin
if origin and origin not in {"built-in", "frozen"}:
return str(Path(origin).resolve().parent)
return ""
paths = [
package_directory("nvidia.cublas.lib"),
package_directory("nvidia.cudnn.lib"),
]
print(":".join(dict.fromkeys(path for path in paths if path)))
PY
)"
if [[ -n "${WHISPER_CUDA_LIBRARY_PATH}" ]]; then
export LD_LIBRARY_PATH="${WHISPER_CUDA_LIBRARY_PATH}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
# -----------------------------
# Trainer server env
# -----------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because it is too large Load Diff

2
static/ui/trainer-ui.css Normal file

File diff suppressed because one or more lines are too long

4748
static/ui/trainer-ui.js Normal file

File diff suppressed because it is too large Load Diff

748
tests/test_auto_train.py Normal file
View File

@@ -0,0 +1,748 @@
import io
import json
import queue
import sys
import tempfile
import unittest
import wave
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import trainer_server as trainer
def silent_wav_bytes(duration_s: float = 0.25) -> bytes:
output = io.BytesIO()
with wave.open(output, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
wav_file.writeframes(b"\x00\x00" * int(16000 * duration_s))
return output.getvalue()
class AutoTrainTests(unittest.TestCase):
def clear_review_queue(self):
while True:
try:
trainer.AUTO_TRAIN_REVIEW_QUEUE.get_nowait()
except queue.Empty:
break
else:
trainer.AUTO_TRAIN_REVIEW_QUEUE.task_done()
trainer.AUTO_TRAIN_QUEUED_FILES.clear()
def setUp(self):
self.clear_review_queue()
self.tempdir = tempfile.TemporaryDirectory()
root = Path(self.tempdir.name)
self.original_paths = (
trainer.CAPTURED_DIR,
trainer.NEGATIVE_DIR,
trainer.PERSONAL_DIR,
trainer.AUTO_TRAIN_CONFIG_FILE,
trainer.AUTO_TRAIN_STATE_FILE,
trainer.AUTO_TRAIN_MODEL_DIR,
)
trainer.CAPTURED_DIR = root / "captured_audio"
trainer.NEGATIVE_DIR = root / "negative_samples"
trainer.PERSONAL_DIR = root / "personal_samples"
trainer.AUTO_TRAIN_CONFIG_FILE = root / "auto_train_config.json"
trainer.AUTO_TRAIN_STATE_FILE = root / "auto_train_state.json"
trainer.AUTO_TRAIN_MODEL_DIR = root / "auto_train_models"
for directory in (trainer.CAPTURED_DIR, trainer.NEGATIVE_DIR, trainer.PERSONAL_DIR):
directory.mkdir(parents=True)
self.original_config = dict(trainer.AUTO_TRAIN_CONFIG)
self.original_state = dict(trainer.AUTO_TRAIN_STATE)
trainer.AUTO_TRAIN_CONFIG.clear()
trainer.AUTO_TRAIN_CONFIG.update(
trainer._normalize_auto_train_config(
{
"enabled": True,
"wake_phrase": "hey tater",
"language": "en",
"tater_url": "http://127.0.0.1:8501",
}
)
)
trainer.AUTO_TRAIN_STATE.clear()
trainer.AUTO_TRAIN_STATE.update(trainer.AUTO_TRAIN_DEFAULT_STATE)
def tearDown(self):
(
trainer.CAPTURED_DIR,
trainer.NEGATIVE_DIR,
trainer.PERSONAL_DIR,
trainer.AUTO_TRAIN_CONFIG_FILE,
trainer.AUTO_TRAIN_STATE_FILE,
trainer.AUTO_TRAIN_MODEL_DIR,
) = self.original_paths
trainer.AUTO_TRAIN_CONFIG.clear()
trainer.AUTO_TRAIN_CONFIG.update(self.original_config)
trainer.AUTO_TRAIN_STATE.clear()
trainer.AUTO_TRAIN_STATE.update(self.original_state)
self.clear_review_queue()
self.tempdir.cleanup()
def add_capture(
self,
name: str = "wake.wav",
wake_word: str = "hey_tater",
event_type: str = "wake_detected",
blocked_by_vad: bool = False,
) -> Path:
audio_path = trainer.CAPTURED_DIR / name
audio_path.write_bytes(silent_wav_bytes())
trainer._write_sidecar_json(
audio_path,
{
"original_name": name,
"wake_word": wake_word,
"event_type": event_type,
"blocked_by_vad": blocked_by_vad,
"review_status": "pending",
},
)
return audio_path
def test_phrase_matching_normalizes_case_punctuation_and_underscores(self):
self.assertTrue(trainer._transcript_contains_wake_phrase("Okay, HEY TATER!", "hey_tater"))
self.assertFalse(trainer._transcript_contains_wake_phrase("Turn on the television", "hey tater"))
def test_phrase_similarity_recognizes_real_short_clip_mishearings(self):
for transcript in ("Hey, haters.", "Hate hater.", "Hey Ganger.", "Hey, gator."):
with self.subTest(transcript=transcript):
self.assertGreaterEqual(
trainer._wake_phrase_similarity(transcript, "hey tater"),
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
for transcript in ("turn on the lights", "what is the weather", "play some music"):
with self.subTest(transcript=transcript):
self.assertLess(
trainer._wake_phrase_similarity(transcript, "hey tater"),
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
def test_stt_engine_selection_uses_managed_models(self):
config = trainer._normalize_auto_train_config(
{
"stt_engine": "parakeet-onnx",
"stt_model": "user/should-not-be-used",
"stt_device": "cpu",
"stt_compute_type": "float32",
}
)
self.assertEqual(config["stt_engine"], trainer.STT_ENGINE_PARAKEET_ONNX)
self.assertNotIn("stt_model", config)
self.assertNotIn("stt_device", config)
self.assertNotIn("stt_compute_type", config)
self.assertEqual(
trainer._managed_stt_model(config["stt_engine"], "en"),
trainer.DEFAULT_PARAKEET_ONNX_MODEL,
)
self.assertEqual(
trainer._managed_stt_model(trainer.STT_ENGINE_FASTER_WHISPER, "de"),
trainer.DEFAULT_FASTER_WHISPER_MULTILINGUAL_MODEL,
)
def test_stt_router_supports_both_nvidia_engines(self):
audio_path = Path("wake.wav")
with (
patch.object(trainer, "_transcribe_capture_with_faster_whisper", return_value="faster") as faster,
patch.object(trainer, "_transcribe_capture_with_parakeet", return_value="parakeet") as parakeet,
):
self.assertEqual(
trainer._transcribe_capture(
audio_path,
engine=trainer.STT_ENGINE_FASTER_WHISPER,
language="en",
),
"faster",
)
self.assertEqual(
trainer._transcribe_capture(
audio_path,
engine=trainer.STT_ENGINE_PARAKEET_ONNX,
language="en",
),
"parakeet",
)
faster.assert_called_once()
parakeet.assert_called_once()
def test_guided_faster_whisper_uses_dynamic_wake_phrase(self):
fake_model = SimpleNamespace(
transcribe=Mock(
return_value=(
iter([SimpleNamespace(text=" hello "), SimpleNamespace(text="potato ")]),
SimpleNamespace(),
)
)
)
with (
patch.object(
trainer,
"_resolve_faster_whisper_runtime",
return_value=("cuda", "float16"),
),
patch.object(trainer, "_load_faster_whisper_model", return_value=fake_model),
):
transcript = trainer._transcribe_capture_with_faster_whisper_guided(
Path("wake.wav"),
model="small.en",
language="en",
wake_phrase="Hello_Potato",
)
self.assertEqual(transcript, "hello potato")
_, kwargs = fake_model.transcribe.call_args
self.assertEqual(kwargs["hotwords"], "hello potato")
self.assertIn("hello potato", kwargs["initial_prompt"])
self.assertEqual(kwargs["beam_size"], 5)
self.assertEqual(kwargs["best_of"], 5)
self.assertEqual(kwargs["temperature"], 0.0)
self.assertFalse(kwargs["condition_on_previous_text"])
def test_parakeet_loader_prefers_cuda_then_cpu(self):
fake_model = object()
fake_onnx_asr = SimpleNamespace(load_model=Mock(return_value=fake_model))
fake_huggingface_hub = SimpleNamespace(
snapshot_download=Mock(return_value=str(trainer.AUTO_TRAIN_MODEL_DIR))
)
with (
patch.dict(
sys.modules,
{
"onnx_asr": fake_onnx_asr,
"huggingface_hub": fake_huggingface_hub,
},
),
patch.object(
trainer,
"_parakeet_onnx_providers",
return_value=["CUDAExecutionProvider", "CPUExecutionProvider"],
),
):
with trainer.PARAKEET_ONNX_MODEL_LOCK:
trainer.PARAKEET_ONNX_MODEL_CACHE.clear()
loaded = trainer._load_parakeet_onnx_model()
self.assertIs(loaded, fake_model)
fake_huggingface_hub.snapshot_download.assert_called_once_with(
repo_id=trainer.DEFAULT_PARAKEET_ONNX_REPO,
local_dir=str(trainer.AUTO_TRAIN_MODEL_DIR),
allow_patterns=[
"config.json",
"vocab.txt",
"encoder-model.int8.onnx",
"encoder-model.int8.onnx.data",
"decoder_joint-model.int8.onnx",
"decoder_joint-model.int8.onnx.data",
],
)
fake_onnx_asr.load_model.assert_called_once_with(
trainer.DEFAULT_PARAKEET_ONNX_MODEL,
str(trainer.AUTO_TRAIN_MODEL_DIR),
quantization="int8",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
def test_parakeet_loader_reuses_complete_snapshot_offline(self):
fake_model = object()
fake_onnx_asr = SimpleNamespace(load_model=Mock(return_value=fake_model))
fake_huggingface_hub = SimpleNamespace(snapshot_download=Mock())
trainer.AUTO_TRAIN_MODEL_DIR.mkdir(parents=True, exist_ok=True)
for filename in (
"config.json",
"vocab.txt",
"encoder-model.int8.onnx",
"decoder_joint-model.int8.onnx",
):
(trainer.AUTO_TRAIN_MODEL_DIR / filename).touch()
with (
patch.dict(
sys.modules,
{
"onnx_asr": fake_onnx_asr,
"huggingface_hub": fake_huggingface_hub,
},
),
patch.object(
trainer,
"_parakeet_onnx_providers",
return_value=["CUDAExecutionProvider", "CPUExecutionProvider"],
),
):
with trainer.PARAKEET_ONNX_MODEL_LOCK:
trainer.PARAKEET_ONNX_MODEL_CACHE.clear()
loaded = trainer._load_parakeet_onnx_model()
self.assertIs(loaded, fake_model)
fake_huggingface_hub.snapshot_download.assert_not_called()
fake_onnx_asr.load_model.assert_called_once_with(
trainer.DEFAULT_PARAKEET_ONNX_MODEL,
str(trainer.AUTO_TRAIN_MODEL_DIR),
quantization="int8",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
def test_ui_exposes_engine_selector_without_manual_runtime_fields(self):
source = (Path(__file__).resolve().parents[1] / "frontend" / "src" / "TrainerApp.vue").read_text(
encoding="utf-8"
)
self.assertIn('v-model="trainer.autoForm.stt_engine"', source)
self.assertNotIn('trainer.autoForm.stt_model', source)
self.assertNotIn('trainer.autoForm.stt_device', source)
self.assertNotIn('trainer.autoForm.stt_compute_type', source)
self.assertIn("Guided wake check", source)
def test_phrase_miss_moves_wake_trigger_to_negative_samples(self):
self.add_capture()
with patch.object(trainer, "_transcribe_capture", return_value="turn on the kitchen lights"):
trainer._auto_review_capture("wake.wav")
self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists())
negatives = list(trainer.NEGATIVE_DIR.glob("*.wav"))
self.assertEqual(len(negatives), 1)
metadata = trainer._load_sidecar_json(negatives[0])
self.assertTrue(metadata["auto_negative"])
self.assertEqual(metadata["review_status"], "auto_approved_negative")
self.assertEqual(metadata["transcript"], "turn on the kitchen lights")
self.assertEqual(metadata["auto_review_stt_engine"], "faster_whisper")
self.assertEqual(metadata["auto_review_stt_model"], "small.en")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 1)
def test_matching_phrase_stays_in_manual_review_inbox(self):
audio_path = self.add_capture()
with patch.object(trainer, "_transcribe_capture", return_value="hey tater turn on the lights"):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_detected")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
def test_close_transcript_uses_guided_faster_whisper_confirmation(self):
audio_path = self.add_capture()
with (
patch.object(trainer, "_transcribe_capture", return_value="Hey, haters."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hey Tater",
) as guided,
):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_detected")
self.assertEqual(metadata["transcript"], "Hey, haters.")
self.assertEqual(metadata["auto_review_guided_transcript"], "Hey Tater")
self.assertEqual(metadata["auto_review_match_method"], "guided_close_match")
self.assertGreaterEqual(
metadata["auto_review_phrase_similarity"],
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
guided.assert_called_once()
guided_args, guided_kwargs = guided.call_args
self.assertEqual(guided_args[0].resolve(), audio_path.resolve())
self.assertEqual(
guided_kwargs,
{
"model": "small.en",
"language": "en",
"wake_phrase": "hey tater",
},
)
def test_unconfirmed_close_transcript_stays_for_manual_review(self):
audio_path = self.add_capture()
with (
patch.object(trainer, "_transcribe_capture", return_value="Hate hater."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hate hater.",
),
):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_ambiguous")
self.assertEqual(metadata["transcript"], "Hate hater.")
self.assertEqual(metadata["auto_review_guided_transcript"], "Hate hater.")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
self.assertEqual(trainer._queue_pending_auto_reviews(), 0)
self.assertEqual(trainer._queue_pending_auto_reviews(force=True), 1)
def test_close_parakeet_transcript_stays_for_manual_review(self):
audio_path = self.add_capture()
trainer.AUTO_TRAIN_CONFIG["stt_engine"] = trainer.STT_ENGINE_PARAKEET_ONNX
with (
patch.object(trainer, "_transcribe_capture", return_value="Hey Ganger."),
patch.object(trainer, "_transcribe_capture_with_faster_whisper_guided") as guided,
):
trainer._auto_review_capture("wake.wav")
guided.assert_not_called()
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_ambiguous")
self.assertEqual(metadata["auto_review_stt_engine"], "parakeet_onnx")
def test_matching_phrase_is_deleted_when_cleanup_is_enabled(self):
audio_path = self.add_capture()
trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True
with patch.object(
trainer,
"_transcribe_capture",
return_value="hey tater turn on the lights",
):
trainer._auto_review_capture("wake.wav")
self.assertFalse(audio_path.exists())
self.assertFalse(audio_path.with_suffix(".json").exists())
self.assertFalse(list(trainer.PERSONAL_DIR.glob("*.wav")))
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_review_result"], "deleted_confirmed_wake")
def test_cleanup_processes_previously_confirmed_wake_without_retranscribing(self):
audio_path = self.add_capture()
metadata = trainer._load_sidecar_json(audio_path)
metadata.update(
{
"auto_review_status": "wake_phrase_detected",
"transcript": "hey tater",
}
)
trainer._write_sidecar_json(audio_path, metadata)
trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True
self.assertEqual(trainer._queue_pending_auto_reviews(), 1)
with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called()
self.assertFalse(audio_path.exists())
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_review_transcript"], "hey tater")
def test_close_miss_is_not_transcribed_by_default(self):
audio_path = self.add_capture(event_type="close_miss")
with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called()
self.assertTrue(audio_path.exists())
self.assertFalse(trainer._load_sidecar_json(audio_path).get("auto_review_status"))
def test_existing_close_miss_is_queued_when_promotion_is_enabled(self):
self.add_capture(event_type="close_miss")
self.assertEqual(trainer._queue_pending_auto_reviews(), 0)
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
self.assertEqual(trainer._queue_pending_auto_reviews(), 1)
def test_close_miss_with_phrase_is_promoted_when_enabled(self):
self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object(trainer, "_transcribe_capture", return_value="hey tater"):
trainer._auto_review_capture("wake.wav")
self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists())
positives = list(trainer.PERSONAL_DIR.glob("*.wav"))
self.assertEqual(len(positives), 1)
metadata = trainer._load_sidecar_json(positives[0])
self.assertTrue(metadata["auto_positive"])
self.assertEqual(metadata["review_status"], "auto_approved_personal")
self.assertEqual(metadata["transcript"], "hey tater")
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
def test_close_miss_without_phrase_stays_in_inbox(self):
audio_path = self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object(
trainer,
"_transcribe_capture",
return_value="turn on the lights",
):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.PERSONAL_DIR.glob("*.wav")))
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "close_miss_phrase_not_detected")
def test_vad_blocked_close_miss_is_never_transcribed(self):
audio_path = self.add_capture(event_type="close_miss", blocked_by_vad=True)
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called()
self.assertTrue(audio_path.exists())
self.assertFalse(trainer._load_sidecar_json(audio_path).get("auto_review_status"))
def test_capture_for_another_wake_word_is_not_transcribed(self):
audio_path = self.add_capture(wake_word="computer")
with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called()
self.assertTrue(audio_path.exists())
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "different_wake_phrase")
def test_due_schedule_starts_training_after_minimum_negatives(self):
trainer.AUTO_TRAIN_CONFIG["schedule_hours"] = 24
trainer.AUTO_TRAIN_CONFIG["minimum_new_negatives"] = 3
trainer.AUTO_TRAIN_STATE["pending_negative_count"] = 3
trainer.AUTO_TRAIN_STATE["next_run_at"] = "2000-01-01T00:00:00+00:00"
with patch.object(trainer, "_start_auto_training", return_value={"ok": True, "started": True}) as start:
trainer._maybe_run_scheduled_auto_training()
start.assert_called_once_with()
self.assertTrue(trainer.AUTO_TRAIN_STATE["next_run_at"])
def test_tater_notification_sets_new_word_globally_with_token(self):
trainer.AUTO_TRAIN_CONFIG.update(
{
"notify_satellites": True,
"tater_url": "http://127.0.0.1:8501",
"tater_link_token": "secret-token",
}
)
class Response:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self):
return b'{"push":{"count":4}}'
trained_word = {
"key": "hey_tater",
"wake_word": "Hey Tater",
"json_url": "http://10.4.20.210:8789/api/trained_wake_words/hey_tater.json",
}
with (
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
patch.object(trainer, "_list_trained_wake_words", return_value=[trained_word]) as catalog,
patch.object(trainer, "urlopen", return_value=Response()) as open_url,
):
result = trainer._notify_tater_satellites("hey_tater")
self.assertTrue(result["ok"])
self.assertEqual(result["count"], 4)
self.assertEqual(result["wake_word"], "Hey Tater")
self.assertEqual(result["wake_word_url"], trained_word["json_url"])
catalog.assert_called_once_with("http://10.4.20.210:8789")
self.assertEqual(open_url.call_count, 1)
request = open_url.call_args.args[0]
self.assertEqual(request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/trainer/wake-word")
self.assertEqual(request.get_method(), "POST")
self.assertEqual(request.get_header("X-tater-trainer-token"), "secret-token")
self.assertEqual(
json.loads(request.data),
{
"wake_word_name": "hey_tater",
"wake_word_url": trained_word["json_url"],
},
)
def test_trained_word_catalog_keeps_url_alias_for_json_package(self):
with tempfile.TemporaryDirectory() as directory:
trained_dir = Path(directory)
(trained_dir / "hey_tater.tflite").write_bytes(b"model")
(trained_dir / "hey_tater.json").write_text(
json.dumps({"wake_word": "hey tater", "model": "hey_tater.tflite"}),
encoding="utf-8",
)
with (
patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir),
patch.object(trainer, "_sync_trained_wake_word_artifacts"),
):
rows = trainer._list_trained_wake_words("http://10.4.20.210:8789")
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"))
def test_tater_notification_fails_when_trained_word_is_missing(self):
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
with (
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
patch.object(trainer, "_list_trained_wake_words", return_value=[]),
patch.object(trainer, "urlopen") as open_url,
):
result = trainer._notify_tater_satellites("missing_word")
self.assertFalse(result["ok"])
self.assertIn("missing_word", result["error"])
open_url.assert_not_called()
def test_tater_notification_requires_secure_link(self):
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = ""
with patch.object(trainer, "urlopen") as open_url:
result = trainer._notify_tater_satellites("hey_tater")
self.assertFalse(result["ok"])
self.assertIn("not linked", result["error"])
open_url.assert_not_called()
def test_claim_tater_link_uses_tater_code_and_keeps_token_private(self):
class Response:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, *_args):
return json.dumps(
{
"ok": True,
"token": "a" * 43,
"tater_name": "Tater",
"linked_at": "2026-07-24T12:00:00+00:00",
}
).encode("utf-8")
with (
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
patch.object(trainer, "urlopen", return_value=Response()) as open_url,
):
result = trainer._claim_tater_link("http://127.0.0.1:8501", "ABCD-EFGH")
self.assertTrue(result["linked"])
self.assertEqual(trainer.AUTO_TRAIN_CONFIG["tater_link_token"], "a" * 43)
self.assertNotIn("tater_link_token", trainer._public_auto_train_config())
request = open_url.call_args.args[0]
self.assertEqual(
request.full_url,
"http://127.0.0.1:8501/api/tater/satellite/v1/trainer/link/claim",
)
payload = json.loads(request.data)
self.assertEqual(payload["pairing_code"], "ABCDEFGH")
self.assertEqual(payload["publish_base_url"], "http://10.4.20.210:8789")
self.assertTrue(payload["trainer_id"])
def test_advertised_url_uses_non_loopback_browser_host(self):
request = SimpleNamespace(
base_url="http://192.168.1.50:8789/",
url=SimpleNamespace(hostname="192.168.1.50", scheme="http", port=8789),
)
self.assertEqual(trainer._advertised_base_url(request), "http://192.168.1.50:8789")
def test_advertised_url_replaces_localhost_with_discovered_lan_host(self):
request = SimpleNamespace(
base_url="http://127.0.0.1:8789/",
url=SimpleNamespace(hostname="127.0.0.1", scheme="http", port=8789),
)
with patch.object(trainer, "_discover_lan_ipv4", return_value="192.168.1.60"):
self.assertEqual(trainer._advertised_base_url(request), "http://192.168.1.60:8789")
def test_configured_public_url_takes_precedence(self):
trainer.AUTO_TRAIN_CONFIG["advertised_base_url"] = "http://trainer.local:8789"
request = SimpleNamespace(
base_url="http://127.0.0.1:8789/",
url=SimpleNamespace(hostname="127.0.0.1", scheme="http", port=8789),
)
self.assertEqual(trainer._advertised_base_url(request), "http://trainer.local:8789")
def test_faster_whisper_auto_runtime_prefers_cuda_and_float16(self):
fake_ctranslate2 = SimpleNamespace(get_cuda_device_count=lambda: 1)
with patch.dict(sys.modules, {"ctranslate2": fake_ctranslate2}):
self.assertEqual(
trainer._resolve_faster_whisper_runtime("auto", "auto"),
("cuda", "float16"),
)
def test_faster_whisper_auto_runtime_falls_back_to_cpu_int8(self):
fake_ctranslate2 = SimpleNamespace(get_cuda_device_count=lambda: 0)
with patch.dict(sys.modules, {"ctranslate2": fake_ctranslate2}):
self.assertEqual(
trainer._resolve_faster_whisper_runtime("auto", "auto"),
("cpu", "int8"),
)
def test_faster_whisper_transcription_joins_segments_and_records_runtime(self):
fake_model = SimpleNamespace()
fake_model.transcribe = Mock(
return_value=(
iter([SimpleNamespace(text=" turn on "), SimpleNamespace(text="the lights ")]),
SimpleNamespace(),
)
)
with (
patch.object(trainer, "_resolve_faster_whisper_runtime", return_value=("cuda", "float16")),
patch.object(trainer, "_load_faster_whisper_model", return_value=fake_model),
):
transcript = trainer._transcribe_capture_with_faster_whisper(
Path("wake.wav"),
model="small.en",
language="en",
)
self.assertEqual(transcript, "turn on the lights")
fake_model.transcribe.assert_called_once_with(
"wake.wav",
language="en",
beam_size=1,
condition_on_previous_text=False,
)
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_stt_device"], "cuda")
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_stt_compute_type"], "float16")
def test_train_status_reads_and_increments_training_log_tail(self):
log_path = Path(self.tempdir.name) / "training.log"
log_path.write_text("first\nsecond\nthird\n", encoding="utf-8")
with trainer.STATE_LOCK:
original_training = dict(trainer.STATE["training"])
trainer.STATE["training"].update(
{
"log_path": str(log_path),
"last_sent_tail": [],
"last_log_size": 0,
}
)
try:
with (
patch.object(trainer, "TRAIN_LOG_TAIL_LINES", 2),
patch.object(trainer, "TRAIN_LOG_MAX_BYTES", 1024),
):
first_status = trainer.train_status()
self.assertEqual(first_status["training"]["log_lines"], ["second", "third"])
self.assertEqual(first_status["training"]["log_text"], "second\nthird")
with log_path.open("a", encoding="utf-8") as log_file:
log_file.write("fourth\n")
next_status = trainer.train_status()
self.assertEqual(next_status["training"]["log_lines"], ["third", "fourth"])
self.assertEqual(next_status["training"]["log_text"], "fourth")
finally:
with trainer.STATE_LOCK:
trainer.STATE["training"].clear()
trainer.STATE["training"].update(original_training)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,88 @@
import importlib.util
import unittest
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parents[1]
/ "cli"
/ "calibrate_detector.py"
)
SPEC = importlib.util.spec_from_file_location("calibrate_detector", SCRIPT_PATH)
calibrate_detector = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(calibrate_detector)
def candidate(cutoff, window, recall, false_accepts_per_hour):
return {
"probability_cutoff": cutoff,
"sliding_window_size": window,
"recall": recall,
"false_accepts_per_hour": false_accepts_per_hour,
}
class CalibrationSelectionTests(unittest.TestCase):
def test_defaults_are_conservative(self):
self.assertEqual(calibrate_detector.DEFAULT_WINDOW_SIZES, [5, 6, 7])
self.assertEqual(calibrate_detector.DEFAULT_CUTOFF_MIN, 0.95)
self.assertEqual(calibrate_detector.DEFAULT_RECALL_MARGIN, 0.005)
def test_prefers_zero_false_accepts_within_recall_margin(self):
candidates = [
candidate(0.95, 5, 0.99894, 0.103408),
candidate(0.95, 6, 0.99744, 0.0),
candidate(0.95, 7, 0.99554, 0.0),
]
best, selected_limit = calibrate_detector._select_best_candidate(
candidates,
target_faph=0.25,
recall_margin=0.005,
)
self.assertEqual(best["sliding_window_size"], 6)
self.assertEqual(best["false_accepts_per_hour"], 0.0)
self.assertEqual(selected_limit, 0.25)
def test_does_not_trade_away_recall_beyond_margin(self):
candidates = [
candidate(0.95, 5, 0.99, 0.1),
candidate(0.99, 6, 0.90, 0.0),
]
best, _ = calibrate_detector._select_best_candidate(
candidates,
target_faph=0.25,
recall_margin=0.005,
)
self.assertEqual(best["sliding_window_size"], 5)
def test_uses_strictest_available_false_accept_tier(self):
candidates = [
candidate(0.95, 5, 0.99, 0.6),
candidate(0.99, 6, 0.99, 1.5),
]
best, selected_limit = calibrate_detector._select_best_candidate(
candidates,
target_faph=0.25,
recall_margin=0.005,
)
self.assertEqual(best["false_accepts_per_hour"], 0.6)
self.assertEqual(selected_limit, 0.75)
def test_rejects_negative_recall_margin(self):
with self.assertRaises(ValueError):
calibrate_detector._select_best_candidate(
[candidate(0.95, 6, 0.99, 0.0)],
target_faph=0.25,
recall_margin=-0.001,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,83 @@
import tempfile
import unittest
from pathlib import Path
import trainer_server as trainer
class DataManagementTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
root = Path(self.tempdir.name)
self.original_paths = {
"DATA_DIR": trainer.DATA_DIR,
"PERSONAL_DIR": trainer.PERSONAL_DIR,
"CAPTURED_DIR": trainer.CAPTURED_DIR,
"NEGATIVE_DIR": trainer.NEGATIVE_DIR,
"TRIM_HISTORY_DIR": trainer.TRIM_HISTORY_DIR,
"TRAINED_WAKE_WORDS_DIR": trainer.TRAINED_WAKE_WORDS_DIR,
"AUTO_TRAIN_MODEL_DIR": trainer.AUTO_TRAIN_MODEL_DIR,
"PIPER_ROOT": trainer.PIPER_ROOT,
"PIPER_VOICES_DIR": trainer.PIPER_VOICES_DIR,
"PIPER_CATALOG_CACHE_FILE": trainer.PIPER_CATALOG_CACHE_FILE,
"OMNIVOICE_CATALOG_CACHE_FILE": trainer.OMNIVOICE_CATALOG_CACHE_FILE,
}
trainer.DATA_DIR = root
trainer.PERSONAL_DIR = root / "personal_samples"
trainer.CAPTURED_DIR = root / "captured_audio"
trainer.NEGATIVE_DIR = root / "negative_samples"
trainer.TRIM_HISTORY_DIR = root / "trim_history"
trainer.TRAINED_WAKE_WORDS_DIR = root / "trained_wake_words"
trainer.AUTO_TRAIN_MODEL_DIR = root / "auto_train_models"
trainer.PIPER_ROOT = root / "tools" / "piper-sample-generator"
trainer.PIPER_VOICES_DIR = trainer.PIPER_ROOT / "voices"
trainer.PIPER_CATALOG_CACHE_FILE = root / ".cache" / "piper_voices_catalog.json"
trainer.OMNIVOICE_CATALOG_CACHE_FILE = root / ".cache" / "omnivoice_languages.json"
self.original_training_running = trainer.STATE["training"]["running"]
self.original_review_running = trainer.AUTO_TRAIN_RUNTIME["review_running"]
trainer.STATE["training"]["running"] = False
trainer.AUTO_TRAIN_RUNTIME["review_running"] = False
def tearDown(self):
for name, value in self.original_paths.items():
setattr(trainer, name, value)
trainer.STATE["training"]["running"] = self.original_training_running
trainer.AUTO_TRAIN_RUNTIME["review_running"] = self.original_review_running
self.tempdir.cleanup()
def test_payload_counts_each_managed_item_and_does_not_follow_symlinks(self):
generated = trainer.DATA_DIR / "work" / "wake_word_samples"
generated.mkdir(parents=True)
(generated / "one.wav").write_bytes(b"a" * 128)
outside = trainer.DATA_DIR / "outside.bin"
outside.write_bytes(b"b" * 8192)
(generated / "outside-link").symlink_to(outside)
payload = trainer._managed_data_payload()
item = next(row for row in payload["items"] if row["id"] == "generated_samples")
self.assertEqual(item["file_count"], 2)
self.assertGreater(item["size_bytes"], 0)
self.assertEqual(item["location"], "work/wake_word_samples")
self.assertEqual(payload["total_file_count"], 2)
deleted = trainer._delete_managed_data_item("generated_samples")
self.assertFalse(generated.exists())
self.assertTrue(outside.exists())
self.assertEqual(deleted["deleted_id"], "generated_samples")
def test_unknown_ids_and_active_training_are_rejected(self):
with self.assertRaises(KeyError):
trainer._delete_managed_data_item("../../not-allowed")
generated = trainer.DATA_DIR / "work" / "wake_word_samples"
generated.mkdir(parents=True)
(generated / "keep.wav").write_bytes(b"keep")
trainer.STATE["training"]["running"] = True
with self.assertRaisesRegex(RuntimeError, "Stop training"):
trainer._delete_managed_data_item("generated_samples")
self.assertTrue((generated / "keep.wav").exists())
if __name__ == "__main__":
unittest.main()

603
tests/test_modern_tts.py Normal file
View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import math
import shutil
import subprocess
import tempfile
import unittest
import wave
from array import array
from pathlib import Path
from unittest.mock import patch
from tts_config import parse_omnivoice_catalog
try:
import trainer_server as trainer
except ModuleNotFoundError:
trainer = None
REPO_ROOT = Path(__file__).resolve().parents[1]
GENERATOR_PATH = REPO_ROOT / "cli" / "tts_generate_samples.py"
SPEC = importlib.util.spec_from_file_location("tts_generate_samples", GENERATOR_PATH)
assert SPEC is not None and SPEC.loader is not None
generator_module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(generator_module)
QA_PATH = REPO_ROOT / "cli" / "tts_reference_qa.py"
QA_SPEC = importlib.util.spec_from_file_location("tts_reference_qa", QA_PATH)
assert QA_SPEC is not None and QA_SPEC.loader is not None
qa_module = importlib.util.module_from_spec(QA_SPEC)
QA_SPEC.loader.exec_module(qa_module)
def write_tone(
path: Path,
*,
duration: float = 0.8,
amplitude: int = 4000,
frequency: float = 220.0,
) -> None:
rate = 16000
samples = array(
"h",
(
int(amplitude * math.sin(2 * math.pi * frequency * index / rate))
for index in range(int(rate * duration))
),
)
with wave.open(str(path), "wb") as stream:
stream.setnchannels(1)
stream.setsampwidth(2)
stream.setframerate(rate)
stream.writeframes(samples.tobytes())
class ModernTtsTests(unittest.TestCase):
def test_direct_generator_uses_one_wake_phrase(self) -> None:
self.assertEqual(generator_module.reference_text("hey tater"), "hey tater.")
self.assertEqual(generator_module.reference_text("hey tater!"), "hey tater.")
self.assertIn("four-provider-direct-corpus", generator_module.GENERATOR_VERSION)
self.assertIn("safe-limits", generator_module.GENERATOR_VERSION)
def test_omnivoice_uses_upstream_sampling_defaults(self) -> None:
self.assertEqual(
generator_module.omnivoice_stability_args(),
["--position_temperature", "5.0", "--class_temperature", "0.0"],
)
def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=1,
batch_size=4,
voice_count=2,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
destination = data_dir / "bank"
destination.mkdir()
def create_model_outputs(command, *, only_first: bool = False) -> None:
input_flag = "--test_list" if "--test_list" in command else "--input-jsonl"
output_flag = "--res_dir" if "--res_dir" in command else "--output-dir"
input_path = Path(command[command.index(input_flag) + 1])
output_dir = Path(command[command.index(output_flag) + 1])
output_dir.mkdir(parents=True, exist_ok=True)
model_entries = [
json.loads(line)
for line in input_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
for item in model_entries[:1] if only_first else model_entries:
write_tone(output_dir / f"{item['id']}.wav")
with (
patch.object(instance, "ensure_environment"),
patch.object(
generator_module,
"run_with_batch_retry",
side_effect=lambda command, _flag, **_kwargs: create_model_outputs(command),
) as run_batch,
):
entries = instance._generate_omni_bank(2, 0, destination)
self.assertEqual(run_batch.call_count, 2)
self.assertEqual(entries[0]["text"], "hey tater.")
self.assertEqual(
entries[0]["ref_text"],
"In a calm and natural voice, I say hey tater clearly, then continue speaking at an even pace.",
)
self.assertEqual(entries[0]["ref_text"].lower().count("hey tater"), 1)
self.assertIn(".omnivoice-prompts", entries[0]["ref_audio"])
self.assertEqual(
entries[0]["voice_description"],
"automatic random voice",
)
self.assertNotIn("instruct", entries[0])
for call in run_batch.call_args_list:
command = call.args[0]
if "--position_temperature" in command:
self.assertEqual(command[command.index("--position_temperature") + 1], "5.0")
self.assertEqual(command[command.index("--class_temperature") + 1], "0.0")
def test_omnivoice_corpus_uses_reference_without_a_second_instruction(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=1,
batch_size=4,
voice_count=2,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
entries = instance.make_entries(
generator_module.ENGINE_OMNIVOICE,
1,
[{
"id": "omni_ref",
"path": "/tmp/short.wav",
"ref_text": "hey tater.",
"omnivoice_prompt_path": "/tmp/prompt.wav",
"omnivoice_prompt_text": "A natural carrier sentence.",
"instruct": "female, elderly, low pitch, british accent",
}],
data_dir,
)
self.assertNotIn("instruct", entries[0])
def test_omnivoice_corpus_repairs_only_vad_rejected_outputs(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=2,
batch_size=4,
voice_count=2,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
destination = data_dir / "raw"
destination.mkdir()
entries = [{"id": "omni_a", "text": "hey tater."}, {"id": "omni_b", "text": "hey tater."}]
for entry in entries:
write_tone(destination / f"{entry['id']}.wav")
generation_command = [
"omnivoice",
"--test_list",
str(data_dir / "input.jsonl"),
"--res_dir",
str(destination),
"--batch_size",
"4",
]
qa_calls = 0
def fake_qa(command, **_kwargs):
nonlocal qa_calls
qa_calls += 1
self.assertIn("--speech-only", command)
qa_input = Path(command[command.index("--input-jsonl") + 1])
qa_output = Path(command[command.index("--output-jsonl") + 1])
candidates = [json.loads(line) for line in qa_input.read_text().splitlines()]
results = [
{
"id": item["id"],
"accepted": qa_calls > 1 or item["id"] == "omni_a",
}
for item in candidates
]
qa_output.write_text("".join(json.dumps(item) + "\n" for item in results))
def fake_retry(command, _flag, **_kwargs):
retry_input = Path(command[command.index("--test_list") + 1])
retry_entries = [json.loads(line) for line in retry_input.read_text().splitlines()]
for entry in retry_entries:
write_tone(destination / f"{entry['id']}.wav")
with (
patch.object(instance, "_reference_qa_python", return_value=data_dir / "python"),
patch.object(generator_module, "run", side_effect=fake_qa),
patch.object(generator_module, "run_with_batch_retry", side_effect=fake_retry) as retry,
):
accepted = instance._repair_generated_corpus(
generator_module.ENGINE_OMNIVOICE,
entries,
destination,
generation_command,
"",
speech_only=True,
input_flag="--test_list",
batch_flag="--batch_size",
)
retry_input = Path(retry.call_args.args[0][retry.call_args.args[0].index("--test_list") + 1])
retried_ids = [json.loads(line)["id"] for line in retry_input.read_text().splitlines()]
self.assertEqual([path.name for path in accepted], ["omni_a.wav", "omni_b.wav"])
self.assertEqual(retried_ids, ["omni_b"])
self.assertEqual(qa_calls, 2)
def test_omnivoice_repairs_outputs_missing_from_a_successful_seed_batch(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=1,
batch_size=4,
voice_count=2,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
destination = data_dir / "bank"
destination.mkdir()
def create_outputs(command, *, only_first: bool = False) -> None:
input_flag = "--test_list" if "--test_list" in command else "--input-jsonl"
output_flag = "--res_dir" if "--res_dir" in command else "--output-dir"
input_path = Path(command[command.index(input_flag) + 1])
output_dir = Path(command[command.index(output_flag) + 1])
output_dir.mkdir(parents=True, exist_ok=True)
model_entries = [
json.loads(line)
for line in input_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
for item in model_entries[:1] if only_first else model_entries:
write_tone(output_dir / f"{item['id']}.wav")
batched_calls = 0
def fake_batched(command, _flag, **_kwargs):
nonlocal batched_calls
batched_calls += 1
create_outputs(command, only_first=batched_calls == 1)
with (
patch.object(instance, "ensure_environment"),
patch.object(generator_module, "run_with_batch_retry", side_effect=fake_batched),
patch.object(
generator_module,
"run",
side_effect=lambda command, **_kwargs: create_outputs(command),
) as run_single,
):
entries = instance._generate_omni_bank(2, 0, destination)
retry_command = run_single.call_args.args[0]
retry_input = Path(retry_command[retry_command.index("--test_list") + 1])
retried_ids = [json.loads(line)["id"] for line in retry_input.read_text().splitlines()]
self.assertEqual(len(entries), 2)
self.assertEqual(batched_calls, 2)
self.assertEqual(run_single.call_count, 1)
self.assertEqual(retry_command[retry_command.index("--batch_size") + 1], "1")
self.assertEqual(retried_ids, ["omni_prompt_0001"])
def test_reference_semantic_qa_rejects_noise_and_missing_words(self) -> None:
self.assertTrue(qa_module.transcript_matches_phrase("Hey, Tater.", "hey tater"))
self.assertTrue(qa_module.transcript_matches_phrase("Hey, gator.", "hey tater"))
self.assertFalse(qa_module.transcript_matches_phrase("Tater.", "hey tater"))
self.assertFalse(qa_module.transcript_matches_phrase("Hater.", "hey tater"))
self.assertFalse(qa_module.transcript_matches_phrase("Thanks for watching!", "hey tater"))
self.assertFalse(
qa_module.transcript_matches_phrase("Hey tater. Hey tater.", "hey tater")
)
self.assertFalse(qa_module.transcript_matches_phrase("Hey hey Tate", "hey tater"))
self.assertFalse(qa_module.transcript_matches_phrase("", "hey tater"))
self.assertEqual(
qa_module.semantic_rejection_reason("Ehhhhh...", "hey tater", 0.8),
"decoder_collapse",
)
self.assertEqual(
qa_module.semantic_rejection_reason("Hey tater. Hey tater.", "hey tater", 0.8),
"repeated_phrase",
)
self.assertEqual(
qa_module.semantic_rejection_reason("Hey hey Tate", "hey tater", 0.8),
"repeated_phrase",
)
self.assertEqual(
qa_module.semantic_rejection_reason("Hey Taylor", "hey tater", 0.8),
"phrase_mismatch",
)
def test_omnivoice_sample_generation_requires_a_stable_prompt(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=1,
batch_size=4,
voice_count=2,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
with self.assertRaisesRegex(RuntimeError, "long-form seed prompt"):
instance.make_entries(
generator_module.ENGINE_OMNIVOICE,
1,
[{"id": "qwen_ref", "path": "/tmp/qwen.wav", "ref_text": "hey tater."}],
data_dir,
)
def test_omnivoice_markdown_catalog_parser(self) -> None:
markdown = """
| # | Language | OmniVoice ID | ISO 639-3 | Duration (h) |
|--:|----------|:------------:|:---------:|:------------:|
| 1 | English | en | eng | 100000.5 |
| 2 | Amdo Tibetan | adx | adx | 56.94 |
"""
parsed = parse_omnivoice_catalog(markdown)
self.assertEqual(parsed["en"]["name"], "English")
self.assertEqual(parsed["adx"]["iso_639_3"], "adx")
self.assertEqual(parsed["adx"]["duration_hours"], 56.94)
@unittest.skipIf(trainer is None, "trainer server dependencies are not installed")
def test_language_catalog_merges_engine_coverage_and_quality(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
with (
patch.object(
trainer,
"_load_omnivoice_catalog",
return_value={
"en": {"name": "English"},
"zu": {"name": "Zulu"},
},
),
patch.object(trainer, "_load_piper_catalog", return_value={}),
patch.object(trainer, "PIPER_ROOT", Path(temp_dir) / "piper"),
patch.object(trainer, "PIPER_VOICES_DIR", Path(temp_dir) / "voices"),
):
catalog = {item["code"]: item for item in trainer._available_languages()}
self.assertEqual(catalog["en"]["quality"], "recommended")
self.assertEqual(catalog["en"]["engines"], ["omnivoice", "qwen3", "moss"])
self.assertEqual(catalog["zu"]["quality"], "experimental")
self.assertEqual(catalog["zu"]["engines"], ["omnivoice"])
@unittest.skipIf(trainer is None, "trainer server dependencies are not installed")
def test_server_resolves_unavailable_tts_modes_safely(self) -> None:
languages = [
{"code": "en", "engines": ["omnivoice", "qwen3", "moss"]},
{"code": "legacy", "engines": ["piper"]},
]
self.assertEqual(
trainer._resolve_tts_mode_for_language("piper", "en", languages),
"modern",
)
self.assertEqual(
trainer._resolve_tts_mode_for_language("modern", "legacy", languages),
"piper",
)
def test_generator_plan_and_piper_discovery_do_not_load_models(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
output_dir = data_dir / "work" / "wake_word_samples"
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="modern",
samples=101,
batch_size=8,
voice_count=128,
data_dir=data_dir,
output_dir=output_dir,
ffmpeg="ffmpeg",
dry_run=True,
)
instance = generator_module.Generator(args)
self.assertEqual(instance.spoken_phrase, "hey tater")
self.assertEqual(instance.reference_text, "hey tater.")
self.assertEqual(instance.voice_bank_dir.name, generator_module.phrase_key("hey tater"))
self.assertEqual(instance.engines(), ["omnivoice", "qwen3", "moss"])
self.assertEqual(sum(generator_module.distribute_samples(101, instance.engines()).values()), 101)
model = data_dir / "tools" / "piper-sample-generator" / "models" / "en_US-libritts_r-medium.pt"
model.parent.mkdir(parents=True)
model.touch()
args.tts_mode = "hybrid"
self.assertEqual(instance.engines()[-1], "piper")
def test_voice_descriptions_are_distinct_for_default_bank(self) -> None:
descriptions = generator_module.qwen_descriptions("English", 128)
self.assertEqual(len(descriptions), 128)
self.assertEqual(len(set(descriptions)), 128)
first_bank = descriptions[:64]
self.assertEqual(sum(" female speaker " in item for item in first_bank), 32)
self.assertEqual(sum(" male speaker " in item for item in first_bank), 32)
for trait in (
"child",
"teenager",
"young adult",
"middle-aged adult",
"elderly adult",
"low pitch",
"medium pitch",
"high pitch",
"calm neutral delivery",
"bright energetic delivery",
"soft careful delivery",
"confident resonant delivery",
"casual conversational delivery",
"clear timbre",
"warm timbre",
"slightly breathy timbre",
"crisp timbre",
"gently rough timbre",
):
self.assertGreaterEqual(sum(trait in item for item in first_bank), 10, trait)
def test_failed_model_batch_retries_one_item_at_a_time(self) -> None:
command = ["worker", "--batch-size", "4"]
with patch.object(
generator_module,
"run",
side_effect=(subprocess.CalledProcessError(1, command), None),
) as mocked_run:
generator_module.run_with_batch_retry(command, "--batch-size")
self.assertEqual(mocked_run.call_count, 2)
self.assertEqual(mocked_run.call_args_list[1].args[0], ["worker", "--batch-size", "1"])
def test_acoustic_qa_accepts_speech_like_pcm_and_rejects_silence(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
tone = root / "tone.wav"
silence = root / "silence.wav"
write_tone(tone)
write_tone(silence, amplitude=0)
self.assertTrue(generator_module.valid_sample(tone))
self.assertTrue(generator_module.valid_reference(tone))
self.assertFalse(generator_module.valid_sample(silence))
def test_provider_safety_gate_rejects_static_and_rambling(self) -> None:
clean = {
"duration": 1.2,
"rms": 0.08,
"peak": 0.5,
"clipped_ratio": 0.0,
"dc_offset": 0.0,
"spectral_flatness": 0.05,
"high_frequency_ratio": 0.04,
"zero_crossing_rate": 0.08,
}
self.assertEqual(
qa_module.acoustic_rejection_reason(clean, 0.7, "omnivoice", 0.4, 2.7),
"accepted",
)
self.assertEqual(
qa_module.acoustic_rejection_reason(
{**clean, "spectral_flatness": 0.8}, 0.8, "omnivoice", 0.4, 2.7
),
"static_or_broadband_noise",
)
self.assertEqual(
qa_module.acoustic_rejection_reason(
{**clean, "duration": 3.5}, 0.8, "qwen3", 0.4, 2.7
),
"too_long_or_rambling",
)
def test_direct_entries_do_not_clone_the_old_voice_bank(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
args = argparse.Namespace(
phrase="hey_tater",
language="en",
tts_mode="hybrid",
samples=12,
batch_size=4,
voice_count=128,
data_dir=data_dir,
output_dir=data_dir / "work" / "samples",
ffmpeg="ffmpeg",
dry_run=False,
)
instance = generator_module.Generator(args)
qwen = instance.make_direct_entries("qwen3", 4, data_dir, [])
omni = instance.make_direct_entries("omnivoice", 4, data_dir, [])
refs = [data_dir / f"accepted-{index}.wav" for index in range(4)]
moss = instance.make_direct_entries("moss", 4, data_dir, refs)
self.assertTrue(all("ref_audio" not in item for item in qwen + omni))
self.assertEqual(len({item["instruct"] for item in qwen}), 4)
self.assertEqual([item["ref_audio"] for item in moss], [str(path) for path in refs])
@unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg is required for normalization")
def test_orchestrator_produces_exact_normalized_corpus_and_manifest(self) -> None:
class FakeGenerator(generator_module.Generator):
generated = 0
def generate_direct_engine(self, engine, count, reference_paths, prefix=""):
destination = self.raw_dir / f"{engine}_{prefix or 'main'}"
destination.mkdir(parents=True, exist_ok=True)
paths = []
for index in range(count):
path = destination / f"{engine}_{prefix}{index}.wav"
write_tone(path, frequency=180 + self.generated)
self.generated += 1
self.speed_by_path[path.resolve()] = 1.0
paths.append(path)
entries = [
{
"id": path.stem,
"minimum_duration": self.minimum_duration,
"maximum_duration": self.maximum_duration,
}
for path in paths
]
return entries, paths
def qualify_direct_candidates(self, engine, entries, paths, prefix=""):
return paths
with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir)
output_dir = data_dir / "work" / "wake_word_samples"
args = argparse.Namespace(
phrase="hey tater",
language="en",
tts_mode="modern",
samples=13,
batch_size=4,
voice_count=8,
data_dir=data_dir,
output_dir=output_dir,
ffmpeg=shutil.which("ffmpeg"),
dry_run=False,
)
instance = FakeGenerator(args)
instance.generate()
self.assertEqual(len(list(output_dir.glob("*.wav"))), 13)
self.assertTrue((output_dir / ".generation_manifest.json").is_file())
self.assertTrue(instance.cache_hit())
def test_docker_and_ui_are_wired_for_modern_tts(self) -> None:
for dockerfile in ("dockerfile", "dockerfile.blackwell"):
source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8")
self.assertIn("ffmpeg", source)
self.assertIn("tts_config.py", source)
ui = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
store = (REPO_ROOT / "frontend" / "src" / "trainerStore.ts").read_text(encoding="utf-8")
self.assertIn('v-model="trainer.ttsMode"', ui)
self.assertIn("tts_mode: trainer.ttsMode", store)
self.assertIn("OmniVoice", store)
if __name__ == "__main__":
unittest.main()

73
tests/test_run_sh.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RUN_SH = REPO_ROOT / "run.sh"
def _cuda_path_probe() -> str:
source = RUN_SH.read_text(encoding="utf-8")
match = re.search(
r'WHISPER_CUDA_LIBRARY_PATH="\$\("\$\{PY\}" - <<\'PY\'\n(?P<probe>.*?)\nPY\n\)"',
source,
flags=re.DOTALL,
)
if match is None:
raise AssertionError("Could not locate the CUDA library path probe in run.sh")
return match.group("probe")
class RunShCudaLibraryPathTests(unittest.TestCase):
def _run_probe(self, python_path: Path) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
env["PYTHONPATH"] = str(python_path)
return subprocess.run(
[sys.executable, "-S", "-"],
input=_cuda_path_probe(),
text=True,
capture_output=True,
check=False,
env=env,
)
def test_namespace_cuda_packages_do_not_require_module_file(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
cublas_lib = root / "nvidia" / "cublas" / "lib"
cudnn_lib = root / "nvidia" / "cudnn" / "lib"
cublas_lib.mkdir(parents=True)
cudnn_lib.mkdir(parents=True)
result = self._run_probe(root)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
result.stdout.strip().split(":"),
[str(cublas_lib.resolve()), str(cudnn_lib.resolve())],
)
def test_missing_cuda_packages_return_an_empty_path(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
result = self._run_probe(Path(temp_dir))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "")
def test_parakeet_uses_cuda_onnxruntime_package(self) -> None:
source = RUN_SH.read_text(encoding="utf-8")
self.assertIn('"onnx-asr[hub]>=0.12.0"', source)
self.assertIn('"onnxruntime-gpu[cuda,cudnn]<1.27"', source)
self.assertIn('"CUDAExecutionProvider"', source)
if __name__ == "__main__":
unittest.main()

116
tests/test_session_stop.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import io
import signal
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
import trainer_server as trainer
class _FakeTrainingProcess:
def __init__(self):
self.pid = 5432
self.returncode = None
def poll(self):
return self.returncode
def wait(self, timeout=None):
self.returncode = -signal.SIGTERM
return self.returncode
def terminate(self):
self.returncode = -signal.SIGTERM
def kill(self):
self.returncode = -signal.SIGKILL
class _CompletedTrainingProcess:
def __init__(self):
self.pid = 6543
self.returncode = 0
self.stdout = io.StringIO("worker started\n")
def poll(self):
return self.returncode
def wait(self, timeout=None):
return self.returncode
class SessionStopTests(unittest.TestCase):
def tearDown(self):
trainer.TRAINING_STOP_EVENT.clear()
def test_session_stop_terminates_the_process_group_and_allows_another_run(self):
proc = _FakeTrainingProcess()
original_process = trainer.TRAINING_PROCESS
original_thread = trainer.TRAINING_THREAD
try:
trainer.TRAINING_PROCESS = proc
trainer.TRAINING_THREAD = None
with (
patch.object(trainer.os, "getpgid", return_value=proc.pid),
patch.object(trainer.os, "getpgrp", return_value=999),
patch.object(trainer.os, "killpg") as killpg,
):
self.assertTrue(trainer._stop_current_training(timeout=0.2))
killpg.assert_called_once_with(proc.pid, signal.SIGTERM)
self.assertFalse(trainer.TRAINING_STOP_EVENT.is_set())
finally:
trainer.TRAINING_PROCESS = original_process
trainer.TRAINING_THREAD = original_thread
def test_reserved_running_state_starts_the_background_worker(self):
original_process = trainer.TRAINING_PROCESS
original_thread = trainer.TRAINING_THREAD
original_raw_phrase = trainer.STATE.get("raw_phrase")
original_training = dict(trainer.STATE["training"])
try:
with tempfile.TemporaryDirectory() as directory:
data_dir = Path(directory)
process = _CompletedTrainingProcess()
trainer.TRAINING_PROCESS = None
trainer.TRAINING_THREAD = threading.current_thread()
with trainer.STATE_LOCK:
trainer.STATE["raw_phrase"] = "hey tater"
trainer.STATE["training"]["running"] = True
with (
patch.object(trainer, "DATA_DIR", data_dir),
patch.object(trainer, "_ensure_training_venv"),
patch.object(trainer, "_ensure_training_datasets"),
patch.object(trainer.subprocess, "Popen", return_value=process) as popen,
patch.object(trainer, "_normalize_output_artifacts"),
):
trainer._run_training_background(
"hey_tater",
"en",
True,
auto_run=False,
tts_mode="modern",
)
popen.assert_called_once()
log_text = (data_dir / "recorder_training.log").read_text(encoding="utf-8")
self.assertIn("Nvidia Docker Training Run", log_text)
self.assertIn("worker started", log_text)
self.assertFalse(trainer.STATE["training"]["running"])
self.assertEqual(trainer.STATE["training"]["exit_code"], 0)
self.assertIsNone(trainer.TRAINING_THREAD)
finally:
with trainer.STATE_LOCK:
trainer.STATE["raw_phrase"] = original_raw_phrase
trainer.STATE["training"].clear()
trainer.STATE["training"].update(original_training)
trainer.TRAINING_PROCESS = original_process
trainer.TRAINING_THREAD = original_thread
if __name__ == "__main__":
unittest.main()

61
tests/test_tts_config.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import unittest
from tts_config import (
ENGINE_MOSS,
ENGINE_OMNIVOICE,
ENGINE_PIPER,
ENGINE_QWEN3,
distribute_samples,
engines_for_language,
language_for_engine,
normalize_tts_mode,
quality_for_engines,
)
class TtsConfigTests(unittest.TestCase):
def test_recommended_languages_use_all_modern_engines(self) -> None:
self.assertEqual(
engines_for_language("en", "modern"),
[ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS],
)
self.assertEqual(
quality_for_engines(engines_for_language("fr", "modern")),
"recommended",
)
def test_broad_language_coverage_routes_through_omnivoice(self) -> None:
self.assertEqual(engines_for_language("zu", "modern"), [ENGINE_OMNIVOICE])
self.assertEqual(quality_for_engines([ENGINE_OMNIVOICE]), "experimental")
def test_hybrid_and_legacy_modes_require_available_piper(self) -> None:
self.assertEqual(
engines_for_language("en", "hybrid", piper_available=True),
[ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS, ENGINE_PIPER],
)
self.assertEqual(engines_for_language("en", "piper"), [])
self.assertEqual(
engines_for_language("en", "piper", piper_available=True),
[ENGINE_PIPER],
)
def test_sample_distribution_is_exact_and_deterministic(self) -> None:
self.assertEqual(
distribute_samples(10, [ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS]),
{ENGINE_OMNIVOICE: 4, ENGINE_QWEN3: 3, ENGINE_MOSS: 3},
)
self.assertEqual(sum(distribute_samples(50000, ["a", "b", "c"]).values()), 50000)
def test_invalid_mode_falls_back_to_four_provider_route(self) -> None:
self.assertEqual(normalize_tts_mode("unknown"), "hybrid")
def test_common_language_aliases_use_model_catalog_ids(self) -> None:
self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ar"), "arb")
self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ne"), "npi")
self.assertEqual(language_for_engine(ENGINE_MOSS, "ar"), "ar")
if __name__ == "__main__":
unittest.main()

109
tests/test_vue_ui.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import json
import pathlib
import unittest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
class VueTrainerUiTests(unittest.TestCase):
def test_frontend_uses_typed_vue_and_vite(self) -> None:
package = json.loads((REPO_ROOT / "frontend" / "package.json").read_text(encoding="utf-8"))
self.assertEqual(package["dependencies"]["vue"], "3.5.40")
self.assertIn("vue-tsc --noEmit", package["scripts"]["build"])
self.assertIn("vite build", package["scripts"]["build"])
config = (REPO_ROOT / "frontend" / "vite.config.ts").read_text(encoding="utf-8")
self.assertIn('"../static/ui"', config)
self.assertIn('fileName: () => "trainer-ui.js"', config)
def test_static_shell_loads_prebuilt_bundle(self) -> None:
index = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
self.assertIn('id="trainer-app"', index)
self.assertIn('/static/ui/trainer-ui.css', index)
self.assertIn('/static/ui/trainer-ui.js', index)
self.assertNotIn("fonts.googleapis.com", index)
self.assertGreater((REPO_ROOT / "static" / "ui" / "trainer-ui.js").stat().st_size, 100_000)
self.assertGreater((REPO_ROOT / "static" / "ui" / "trainer-ui.css").stat().st_size, 10_000)
def test_theme_uses_tater_orange_and_neutral_greys(self) -> None:
styles = (REPO_ROOT / "frontend" / "src" / "trainer.css").read_text(encoding="utf-8")
self.assertIn("--orange: #ff9134", styles)
self.assertIn("--surface: rgba(29, 29, 31, .9)", styles)
for old_blue in ("#070b15", "#11192b", "#5db6ff", "#8d75ff", "#7fc7ff", "#78caff"):
self.assertNotIn(old_blue, styles)
def test_reactive_ui_keeps_trainer_workflows(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
store = (REPO_ROOT / "frontend" / "src" / "trainerStore.ts").read_text(encoding="utf-8")
trim = (REPO_ROOT / "frontend" / "src" / "components" / "AudioTrimModal.vue").read_text(encoding="utf-8")
for workflow in (
"startSession",
"stopSession",
"startTraining",
"saveAuto",
"runAutoAction",
"reviewCaptured",
"uploadSelectedFiles",
"copyWakeWord",
"deleteManagedData",
):
self.assertIn(workflow, app)
for endpoint in (
"/api/start_session",
"/api/stop_session",
"/api/upload_personal_sample",
"/api/captured_audio",
"/api/auto_train",
"/api/train_status",
"/api/trained_wake_words/catalog",
"/api/data",
):
self.assertIn(endpoint, store)
self.assertIn("OfflineAudioContext", trim)
self.assertIn("/api/samples/trim", trim)
self.assertIn(':disabled="Boolean(trainer.session.safe_word)', app)
self.assertIn('{ id: "data", label: "Data"', app)
def test_training_console_pauses_follow_mode_when_scrolled_up(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
self.assertIn("const consoleFollowing = ref(true)", app)
self.assertIn("distanceFromBottom <= 32", app)
self.assertIn('if (!consoleFollowing.value) return', app)
self.assertIn('@scroll.passive="onConsoleScroll"', app)
self.assertIn("Jump to latest", app)
def test_wake_word_card_uses_explicit_json_catalog_url(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("item.json_url || item.url || item.jsonUrl", app)
self.assertIn("copyWakeWord(wordJsonUrl(word))", app)
self.assertNotIn("copyWakeWord(word.url)", app)
self.assertIn("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:
if not dockerfile.exists():
continue
source = dockerfile.read_text(encoding="utf-8")
self.assertIn("COPY --chown=root:root static/ /root/mww-scripts/static/", source)
self.assertNotIn("npm install", source)
macos_builder = REPO_ROOT / "macos" / "WakeWordTrainer" / "scripts" / "build_app.sh"
if macos_builder.exists():
source = macos_builder.read_text(encoding="utf-8")
self.assertIn("--exclude='frontend/node_modules/'", source)
self.assertNotIn("--exclude='static/'", source)
if __name__ == "__main__":
unittest.main()

View File

@@ -5,7 +5,7 @@ PROGPATH=$(realpath "$0")
PROGDIR=$(dirname "${PROGPATH}")
CLIDIR="${PROGDIR}/cli"
KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language )
KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language tts-mode tts-voice-count )
source "${CLIDIR}/shell.functions"
WAKE_WORD=${POSITIONAL_ARGS[0]}
@@ -19,6 +19,8 @@ if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then
Usage: train_wake_word [ --samples=<samples> ] [ --batch-size=<batch_size> ]
[ --training-steps=<steps> ] [ --cleanup-work-dir ]
[ --language=<lang> ]
[ --tts-mode=<modern|hybrid|piper> ]
[ --tts-voice-count=<voices> ]
<wake_word> [ <wake_word_title> ]
Options:
@@ -39,6 +41,12 @@ Options:
--language: Language for TTS voice selection (e.g. "en", "nl").
Default: ${DEFAULT_LANGUAGE}
--tts-mode: TTS source: modern (OmniVoice plus Qwen3/MOSS where
supported), hybrid (modern plus Piper), or piper.
Default: ${DEFAULT_TTS_MODE}
--tts-voice-count: Deprecated compatibility option; direct generation ignores it.
<wake_word> The word to train spelled phonetically.
Required.
@@ -116,6 +124,8 @@ export GRPC_VERBOSITY=ERROR
--samples=${SAMPLES} \
--batch-size=${BATCH_SIZE} \
--language="${LANGUAGE}" \
--tts-mode="${TTS_MODE}" \
--tts-voice-count="${TTS_VOICE_COUNT}" \
--data-dir="${DATA_DIR}" "${WAKE_WORD}"
POST_GEN_TS=$EPOCHSECONDS

File diff suppressed because it is too large Load Diff

221
tts_config.py Normal file
View File

@@ -0,0 +1,221 @@
"""Shared modern-TTS catalog and routing helpers.
This module intentionally has no third-party dependencies. It is imported by
the web server, the shell-facing generator, and unit tests before any of the
large model environments have been installed.
"""
from __future__ import annotations
from typing import Iterable
TTS_MODE_MODERN = "modern"
TTS_MODE_HYBRID = "hybrid"
TTS_MODE_PIPER = "piper"
TTS_MODES = (TTS_MODE_MODERN, TTS_MODE_HYBRID, TTS_MODE_PIPER)
DEFAULT_TTS_MODE = TTS_MODE_HYBRID
ENGINE_OMNIVOICE = "omnivoice"
ENGINE_QWEN3 = "qwen3"
ENGINE_MOSS = "moss"
ENGINE_PIPER = "piper"
MODERN_ENGINES = (ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS)
# Friendly/common codes whose OmniVoice IDs follow the model's catalog IDs.
OMNIVOICE_LANGUAGE_ALIASES = {
"ar": "arb", # Standard Arabic
"ne": "npi", # Nepali
}
QWEN_LANGUAGES = {
"zh": "Chinese",
"en": "English",
"ja": "Japanese",
"ko": "Korean",
"de": "German",
"fr": "French",
"ru": "Russian",
"pt": "Portuguese",
"es": "Spanish",
"it": "Italian",
}
# The upstream MOSS-TTS-Nano README calls this a 20-language list, although
# the published table currently contains the 19 concrete entries below.
MOSS_LANGUAGES = {
"zh": "Chinese",
"en": "English",
"de": "German",
"es": "Spanish",
"fr": "French",
"ja": "Japanese",
"it": "Italian",
"hu": "Hungarian",
"ko": "Korean",
"ru": "Russian",
"fa": "Persian (Farsi)",
"ar": "Arabic",
"pl": "Polish",
"pt": "Portuguese",
"cs": "Czech",
"da": "Danish",
"sv": "Swedish",
"el": "Greek",
"tr": "Turkish",
}
# Used when the live OmniVoice catalog has not been downloaded yet. The web
# server expands this to the full upstream catalog (currently 646 languages)
# and persists it under /data/.cache.
COMMON_OMNIVOICE_LANGUAGES = {
**QWEN_LANGUAGES,
**MOSS_LANGUAGES,
"af": "Afrikaans",
"am": "Amharic",
"as": "Assamese",
"az": "Azerbaijani",
"be": "Belarusian",
"bg": "Bulgarian",
"bn": "Bengali",
"bs": "Bosnian",
"ca": "Catalan",
"cy": "Welsh",
"et": "Estonian",
"eu": "Basque",
"fi": "Finnish",
"fil": "Filipino",
"gl": "Galician",
"gu": "Gujarati",
"he": "Hebrew",
"hi": "Hindi",
"hr": "Croatian",
"hy": "Armenian",
"id": "Indonesian",
"ka": "Georgian",
"kk": "Kazakh",
"lt": "Lithuanian",
"lv": "Latvian",
"mk": "Macedonian",
"ml": "Malayalam",
"mr": "Marathi",
"ms": "Malay",
"my": "Burmese",
"ne": "Nepali",
"nl": "Dutch",
"no": "Norwegian",
"pa": "Punjabi",
"ro": "Romanian",
"sk": "Slovak",
"sl": "Slovenian",
"sq": "Albanian",
"sr": "Serbian",
"sw": "Swahili",
"ta": "Tamil",
"te": "Telugu",
"th": "Thai",
"uk": "Ukrainian",
"ur": "Urdu",
"vi": "Vietnamese",
"yue": "Cantonese",
"yo": "Yoruba",
"zu": "Zulu",
}
QWEN_LANGUAGE_NAMES = {code: name for code, name in QWEN_LANGUAGES.items()}
def parse_omnivoice_catalog(markdown: str) -> dict[str, dict[str, object]]:
"""Parse the upstream Markdown language table without third-party packages."""
entries: dict[str, dict[str, object]] = {}
for line in str(markdown or "").splitlines():
if not line.lstrip().startswith("|"):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) < 5 or not cells[0].isdigit():
continue
name, code, iso_code, duration_text = cells[1:5]
code = code.strip().lower().replace("-", "_")
if not code or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_" for character in code):
continue
try:
duration_hours = float(duration_text.replace(",", ""))
except ValueError:
duration_hours = 0.0
entries[code] = {
"name": name or code.upper(),
"iso_639_3": iso_code,
"duration_hours": duration_hours,
}
return entries
def normalize_tts_mode(value: object) -> str:
token = str(value or DEFAULT_TTS_MODE).strip().lower().replace("-", "_")
return token if token in TTS_MODES else DEFAULT_TTS_MODE
def language_for_engine(engine: str, language: str) -> str:
code = str(language or "en").strip().lower().replace("-", "_")
if engine == ENGINE_OMNIVOICE:
return OMNIVOICE_LANGUAGE_ALIASES.get(code, code)
return code
def modern_engines_for_language(language: str) -> list[str]:
"""Return modern engines ordered from broadest to most specialized."""
code = str(language or "en").strip().lower().replace("-", "_")
engines = [ENGINE_OMNIVOICE]
if code in QWEN_LANGUAGES:
engines.append(ENGINE_QWEN3)
if code in MOSS_LANGUAGES:
engines.append(ENGINE_MOSS)
return engines
def engines_for_language(
language: str,
mode: object = DEFAULT_TTS_MODE,
*,
piper_available: bool = False,
) -> list[str]:
selected_mode = normalize_tts_mode(mode)
if selected_mode == TTS_MODE_PIPER:
return [ENGINE_PIPER] if piper_available else []
engines = modern_engines_for_language(language)
if selected_mode == TTS_MODE_HYBRID and piper_available:
engines.append(ENGINE_PIPER)
return engines
def quality_for_engines(engines: Iterable[str]) -> str:
engine_set = set(engines)
if ENGINE_QWEN3 in engine_set and ENGINE_MOSS in engine_set:
return "recommended"
if ENGINE_MOSS in engine_set:
return "supported"
if ENGINE_OMNIVOICE in engine_set:
return "experimental"
return "legacy"
def distribute_samples(total: int, engines: Iterable[str]) -> dict[str, int]:
"""Distribute an exact sample total as evenly as possible."""
ordered = list(dict.fromkeys(str(engine) for engine in engines if engine))
if total < 0:
raise ValueError("total must be non-negative")
if not ordered:
if total:
raise ValueError("at least one engine is required")
return {}
quotient, remainder = divmod(total, len(ordered))
return {
engine: quotient + (1 if index < remainder else 0)
for index, engine in enumerate(ordered)
}