5 Commits
v13 ... v18

Author SHA1 Message Date
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
9 changed files with 990 additions and 171 deletions

View File

@@ -22,7 +22,7 @@ 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:v13 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. The release tag must match `VERSION`. Update `WHATS_NEW.md` before tagging; the Docker workflow prepends it to GitHub's automatically generated release notes.
@@ -32,7 +32,7 @@ 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:v13-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
@@ -53,9 +53,9 @@ docker run -d \
ghcr.io/tatertotterson/microwakeword:latest ghcr.io/tatertotterson/microwakeword:latest
``` ```
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v13` 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:v13-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:
@@ -171,7 +171,7 @@ Starting a new session does not clear samples. Use the clear buttons in `Samples
For each new wake-trigger clip sent to the trainer: For each new wake-trigger clip sent to the trainer:
1. Faster Whisper transcribes the audio locally. 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. 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. 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. 4. Empty transcripts, VAD-blocked captures, and captures for another wake word stay out of the automatic negative path.
@@ -183,13 +183,13 @@ Two optional cleanup rules are available:
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. 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.
The default `small.en` model uses CUDA with `float16` when CTranslate2 can see an NVIDIA GPU, and falls back to CPU with `int8`. Choose a multilingual Faster Whisper model such as `small` when the wake phrase is not English. Downloaded STT models are cached in `/data/auto_train_models/`. 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 publishes the replacement model at the same wake-word URL and can call Tater's native satellite settings API to make connected satellites pull it again. This refresh uses the existing Tater Native update path, so no satellite firmware change is required. 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 `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. The optional API token is stored in `/data/auto_train_config.json` with owner-only permissions. 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.
--- ---

View File

@@ -1 +1 @@
13 18

View File

@@ -1,6 +1,3 @@
- Added opt-in Auto Training for false-positive wake triggers, using Faster Whisper with automatic CUDA/float16 selection and CPU/int8 fallback. - Added Parakeet ONNX as a second local Auto Training STT engine alongside Faster Whisper.
- Wake triggers whose transcripts do not contain the configured phrase can now become hard negatives automatically; empty transcripts and phrase matches remain available for manual review unless optional cleanup is enabled. - Replaced manual model, device, and compute fields with a simple engine selector and managed language-aware models.
- Added optional confirmed-good-wake cleanup and conservative close-miss recovery that promotes only VAD-approved, STT-confirmed phrase matches to positive samples. - Added CUDA-enabled ONNX Runtime with CPU fallback, runtime reporting, and model-cache cleanup when switching engines.
- Added scheduled retraining with a minimum-new-negatives threshold and automatic Tater Native satellite refresh after a successful model build.
- Wake-word download links now advertise a LAN-reachable trainer URL instead of `127.0.0.1`.
- Tightened detector calibration defaults to favor fewer ambient false accepts while preserving candidates within 0.5 percentage points of the best recall.

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}" || :

47
run.sh
View File

