mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ee63a65b | ||
|
|
518df63161 | ||
|
|
6ee228e8d3 | ||
|
|
2eee70cb34 | ||
|
|
426e4ec83f | ||
|
|
c474deb8b5 | ||
|
|
931694b711 | ||
|
|
5554b2eb5e | ||
|
|
7d77f71dc3 |
18
.github/workflows/docker-publish.yml
vendored
18
.github/workflows/docker-publish.yml
vendored
@@ -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}\`"
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -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:v11
|
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:v11-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,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:v11` 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:v11-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:
|
||||||
@@ -78,6 +80,7 @@ If you change `REC_PORT`, open that port instead and use the same port in the sa
|
|||||||
## 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.
|
||||||
|
- `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.
|
- `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.
|
||||||
- `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater.
|
- `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater.
|
||||||
@@ -162,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`.
|
||||||
@@ -212,6 +243,7 @@ After those assets are prepared, later runs reuse the local copies unless the mo
|
|||||||
The `Wake Words` tab lists locally trained wake-word packages from `/data/trained_wake_words/`.
|
The `Wake Words` tab lists locally trained wake-word packages from `/data/trained_wake_words/`.
|
||||||
|
|
||||||
- Copy the JSON URL into the Tater Native satellite settings to switch wake words live.
|
- Copy the JSON URL into the Tater Native satellite settings to switch wake words live.
|
||||||
|
- Links use the configured public trainer URL, a non-loopback browser host, or the detected LAN address instead of advertising `127.0.0.1` to satellites.
|
||||||
- Open the JSON or model links directly for quick inspection.
|
- Open the JSON or model links directly for quick inspection.
|
||||||
- The JSON includes the matching model path plus Tater tuning metadata.
|
- The JSON includes the matching model path plus Tater tuning metadata.
|
||||||
- No firmware flashing happens from this trainer app anymore.
|
- No firmware flashing happens from this trainer app anymore.
|
||||||
@@ -244,7 +276,7 @@ The JSON keeps the standard microWakeWord fields for compatibility:
|
|||||||
{
|
{
|
||||||
"micro": {
|
"micro": {
|
||||||
"probability_cutoff": 0.97,
|
"probability_cutoff": 0.97,
|
||||||
"sliding_window_size": 5
|
"sliding_window_size": 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -259,8 +291,8 @@ It also includes Tater Native metadata used by newer satellites and the Tater se
|
|||||||
"tater_native": {
|
"tater_native": {
|
||||||
"format_version": 1,
|
"format_version": 1,
|
||||||
"wake_threshold": 0.97,
|
"wake_threshold": 0.97,
|
||||||
"wake_sliding_window": 5,
|
"wake_sliding_window": 6,
|
||||||
"close_miss_threshold": 0.78,
|
"close_miss_threshold": 0.80,
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "tflm_microfrontend",
|
"name": "tflm_microfrontend",
|
||||||
"sample_rate": 16000,
|
"sample_rate": 16000,
|
||||||
@@ -273,6 +305,7 @@ It also includes Tater Native metadata used by newer satellites and the Tater se
|
|||||||
```
|
```
|
||||||
|
|
||||||
Calibration metrics are included under `calibration` so false accepts/hour and recall can be surfaced in the UI.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,6 +322,7 @@ That removes:
|
|||||||
- cached datasets
|
- cached datasets
|
||||||
- training environments
|
- training environments
|
||||||
- trained models
|
- trained models
|
||||||
|
- Auto Training settings, state, transcripts, and cached Faster Whisper models
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -296,6 +330,7 @@ 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`.
|
||||||
- Trainer capture settings live in Tater for Tater Native satellites, and on device entities for older ESPHome satellites.
|
- Trainer capture settings live in Tater for Tater Native satellites, and on device entities for older ESPHome satellites.
|
||||||
|
|||||||
3
WHATS_NEW.md
Normal file
3
WHATS_NEW.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
- Fixed the v19 container startup failure caused by malformed indentation in the Parakeet ONNX loader.
|
||||||
|
- Preserved automatic download, resume, and offline reuse of the required Parakeet INT8 model snapshot.
|
||||||
|
- Revalidated both CUDA and CPU Parakeet provider paths with the complete trainer test suite.
|
||||||
@@ -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_WINDOW_SIZES = [4, 5, 6, 7]
|
|
||||||
DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "0.25"))
|
DEFAULT_TARGET_FAPH = float(os.environ.get("MWW_CALIBRATION_TARGET_FAPH", "0.25"))
|
||||||
DEFAULT_COOLDOWN_SLICES = int(os.environ.get("MWW_CALIBRATION_COOLDOWN_SLICES", "25"))
|
DEFAULT_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.85"))
|
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(),
|
||||||
|
|||||||
@@ -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}" || :
|
||||||
|
|||||||
@@ -467,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,8 +501,8 @@ from pathlib import Path
|
|||||||
json_path = Path(os.environ["JSON_PATH"])
|
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.85
|
probability_cutoff = 0.97
|
||||||
sliding_window_size = 4
|
sliding_window_size = 6
|
||||||
strict_min_close_miss_threshold = 0.68
|
strict_min_close_miss_threshold = 0.68
|
||||||
calibration = {}
|
calibration = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
57
run.sh
57
run.sh
@@ -31,7 +31,13 @@ install_ui_deps() {
|
|||||||
"uvicorn[standard]==${UVICORN_VERSION}" \
|
"uvicorn[standard]==${UVICORN_VERSION}" \
|
||||||
"python-multipart==${PY_MULTIPART_VERSION}" \
|
"python-multipart==${PY_MULTIPART_VERSION}" \
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -78,8 +84,15 @@ exact = {
|
|||||||
minimum = {
|
minimum = {
|
||||||
"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": "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:
|
||||||
@@ -89,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
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|||||||
@@ -210,6 +210,35 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.autoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.autoGrid .wide { grid-column: 1 / -1; }
|
||||||
|
.checkField {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.09);
|
||||||
|
background: rgba(255,255,255,0.035);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.checkField input { margin-top: 3px; }
|
||||||
|
.checkField[hidden] { display: none !important; }
|
||||||
|
.checkField strong { color: var(--text); display: block; margin-bottom: 3px; }
|
||||||
|
.autoActions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.autoAudit {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: rgba(0,0,0,0.22);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.firmwareGrid {
|
.firmwareGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(220px, 280px);
|
grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(220px, 280px);
|
||||||
@@ -1055,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);
|
||||||
@@ -1074,6 +1140,8 @@
|
|||||||
input[type="text"] { width: 100%; }
|
input[type="text"] { width: 100%; }
|
||||||
.fileItem { align-items: flex-start; flex-direction: column; }
|
.fileItem { align-items: flex-start; flex-direction: column; }
|
||||||
.firmwareGrid { grid-template-columns: 1fr; }
|
.firmwareGrid { grid-template-columns: 1fr; }
|
||||||
|
.autoGrid { grid-template-columns: 1fr; }
|
||||||
|
.autoGrid .wide { grid-column: auto; }
|
||||||
.firmwareLayout,
|
.firmwareLayout,
|
||||||
.firmwareTargetGrid,
|
.firmwareTargetGrid,
|
||||||
.firmwareActionsPanel {
|
.firmwareActionsPanel {
|
||||||
@@ -1164,6 +1232,7 @@
|
|||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button id="tabTrainer" class="tabBtn active" type="button">Trainer</button>
|
<button id="tabTrainer" class="tabBtn active" type="button">Trainer</button>
|
||||||
|
<button id="tabAuto" class="tabBtn" type="button">Auto Training</button>
|
||||||
<button id="tabFirmware" class="tabBtn" type="button">Wake Words</button>
|
<button id="tabFirmware" class="tabBtn" type="button">Wake Words</button>
|
||||||
<button id="tabCaptured" class="tabBtn" type="button">Captured Audio</button>
|
<button id="tabCaptured" class="tabBtn" type="button">Captured Audio</button>
|
||||||
<button id="tabSamples" class="tabBtn" type="button">Samples</button>
|
<button id="tabSamples" class="tabBtn" type="button">Samples</button>
|
||||||
@@ -1250,6 +1319,145 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="autoView" class="viewStack stack" hidden>
|
||||||
|
<div class="card studioHero trainerHero">
|
||||||
|
<div class="row space">
|
||||||
|
<div>
|
||||||
|
<div class="studioKicker">False-Positive Loop</div>
|
||||||
|
<h3>Auto Training</h3>
|
||||||
|
<p>Sort false wakes into negatives, recover spoken close misses as positives, clean up confirmed wakes, retrain on your schedule, and refresh connected satellites.</p>
|
||||||
|
<div class="studioSteps" aria-label="Auto Training steps">
|
||||||
|
<span class="studioStepChip"><b>1</b> Transcribe wakes</span>
|
||||||
|
<span class="studioStepChip"><b>2</b> Collect negatives</span>
|
||||||
|
<span class="studioStepChip"><b>3</b> Retrain + refresh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="autoStatus" class="pill">Disabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card studioPanel stack">
|
||||||
|
<div class="studioPanelHeader">
|
||||||
|
<div class="studioPanelTitle">
|
||||||
|
<span class="studioStepBadge">1</span>
|
||||||
|
<div>
|
||||||
|
<h3>Review Rules</h3>
|
||||||
|
<p>False-wake sorting is always conservative. Cleanup and close-miss promotion remain optional, and empty STT results stay in the inbox.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="checkField">
|
||||||
|
<input id="autoEnabled" type="checkbox" />
|
||||||
|
<span><strong>Enable Auto Training</strong>Eligible wake triggers will be queued for transcription with the selected local STT engine.</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkField">
|
||||||
|
<input id="autoDeleteConfirmedWakes" type="checkbox" />
|
||||||
|
<span><strong>Delete confirmed good wakes</strong>When a normal wake trigger contains the configured phrase, remove it from Captured Audio instead of keeping it for manual review.</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkField">
|
||||||
|
<input id="autoPromoteCloseMisses" type="checkbox" />
|
||||||
|
<span><strong>Promote confirmed close misses</strong>Transcribe close misses that passed VAD and move them to Positive samples only when STT finds the configured phrase.</span>
|
||||||
|
</label>
|
||||||
|
<div class="autoGrid">
|
||||||
|
<label class="field">
|
||||||
|
<strong>Wake phrase</strong>
|
||||||
|
<input id="autoWakePhrase" type="text" placeholder='e.g. "hey tater"' />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>STT language</strong>
|
||||||
|
<input id="autoLanguage" type="text" value="en" placeholder="en" />
|
||||||
|
</label>
|
||||||
|
<label class="field wide">
|
||||||
|
<strong>STT engine</strong>
|
||||||
|
<select id="autoSttEngine">
|
||||||
|
<option value="faster_whisper" selected>Faster Whisper (recommended)</option>
|
||||||
|
<option value="parakeet_onnx">Parakeet ONNX</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>Minimum transcript characters</strong>
|
||||||
|
<input id="autoMinimumChars" type="number" min="1" max="100" value="2" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card studioPanel stack">
|
||||||
|
<div class="studioPanelHeader">
|
||||||
|
<div class="studioPanelTitle">
|
||||||
|
<span class="studioStepBadge">2</span>
|
||||||
|
<div>
|
||||||
|
<h3>Training Schedule</h3>
|
||||||
|
<p>A scheduled run starts only after enough new auto-reviewed negatives have accumulated.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autoGrid">
|
||||||
|
<label class="field">
|
||||||
|
<strong>Run training</strong>
|
||||||
|
<select id="autoScheduleHours">
|
||||||
|
<option value="0">Manually only</option>
|
||||||
|
<option value="6">Every 6 hours</option>
|
||||||
|
<option value="12">Every 12 hours</option>
|
||||||
|
<option value="24" selected>Every day</option>
|
||||||
|
<option value="48">Every 2 days</option>
|
||||||
|
<option value="168">Every week</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>Minimum new negatives</strong>
|
||||||
|
<input id="autoMinimumNegatives" type="number" min="1" max="10000" value="3" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="statGrid">
|
||||||
|
<div class="stat"><span class="label">Pending Negatives</span><span class="value" id="autoPendingNegatives">0</span></div>
|
||||||
|
<div class="stat"><span class="label">Next Scheduled Check</span><span class="value" id="autoNextRun" style="font-size:16px;">Manual</span></div>
|
||||||
|
<div class="stat"><span class="label">Last Training</span><span class="value" id="autoLastTraining" style="font-size:16px;">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card studioPanel stack">
|
||||||
|
<div class="studioPanelHeader">
|
||||||
|
<div class="studioPanelTitle">
|
||||||
|
<span class="studioStepBadge">3</span>
|
||||||
|
<div>
|
||||||
|
<h3>Publish to Tater</h3>
|
||||||
|
<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 class="autoGrid">
|
||||||
|
<label class="field wide">
|
||||||
|
<strong>Trainer public URL</strong>
|
||||||
|
<input id="autoAdvertisedUrl" type="text" placeholder="Auto-detect this host's LAN IP" />
|
||||||
|
<span id="autoDetectedUrl" class="muted">Auto-detected when left blank.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field wide">
|
||||||
|
<strong>Tater URL</strong>
|
||||||
|
<input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="taterLinkActions">
|
||||||
|
<span id="autoTaterLinkStatus" class="pill">Not linked</span>
|
||||||
|
<button id="autoLinkTaterBtn" class="primary" type="button">Link Tater</button>
|
||||||
|
<button id="autoUnlinkTaterBtn" class="danger" type="button" hidden>Unlink</button>
|
||||||
|
</div>
|
||||||
|
<label class="checkField">
|
||||||
|
<input id="autoNotifySatellites" type="checkbox" checked />
|
||||||
|
<span><strong>Activate the new word after successful training</strong>Tater applies it globally and updates every connected satellite.</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card studioPanel stack">
|
||||||
|
<div class="autoActions">
|
||||||
|
<button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button>
|
||||||
|
<button id="autoReviewNowBtn" type="button">Review inbox now</button>
|
||||||
|
<button id="autoTrainNowBtn" type="button">Train now</button>
|
||||||
|
<button id="autoNotifyNowBtn" type="button">Publish current wake word now</button>
|
||||||
|
</div>
|
||||||
|
<div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="capturedView" class="viewStack stack" hidden>
|
<div id="capturedView" class="viewStack stack" hidden>
|
||||||
<div class="card studioHero captureHero">
|
<div class="card studioHero captureHero">
|
||||||
<div class="row space">
|
<div class="row space">
|
||||||
@@ -1487,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);
|
||||||
|
|
||||||
@@ -1498,9 +1722,11 @@
|
|||||||
captured: { items: [], captured_count: 0, negative_count: 0, personal_count: 0 },
|
captured: { items: [], captured_count: 0, negative_count: 0, personal_count: 0 },
|
||||||
samples: { personal: [], negative: [], personal_count: 0, negative_count: 0, activeBucket: "personal", pages: { personal: 0, negative: 0 } },
|
samples: { personal: [], negative: [], personal_count: 0, negative_count: 0, activeBucket: "personal", pages: { personal: 0, negative: 0 } },
|
||||||
firmware: { wakeWords: [] },
|
firmware: { wakeWords: [] },
|
||||||
|
autoTrain: null,
|
||||||
uploadBusy: false,
|
uploadBusy: false,
|
||||||
reviewBusy: false,
|
reviewBusy: false,
|
||||||
firmwareBusy: false,
|
firmwareBusy: false,
|
||||||
|
autoBusy: false,
|
||||||
trainingPoller: null,
|
trainingPoller: null,
|
||||||
activeView: "trainer",
|
activeView: "trainer",
|
||||||
};
|
};
|
||||||
@@ -1774,12 +2000,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setActiveView(view) {
|
function setActiveView(view) {
|
||||||
uiState.activeView = ["captured", "samples", "firmware"].includes(view) ? view : "trainer";
|
uiState.activeView = ["auto", "captured", "samples", "firmware"].includes(view) ? view : "trainer";
|
||||||
$("trainerView").hidden = uiState.activeView !== "trainer";
|
$("trainerView").hidden = uiState.activeView !== "trainer";
|
||||||
|
$("autoView").hidden = uiState.activeView !== "auto";
|
||||||
$("capturedView").hidden = uiState.activeView !== "captured";
|
$("capturedView").hidden = uiState.activeView !== "captured";
|
||||||
$("samplesView").hidden = uiState.activeView !== "samples";
|
$("samplesView").hidden = uiState.activeView !== "samples";
|
||||||
$("firmwareView").hidden = uiState.activeView !== "firmware";
|
$("firmwareView").hidden = uiState.activeView !== "firmware";
|
||||||
$("tabTrainer").classList.toggle("active", uiState.activeView === "trainer");
|
$("tabTrainer").classList.toggle("active", uiState.activeView === "trainer");
|
||||||
|
$("tabAuto").classList.toggle("active", uiState.activeView === "auto");
|
||||||
$("tabCaptured").classList.toggle("active", uiState.activeView === "captured");
|
$("tabCaptured").classList.toggle("active", uiState.activeView === "captured");
|
||||||
$("tabSamples").classList.toggle("active", uiState.activeView === "samples");
|
$("tabSamples").classList.toggle("active", uiState.activeView === "samples");
|
||||||
$("tabFirmware").classList.toggle("active", uiState.activeView === "firmware");
|
$("tabFirmware").classList.toggle("active", uiState.activeView === "firmware");
|
||||||
@@ -1835,6 +2063,239 @@
|
|||||||
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
|
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAutoTrain(payload, populateForm = true) {
|
||||||
|
const data = payload || {};
|
||||||
|
const config = data.config || {};
|
||||||
|
const state = data.state || {};
|
||||||
|
const runtime = data.runtime || {};
|
||||||
|
const trainerLink = data.trainer_link || {};
|
||||||
|
uiState.autoTrain = data;
|
||||||
|
|
||||||
|
if (populateForm) {
|
||||||
|
$("autoEnabled").checked = Boolean(config.enabled);
|
||||||
|
$("autoWakePhrase").value = config.wake_phrase || uiState.session?.raw_phrase || "";
|
||||||
|
$("autoLanguage").value = config.language || uiState.session?.language || "en";
|
||||||
|
$("autoSttEngine").value = config.stt_engine || "faster_whisper";
|
||||||
|
$("autoMinimumChars").value = String(config.minimum_transcript_chars ?? 2);
|
||||||
|
$("autoDeleteConfirmedWakes").checked = Boolean(config.delete_confirmed_wakes);
|
||||||
|
$("autoPromoteCloseMisses").checked = Boolean(config.promote_close_misses);
|
||||||
|
$("autoScheduleHours").value = String(config.schedule_hours ?? 24);
|
||||||
|
$("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3);
|
||||||
|
$("autoAdvertisedUrl").value = config.advertised_base_url || "";
|
||||||
|
$("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501";
|
||||||
|
$("autoNotifySatellites").checked = config.notify_satellites !== 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
|
||||||
|
? `Using configured URL: ${config.advertised_base_url}`
|
||||||
|
: `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`;
|
||||||
|
$("autoPendingNegatives").textContent = String(Number(state.pending_negative_count || 0));
|
||||||
|
$("autoNextRun").textContent = state.next_run_at ? formatTimestamp(state.next_run_at) : "Manual";
|
||||||
|
const lastExit = state.last_train_exit_code;
|
||||||
|
$("autoLastTraining").textContent = state.last_train_finished_at
|
||||||
|
? `${formatTimestamp(state.last_train_finished_at)}${lastExit === null || lastExit === undefined ? "" : ` · exit ${lastExit}`}`
|
||||||
|
: "Never";
|
||||||
|
|
||||||
|
if (runtime.review_running) {
|
||||||
|
setPill($("autoStatus"), `Transcribing ${runtime.review_file || "wake"}`, "warn");
|
||||||
|
} else if (uiState.training?.running && config.enabled) {
|
||||||
|
setPill($("autoStatus"), "Training running", "warn");
|
||||||
|
} else if (config.enabled) {
|
||||||
|
setPill($("autoStatus"), "Enabled", "ok");
|
||||||
|
} else {
|
||||||
|
setPill($("autoStatus"), "Disabled", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const audit = [];
|
||||||
|
if (state.last_review_result) audit.push(`Last review: ${String(state.last_review_result).replaceAll("_", " ")}`);
|
||||||
|
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_error) audit.push(`Error: ${state.last_review_error}`);
|
||||||
|
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) {
|
||||||
|
audit.push(state.last_notify_error
|
||||||
|
? `Wake-word publish failed: ${state.last_notify_error}`
|
||||||
|
: `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.";
|
||||||
|
syncButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAutoTrain(populateForm = true) {
|
||||||
|
const data = await api("/api/auto_train", { method: "GET" });
|
||||||
|
renderAutoTrain(data, populateForm);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoTrainFormPayload() {
|
||||||
|
const payload = {
|
||||||
|
enabled: $("autoEnabled").checked,
|
||||||
|
wake_phrase: ($("autoWakePhrase").value || "").trim(),
|
||||||
|
language: ($("autoLanguage").value || "en").trim(),
|
||||||
|
stt_engine: $("autoSttEngine").value || "faster_whisper",
|
||||||
|
minimum_transcript_chars: Number($("autoMinimumChars").value || 2),
|
||||||
|
delete_confirmed_wakes: $("autoDeleteConfirmedWakes").checked,
|
||||||
|
promote_close_misses: $("autoPromoteCloseMisses").checked,
|
||||||
|
schedule_hours: Number($("autoScheduleHours").value || 0),
|
||||||
|
minimum_new_negatives: Number($("autoMinimumNegatives").value || 3),
|
||||||
|
advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(),
|
||||||
|
tater_url: ($("autoTaterUrl").value || "").trim(),
|
||||||
|
notify_satellites: $("autoNotifySatellites").checked,
|
||||||
|
};
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAutoTrain() {
|
||||||
|
uiState.autoBusy = true;
|
||||||
|
syncButtons();
|
||||||
|
setPill($("autoStatus"), "Saving...", "warn");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/auto_train", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(autoTrainFormPayload()),
|
||||||
|
});
|
||||||
|
renderAutoTrain(data, true);
|
||||||
|
setPill($("autoStatus"), data.config?.enabled ? "Saved + enabled" : "Saved + disabled", data.config?.enabled ? "ok" : "");
|
||||||
|
await refreshTrainedWakeWords().catch(() => {});
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
uiState.autoBusy = false;
|
||||||
|
syncButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoTrainAction(action) {
|
||||||
|
uiState.autoBusy = true;
|
||||||
|
syncButtons();
|
||||||
|
setPill($("autoStatus"), "Working...", "warn");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/auto_train/action", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
});
|
||||||
|
renderAutoTrain(data, false);
|
||||||
|
if (action === "review_now") {
|
||||||
|
setPill($("autoStatus"), `${Number(data.queued || 0)} clip${Number(data.queued || 0) === 1 ? "" : "s"} queued`, "ok");
|
||||||
|
} else if (action === "train_now") {
|
||||||
|
setPill($("autoStatus"), "Training started", "warn");
|
||||||
|
await refreshSession();
|
||||||
|
pollTraining();
|
||||||
|
} else {
|
||||||
|
setPill($("autoStatus"), `Wake word published${data.count === null || data.count === undefined ? "" : ` to ${data.count} satellite(s)`}`, "ok");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
uiState.autoBusy = false;
|
||||||
|
syncButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -1879,6 +2340,7 @@
|
|||||||
if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`);
|
if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`);
|
||||||
if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`);
|
if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`);
|
||||||
if (item.detection_profile) meta.push(`<span class="pill">profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}</span>`);
|
if (item.detection_profile) meta.push(`<span class="pill">profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}</span>`);
|
||||||
|
if (item.auto_review_status) meta.push(`<span class="pill ${item.auto_review_status === "error" ? "err" : "warn"}">auto ${escapeHtml(String(item.auto_review_status).replaceAll("_", " "))}</span>`);
|
||||||
if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`<span class="pill">peak cutoff ${escapeHtml(item.peak_probability_cutoff)}</span>`);
|
if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`<span class="pill">peak cutoff ${escapeHtml(item.peak_probability_cutoff)}</span>`);
|
||||||
if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`<span class="pill">avg cutoff ${escapeHtml(item.probability_cutoff)}</span>`);
|
if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`<span class="pill">avg cutoff ${escapeHtml(item.probability_cutoff)}</span>`);
|
||||||
if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) {
|
if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) {
|
||||||
@@ -1906,6 +2368,8 @@
|
|||||||
<span class="pill ${badge.cls}">${escapeHtml(badge.label)}</span>
|
<span class="pill ${badge.cls}">${escapeHtml(badge.label)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fileMeta">${meta.join("") || `<span class="muted">No metadata attached</span>`}</div>
|
<div class="fileMeta">${meta.join("") || `<span class="muted">No metadata attached</span>`}</div>
|
||||||
|
${item.transcript ? `<div class="autoAudit"><strong>STT transcript</strong><br>${escapeHtml(item.transcript)}</div>` : ""}
|
||||||
|
${item.auto_review_error ? `<div class="muted">Auto review error: ${escapeHtml(item.auto_review_error)}</div>` : ""}
|
||||||
<audio class="audioPlayer" controls preload="none" src="${escapeHtml(item.audio_url || `/api/audio/captured/${encodeURIComponent(item.saved_as)}`)}"></audio>
|
<audio class="audioPlayer" controls preload="none" src="${escapeHtml(item.audio_url || `/api/audio/captured/${encodeURIComponent(item.saved_as)}`)}"></audio>
|
||||||
<div class="muted">Stored as ${escapeHtml(item.saved_as)} · ${escapeHtml(formatSummary)}</div>
|
<div class="muted">Stored as ${escapeHtml(item.saved_as)} · ${escapeHtml(formatSummary)}</div>
|
||||||
<div class="captureActions">
|
<div class="captureActions">
|
||||||
@@ -1949,6 +2413,8 @@
|
|||||||
if (item.original_name && item.original_name !== item.saved_as) subtitleParts.push(`From ${item.original_name}`);
|
if (item.original_name && item.original_name !== item.saved_as) subtitleParts.push(`From ${item.original_name}`);
|
||||||
if (when) subtitleParts.push(`Saved ${when}`);
|
if (when) subtitleParts.push(`Saved ${when}`);
|
||||||
if (item.message) subtitleParts.push(item.message);
|
if (item.message) subtitleParts.push(item.message);
|
||||||
|
if (item.auto_negative) subtitleParts.push("Auto-reviewed false positive");
|
||||||
|
if (item.auto_positive) subtitleParts.push("Auto-promoted close miss");
|
||||||
let revertBtn = '';
|
let revertBtn = '';
|
||||||
if (item.trimmed) {
|
if (item.trimmed) {
|
||||||
revertBtn = `<button type="button" data-sample-revert="${escapeAttr(item.saved_as)}" data-bucket="${escapeAttr(bucket)}">Revert</button>`;
|
revertBtn = `<button type="button" data-sample-revert="${escapeAttr(item.saved_as)}" data-bucket="${escapeAttr(bucket)}">Revert</button>`;
|
||||||
@@ -1963,6 +2429,7 @@
|
|||||||
<span class="pill ${badge.cls}">${badge.label}</span>
|
<span class="pill ${badge.cls}">${badge.label}</span>
|
||||||
${trimBadgeHtml}
|
${trimBadgeHtml}
|
||||||
</div>
|
</div>
|
||||||
|
${item.transcript ? `<div class="autoAudit"><strong>STT transcript</strong><br>${escapeHtml(item.transcript)}</div>` : ""}
|
||||||
<audio class="audioPlayer" controls preload="none" src="${escapeAttr(item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`)}?t=${encodeURIComponent(item.created_at || '')}"></audio>
|
<audio class="audioPlayer" controls preload="none" src="${escapeAttr(item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`)}?t=${encodeURIComponent(item.created_at || '')}"></audio>
|
||||||
<div class="muted">Stored in ${bucket === "negative" ? "negative_samples" : "personal_samples"} · ${escapeHtml(formatSummary)}</div>
|
<div class="muted">Stored in ${bucket === "negative" ? "negative_samples" : "personal_samples"} · ${escapeHtml(formatSummary)}</div>
|
||||||
<div class="captureActions">
|
<div class="captureActions">
|
||||||
@@ -2250,6 +2717,15 @@
|
|||||||
if (refreshWakeWordsBtn) {
|
if (refreshWakeWordsBtn) {
|
||||||
refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
|
refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
|
||||||
}
|
}
|
||||||
|
for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn", "autoLinkTaterBtn", "autoUnlinkTaterBtn"]) {
|
||||||
|
const button = $(id);
|
||||||
|
if (button) {
|
||||||
|
button.disabled =
|
||||||
|
uiState.autoBusy ||
|
||||||
|
(id === "autoTrainNowBtn" && Boolean(training.running)) ||
|
||||||
|
(id === "autoNotifyNowBtn" && !Boolean(uiState.autoTrain?.trainer_link?.linked));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSessionUI(session) {
|
function refreshSessionUI(session) {
|
||||||
@@ -2506,6 +2982,13 @@
|
|||||||
|
|
||||||
$("phrase").addEventListener("input", syncButtons);
|
$("phrase").addEventListener("input", syncButtons);
|
||||||
$("tabTrainer").addEventListener("click", () => setActiveView("trainer"));
|
$("tabTrainer").addEventListener("click", () => setActiveView("trainer"));
|
||||||
|
$("tabAuto").addEventListener("click", () => {
|
||||||
|
setActiveView("auto");
|
||||||
|
refreshAutoTrain(true).catch((error) => {
|
||||||
|
setPill($("autoStatus"), "Status failed", "err");
|
||||||
|
alert("Auto Training status failed: " + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
$("tabCaptured").addEventListener("click", () => setActiveView("captured"));
|
$("tabCaptured").addEventListener("click", () => setActiveView("captured"));
|
||||||
$("tabSamples").addEventListener("click", () => {
|
$("tabSamples").addEventListener("click", () => {
|
||||||
setActiveView("samples");
|
setActiveView("samples");
|
||||||
@@ -2556,6 +3039,55 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("autoSaveBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await saveAutoTrain();
|
||||||
|
} catch (error) {
|
||||||
|
setPill($("autoStatus"), "Save failed", "err");
|
||||||
|
alert("Auto Training save failed: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("autoReviewNowBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await runAutoTrainAction("review_now");
|
||||||
|
} catch (error) {
|
||||||
|
setPill($("autoStatus"), "Review failed", "err");
|
||||||
|
alert("Auto review failed: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("autoTrainNowBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await runAutoTrainAction("train_now");
|
||||||
|
} catch (error) {
|
||||||
|
setPill($("autoStatus"), "Training failed", "err");
|
||||||
|
alert("Auto training failed: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("autoNotifyNowBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await runAutoTrainAction("notify_now");
|
||||||
|
} catch (error) {
|
||||||
|
setPill($("autoStatus"), "Publish failed", "err");
|
||||||
|
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)");
|
||||||
openConsole(true, "Training Console", "Live training output appears here with color-coded console styling.");
|
openConsole(true, "Training Console", "Live training output appears here with color-coded console styling.");
|
||||||
@@ -2573,6 +3105,7 @@
|
|||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
closeTaterLinkModal();
|
||||||
closeConsole();
|
closeConsole();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2774,6 +3307,7 @@
|
|||||||
await refreshSession();
|
await refreshSession();
|
||||||
await refreshSamples();
|
await refreshSamples();
|
||||||
await refreshCapturedAudio();
|
await refreshCapturedAudio();
|
||||||
|
await refreshAutoTrain(true);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2864,6 +3398,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
setInterval(() => {
|
||||||
|
if (uiState.activeView !== "auto" || uiState.autoBusy) return;
|
||||||
|
refreshAutoTrain(false).catch(() => {});
|
||||||
|
}, 2500);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
607
tests/test_auto_train.py
Normal file
607
tests/test_auto_train.py
Normal 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()
|
||||||
88
tests/test_calibrate_detector.py
Normal file
88
tests/test_calibrate_detector.py
Normal 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
73
tests/test_run_sh.py
Normal 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()
|
||||||
1322
trainer_server.py
1322
trainer_server.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user