10 Commits
v9 ... v19

Author SHA1 Message Date
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
15 changed files with 3090 additions and 3586 deletions

View File

@@ -26,6 +26,18 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -94,6 +106,7 @@ jobs:
title="microWakeWord Nvidia Trainer ${TAG_NAME}" title="microWakeWord Nvidia Trainer ${TAG_NAME}"
generated_notes="$(mktemp)" generated_notes="$(mktemp)"
release_notes="$(mktemp)" release_notes="$(mktemp)"
test -s WHATS_NEW.md
gh api "repos/${REPO}/releases/generate-notes" \ gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="${TAG_NAME}" \ -f tag_name="${TAG_NAME}" \
@@ -101,6 +114,11 @@ jobs:
--jq '.body' > "${generated_notes}" --jq '.body' > "${generated_notes}"
{ {
echo "## What's New"
echo
cat WHATS_NEW.md
echo
echo
echo "## Docker Images" echo "## Docker Images"
echo echo
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}\`" echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}\`"

132
README.md
View File

@@ -7,7 +7,7 @@
<a href="https://taterassistant.com">taterassistant.com</a> <a href="https://taterassistant.com">taterassistant.com</a>
</h3> </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 prebuilt Tater firmware flashing. Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, 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. 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.
@@ -22,15 +22,17 @@ docker pull ghcr.io/tatertotterson/microwakeword:latest
Tagged releases also publish matching immutable image tags: Tagged releases also publish matching immutable image tags:
```bash ```bash
docker pull ghcr.io/tatertotterson/microwakeword:v5 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 RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
Python 3.13 TensorFlow build for `sm_120`: Python 3.13 TensorFlow build for `sm_120`:
```bash ```bash
docker pull ghcr.io/tatertotterson/microwakeword:blackwell docker pull ghcr.io/tatertotterson/microwakeword:blackwell
docker pull ghcr.io/tatertotterson/microwakeword:v5-blackwell docker pull ghcr.io/tatertotterson/microwakeword:v17-blackwell
``` ```
Use the Blackwell image only for RTX 50-series cards. It includes the Use the Blackwell image only for RTX 50-series cards. It includes the
@@ -51,19 +53,19 @@ docker run -d \
ghcr.io/tatertotterson/microwakeword:latest ghcr.io/tatertotterson/microwakeword:latest
``` ```
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v5` when you want to pin a known release instead of tracking `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` For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v5-blackwell` or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v17-blackwell`
in the same `docker run` command. in the same `docker run` command.
The flags: The flags:
- `--gpus all` enables GPU acceleration. - `--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. - `-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: Open:
@@ -71,31 +73,38 @@ Open:
http://localhost:8789 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 ## What The UI Does
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training. - `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. - `Samples` plays, removes, clears, and manually imports personal or negative samples.
- `Firmware` pulls verified prebuilt Tater firmware images from GitHub and flashes supported satellites over OTA. - `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater.
- Popup consoles show colorized training and firmware logs while long-running jobs are active. - Popup consoles show colorized training logs while long-running jobs are active.
--- ---
## Captured Audio Workflow ## 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 pull verified prebuilt OTA images from that repo for fast firmware updates. 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 Wake Audio` toggles upload of wake-word triggers.
- `Capture Close Misses` toggles upload of near misses. - `Capture Close Misses` toggles upload of near misses.
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`. - `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 ```text
/api/upload_captured_audio_raw /api/upload_captured_audio_raw
@@ -156,6 +165,34 @@ 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 ## Training Flow
1. Enter the wake phrase in `Trainer`. 1. Enter the wake phrase in `Trainer`.
@@ -201,20 +238,17 @@ After those assets are prepared, later runs reuse the local copies unless the mo
--- ---
## Firmware Flashing ## Trained Wake Words
The `Firmware` tab flashes prebuilt Tater firmware for supported ESPHome satellites. The `Wake Words` tab lists locally trained wake-word packages from `/data/trained_wake_words/`.
- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from `TaterTotterson/microWakeWords`. - Copy the JSON URL into the Tater Native satellite settings to switch wake words live.
- Verifies downloaded images by size and SHA before upload. - 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.
- Auto-detects ESPHome devices with mDNS when the container is running with host networking. - Open the JSON or model links directly for quick inspection.
- Allows manual IP or hostname entry if discovery does not find the device. - The JSON includes the matching model path plus Tater tuning metadata.
- Saves the selected OTA target for each firmware family. - No firmware flashing happens from this trainer app anymore.
- Flashes the prebuilt factory image over Browser USB for first installs or recovery when opened in Chrome or Edge.
- Lists locally trained wake words from `/data/trained_wake_words/` for live model switching.
- Streams download, verification, and OTA upload progress in a colorized firmware console.
You usually only flash for firmware updates. New satellites, or devices older than Tater firmware `3.0.3`, need one USB flash first before OTA updates and live wake-word switching are available. Use the main Tater app for satellite firmware updates and USB flashing.
--- ---
@@ -227,14 +261,51 @@ Successful runs produce timestamped training output folders such as:
/data/output/<timestamp>-<wake_word>-<samples>-<steps>/<wake_word>.json /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 ```text
/data/trained_wake_words/<wake_word>.tflite /data/trained_wake_words/<wake_word>.tflite
/data/trained_wake_words/<wake_word>.json /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`.
--- ---
@@ -251,7 +322,7 @@ That removes:
- cached datasets - cached datasets
- training environments - training environments
- trained models - trained models
- downloaded firmware images - Auto Training settings, state, transcripts, and cached Faster Whisper models
--- ---
@@ -259,9 +330,10 @@ That removes:
- Personal samples are optional. - Personal samples are optional.
- Negative samples are optional but useful for reducing false wakes. - 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 UI server is `trainer_server.py`.
- The launcher is `run.sh`. - 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.
--- ---

1
VERSION Normal file
View File

@@ -0,0 +1 @@
19

3
WHATS_NEW.md Normal file
View File

@@ -0,0 +1,3 @@
- Fixed first-run Parakeet ONNX setup failing when its empty model directory was mistaken for a complete offline model.
- Parakeet now downloads or resumes the required INT8 snapshot before loading through ONNX ASR.
- Complete local snapshots are reused without Hub access, preserving offline startup after the initial download.

View File

@@ -9,24 +9,22 @@ import math
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Iterable, Sequence from typing import Any, Iterable, Sequence
import numpy as np import numpy as np
import yaml import yaml
from microwakeword.data import FeatureHandler DEFAULT_WINDOW_SIZES = [5, 6, 7]
from microwakeword.inference import Model DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "0.25"))
DEFAULT_WINDOW_SIZES = [3, 4, 5, 6, 7]
DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "1.0"))
DEFAULT_COOLDOWN_SLICES = int(os.environ.get("MWW_CALIBRATION_COOLDOWN_SLICES", "25")) DEFAULT_COOLDOWN_SLICES = int(os.environ.get("MWW_CALIBRATION_COOLDOWN_SLICES", "25"))
DEFAULT_POSITIVE_SKIP_SLICES = int( DEFAULT_POSITIVE_SKIP_SLICES = int(
os.environ.get("MWW_CALIBRATION_POSITIVE_SKIP_SLICES", "25") os.environ.get("MWW_CALIBRATION_POSITIVE_SKIP_SLICES", "25")
) )
DEFAULT_CUTOFF_STEP = float(os.environ.get("MWW_CALIBRATION_CUTOFF_STEP", "0.01")) 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_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: def parse_args() -> argparse.Namespace:
@@ -65,6 +63,15 @@ def parse_args() -> argparse.Namespace:
default=DEFAULT_TARGET_FAPH, default=DEFAULT_TARGET_FAPH,
help="Target ambient false accepts per hour for the selected operating point.", 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( parser.add_argument(
"--cooldown-slices", "--cooldown-slices",
type=int, type=int,
@@ -159,7 +166,13 @@ def _compute_false_accepts_per_hour(
def _select_best_candidate( def _select_best_candidate(
candidates: list[dict[str, float]], candidates: list[dict[str, float]],
target_faph: float, target_faph: float,
recall_margin: float = DEFAULT_RECALL_MARGIN,
) -> tuple[dict[str, float], float]: ) -> 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 = [ fallback_limits = [
target_faph, target_faph,
max(target_faph * 2.0, target_faph + 0.5), max(target_faph * 2.0, target_faph + 0.5),
@@ -172,13 +185,27 @@ def _select_best_candidate(
return index return index
return len(fallback_limits) 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( best = min(
candidates, viable_candidates,
key=lambda candidate: ( key=lambda candidate: (
tier(candidate),
-candidate["recall"],
candidate["false_accepts_per_hour"], candidate["false_accepts_per_hour"],
abs(candidate["sliding_window_size"] - 5), -candidate["recall"],
abs(candidate["sliding_window_size"] - PREFERRED_WINDOW_SIZE),
-candidate["probability_cutoff"], -candidate["probability_cutoff"],
), ),
) )
@@ -195,7 +222,7 @@ def _load_config(config_path: Path) -> dict:
def _load_eval_sets( def _load_eval_sets(
handler: FeatureHandler, handler: Any,
config: dict, config: dict,
) -> tuple[str, str, list[np.ndarray], list[np.ndarray]]: ) -> tuple[str, str, list[np.ndarray], list[np.ndarray]]:
for positive_mode, ambient_mode in ( for positive_mode, ambient_mode in (
@@ -228,7 +255,7 @@ def _load_eval_sets(
def _predict_tracks( def _predict_tracks(
model: Model, model: Any,
tracks: Sequence[np.ndarray], tracks: Sequence[np.ndarray],
label: str, label: str,
) -> list[np.ndarray]: ) -> list[np.ndarray]:
@@ -244,8 +271,13 @@ def _predict_tracks(
def main() -> int: def main() -> int:
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
args = parse_args() args = parse_args()
window_sizes = _parse_window_sizes(args.window_sizes) 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: if args.cutoff_step <= 0:
raise ValueError("cutoff-step must be > 0") raise ValueError("cutoff-step must be > 0")
if args.cutoff_max < args.cutoff_min: if args.cutoff_max < args.cutoff_min:
@@ -276,6 +308,10 @@ def main() -> int:
f"→ Evaluating window sizes {window_sizes} with target <= " f"→ Evaluating window sizes {window_sizes} with target <= "
f"{args.target_faph:.2f} false accepts/hour" 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 = _load_config(config_path)
config["flags"] = config.get("flags", {}) config["flags"] = config.get("flags", {})
@@ -338,7 +374,11 @@ def main() -> int:
candidates.append(candidate) candidates.append(candidate)
window_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) best_by_window.append(best_window)
print( print(
" window={window}: cutoff={cutoff:.2f}; recall={recall:.2%}; " " 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: if best["false_accepts_per_hour"] > args.target_faph + 1e-9:
print( print(
"⚠️ No candidate met the target false accepts/hour budget; " "⚠️ No candidate met the target false accepts/hour budget; "
@@ -390,6 +434,8 @@ def main() -> int:
"cutoff_min": round(float(cutoffs[0]), 4), "cutoff_min": round(float(cutoffs[0]), 4),
"cutoff_max": round(float(cutoffs[-1]), 4), "cutoff_max": round(float(cutoffs[-1]), 4),
"cutoff_step": float(args.cutoff_step), "cutoff_step": float(args.cutoff_step),
"recall_margin": float(args.recall_margin),
"preferred_window_size": PREFERRED_WINDOW_SIZE,
}, },
"per_window_best": best_by_window, "per_window_best": best_by_window,
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),

View File

@@ -103,7 +103,8 @@ else
if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then if [ "${actual_filecount}" -eq 0 ] || [ "${actual_filecount}" -ne "${expected_filecount}" ] ; then
if [ ! -f "${AUDIO_ZIP}" ] ; then if [ ! -f "${AUDIO_ZIP}" ] ; then
echo " Downloading ${AUDIO_ZIPFILE}" 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 fi
rm -rf "${AUDIO_DIR}" || : rm -rf "${AUDIO_DIR}" || :

View File

@@ -302,11 +302,11 @@ TRAIN_ARGS=(
--test_tflite_streaming_quantized 1 --test_tflite_streaming_quantized 1
--use_weights best_weights --use_weights best_weights
mixednet mixednet
--pointwise_filters "64,64,64,64" --pointwise_filters "128,128,128,128"
--repeat_in_block "1,1,1,1" --repeat_in_block "1,1,1,1"
--mixconv_kernel_sizes "[5], [7,11], [9,15], [23]" --mixconv_kernel_sizes "[5], [7,11], [9,15], [23]"
--residual_connection "0,0,0,0" --residual_connection "0,0,0,0"
--first_conv_filters 32 --first_conv_filters 64
--first_conv_kernel_size 5 --first_conv_kernel_size 5
--stride 2 --stride 2
) )
@@ -386,6 +386,7 @@ fi
TRAINING_DONE="false" TRAINING_DONE="false"
echo "🏋️ Starting model training and TFLite export (this is the longest stage)…" 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 if run_attempt "Attempt 1/3: GPU training (default runtime profile)" ; then
echo "✅ Training complete (GPU path)." echo "✅ Training complete (GPU path)."
TRAINING_DONE="true" TRAINING_DONE="true"
@@ -466,7 +467,12 @@ echo "🎯 Calibrating detector settings for on-device use…"
if "${PYTHON_BIN:-python}" "${PROGDIR}/calibrate_detector.py" \ if "${PYTHON_BIN:-python}" "${PROGDIR}/calibrate_detector.py" \
--training-config "${WORK_DIR}/trained_models/wakeword/training_config.yaml" \ --training-config "${WORK_DIR}/trained_models/wakeword/training_config.yaml" \
--model "${source_path}" \ --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." echo "✅ Detector calibration complete."
else else
echo "⚠️ Detector calibration failed; packaging with default detector settings." echo "⚠️ Detector calibration failed; packaging with default detector settings."
@@ -496,7 +502,9 @@ json_path = Path(os.environ["JSON_PATH"])
calibration_path = Path(os.environ.get("CALIBRATION_PATH", "")) calibration_path = Path(os.environ.get("CALIBRATION_PATH", ""))
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower() language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
probability_cutoff = 0.97 probability_cutoff = 0.97
sliding_window_size = 5 sliding_window_size = 6
strict_min_close_miss_threshold = 0.68
calibration = {}
if calibration_path.exists(): if calibration_path.exists():
try: try:
@@ -510,21 +518,63 @@ if calibration_path.exists():
except Exception as exc: except Exception as exc:
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.") 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 = { meta = {
"type": "micro", "type": "micro",
"wake_word": os.environ["WAKE_WORD_TITLE"], "wake_word": os.environ["WAKE_WORD_TITLE"],
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
"author": "Tater Totterson", "author": "Tater Totterson",
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git", "website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
"model": os.environ["TFLITE_FILENAME"], "model": os.environ["TFLITE_FILENAME"],
"trained_languages": [language], "trained_languages": [language],
"version": 2, "version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": { "micro": {
"probability_cutoff": round(probability_cutoff, 2), "probability_cutoff": probability_cutoff,
"sliding_window_size": sliding_window_size, "sliding_window_size": sliding_window_size,
"feature_step_size": 10, "feature_step_size": 10,
"tensor_arena_size": 30000, "tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0", "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") json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
PY PY

View File

@@ -6,7 +6,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# System deps # System deps
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3.12 python3.12-venv python3.12-dev python3-pip python-is-python3 \ python3.12 python3.12-venv python3.12-dev python3-pip python-is-python3 \
git wget curl unzip patch ninja-build ca-certificates nano less \ git wget curl unzip patch ninja-build ca-certificates nano less libgomp1 \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data && mkdir -p /data

View File

@@ -13,7 +13,7 @@ ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell-
# Python 3.13 is used only for the Blackwell TensorFlow training step. # Python 3.13 is used only for the Blackwell TensorFlow training step.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git wget unzip patch \ software-properties-common ca-certificates curl git wget unzip patch \
ninja-build nano less \ ninja-build nano less libgomp1 \
&& add-apt-repository -y ppa:deadsnakes/ppa \ && add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \

59
run.sh
View File

@@ -30,9 +30,14 @@ install_ui_deps() {
"fastapi==${FASTAPI_VERSION}" \ "fastapi==${FASTAPI_VERSION}" \
"uvicorn[standard]==${UVICORN_VERSION}" \ "uvicorn[standard]==${UVICORN_VERSION}" \
"python-multipart==${PY_MULTIPART_VERSION}" \ "python-multipart==${PY_MULTIPART_VERSION}" \
"zeroconf>=0.132.2" \
"silero-vad>=5.0.0" \ "silero-vad>=5.0.0" \
"numpy>=1.24.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"
} }
# ----------------------------- # -----------------------------
@@ -79,9 +84,15 @@ exact = {
minimum = { minimum = {
"silero-vad": "5.0.0", "silero-vad": "5.0.0",
"numpy": "1.24.0", "numpy": "1.24.0",
"zeroconf": "0.132.2", "faster-whisper": "1.0.0",
"onnx-asr": "0.12.0",
"nvidia-cudnn-cu12": "9.0.0",
} }
present = ("torch",) present = (
"torch",
"nvidia-cublas-cu12",
"onnxruntime-gpu",
)
for package, expected in exact.items(): for package, expected in exact.items():
if md.version(package) != expected: if md.version(package) != expected:
@@ -91,12 +102,52 @@ for package, minimum_version in minimum.items():
raise SystemExit(1) raise SystemExit(1)
for package in present: for package in present:
md.version(package) md.version(package)
import onnxruntime as ort
if "CUDAExecutionProvider" not in ort.get_available_providers():
raise SystemExit(1)
PY PY
then then
echo "UI dependencies missing or stale; installing recorder dependencies" echo "UI dependencies missing or stale; installing recorder dependencies"
install_ui_deps install_ui_deps
fi fi
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 # Trainer server env
# ----------------------------- # -----------------------------

File diff suppressed because it is too large Load Diff

607
tests/test_auto_train.py Normal file
View File

@@ -0,0 +1,607 @@
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_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_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] / "static" / "index.html").read_text(
encoding="utf-8"
)
self.assertIn('id="autoSttEngine"', source)
self.assertNotIn('id="autoSttModel"', source)
self.assertNotIn('id="autoSttDevice"', source)
self.assertNotIn('id="autoSttComputeType"', 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_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_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()

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

File diff suppressed because it is too large Load Diff