7 Commits
v20 ... v27

Author SHA1 Message Date
MasterPhooey
13f229e451 Release NVIDIA WakeWord Trainer v27 2026-08-10 07:06:44 -05:00
MasterPhooey
68c4227cb7 Release NVIDIA WakeWord Trainer v26 2026-08-04 06:25:09 -05:00
MasterPhooey
bd71567a4f Release NVIDIA WakeWord Trainer v25 2026-08-03 19:18:06 -05:00
MasterPhooey
293318ad20 Release NVIDIA WakeWord Trainer v24 2026-08-03 07:25:58 -05:00
MasterPhooey
1f16f6f916 Release NVIDIA WakeWord Trainer v23 2026-08-03 06:45:32 -05:00
MasterPhooey
2a88090b85 Release NVIDIA WakeWord Trainer v22 2026-08-02 20:46:04 -05:00
MasterPhooey
2b1320f1f3 Release NVIDIA WakeWord Trainer v21 2026-07-26 18:40:25 -05:00
37 changed files with 12620 additions and 3632 deletions

3
.gitignore vendored
View File

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

View File

@@ -7,7 +7,7 @@
<a href="https://taterassistant.com">taterassistant.com</a>
</h3>
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, device-captured samples, reviewed false-wake negatives, live training logs, and local wake-word links for Tater Native satellites.
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, modern multilingual TTS ensembles, device-captured samples, reviewed false-wake negatives, live training logs, and local wake-word links for Tater Native satellites.
Real samples come from device-captured wake audio, close misses, or manual uploads. Every saved sample is normalized to `16 kHz / mono / 16-bit PCM WAV` before training.
@@ -79,6 +79,7 @@ If you change `REC_PORT`, open that port instead and use the same port in the sa
## What The UI Does
- The entire interface is reactive Vue 3 + TypeScript, following the same typed component pattern as Tater's newer UI surfaces.
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
- `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.
@@ -86,6 +87,16 @@ If you change `REC_PORT`, open that port instead and use the same port in the sa
- `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater.
- Popup consoles show colorized training logs while long-running jobs are active.
The production bundle is committed under `static/ui`, so neither NVIDIA Docker image needs Node.js. To change the UI, edit `frontend/src` and rebuild it before building the image:
```bash
cd frontend
npm install
npm run build
```
`npm run build` type-checks every Vue component before writing the offline bundle copied into both the standard CUDA and Blackwell images.
---
## Captured Audio Workflow
@@ -196,8 +207,8 @@ The default Tater URL, `http://127.0.0.1:8501`, assumes the documented host netw
## Training Flow
1. Enter the wake phrase in `Trainer`.
2. Choose the language.
3. Optionally test pronunciation with `Test TTS`.
2. Choose the language and TTS source.
3. Optionally check browser pronunciation with `System preview`.
4. Review the positive and negative sample counts.
5. Click `Start training`.
6. Watch the popup training console.
@@ -212,16 +223,21 @@ On RTX 50-series / Blackwell GPUs, the Blackwell Docker image keeps sample gener
## Language Support
The language picker is dynamic.
The language picker is built from OmniVoice's live catalog (currently more than 600 languages), with a bundled common-language fallback for offline startup. Languages covered by Qwen3-TTS and MOSS-TTS-Nano are automatically marked `Recommended`; OmniVoice-only languages are marked `Experimental` so lower-resource coverage is not presented as equal quality.
- `en` is always available.
- English keeps the existing dedicated generator model path.
- Non-English languages are discovered from the Piper voices catalog and any local Piper voice metadata.
- When a non-English language is selected, the trainer downloads all voices for that selected language only.
- Already-downloaded voices are reused.
- It does not download every language up front.
The selected code is sent directly to the supporting model. Model downloads happen only when a language is used, and the Hugging Face cache is persisted under `/data/.cache/huggingface`. The fetched language catalog is cached under `/data/.cache/omnivoice_languages.json`.
If the upstream Piper catalog is unavailable, already-installed local voices are used when available.
### TTS modes
- `Four-provider ensemble` is the default. It uses OmniVoice for every catalog language, adds Qwen3-TTS and MOSS-TTS-Nano where supported, and adds Piper when a compatible model exists.
- `Modern only` uses the multilingual providers without Piper.
- `Piper only` preserves the previous generator as an explicit legacy fallback.
Where a Piper voice is unavailable, the default route automatically continues with the modern providers.
Qwen, OmniVoice, and Piper now generate final corpus candidates directly instead of cloning a 128-profile bank. Qwen provides 18,750 balanced voice conditions before an instruction repeats, and Piper uses every speaker in its installed model. MOSS Nano is clone-only, so each MOSS take uses a different already-accepted direct take as its carrier rather than cycling a small bank.
Every generated file is normalized to `16 kHz / mono / 16-bit PCM WAV` and rejected if it contains static, broadband/high-frequency noise, silence, clipping, excessive duration/rambling, or an exact duplicate. A generation manifest records the planned and accepted provider counts and the applied safety limits.
---
@@ -229,12 +245,14 @@ If the upstream Piper catalog is unavailable, already-installed local voices are
The first training run downloads and prepares missing training assets into `/data`, including:
- Piper voices for the selected language
- isolated Python environments for each selected modern TTS engine
- selected TTS model weights and the direct generated corpus
- additional language-specific Piper voices only when hybrid or legacy Piper mode is selected
- negative datasets and background data
- the Python training environment
- generated samples and augmented feature caches
After those assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted.
The three modern engines deliberately use separate environments under `/data/tts-envs/`; their required PyTorch and Transformers versions conflict with one another and with the trainer environment. Model weights can require many gigabytes, so allow extra disk space and time on the first run. After the assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted.
---
@@ -319,6 +337,7 @@ That removes:
- negative samples
- captured inbox clips
- downloaded Piper voices
- modern TTS environments, model weights, and completed direct-generated corpora
- cached datasets
- training environments
- trained models
@@ -343,4 +362,7 @@ Built on top of:
- [microWakeWord](https://github.com/kahrendt/microWakeWord)
- [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator)
- [OmniVoice](https://github.com/k2-fsa/OmniVoice)
- [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)
- [MOSS-TTS-Nano](https://github.com/OpenMOSS/MOSS-TTS-Nano)
- [tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313) for the optional RTX 50-series / Blackwell image

View File

@@ -1 +1 @@
20
27

View File

@@ -1,3 +1 @@
- 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.
- Added English accent emphasis for Mixed English, Australian, American, British, Canadian, Irish, Scottish, New Zealand, Indian, and South African voices. Qwen shapes the selected accent and MOSS carries it into cloned references, with the setting available in both manual and automatic training.

113
cli/setup_modern_tts_envs Executable file
View File

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

View File

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

1567
cli/tts_generate_samples.py Executable file

File diff suppressed because it is too large Load Diff

98
cli/tts_moss_worker.py Executable file
View File

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

151
cli/tts_qwen_worker.py Executable file
View File

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

374
cli/tts_reference_qa.py Normal file
View File

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

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell-
# Python 3.13 is used only for the Blackwell TensorFlow training step.
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git wget unzip patch \
ninja-build nano less libgomp1 \
ninja-build build-essential cmake pkg-config nano less libgomp1 \
ffmpeg sox libsox-fmt-all libsndfile1 espeak-ng \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -41,14 +42,16 @@ COPY --chown=root:root --chmod=0755 \
requirements.txt \
/root/mww-scripts/
COPY --chown=root:root --chmod=0644 tts_config.py /root/mww-scripts/tts_config.py
# CLI folder
COPY --chown=root:root cli/ /root/mww-scripts/cli/
# Make all CLI scripts executable (avoids "Permission denied")
RUN chmod -R a+x /root/mww-scripts/cli
# Static UI for trainer
COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html
# Prebuilt Vue/TypeScript UI (Node.js is not required at runtime)
COPY --chown=root:root static/ /root/mww-scripts/static/
# trainer server
CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"]

1199
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

17
frontend/tsconfig.json Normal file
View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,21 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(trainer._transcript_contains_wake_phrase("Okay, HEY TATER!", "hey_tater"))
self.assertFalse(trainer._transcript_contains_wake_phrase("Turn on the television", "hey tater"))
def test_phrase_similarity_recognizes_real_short_clip_mishearings(self):
for transcript in ("Hey, haters.", "Hate hater.", "Hey Ganger.", "Hey, gator."):
with self.subTest(transcript=transcript):
self.assertGreaterEqual(
trainer._wake_phrase_similarity(transcript, "hey tater"),
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
for transcript in ("turn on the lights", "what is the weather", "play some music"):
with self.subTest(transcript=transcript):
self.assertLess(
trainer._wake_phrase_similarity(transcript, "hey tater"),
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
def test_stt_engine_selection_uses_managed_models(self):
config = trainer._normalize_auto_train_config(
{
@@ -160,6 +175,39 @@ class AutoTrainTests(unittest.TestCase):
faster.assert_called_once()
parakeet.assert_called_once()
def test_guided_faster_whisper_uses_dynamic_wake_phrase(self):
fake_model = SimpleNamespace(
transcribe=Mock(
return_value=(
iter([SimpleNamespace(text=" hello "), SimpleNamespace(text="potato ")]),
SimpleNamespace(),
)
)
)
with (
patch.object(
trainer,
"_resolve_faster_whisper_runtime",
return_value=("cuda", "float16"),
),
patch.object(trainer, "_load_faster_whisper_model", return_value=fake_model),
):
transcript = trainer._transcribe_capture_with_faster_whisper_guided(
Path("wake.wav"),
model="small.en",
language="en",
wake_phrase="Hello_Potato",
)
self.assertEqual(transcript, "hello potato")
_, kwargs = fake_model.transcribe.call_args
self.assertEqual(kwargs["hotwords"], "hello potato")
self.assertIn("hello potato", kwargs["initial_prompt"])
self.assertEqual(kwargs["beam_size"], 5)
self.assertEqual(kwargs["best_of"], 5)
self.assertEqual(kwargs["temperature"], 0.0)
self.assertFalse(kwargs["condition_on_previous_text"])
def test_parakeet_loader_prefers_cuda_then_cpu(self):
fake_model = object()
fake_onnx_asr = SimpleNamespace(load_model=Mock(return_value=fake_model))
@@ -244,13 +292,14 @@ class AutoTrainTests(unittest.TestCase):
)
def test_ui_exposes_engine_selector_without_manual_runtime_fields(self):
source = (Path(__file__).resolve().parents[1] / "static" / "index.html").read_text(
source = (Path(__file__).resolve().parents[1] / "frontend" / "src" / "TrainerApp.vue").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)
self.assertIn('v-model="trainer.autoForm.stt_engine"', source)
self.assertNotIn('trainer.autoForm.stt_model', source)
self.assertNotIn('trainer.autoForm.stt_device', source)
self.assertNotIn('trainer.autoForm.stt_compute_type', source)
self.assertIn("Guided wake check", source)
def test_phrase_miss_moves_wake_trigger_to_negative_samples(self):
self.add_capture()
@@ -266,6 +315,10 @@ class AutoTrainTests(unittest.TestCase):
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")
sample_item = trainer._sample_item_from_path(negatives[0], "negative")
self.assertEqual(sample_item["transcript"], "turn on the kitchen lights")
self.assertEqual(sample_item["auto_review_stt_engine"], "faster_whisper")
self.assertEqual(sample_item["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):
@@ -279,6 +332,80 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(metadata["auto_review_status"], "wake_phrase_detected")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
def test_close_transcript_uses_guided_faster_whisper_confirmation(self):
audio_path = self.add_capture()
with (
patch.object(trainer, "_transcribe_capture", return_value="Hey, haters."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hey Tater",
) as guided,
):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_detected")
self.assertEqual(metadata["transcript"], "Hey, haters.")
self.assertEqual(metadata["auto_review_guided_transcript"], "Hey Tater")
self.assertEqual(metadata["auto_review_match_method"], "guided_close_match")
self.assertGreaterEqual(
metadata["auto_review_phrase_similarity"],
trainer.WAKE_PHRASE_GUIDANCE_MIN_SIMILARITY,
)
guided.assert_called_once()
guided_args, guided_kwargs = guided.call_args
self.assertEqual(guided_args[0].resolve(), audio_path.resolve())
self.assertEqual(
guided_kwargs,
{
"model": "small.en",
"language": "en",
"wake_phrase": "hey tater",
},
)
def test_unconfirmed_close_transcript_stays_for_manual_review(self):
audio_path = self.add_capture()
with (
patch.object(trainer, "_transcribe_capture", return_value="Hate hater."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hate hater.",
),
):
trainer._auto_review_capture("wake.wav")
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_ambiguous")
self.assertEqual(metadata["transcript"], "Hate hater.")
self.assertEqual(metadata["auto_review_guided_transcript"], "Hate hater.")
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
self.assertEqual(trainer._queue_pending_auto_reviews(), 0)
self.assertEqual(trainer._queue_pending_auto_reviews(force=True), 1)
def test_close_parakeet_transcript_stays_for_manual_review(self):
audio_path = self.add_capture()
trainer.AUTO_TRAIN_CONFIG["stt_engine"] = trainer.STT_ENGINE_PARAKEET_ONNX
with (
patch.object(trainer, "_transcribe_capture", return_value="Hey Ganger."),
patch.object(trainer, "_transcribe_capture_with_faster_whisper_guided") as guided,
):
trainer._auto_review_capture("wake.wav")
guided.assert_not_called()
self.assertTrue(audio_path.exists())
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
metadata = trainer._load_sidecar_json(audio_path)
self.assertEqual(metadata["auto_review_status"], "wake_phrase_ambiguous")
self.assertEqual(metadata["auto_review_stt_engine"], "parakeet_onnx")
def test_matching_phrase_is_deleted_when_cleanup_is_enabled(self):
audio_path = self.add_capture()
trainer.AUTO_TRAIN_CONFIG["delete_confirmed_wakes"] = True
@@ -344,9 +471,30 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(metadata["auto_positive"])
self.assertEqual(metadata["review_status"], "auto_approved_personal")
self.assertEqual(metadata["transcript"], "hey tater")
sample_item = trainer._sample_item_from_path(positives[0], "personal")
self.assertEqual(sample_item["transcript"], "hey tater")
self.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0)
def test_guided_stt_remains_visible_after_positive_auto_sort(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, haters."),
patch.object(
trainer,
"_transcribe_capture_with_faster_whisper_guided",
return_value="Hey Tater",
),
):
trainer._auto_review_capture("wake.wav")
positives = list(trainer.PERSONAL_DIR.glob("*.wav"))
self.assertEqual(len(positives), 1)
sample_item = trainer._sample_item_from_path(positives[0], "personal")
self.assertEqual(sample_item["transcript"], "Hey, haters.")
self.assertEqual(sample_item["auto_review_guided_transcript"], "Hey Tater")
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
@@ -443,6 +591,74 @@ class AutoTrainTests(unittest.TestCase):
},
)
def test_trained_word_catalog_keeps_url_alias_for_json_package(self):
with tempfile.TemporaryDirectory() as directory:
trained_dir = Path(directory)
(trained_dir / "hey_tater.tflite").write_bytes(b"model")
(trained_dir / "hey_tater.json").write_text(
json.dumps({"wake_word": "hey tater", "model": "hey_tater.tflite"}),
encoding="utf-8",
)
with (
patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir),
patch.object(trainer, "_sync_trained_wake_word_artifacts"),
):
rows = trainer._list_trained_wake_words("http://10.4.20.210:8789")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["url"], rows[0]["json_url"])
self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json"))
self.assertTrue(
rows[0]["esphome_json_url"].endswith(
"/api/trained_wake_words/hey_tater.esphome.json"
)
)
def test_esphome_manifest_route_removes_tater_extensions(self):
with tempfile.TemporaryDirectory() as directory:
trained_dir = Path(directory)
(trained_dir / "hey_tater.tflite").write_bytes(b"model")
metadata = {
"type": "micro",
"wake_word": "hey tater",
"label": "Hey Tater",
"author": "Tater Totterson",
"website": "https://example.com",
"model": "hey_tater.tflite",
"trained_languages": ["en"],
"version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": {
"probability_cutoff": 0.97,
"sliding_window_size": 5,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0",
},
"tater_native": {"format_version": 1},
"calibration": {"recall": 0.99},
}
(trained_dir / "hey_tater.json").write_text(
json.dumps(metadata),
encoding="utf-8",
)
with (
patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir),
patch.object(trainer, "_sync_trained_wake_word_artifacts"),
):
response = trainer.trained_wake_word_artifact(
"hey_tater.esphome.json"
)
payload = json.loads(response.body)
self.assertEqual(set(payload), set(trainer.ESPHOME_MANIFEST_KEYS))
self.assertEqual(payload["micro"], metadata["micro"])
self.assertNotIn("label", payload)
self.assertNotIn("tater_native", payload)
self.assertNotIn("calibration", payload)
def test_tater_notification_fails_when_trained_word_is_missing(self):
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
with (

View File

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

724
tests/test_modern_tts.py Normal file
View File

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

116
tests/test_session_stop.py Normal file
View File

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

68
tests/test_tts_config.py Normal file
View File

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

126
tests/test_vue_ui.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

274
tts_config.py Normal file
View File

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