@@ -33,8 +33,11 @@ install_ui_deps() {
"silero-vad>=5.0.0" \ "silero-vad>=5.0.0" \
"numpy>=1.24.0" \ "numpy>=1.24.0" \
"faster-whisper>=1.0.0" \ "faster-whisper>=1.0.0" \
"onnx-asr[hub]>=0.12.0" \
"nvidia-cublas-cu12" \ "nvidia-cublas-cu12" \
"nvidia-cudnn-cu12==9.*" "nvidia-cudnn-cu12==9.*"
${PIP} uninstall -y onnxruntime
${PIP} install "onnxruntime-gpu[cuda,cudnn]<1.27"
} }
# ----------------------------- # -----------------------------
@@ -82,11 +85,13 @@ minimum = {
"silero-vad": "5.0.0", "silero-vad": "5.0.0",
"numpy": "1.24.0", "numpy": "1.24.0",
"faster-whisper": "1.0.0", "faster-whisper": "1.0.0",
"onnx-asr": "0.12.0",
"nvidia-cudnn-cu12": "9.0.0", "nvidia-cudnn-cu12": "9.0.0",
} }
present = ( present = (
"torch", "torch",
"nvidia-cublas-cu12", "nvidia-cublas-cu12",
"onnxruntime-gpu",
) )
for package, expected in exact.items(): for package, expected in exact.items():
@@ -97,6 +102,10 @@ 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"
@@ -107,19 +116,33 @@ fi
# Faster Whisper/CTranslate2 loads these CUDA libraries before Python starts. # 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. # They live in the persistent UI venv so both Docker image variants can use GPU STT.
WHISPER_CUDA_LIBRARY_PATH="$("${PY}" - <<'PY' WHISPER_CUDA_LIBRARY_PATH="$("${PY}" - <<'PY'
import os from importlib.util import find_spec
from pathlib import Path
try:
import nvidia.cublas.lib def package_directory(name):
import nvidia.cudnn.lib try:
except ImportError: spec = find_spec(name)
print("") except (ImportError, AttributeError, ValueError):
else: return ""
print( if spec is None:
os.path.dirname(nvidia.cublas.lib.__file__) return ""
+ ":"
+ os.path.dirname(nvidia.cudnn.lib.__file__) 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 PY
)" )"
if [[ -n "${WHISPER_CUDA_LIBRARY_PATH}" ]]; then if [[ -n "${WHISPER_CUDA_LIBRARY_PATH}" ]]; then

View File

@@ -1084,6 +1084,43 @@
.trimActions { display: flex; gap: 8px; flex-wrap: wrap; } .trimActions { display: flex; gap: 8px; flex-wrap: wrap; }
.trimActions button { flex: 1; min-width: 120px; } .trimActions button { flex: 1; min-width: 120px; }
.taterLinkOverlay {
position: fixed; inset: 0; padding: 22px;
display: flex; align-items: center; justify-content: center;
background: rgba(4,5,10,0.62); backdrop-filter: blur(12px);
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity 0.18s ease, visibility 0.18s ease;
z-index: 12000;
}
.taterLinkOverlay.open { opacity: 1; visibility: visible; pointer-events: auto; }
.taterLinkDialog {
width: min(560px, calc(100vw - 36px));
display: grid; gap: 18px; padding: 22px; border-radius: 24px;
border: 1px solid rgba(255,138,42,0.28);
background:
radial-gradient(circle at top right, rgba(255,138,42,0.16), transparent 46%),
linear-gradient(180deg, rgba(17,20,28,0.96), rgba(8,10,16,0.98));
box-shadow: 0 30px 90px rgba(0,0,0,0.64);
}
.taterLinkCodePanel {
display: grid; gap: 10px; text-align: center; padding: 24px;
border-radius: 18px; border: 1px solid rgba(255,138,42,0.28);
background: rgba(255,138,42,0.09);
}
.taterLinkCode {
color: var(--orange2); font: 800 clamp(30px, 8vw, 46px)/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.12em;
}
.taterLinkSuccess {
display: grid; justify-items: center; gap: 12px; padding: 28px; text-align: center;
}
.taterLinkSuccessMark {
display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%;
background: rgba(57,212,160,0.15); border: 1px solid rgba(57,212,160,0.42);
color: #6ee0af; font-size: 24px; font-weight: 900;
}
.taterLinkActions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.pill.trimBadge { .pill.trimBadge {
color: #89d4ff; color: #89d4ff;
border-color: rgba(137,212,255,0.25); border-color: rgba(137,212,255,0.25);
@@ -1311,7 +1348,7 @@
</div> </div>
<label class="checkField"> <label class="checkField">
<input id="autoEnabled" type="checkbox" /> <input id="autoEnabled" type="checkbox" />
<span><strong>Enable Auto Training</strong>Eligible wake triggers will be queued for local Faster Whisper transcription.</span> <span><strong>Enable Auto Training</strong>Eligible wake triggers will be queued for transcription with the selected local STT engine.</span>
</label> </label>
<label class="checkField"> <label class="checkField">
<input id="autoDeleteConfirmedWakes" type="checkbox" /> <input id="autoDeleteConfirmedWakes" type="checkbox" />
@@ -1331,26 +1368,10 @@
<input id="autoLanguage" type="text" value="en" placeholder="en" /> <input id="autoLanguage" type="text" value="en" placeholder="en" />
</label> </label>
<label class="field wide"> <label class="field wide">
<strong>Faster Whisper model</strong> <strong>STT engine</strong>
<input id="autoSttModel" type="text" value="small.en" placeholder="small.en" /> <select id="autoSttEngine">
</label> <option value="faster_whisper" selected>Faster Whisper (recommended)</option>
<label class="field"> <option value="parakeet_onnx">Parakeet ONNX</option>
<strong>STT device</strong>
<select id="autoSttDevice">
<option value="auto" selected>Auto (prefer CUDA)</option>
<option value="cuda">CUDA</option>
<option value="cpu">CPU</option>
</select>
</label>
<label class="field">
<strong>Compute type</strong>
<select id="autoSttComputeType">
<option value="auto" selected>Auto (float16 CUDA / int8 CPU)</option>
<option value="float16">float16</option>
<option value="int8_float16">int8_float16</option>
<option value="int8">int8</option>
<option value="float32">float32</option>
<option value="default">CTranslate2 default</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
@@ -1399,8 +1420,8 @@
<div class="studioPanelTitle"> <div class="studioPanelTitle">
<span class="studioStepBadge">3</span> <span class="studioStepBadge">3</span>
<div> <div>
<h3>Publish + Satellite Refresh</h3> <h3>Publish to Tater</h3>
<p>The trainer publishes a LAN-reachable model URL, then asks Tater to re-push live settings so the firmware downloads the updated model at the same URL.</p> <p>The trainer publishes a LAN-reachable model URL, then tells Tater to make the newly trained wake word active on every satellite.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1414,22 +1435,15 @@
<strong>Tater URL</strong> <strong>Tater URL</strong>
<input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" /> <input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" />
</label> </label>
<label class="field"> </div>
<strong>Satellite selector (optional)</strong> <div class="taterLinkActions">
<input id="autoTaterSelector" type="text" placeholder="Blank refreshes all connected sats" /> <span id="autoTaterLinkStatus" class="pill">Not linked</span>
</label> <button id="autoLinkTaterBtn" class="primary" type="button">Link Tater</button>
<label class="field"> <button id="autoUnlinkTaterBtn" class="danger" type="button" hidden>Unlink</button>
<strong>Tater API token (if enabled)</strong>
<input id="autoTaterToken" type="password" placeholder="Not configured" autocomplete="off" />
</label>
</div> </div>
<label class="checkField"> <label class="checkField">
<input id="autoNotifySatellites" type="checkbox" checked /> <input id="autoNotifySatellites" type="checkbox" checked />
<span><strong>Refresh satellites after successful training</strong>Uses Tater's existing native satellite settings API.</span> <span><strong>Activate the new word after successful training</strong>Tater applies it globally and updates every connected satellite.</span>
</label>
<label id="autoClearTokenRow" class="checkField" hidden>
<input id="autoClearTaterToken" type="checkbox" />
<span><strong>Clear the saved Tater token</strong>The token is otherwise preserved when the password field is blank.</span>
</label> </label>
</section> </section>
@@ -1438,7 +1452,7 @@
<button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button> <button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button>
<button id="autoReviewNowBtn" type="button">Review inbox now</button> <button id="autoReviewNowBtn" type="button">Review inbox now</button>
<button id="autoTrainNowBtn" type="button">Train now</button> <button id="autoTrainNowBtn" type="button">Train now</button>
<button id="autoNotifyNowBtn" type="button">Refresh satellites now</button> <button id="autoNotifyNowBtn" type="button">Publish current wake word now</button>
</div> </div>
<div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div> <div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div>
</section> </section>
@@ -1681,6 +1695,22 @@
</div> </div>
</div> </div>
<div id="taterLinkOverlay" class="taterLinkOverlay" aria-hidden="true">
<div class="taterLinkDialog" role="dialog" aria-modal="true" aria-labelledby="taterLinkTitle">
<div class="trimHeader">
<div>
<h3 id="taterLinkTitle" class="trimTitle">Link Tater</h3>
<p id="taterLinkHint" class="trimHint">Enter the short-lived code shown in Tater Voice Settings.</p>
</div>
<button id="closeTaterLinkBtn" type="button">Close</button>
</div>
<div id="taterLinkBody">
<div class="emptyState">Enter the secure pairing code from Tater.</div>
</div>
<div id="taterLinkModalStatus" class="muted">Waiting for the Tater code.</div>
</div>
</div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -2038,15 +2068,14 @@
const config = data.config || {}; const config = data.config || {};
const state = data.state || {}; const state = data.state || {};
const runtime = data.runtime || {}; const runtime = data.runtime || {};
const trainerLink = data.trainer_link || {};
uiState.autoTrain = data; uiState.autoTrain = data;
if (populateForm) { if (populateForm) {
$("autoEnabled").checked = Boolean(config.enabled); $("autoEnabled").checked = Boolean(config.enabled);
$("autoWakePhrase").value = config.wake_phrase || uiState.session?.raw_phrase || ""; $("autoWakePhrase").value = config.wake_phrase || uiState.session?.raw_phrase || "";
$("autoLanguage").value = config.language || uiState.session?.language || "en"; $("autoLanguage").value = config.language || uiState.session?.language || "en";
$("autoSttModel").value = config.stt_model || "small.en"; $("autoSttEngine").value = config.stt_engine || "faster_whisper";
$("autoSttDevice").value = config.stt_device || "auto";
$("autoSttComputeType").value = config.stt_compute_type || "auto";
$("autoMinimumChars").value = String(config.minimum_transcript_chars ?? 2); $("autoMinimumChars").value = String(config.minimum_transcript_chars ?? 2);
$("autoDeleteConfirmedWakes").checked = Boolean(config.delete_confirmed_wakes); $("autoDeleteConfirmedWakes").checked = Boolean(config.delete_confirmed_wakes);
$("autoPromoteCloseMisses").checked = Boolean(config.promote_close_misses); $("autoPromoteCloseMisses").checked = Boolean(config.promote_close_misses);
@@ -2054,14 +2083,18 @@
$("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3); $("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3);
$("autoAdvertisedUrl").value = config.advertised_base_url || ""; $("autoAdvertisedUrl").value = config.advertised_base_url || "";
$("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501"; $("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501";
$("autoTaterSelector").value = config.tater_selector || "";
$("autoTaterToken").value = "";
$("autoTaterToken").placeholder = config.tater_api_token_configured ? "Saved token (leave blank to keep)" : "Not configured";
$("autoNotifySatellites").checked = config.notify_satellites !== false; $("autoNotifySatellites").checked = config.notify_satellites !== false;
$("autoClearTokenRow").hidden = !config.tater_api_token_configured;
$("autoClearTaterToken").checked = false;
} }
const taterLinked = Boolean(trainerLink.linked);
setPill(
$("autoTaterLinkStatus"),
taterLinked ? `Linked${trainerLink.tater_name ? ` to ${trainerLink.tater_name}` : ""}` : "Not linked",
taterLinked ? "ok" : "warn"
);
$("autoLinkTaterBtn").textContent = taterLinked ? "Relink Tater" : "Link Tater";
$("autoUnlinkTaterBtn").hidden = !taterLinked;
$("autoDetectedUrl").textContent = config.advertised_base_url $("autoDetectedUrl").textContent = config.advertised_base_url
? `Using configured URL: ${config.advertised_base_url}` ? `Using configured URL: ${config.advertised_base_url}`
: `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`; : `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`;
@@ -2087,11 +2120,14 @@
if (state.last_review_file) audit.push(state.last_review_file); if (state.last_review_file) audit.push(state.last_review_file);
if (state.last_review_transcript) audit.push(`STT: “${state.last_review_transcript}`); if (state.last_review_transcript) audit.push(`STT: “${state.last_review_transcript}`);
if (state.last_review_error) audit.push(`Error: ${state.last_review_error}`); if (state.last_review_error) audit.push(`Error: ${state.last_review_error}`);
if (state.last_stt_device) audit.push(`STT runtime: ${state.last_stt_device} / ${state.last_stt_compute_type || "default"}`); if (state.last_stt_engine) {
const runtimeLabel = [state.last_stt_device, state.last_stt_compute_type].filter(Boolean).join(" / ");
audit.push(`STT engine: ${String(state.last_stt_engine).replaceAll("_", " ")}${runtimeLabel ? ` · ${runtimeLabel}` : ""}`);
}
if (state.last_notify_at) { if (state.last_notify_at) {
audit.push(state.last_notify_error audit.push(state.last_notify_error
? `Satellite refresh failed: ${state.last_notify_error}` ? `Wake-word publish failed: ${state.last_notify_error}`
: `Satellite refresh: ${state.last_notify_count ?? "requested"} connected at ${formatTimestamp(state.last_notify_at)}`); : `Wake word published to ${state.last_notify_count ?? "all"} connected satellite(s) at ${formatTimestamp(state.last_notify_at)}`);
} }
$("autoAudit").textContent = audit.join(" · ") || "No automatic review has run yet."; $("autoAudit").textContent = audit.join(" · ") || "No automatic review has run yet.";
syncButtons(); syncButtons();
@@ -2108,9 +2144,7 @@
enabled: $("autoEnabled").checked, enabled: $("autoEnabled").checked,
wake_phrase: ($("autoWakePhrase").value || "").trim(), wake_phrase: ($("autoWakePhrase").value || "").trim(),
language: ($("autoLanguage").value || "en").trim(), language: ($("autoLanguage").value || "en").trim(),
stt_model: ($("autoSttModel").value || "").trim(), stt_engine: $("autoSttEngine").value || "faster_whisper",
stt_device: $("autoSttDevice").value || "auto",
stt_compute_type: $("autoSttComputeType").value || "auto",
minimum_transcript_chars: Number($("autoMinimumChars").value || 2), minimum_transcript_chars: Number($("autoMinimumChars").value || 2),
delete_confirmed_wakes: $("autoDeleteConfirmedWakes").checked, delete_confirmed_wakes: $("autoDeleteConfirmedWakes").checked,
promote_close_misses: $("autoPromoteCloseMisses").checked, promote_close_misses: $("autoPromoteCloseMisses").checked,
@@ -2118,12 +2152,8 @@
minimum_new_negatives: Number($("autoMinimumNegatives").value || 3), minimum_new_negatives: Number($("autoMinimumNegatives").value || 3),
advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(), advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(),
tater_url: ($("autoTaterUrl").value || "").trim(), tater_url: ($("autoTaterUrl").value || "").trim(),
tater_selector: ($("autoTaterSelector").value || "").trim(),
notify_satellites: $("autoNotifySatellites").checked, notify_satellites: $("autoNotifySatellites").checked,
clear_tater_api_token: $("autoClearTaterToken").checked,
}; };
const token = ($("autoTaterToken").value || "").trim();
if (token) payload.tater_api_token = token;
return payload; return payload;
} }
@@ -2165,7 +2195,7 @@
await refreshSession(); await refreshSession();
pollTraining(); pollTraining();
} else { } else {
setPill($("autoStatus"), `Satellite refresh requested${data.count === null || data.count === undefined ? "" : ` for ${data.count}`}`, "ok"); setPill($("autoStatus"), `Wake word published${data.count === null || data.count === undefined ? "" : ` to ${data.count} satellite(s)`}`, "ok");
} }
return data; return data;
} finally { } finally {
@@ -2174,6 +2204,98 @@
} }
} }
function closeTaterLinkModal() {
$("taterLinkOverlay").classList.remove("open");
$("taterLinkOverlay").setAttribute("aria-hidden", "true");
}
function showTaterLinkSuccess(status) {
const taterName = status?.tater_name ? ` to ${escapeHtml(status.tater_name)}` : "";
$("taterLinkTitle").textContent = "Tater linked";
$("taterLinkHint").textContent = "This trainer can now securely publish wake-word updates.";
$("taterLinkBody").innerHTML = `
<div class="taterLinkSuccess">
<div class="taterLinkSuccessMark" aria-hidden="true">✓</div>
<strong>Successfully linked${taterName}</strong>
<span class="muted">The private link key is stored locally and is never shown again.</span>
</div>
`;
$("taterLinkModalStatus").textContent = "You can close this popup.";
}
async function openTaterLinkModal() {
$("taterLinkTitle").textContent = "Link Tater";
$("taterLinkHint").textContent = "Enter the short-lived code shown in Tater Voice Settings.";
const taterUrl = ($("autoTaterUrl").value || "http://127.0.0.1:8501").trim();
$("taterLinkBody").innerHTML = `
<div class="stack">
<label class="field">
<strong>Tater address</strong>
<input id="taterLinkUrl" type="text" value="${escapeAttr(taterUrl)}" placeholder="http://127.0.0.1:8501" />
</label>
<div class="taterLinkCodePanel">
<span class="muted">Tater pairing code</span>
<input id="taterLinkCode" class="taterLinkCode" type="text" inputmode="text" autocomplete="off"
maxlength="9" placeholder="ABCD-EFGH" />
<span class="muted">In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.</span>
</div>
<button id="claimTaterLinkBtn" class="primary" type="button">Link Tater</button>
</div>
`;
$("taterLinkModalStatus").textContent = "Waiting for the Tater code.";
$("taterLinkOverlay").classList.add("open");
$("taterLinkOverlay").setAttribute("aria-hidden", "false");
const codeInput = $("taterLinkCode");
const urlInput = $("taterLinkUrl");
const submit = $("claimTaterLinkBtn");
codeInput.addEventListener("input", () => {
const raw = String(codeInput.value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
codeInput.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
});
submit.addEventListener("click", async () => {
const pairingCode = String(codeInput.value || "").trim();
const targetUrl = String(urlInput.value || "").trim();
if (!pairingCode || !targetUrl) {
$("taterLinkModalStatus").textContent = "Tater address and pairing code are required.";
return;
}
submit.disabled = true;
codeInput.disabled = true;
urlInput.disabled = true;
$("taterLinkModalStatus").textContent = "Linking securely with Tater...";
try {
const result = await api("/api/tater_link/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tater_url: targetUrl, pairing_code: pairingCode }),
});
$("autoTaterUrl").value = targetUrl;
showTaterLinkSuccess(result);
await refreshAutoTrain(false);
} catch (error) {
submit.disabled = false;
codeInput.disabled = false;
urlInput.disabled = false;
$("taterLinkModalStatus").textContent = `Link failed: ${error.message}`;
}
});
window.setTimeout(() => codeInput.focus(), 50);
}
async function unlinkTater() {
if (!window.confirm("Unlink this trainer from Tater? Wake-word publishing will stop until it is linked again.")) return;
uiState.autoBusy = true;
syncButtons();
try {
await api("/api/tater_link/unlink", { method: "POST" });
await refreshAutoTrain(false);
setPill($("autoTaterLinkStatus"), "Not linked", "warn");
} finally {
uiState.autoBusy = false;
syncButtons();
}
}
function captureBadge(item) { function captureBadge(item) {
if (item.blocked_by_vad) return { label: "Blocked by VAD", cls: "warn" }; if (item.blocked_by_vad) return { label: "Blocked by VAD", cls: "warn" };
const eventType = String(item?.event_type || "").toLowerCase(); const eventType = String(item?.event_type || "").toLowerCase();
@@ -2595,9 +2717,14 @@
if (refreshWakeWordsBtn) { if (refreshWakeWordsBtn) {
refreshWakeWordsBtn.disabled = uiState.firmwareBusy; refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
} }
for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn"]) { for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn", "autoLinkTaterBtn", "autoUnlinkTaterBtn"]) {
const button = $(id); const button = $(id);
if (button) button.disabled = uiState.autoBusy || (id === "autoTrainNowBtn" && Boolean(training.running)); if (button) {
button.disabled =
uiState.autoBusy ||
(id === "autoTrainNowBtn" && Boolean(training.running)) ||
(id === "autoNotifyNowBtn" && !Boolean(uiState.autoTrain?.trainer_link?.linked));
}
} }
} }
@@ -2940,10 +3067,26 @@
try { try {
await runAutoTrainAction("notify_now"); await runAutoTrainAction("notify_now");
} catch (error) { } catch (error) {
setPill($("autoStatus"), "Refresh failed", "err"); setPill($("autoStatus"), "Publish failed", "err");
alert("Satellite refresh failed: " + error.message); alert("Wake-word publish failed: " + error.message);
} }
}); });
$("autoLinkTaterBtn").addEventListener("click", () => {
openTaterLinkModal().catch((error) => {
setPill($("autoTaterLinkStatus"), "Link failed", "err");
alert("Tater link failed: " + error.message);
});
});
$("autoUnlinkTaterBtn").addEventListener("click", () => {
unlinkTater().catch((error) => {
setPill($("autoTaterLinkStatus"), "Unlink failed", "err");
alert("Tater unlink failed: " + error.message);
});
});
$("closeTaterLinkBtn").addEventListener("click", closeTaterLinkModal);
$("taterLinkOverlay").addEventListener("click", (event) => {
if (event.target === $("taterLinkOverlay")) closeTaterLinkModal();
});
$("openConsoleBtn").addEventListener("click", () => { $("openConsoleBtn").addEventListener("click", () => {
setConsoleLogAutoScroll($("trainLog"), (uiState.training?.log_lines || []).join("\n") || "(no training started)"); setConsoleLogAutoScroll($("trainLog"), (uiState.training?.log_lines || []).join("\n") || "(no training started)");
@@ -2962,6 +3105,7 @@
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeTaterLinkModal();
closeConsole(); closeConsole();
} }
}); });

View File

@@ -62,8 +62,6 @@ class AutoTrainTests(unittest.TestCase):
"wake_phrase": "hey tater", "wake_phrase": "hey tater",
"language": "en", "language": "en",
"tater_url": "http://127.0.0.1:8501", "tater_url": "http://127.0.0.1:8501",
"stt_device": "auto",
"stt_compute_type": "auto",
} }
) )
) )
@@ -110,9 +108,90 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(trainer._transcript_contains_wake_phrase("Okay, HEY TATER!", "hey_tater")) 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")) 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))
with (
patch.dict(sys.modules, {"onnx_asr": fake_onnx_asr}),
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_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): def test_phrase_miss_moves_wake_trigger_to_negative_samples(self):
self.add_capture() self.add_capture()
with patch.object(trainer, "_transcribe_capture_with_faster_whisper", return_value="turn on the kitchen lights"): with patch.object(trainer, "_transcribe_capture", return_value="turn on the kitchen lights"):
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists()) self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists())
@@ -122,11 +201,13 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(metadata["auto_negative"]) self.assertTrue(metadata["auto_negative"])
self.assertEqual(metadata["review_status"], "auto_approved_negative") self.assertEqual(metadata["review_status"], "auto_approved_negative")
self.assertEqual(metadata["transcript"], "turn on the kitchen lights") 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) self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 1)
def test_matching_phrase_stays_in_manual_review_inbox(self): def test_matching_phrase_stays_in_manual_review_inbox(self):
audio_path = self.add_capture() audio_path = self.add_capture()
with patch.object(trainer, "_transcribe_capture_with_faster_whisper", return_value="hey tater turn on the lights"): with patch.object(trainer, "_transcribe_capture", return_value="hey tater turn on the lights"):
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists()) self.assertTrue(audio_path.exists())
@@ -140,7 +221,7 @@ class AutoTrainTests(unittest.TestCase):
trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True
with patch.object( with patch.object(
trainer, trainer,
"_transcribe_capture_with_faster_whisper", "_transcribe_capture",
return_value="hey tater turn on the lights", return_value="hey tater turn on the lights",
): ):
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
@@ -164,7 +245,7 @@ class AutoTrainTests(unittest.TestCase):
trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True
self.assertEqual(trainer._queue_pending_auto_reviews(), 1) self.assertEqual(trainer._queue_pending_auto_reviews(), 1)
with patch.object(trainer, "_transcribe_capture_with_faster_whisper") as transcribe: with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called() transcribe.assert_not_called()
@@ -173,7 +254,7 @@ class AutoTrainTests(unittest.TestCase):
def test_close_miss_is_not_transcribed_by_default(self): def test_close_miss_is_not_transcribed_by_default(self):
audio_path = self.add_capture(event_type="close_miss") audio_path = self.add_capture(event_type="close_miss")
with patch.object(trainer, "_transcribe_capture_with_faster_whisper") as transcribe: with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called() transcribe.assert_not_called()
@@ -190,7 +271,7 @@ class AutoTrainTests(unittest.TestCase):
def test_close_miss_with_phrase_is_promoted_when_enabled(self): def test_close_miss_with_phrase_is_promoted_when_enabled(self):
self.add_capture(event_type="close_miss") self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object(trainer, "_transcribe_capture_with_faster_whisper", return_value="hey tater"): with patch.object(trainer, "_transcribe_capture", return_value="hey tater"):
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists()) self.assertFalse((trainer.CAPTURED_DIR / "wake.wav").exists())
@@ -208,7 +289,7 @@ class AutoTrainTests(unittest.TestCase):
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object( with patch.object(
trainer, trainer,
"_transcribe_capture_with_faster_whisper", "_transcribe_capture",
return_value="turn on the lights", return_value="turn on the lights",
): ):
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
@@ -222,7 +303,7 @@ class AutoTrainTests(unittest.TestCase):
def test_vad_blocked_close_miss_is_never_transcribed(self): def test_vad_blocked_close_miss_is_never_transcribed(self):
audio_path = self.add_capture(event_type="close_miss", blocked_by_vad=True) audio_path = self.add_capture(event_type="close_miss", blocked_by_vad=True)
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True
with patch.object(trainer, "_transcribe_capture_with_faster_whisper") as transcribe: with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called() transcribe.assert_not_called()
@@ -231,7 +312,7 @@ class AutoTrainTests(unittest.TestCase):
def test_capture_for_another_wake_word_is_not_transcribed(self): def test_capture_for_another_wake_word_is_not_transcribed(self):
audio_path = self.add_capture(wake_word="computer") audio_path = self.add_capture(wake_word="computer")
with patch.object(trainer, "_transcribe_capture_with_faster_whisper") as transcribe: with patch.object(trainer, "_transcribe_capture") as transcribe:
trainer._auto_review_capture("wake.wav") trainer._auto_review_capture("wake.wav")
transcribe.assert_not_called() transcribe.assert_not_called()
@@ -250,13 +331,12 @@ class AutoTrainTests(unittest.TestCase):
start.assert_called_once_with() start.assert_called_once_with()
self.assertTrue(trainer.AUTO_TRAIN_STATE["next_run_at"]) self.assertTrue(trainer.AUTO_TRAIN_STATE["next_run_at"])
def test_tater_refresh_repushes_settings_with_selector_and_token(self): def test_tater_notification_sets_new_word_globally_with_token(self):
trainer.AUTO_TRAIN_CONFIG.update( trainer.AUTO_TRAIN_CONFIG.update(
{ {
"notify_satellites": True, "notify_satellites": True,
"tater_url": "http://127.0.0.1:8501", "tater_url": "http://127.0.0.1:8501",
"tater_selector": "kitchen-sat", "tater_link_token": "secret-token",
"tater_api_token": "secret-token",
} }
) )
@@ -268,17 +348,96 @@ class AutoTrainTests(unittest.TestCase):
return False return False
def read(self): def read(self):
return b'{"push":{"count":2}}' return b'{"push":{"count":4}}'
with patch.object(trainer, "urlopen", return_value=Response()) as open_url: trained_word = {
result = trainer._notify_tater_satellites() "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.assertTrue(result["ok"])
self.assertEqual(result["count"], 2) 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] request = open_url.call_args.args[0]
self.assertEqual(request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/settings") self.assertEqual(request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/trainer/wake-word")
self.assertEqual(request.get_header("X-tater-token"), "secret-token") self.assertEqual(request.get_method(), "POST")
self.assertEqual(json.loads(request.data), {"selector": "kitchen-sat", "settings": {}}) 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): def test_advertised_url_uses_non_loopback_browser_host(self):
request = SimpleNamespace( request = SimpleNamespace(
@@ -347,6 +506,39 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_stt_device"], "cuda") self.assertEqual(trainer.AUTO_TRAIN_STATE["last_stt_device"], "cuda")
self.assertEqual(trainer.AUTO_TRAIN_STATE["last_stt_compute_type"], "float16") 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__": if __name__ == "__main__":
unittest.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()

View File

@@ -2,11 +2,13 @@
# trainer_server.py # trainer_server.py
import contextlib import contextlib
import gc
import io import io
import os import os
import queue import queue
import re import re
import json import json
import secrets
import socket import socket
import shutil import shutil
import subprocess import subprocess
@@ -22,6 +24,7 @@ from math import isfinite, log10
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Callable, Optional, Tuple from typing import Dict, Any, List, Callable, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
from urllib.error import HTTPError
from urllib.request import Request as URLRequest, urlopen from urllib.request import Request as URLRequest, urlopen
from fastapi import FastAPI, UploadFile, File, Form, Header, Request from fastapi import FastAPI, UploadFile, File, Form, Header, Request
@@ -68,6 +71,8 @@ PIPER_CATALOG_CACHE_FILE = Path(
str(DATA_DIR / ".cache" / "piper_voices_catalog.json"), str(DATA_DIR / ".cache" / "piper_voices_catalog.json"),
) )
).resolve() ).resolve()
TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400"))
TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024)))
DATASET_CLEANUP_ARCHIVES = os.environ.get("REC_DATASET_CLEANUP_ARCHIVES", "false").lower() in ("1", "true", "yes", "y") DATASET_CLEANUP_ARCHIVES = os.environ.get("REC_DATASET_CLEANUP_ARCHIVES", "false").lower() in ("1", "true", "yes", "y")
DATASET_CLEANUP_INTERMEDIATE = os.environ.get("REC_DATASET_CLEANUP_INTERMEDIATE_FILES", "false").lower() in ("1", "true", "yes", "y") DATASET_CLEANUP_INTERMEDIATE = os.environ.get("REC_DATASET_CLEANUP_INTERMEDIATE_FILES", "false").lower() in ("1", "true", "yes", "y")
@@ -84,15 +89,37 @@ TARGET_SAMPLE_RATE = 16000
TARGET_CHANNELS = 1 TARGET_CHANNELS = 1
TARGET_SAMPLE_WIDTH_BYTES = 2 TARGET_SAMPLE_WIDTH_BYTES = 2
CAPTURE_GAIN_PROFILE = "capture_rms_v1" CAPTURE_GAIN_PROFILE = "capture_rms_v1"
DEFAULT_FASTER_WHISPER_MODEL = os.environ.get("AUTO_TRAIN_STT_MODEL", "small.en") STT_ENGINE_FASTER_WHISPER = "faster_whisper"
STT_ENGINE_PARAKEET_ONNX = "parakeet_onnx"
SUPPORTED_STT_ENGINES = {
STT_ENGINE_FASTER_WHISPER,
STT_ENGINE_PARAKEET_ONNX,
}
DEFAULT_STT_ENGINE = os.environ.get(
"AUTO_TRAIN_STT_ENGINE",
STT_ENGINE_FASTER_WHISPER,
).strip().lower().replace("-", "_")
if DEFAULT_STT_ENGINE not in SUPPORTED_STT_ENGINES:
DEFAULT_STT_ENGINE = STT_ENGINE_FASTER_WHISPER
DEFAULT_FASTER_WHISPER_EN_MODEL = os.environ.get(
"AUTO_TRAIN_FASTER_WHISPER_EN_MODEL",
"small.en",
)
DEFAULT_FASTER_WHISPER_MULTILINGUAL_MODEL = os.environ.get(
"AUTO_TRAIN_FASTER_WHISPER_MULTILINGUAL_MODEL",
"small",
)
DEFAULT_PARAKEET_ONNX_MODEL = os.environ.get(
"AUTO_TRAIN_PARAKEET_ONNX_MODEL",
"nemo-parakeet-tdt-0.6b-v3",
)
DEFAULT_PARAKEET_ONNX_QUANTIZATION = "int8"
AUTO_TRAIN_DEFAULT_CONFIG: Dict[str, Any] = { AUTO_TRAIN_DEFAULT_CONFIG: Dict[str, Any] = {
"enabled": False, "enabled": False,
"wake_phrase": "", "wake_phrase": "",
"language": DEFAULT_LANGUAGE, "language": DEFAULT_LANGUAGE,
"stt_model": DEFAULT_FASTER_WHISPER_MODEL, "stt_engine": DEFAULT_STT_ENGINE,
"stt_device": "auto",
"stt_compute_type": "auto",
"minimum_transcript_chars": 2, "minimum_transcript_chars": 2,
"delete_confirmed_wakes": False, "delete_confirmed_wakes": False,
"promote_close_misses": False, "promote_close_misses": False,
@@ -100,8 +127,10 @@ AUTO_TRAIN_DEFAULT_CONFIG: Dict[str, Any] = {
"minimum_new_negatives": 3, "minimum_new_negatives": 3,
"advertised_base_url": "", "advertised_base_url": "",
"tater_url": "http://127.0.0.1:8501", "tater_url": "http://127.0.0.1:8501",
"tater_selector": "", "tater_link_token": "",
"tater_api_token": "", "tater_link_id": "",
"tater_linked_at": "",
"tater_link_tater_name": "",
"notify_satellites": True, "notify_satellites": True,
} }
@@ -113,6 +142,8 @@ AUTO_TRAIN_DEFAULT_STATE: Dict[str, Any] = {
"last_review_transcript": "", "last_review_transcript": "",
"last_review_result": "", "last_review_result": "",
"last_review_error": "", "last_review_error": "",
"last_stt_engine": "",
"last_stt_model": "",
"last_stt_device": "", "last_stt_device": "",
"last_stt_compute_type": "", "last_stt_compute_type": "",
"last_train_started_at": "", "last_train_started_at": "",
@@ -180,6 +211,10 @@ AUTO_TRAIN_RUNTIME: Dict[str, Any] = {
LAN_ADDRESS_CACHE: Dict[str, Any] = {"value": "", "fetched_at": 0.0} LAN_ADDRESS_CACHE: Dict[str, Any] = {"value": "", "fetched_at": 0.0}
FASTER_WHISPER_MODEL_LOCK = threading.RLock() FASTER_WHISPER_MODEL_LOCK = threading.RLock()
FASTER_WHISPER_MODEL_CACHE: Dict[Tuple[str, str, str], Any] = {} FASTER_WHISPER_MODEL_CACHE: Dict[Tuple[str, str, str], Any] = {}
FASTER_WHISPER_TRANSCRIBE_LOCK = threading.RLock()
PARAKEET_ONNX_MODEL_LOCK = threading.RLock()
PARAKEET_ONNX_MODEL_CACHE: Dict[Tuple[str, str, Tuple[str, ...]], Any] = {}
PARAKEET_ONNX_TRANSCRIBE_LOCK = threading.RLock()
PIPER_CATALOG_CACHE: Dict[str, Any] = { PIPER_CATALOG_CACHE: Dict[str, Any] = {
"fetched_at": 0.0, "fetched_at": 0.0,
"entries": None, "entries": None,
@@ -455,25 +490,60 @@ def _normalize_http_base_url(value: Any, *, allow_empty: bool = True) -> str:
return token return token
def _normalize_stt_engine(value: Any) -> str:
token = str(value or DEFAULT_STT_ENGINE).strip().lower().replace("-", "_")
aliases = {
"faster": STT_ENGINE_FASTER_WHISPER,
"fasterwhisper": STT_ENGINE_FASTER_WHISPER,
"parakeet": STT_ENGINE_PARAKEET_ONNX,
"onnx_parakeet": STT_ENGINE_PARAKEET_ONNX,
}
token = aliases.get(token, token)
if token not in SUPPORTED_STT_ENGINES:
raise ValueError("STT engine must be Faster Whisper or Parakeet ONNX.")
return token
def _managed_stt_model(engine: Any, language: Any = DEFAULT_LANGUAGE) -> str:
token = _normalize_stt_engine(engine)
language_token = str(language or DEFAULT_LANGUAGE).strip().lower().replace("-", "_")
english = language_token == "en" or language_token.startswith("en_")
if token == STT_ENGINE_FASTER_WHISPER:
return (
DEFAULT_FASTER_WHISPER_EN_MODEL
if english
else DEFAULT_FASTER_WHISPER_MULTILINGUAL_MODEL
)
return DEFAULT_PARAKEET_ONNX_MODEL
def _stt_engine_catalog(language: Any = DEFAULT_LANGUAGE) -> List[Dict[str, Any]]:
return [
{
"value": STT_ENGINE_FASTER_WHISPER,
"label": "Faster Whisper",
"model": _managed_stt_model(STT_ENGINE_FASTER_WHISPER, language),
"recommended": True,
},
{
"value": STT_ENGINE_PARAKEET_ONNX,
"label": "Parakeet ONNX",
"model": _managed_stt_model(STT_ENGINE_PARAKEET_ONNX, language),
},
]
def _normalize_auto_train_config(values: Dict[str, Any] | None, *, base: Dict[str, Any] | None = None) -> Dict[str, Any]: def _normalize_auto_train_config(values: Dict[str, Any] | None, *, base: Dict[str, Any] | None = None) -> Dict[str, Any]:
incoming = values if isinstance(values, dict) else {} incoming = values if isinstance(values, dict) else {}
source = {**AUTO_TRAIN_DEFAULT_CONFIG, **(base or {}), **incoming} source = {**AUTO_TRAIN_DEFAULT_CONFIG, **(base or {}), **incoming}
schedule_hours = _bounded_int(source.get("schedule_hours"), 24, 0, 24 * 30) schedule_hours = _bounded_int(source.get("schedule_hours"), 24, 0, 24 * 30)
language = str(source.get("language") or DEFAULT_LANGUAGE).strip().lower().replace("-", "_") language = str(source.get("language") or DEFAULT_LANGUAGE).strip().lower().replace("-", "_")
language = re.sub(r"[^a-z0-9_]", "", language) or DEFAULT_LANGUAGE language = re.sub(r"[^a-z0-9_]", "", language) or DEFAULT_LANGUAGE
stt_device = str(source.get("stt_device") or "auto").strip().lower()
if stt_device not in {"auto", "cuda", "cpu"}:
raise ValueError("Faster Whisper device must be auto, cuda, or cpu.")
stt_compute_type = str(source.get("stt_compute_type") or "auto").strip().lower()
if stt_compute_type not in {"auto", "default", "float16", "float32", "int8", "int8_float16"}:
raise ValueError("Unsupported Faster Whisper compute type.")
return { return {
"enabled": _config_bool(source.get("enabled")), "enabled": _config_bool(source.get("enabled")),
"wake_phrase": str(source.get("wake_phrase") or "").strip(), "wake_phrase": str(source.get("wake_phrase") or "").strip(),
"language": language, "language": language,
"stt_model": str(source.get("stt_model") or DEFAULT_FASTER_WHISPER_MODEL).strip() or DEFAULT_FASTER_WHISPER_MODEL, "stt_engine": _normalize_stt_engine(source.get("stt_engine")),
"stt_device": stt_device,
"stt_compute_type": stt_compute_type,
"minimum_transcript_chars": _bounded_int(source.get("minimum_transcript_chars"), 2, 1, 100), "minimum_transcript_chars": _bounded_int(source.get("minimum_transcript_chars"), 2, 1, 100),
"delete_confirmed_wakes": _config_bool(source.get("delete_confirmed_wakes")), "delete_confirmed_wakes": _config_bool(source.get("delete_confirmed_wakes")),
"promote_close_misses": _config_bool(source.get("promote_close_misses")), "promote_close_misses": _config_bool(source.get("promote_close_misses")),
@@ -481,8 +551,10 @@ def _normalize_auto_train_config(values: Dict[str, Any] | None, *, base: Dict[st
"minimum_new_negatives": _bounded_int(source.get("minimum_new_negatives"), 3, 1, 10000), "minimum_new_negatives": _bounded_int(source.get("minimum_new_negatives"), 3, 1, 10000),
"advertised_base_url": _normalize_http_base_url(source.get("advertised_base_url")), "advertised_base_url": _normalize_http_base_url(source.get("advertised_base_url")),
"tater_url": _normalize_http_base_url(source.get("tater_url"), allow_empty=False), "tater_url": _normalize_http_base_url(source.get("tater_url"), allow_empty=False),
"tater_selector": str(source.get("tater_selector") or "").strip(), "tater_link_token": str(source.get("tater_link_token") or "").strip(),
"tater_api_token": str(source.get("tater_api_token") or "").strip(), "tater_link_id": str(source.get("tater_link_id") or "").strip(),
"tater_linked_at": str(source.get("tater_linked_at") or "").strip(),
"tater_link_tater_name": str(source.get("tater_link_tater_name") or "").strip(),
"notify_satellites": _config_bool(source.get("notify_satellites"), True), "notify_satellites": _config_bool(source.get("notify_satellites"), True),
} }
@@ -533,18 +605,28 @@ def _schedule_next_auto_run_locked(*, from_time: datetime | None = None) -> None
def _public_auto_train_config() -> Dict[str, Any]: def _public_auto_train_config() -> Dict[str, Any]:
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
config = {key: value for key, value in AUTO_TRAIN_CONFIG.items() if key != "tater_api_token"} config = {
config["tater_api_token_configured"] = bool(AUTO_TRAIN_CONFIG.get("tater_api_token")) key: value
for key, value in AUTO_TRAIN_CONFIG.items()
if key != "tater_link_token"
}
config["tater_linked"] = bool(
AUTO_TRAIN_CONFIG.get("tater_link_token")
and AUTO_TRAIN_CONFIG.get("tater_link_id")
)
return config return config
def _auto_train_status_payload() -> Dict[str, Any]: def _auto_train_status_payload() -> Dict[str, Any]:
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
language = AUTO_TRAIN_CONFIG.get("language") or DEFAULT_LANGUAGE
return { return {
"config": _public_auto_train_config(), "config": _public_auto_train_config(),
"state": dict(AUTO_TRAIN_STATE), "state": dict(AUTO_TRAIN_STATE),
"runtime": dict(AUTO_TRAIN_RUNTIME), "runtime": dict(AUTO_TRAIN_RUNTIME),
"stt_engines": _stt_engine_catalog(language),
"advertised_base_url": _advertised_base_url(), "advertised_base_url": _advertised_base_url(),
"trainer_link": _tater_link_public_status(),
} }
@@ -626,6 +708,115 @@ def _advertised_base_url(request: Request | None = None) -> str:
return f"{scheme}://{host}{'' if default_port else f':{port}'}" return f"{scheme}://{host}{'' if default_port else f':{port}'}"
def _tater_link_public_status() -> Dict[str, Any]:
with AUTO_TRAIN_LOCK:
return {
"linked": bool(
AUTO_TRAIN_CONFIG.get("tater_link_token")
and AUTO_TRAIN_CONFIG.get("tater_link_id")
),
"trainer_id": str(AUTO_TRAIN_CONFIG.get("tater_link_id") or "").strip(),
"linked_at": str(AUTO_TRAIN_CONFIG.get("tater_linked_at") or "").strip(),
"tater_name": str(AUTO_TRAIN_CONFIG.get("tater_link_tater_name") or "").strip(),
}
def _claim_tater_link(tater_url: Any, pairing_code: Any) -> Dict[str, Any]:
base_url = _normalize_http_base_url(tater_url, allow_empty=False)
code = "".join(ch for ch in str(pairing_code or "").upper() if ch.isalnum())
if len(code) != 8:
raise ValueError("Enter the complete pairing code shown by Tater.")
publish_base_url = _normalize_http_base_url(_advertised_base_url(), allow_empty=False)
with AUTO_TRAIN_LOCK:
trainer_id = str(AUTO_TRAIN_CONFIG.get("tater_link_id") or "").strip() or secrets.token_hex(12)
request = URLRequest(
f"{base_url}/api/tater/satellite/v1/trainer/link/claim",
data=json.dumps(
{
"pairing_code": code,
"trainer_id": trainer_id,
"trainer_name": "Wake Word Trainer",
"trainer_url": publish_base_url,
"publish_base_url": publish_base_url,
}
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"User-Agent": "microWakeWord-Trainer/tater-link",
},
method="POST",
)
try:
with urlopen(request, timeout=10) as response:
payload = json.loads(response.read(64 * 1024).decode("utf-8"))
except HTTPError as exc:
detail = ""
with contextlib.suppress(Exception):
error_payload = json.loads(exc.read(64 * 1024).decode("utf-8"))
if isinstance(error_payload, dict):
detail = str(error_payload.get("detail") or error_payload.get("error") or "").strip()
raise ValueError(detail or f"Tater rejected the pairing code (HTTP {exc.code}).") from exc
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Could not reach Tater: {exc}") from exc
if not isinstance(payload, dict) or not bool(payload.get("ok")):
raise ValueError(str((payload or {}).get("error") or "Tater pairing failed."))
link_token = str(payload.get("token") or "").strip()
if len(link_token) < 32:
raise ValueError("Tater pairing response did not contain valid link credentials.")
linked_at = str(payload.get("linked_at") or _iso_now()).strip()
tater_name = str(payload.get("tater_name") or "Tater").strip() or "Tater"
with AUTO_TRAIN_LOCK:
AUTO_TRAIN_CONFIG["tater_url"] = base_url
AUTO_TRAIN_CONFIG["tater_link_token"] = link_token
AUTO_TRAIN_CONFIG["tater_link_id"] = trainer_id
AUTO_TRAIN_CONFIG["tater_linked_at"] = linked_at
AUTO_TRAIN_CONFIG["tater_link_tater_name"] = tater_name
_save_auto_train_config_locked()
return {
"ok": True,
"message": "Tater linked successfully.",
**_tater_link_public_status(),
}
def _unlink_tater() -> Dict[str, Any]:
with AUTO_TRAIN_LOCK:
base_url = str(AUTO_TRAIN_CONFIG.get("tater_url") or "").strip().rstrip("/")
link_token = str(AUTO_TRAIN_CONFIG.get("tater_link_token") or "").strip()
remote_error = ""
if base_url and link_token:
request = URLRequest(
f"{base_url}/api/tater/satellite/v1/trainer/link/unlink",
data=b"{}",
headers={
"Content-Type": "application/json",
"X-Tater-Trainer-Token": link_token,
"User-Agent": "microWakeWord-Trainer/tater-link",
},
method="POST",
)
try:
with urlopen(request, timeout=10):
pass
except Exception as exc:
remote_error = str(exc)
with AUTO_TRAIN_LOCK:
AUTO_TRAIN_CONFIG["tater_link_token"] = ""
AUTO_TRAIN_CONFIG["tater_link_id"] = ""
AUTO_TRAIN_CONFIG["tater_linked_at"] = ""
AUTO_TRAIN_CONFIG["tater_link_tater_name"] = ""
_save_auto_train_config_locked()
return {
"ok": True,
"message": "Tater link removed." if not remote_error else "Local Tater link removed; Tater could not be reached.",
"remote_error": remote_error,
**_tater_link_public_status(),
}
def _normalize_transcript_text(value: Any) -> str: def _normalize_transcript_text(value: Any) -> str:
text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ") text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ")
text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE) text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE)
@@ -703,33 +894,163 @@ def _load_faster_whisper_model(*, model_name: str, device: str, compute_type: st
def _transcribe_capture_with_faster_whisper(audio_path: Path, *, model: str, language: str) -> str: def _transcribe_capture_with_faster_whisper(audio_path: Path, *, model: str, language: str) -> str:
with AUTO_TRAIN_LOCK: device, compute_type = _resolve_faster_whisper_runtime("auto", "auto")
device_value = AUTO_TRAIN_CONFIG.get("stt_device")
compute_value = AUTO_TRAIN_CONFIG.get("stt_compute_type")
device, compute_type = _resolve_faster_whisper_runtime(device_value, compute_value)
whisper_model = _load_faster_whisper_model( whisper_model = _load_faster_whisper_model(
model_name=model, model_name=model,
device=device, device=device,
compute_type=compute_type, compute_type=compute_type,
) )
segments, _info = whisper_model.transcribe( with FASTER_WHISPER_TRANSCRIBE_LOCK:
str(audio_path), segments, _info = whisper_model.transcribe(
language=language or None, str(audio_path),
beam_size=1, language=language or None,
condition_on_previous_text=False, beam_size=1,
) condition_on_previous_text=False,
transcript = re.sub( )
r"\s+", transcript = re.sub(
" ", r"\s+",
" ".join(str(segment.text or "").strip() for segment in segments), " ",
).strip() " ".join(str(segment.text or "").strip() for segment in segments),
).strip()
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
AUTO_TRAIN_STATE["last_stt_engine"] = STT_ENGINE_FASTER_WHISPER
AUTO_TRAIN_STATE["last_stt_model"] = model
AUTO_TRAIN_STATE["last_stt_device"] = device AUTO_TRAIN_STATE["last_stt_device"] = device
AUTO_TRAIN_STATE["last_stt_compute_type"] = compute_type AUTO_TRAIN_STATE["last_stt_compute_type"] = compute_type
_save_auto_train_state_locked() _save_auto_train_state_locked()
return transcript return transcript
def _parakeet_onnx_providers() -> List[str]:
try:
import onnxruntime as ort
except Exception as exc:
raise RuntimeError(f"onnxruntime is unavailable: {exc}") from exc
available = [str(value) for value in ort.get_available_providers()]
preferred = [
"CUDAExecutionProvider",
"CPUExecutionProvider",
]
resolved = [provider for provider in preferred if provider in set(available)]
if not resolved:
raise RuntimeError("ONNX Runtime has no usable CUDA or CPU execution provider.")
return resolved
def _load_parakeet_onnx_model():
try:
import onnx_asr
except Exception as exc:
raise RuntimeError(f"onnx-asr is unavailable: {exc}") from exc
providers = tuple(_parakeet_onnx_providers())
cache_key = (
DEFAULT_PARAKEET_ONNX_MODEL,
DEFAULT_PARAKEET_ONNX_QUANTIZATION,
providers,
)
with PARAKEET_ONNX_MODEL_LOCK:
cached = PARAKEET_ONNX_MODEL_CACHE.get(cache_key)
if cached is not None:
return cached
AUTO_TRAIN_MODEL_DIR.mkdir(parents=True, exist_ok=True)
previous = {
key: os.environ.get(key)
for key in ("HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE")
}
os.environ["HF_HOME"] = str(AUTO_TRAIN_MODEL_DIR)
os.environ["HF_HUB_CACHE"] = str(AUTO_TRAIN_MODEL_DIR / "hub")
os.environ["HUGGINGFACE_HUB_CACHE"] = str(AUTO_TRAIN_MODEL_DIR / "hub")
try:
model = onnx_asr.load_model(
DEFAULT_PARAKEET_ONNX_MODEL,
str(AUTO_TRAIN_MODEL_DIR),
quantization=DEFAULT_PARAKEET_ONNX_QUANTIZATION,
providers=list(providers),
)
finally:
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
PARAKEET_ONNX_MODEL_CACHE.clear()
PARAKEET_ONNX_MODEL_CACHE[cache_key] = model
return model
def _normalized_wav_float32(audio_path: Path):
import numpy as np
with wave.open(str(audio_path), "rb") as wav_file:
channels = wav_file.getnchannels()
sample_width = wav_file.getsampwidth()
sample_rate = wav_file.getframerate()
frames = wav_file.readframes(wav_file.getnframes())
if sample_width != 2 or sample_rate != TARGET_SAMPLE_RATE or channels < 1:
raise RuntimeError("STT input must be 16 kHz, 16-bit PCM WAV audio.")
samples = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
if channels > 1:
samples = samples.reshape((-1, channels)).mean(axis=1)
return samples / 32768.0
def _transcribe_capture_with_parakeet(audio_path: Path, *, model: str, language: str) -> str:
parakeet_model = _load_parakeet_onnx_model()
kwargs: Dict[str, Any] = {
"sample_rate": TARGET_SAMPLE_RATE,
"channel": "mean",
}
if language:
kwargs["language"] = language
with PARAKEET_ONNX_TRANSCRIBE_LOCK:
result = parakeet_model.recognize(
_normalized_wav_float32(audio_path),
**kwargs,
)
providers = _parakeet_onnx_providers()
with AUTO_TRAIN_LOCK:
AUTO_TRAIN_STATE["last_stt_engine"] = STT_ENGINE_PARAKEET_ONNX
AUTO_TRAIN_STATE["last_stt_model"] = model
AUTO_TRAIN_STATE["last_stt_device"] = providers[0]
AUTO_TRAIN_STATE["last_stt_compute_type"] = DEFAULT_PARAKEET_ONNX_QUANTIZATION
_save_auto_train_state_locked()
return re.sub(r"\s+", " ", str(result or "")).strip()
def _transcribe_capture(audio_path: Path, *, engine: str, language: str) -> str:
token = _normalize_stt_engine(engine)
model = _managed_stt_model(token, language)
if token == STT_ENGINE_PARAKEET_ONNX:
return _transcribe_capture_with_parakeet(
audio_path,
model=model,
language=language,
)
return _transcribe_capture_with_faster_whisper(
audio_path,
model=model,
language=language,
)
def _clear_stt_model_caches(*, keep_engine: str) -> None:
token = _normalize_stt_engine(keep_engine)
cleared = False
if token != STT_ENGINE_FASTER_WHISPER:
with FASTER_WHISPER_TRANSCRIBE_LOCK:
with FASTER_WHISPER_MODEL_LOCK:
cleared = bool(FASTER_WHISPER_MODEL_CACHE) or cleared
FASTER_WHISPER_MODEL_CACHE.clear()
if token != STT_ENGINE_PARAKEET_ONNX:
with PARAKEET_ONNX_TRANSCRIBE_LOCK:
with PARAKEET_ONNX_MODEL_LOCK:
cleared = bool(PARAKEET_ONNX_MODEL_CACHE) or cleared
PARAKEET_ONNX_MODEL_CACHE.clear()
if cleared:
gc.collect()
def _queue_auto_review(file_name: str) -> bool: def _queue_auto_review(file_name: str) -> bool:
safe_file_name = Path(str(file_name or "")).name safe_file_name = Path(str(file_name or "")).name
if not safe_file_name: if not safe_file_name:
@@ -835,12 +1156,17 @@ def _auto_review_capture(file_name: str) -> None:
metadata["auto_review_status"] = "transcribing" metadata["auto_review_status"] = "transcribing"
metadata["auto_reviewed_at"] = _iso_now() metadata["auto_reviewed_at"] = _iso_now()
metadata["auto_review_wake_phrase"] = wake_phrase metadata["auto_review_wake_phrase"] = wake_phrase
metadata["auto_review_stt_model"] = config["stt_model"] stt_engine = _normalize_stt_engine(config.get("stt_engine"))
metadata["auto_review_stt_engine"] = stt_engine
metadata["auto_review_stt_model"] = _managed_stt_model(
stt_engine,
config.get("language"),
)
_write_sidecar_json(audio_path, metadata) _write_sidecar_json(audio_path, metadata)
transcript = _transcribe_capture_with_faster_whisper( transcript = _transcribe_capture(
audio_path, audio_path,
model=str(config["stt_model"]), engine=stt_engine,
language=str(config.get("language") or DEFAULT_LANGUAGE), language=str(config.get("language") or DEFAULT_LANGUAGE),
) )
normalized = _normalize_transcript_text(transcript) normalized = _normalize_transcript_text(transcript)
@@ -932,36 +1258,75 @@ def _auto_review_capture(file_name: str) -> None:
AUTO_TRAIN_RUNTIME["review_file"] = "" AUTO_TRAIN_RUNTIME["review_file"] = ""
def _notify_tater_satellites() -> Dict[str, Any]: def _notify_tater_satellites(wake_word_name: str = "") -> Dict[str, Any]:
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
config = dict(AUTO_TRAIN_CONFIG) config = dict(AUTO_TRAIN_CONFIG)
if not config.get("notify_satellites"): if not config.get("notify_satellites"):
return {"ok": True, "skipped": True, "message": "Satellite notification is disabled."} return {"ok": True, "skipped": True, "message": "Satellite notification is disabled."}
endpoint = f"{str(config.get('tater_url') or '').rstrip('/')}/api/tater/satellite/v1/settings" base_url = str(config.get("tater_url") or "").rstrip("/")
body = json.dumps( settings_endpoint = f"{base_url}/api/tater/satellite/v1/trainer/wake-word"
{
"selector": str(config.get("tater_selector") or ""),
"settings": {},
}
).encode("utf-8")
headers = {"Content-Type": "application/json", "User-Agent": "microWakeWord-Trainer/auto-train"} headers = {"Content-Type": "application/json", "User-Agent": "microWakeWord-Trainer/auto-train"}
token = str(config.get("tater_api_token") or "").strip() token = str(config.get("tater_link_token") or "").strip()
if token: if not token:
headers["X-Tater-Token"] = token return {
"ok": False,
"error": "Wake Word Trainer is not linked to Tater. Use Link Tater first.",
}
headers["X-Tater-Trainer-Token"] = token
try: try:
req = URLRequest(endpoint, data=body, headers=headers, method="POST") target_key = safe_name(wake_word_name or config.get("wake_phrase") or "")
with urlopen(req, timeout=15) as response: public_base_url = _advertised_base_url()
wake_words = _list_trained_wake_words(public_base_url)
target = next(
(row for row in wake_words if str(row.get("key") or "").strip() == target_key),
None,
)
if not isinstance(target, dict):
raise FileNotFoundError(f"Trained wake word is not available: {target_key}")
wake_word_url = str(target.get("json_url") or "").strip()
if not wake_word_url.startswith(("http://", "https://")):
raise ValueError("The trained wake-word JSON needs an advertised http(s) URL.")
body = json.dumps(
{
"wake_word_name": target_key,
"wake_word_url": wake_word_url,
}
).encode("utf-8")
request = URLRequest(settings_endpoint, data=body, headers=headers, method="POST")
with urlopen(request, timeout=15) as response:
payload = json.loads(response.read().decode("utf-8")) payload = json.loads(response.read().decode("utf-8"))
push = payload.get("push") if isinstance(payload, dict) and isinstance(payload.get("push"), dict) else {} push = payload.get("push") if isinstance(payload, dict) and isinstance(payload.get("push"), dict) else {}
count = push.get("count") pushed_count = push.get("count")
count = max(0, int(pushed_count)) if isinstance(pushed_count, (int, float)) else 0
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now() AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
AUTO_TRAIN_STATE["last_notify_count"] = count AUTO_TRAIN_STATE["last_notify_count"] = count
AUTO_TRAIN_STATE["last_notify_error"] = "" AUTO_TRAIN_STATE["last_notify_error"] = ""
_save_auto_train_state_locked() _save_auto_train_state_locked()
return {"ok": True, "count": count, "response": payload} return {
"ok": True,
"count": count,
"wake_word": str(target.get("wake_word") or target_key),
"wake_word_name": target_key,
"wake_word_url": wake_word_url,
}
except HTTPError as exc:
detail = ""
with contextlib.suppress(Exception):
error_payload = json.loads(exc.read().decode("utf-8"))
if isinstance(error_payload, dict):
detail = str(error_payload.get("detail") or error_payload.get("error") or "").strip()
error = detail or f"Tater rejected the wake word (HTTP {exc.code})."
with AUTO_TRAIN_LOCK:
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
AUTO_TRAIN_STATE["last_notify_count"] = None
AUTO_TRAIN_STATE["last_notify_error"] = error
_save_auto_train_state_locked()
return {"ok": False, "error": error}
except Exception as exc: except Exception as exc:
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now() AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
@@ -2193,17 +2558,17 @@ def _run_training_background(
AUTO_TRAIN_RUNTIME["training_pending_consumed"] = 0 AUTO_TRAIN_RUNTIME["training_pending_consumed"] = 0
_save_auto_train_state_locked() _save_auto_train_state_locked()
if rc == 0: if rc == 0:
_append_train_log("Asking Tater to refresh the active wake model on connected satellites") _append_train_log("Publishing the newly trained wake word to Tater and all satellites")
notify_result = _notify_tater_satellites() notify_result = _notify_tater_satellites(safe_word)
if notify_result.get("ok"): if notify_result.get("ok"):
if notify_result.get("skipped"): if notify_result.get("skipped"):
_append_train_log("Satellite refresh skipped (disabled in Auto Training)") _append_train_log("Wake-word publish skipped (disabled in Auto Training)")
else: else:
count = notify_result.get("count") count = notify_result.get("count")
suffix = f" ({count} connected)" if count is not None else "" suffix = f" ({count} connected)" if count is not None else ""
_append_train_log(f"Tater satellite refresh requested{suffix}") _append_train_log(f"New wake word activated through Tater{suffix}")
else: else:
_append_train_log(f"✗ Tater satellite refresh failed: {notify_result.get('error')}") _append_train_log(f"✗ Tater wake-word activation failed: {notify_result.get('error')}")
# -------------------- Routes -------------------- # -------------------- Routes --------------------
@@ -2222,19 +2587,22 @@ def auto_train_status(request: Request):
payload = _auto_train_status_payload() payload = _auto_train_status_payload()
payload["ok"] = True payload["ok"] = True
payload["advertised_base_url"] = _advertised_base_url(request) payload["advertised_base_url"] = _advertised_base_url(request)
payload["stt_backend"] = "faster-whisper" payload["stt_backend"] = payload["config"].get("stt_engine")
return payload return payload
@app.put("/api/auto_train") @app.put("/api/auto_train")
def update_auto_train(payload: Dict[str, Any] = None): def update_auto_train(payload: Dict[str, Any] = None):
incoming = dict(payload or {}) incoming = dict(payload or {})
for protected_key in (
"tater_link_token",
"tater_link_id",
"tater_linked_at",
"tater_link_tater_name",
):
incoming.pop(protected_key, None)
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
previous = dict(AUTO_TRAIN_CONFIG) previous = dict(AUTO_TRAIN_CONFIG)
if incoming.pop("clear_tater_api_token", False):
incoming["tater_api_token"] = ""
elif not str(incoming.get("tater_api_token") or "").strip():
incoming.pop("tater_api_token", None)
try: try:
normalized = _normalize_auto_train_config(incoming, base=previous) normalized = _normalize_auto_train_config(incoming, base=previous)
except ValueError as exc: except ValueError as exc:
@@ -2253,6 +2621,8 @@ def update_auto_train(payload: Dict[str, Any] = None):
) )
if schedule_changed or not AUTO_TRAIN_STATE.get("next_run_at"): if schedule_changed or not AUTO_TRAIN_STATE.get("next_run_at"):
_schedule_next_auto_run_locked() _schedule_next_auto_run_locked()
if previous.get("stt_engine") != normalized.get("stt_engine"):
_clear_stt_model_caches(keep_engine=normalized["stt_engine"])
if normalized["enabled"]: if normalized["enabled"]:
queued = _queue_pending_auto_reviews() queued = _queue_pending_auto_reviews()
AUTO_TRAIN_WAKE_EVENT.set() AUTO_TRAIN_WAKE_EVENT.set()
@@ -2261,6 +2631,25 @@ def update_auto_train(payload: Dict[str, Any] = None):
return {"ok": True, "queued": queued, **_auto_train_status_payload()} return {"ok": True, "queued": queued, **_auto_train_status_payload()}
@app.post("/api/tater_link/claim")
def tater_link_claim(payload: Dict[str, Any] = None):
body = payload if isinstance(payload, dict) else {}
try:
return _claim_tater_link(
body.get("tater_url"),
body.get("pairing_code"),
)
except ValueError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
@app.post("/api/tater_link/unlink")
def tater_link_unlink():
return _unlink_tater()
@app.post("/api/auto_train/action") @app.post("/api/auto_train/action")
def auto_train_action(payload: Dict[str, Any] = None): def auto_train_action(payload: Dict[str, Any] = None):
action = str((payload or {}).get("action") or "").strip().lower() action = str((payload or {}).get("action") or "").strip().lower()