mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 16:05:34 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c4227cb7 | ||
|
|
bd71567a4f | ||
|
|
293318ad20 | ||
|
|
1f16f6f916 | ||
|
|
2a88090b85 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ personal_samples/*
|
||||
data/
|
||||
trim_history/
|
||||
.DS_Store
|
||||
frontend/node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
48
README.md
48
README.md
@@ -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
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
- Improved automatic review accuracy for short wake phrases that STT initially hears as similar-sounding words.
|
||||
- Added a conservative Faster Whisper confirmation pass that uses the currently configured wake phrase only when the unbiased transcript is already phonetically close.
|
||||
- Kept unconfirmed close transcripts in the manual review inbox instead of allowing them to become harmful negative training samples.
|
||||
- Added visible guided-transcript and review-reason details, plus retry support for ambiguous clips through Review Now.
|
||||
- Restored MOSS-TTS-Nano generation with the current voice-cloning API so all four providers contribute to the final training corpus.
|
||||
|
||||
113
cli/setup_modern_tts_envs
Executable file
113
cli/setup_modern_tts_envs
Executable 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}"
|
||||
@@ -12,6 +12,8 @@ DEFAULT_SAMPLES=50000
|
||||
DEFAULT_BATCH_SIZE=100
|
||||
DEFAULT_TRAINING_STEPS=40000
|
||||
DEFAULT_LANGUAGE=en
|
||||
DEFAULT_TTS_MODE=hybrid
|
||||
DEFAULT_TTS_VOICE_COUNT=128
|
||||
|
||||
[ -f "${DATA_DIR}/.defaults.env" ] && source "${DATA_DIR}/.defaults.env" || :
|
||||
|
||||
@@ -19,6 +21,8 @@ DEFAULT_LANGUAGE=en
|
||||
: "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}"
|
||||
: "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}"
|
||||
: "${LANGUAGE:=${DEFAULT_LANGUAGE}}"
|
||||
: "${TTS_MODE:=${DEFAULT_TTS_MODE}}"
|
||||
: "${TTS_VOICE_COUNT:=${DEFAULT_TTS_VOICE_COUNT}}"
|
||||
: "${CLEANUP_WORK_DIR:=false}"
|
||||
: "${CLEANUP_ARCHIVES:=false}"
|
||||
: "${CLEANUP_INTERMEDIATE_FILES:=false}"
|
||||
|
||||
1528
cli/tts_generate_samples.py
Executable file
1528
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
98
cli/tts_moss_worker.py
Executable 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
151
cli/tts_qwen_worker.py
Executable 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
374
cli/tts_reference_qa.py
Normal 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())
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
PROGPATH=$(realpath "$0")
|
||||
PROGDIR=$(dirname "${PROGPATH}")
|
||||
PROGPATH="$(realpath "$0")"
|
||||
PROGDIR="$(dirname "${PROGPATH}")"
|
||||
|
||||
KNOWN_ARGS=( samples batch-size data-dir language )
|
||||
KNOWN_ARGS=( samples batch-size data-dir language tts-mode tts-voice-count )
|
||||
# shellcheck source=/dev/null
|
||||
source "${PROGDIR}/shell.functions"
|
||||
WAKE_WORD="${POSITIONAL_ARGS[0]}"
|
||||
WAKE_WORD="${POSITIONAL_ARGS[0]:-}"
|
||||
|
||||
if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then
|
||||
echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2
|
||||
@@ -16,147 +17,48 @@ fi
|
||||
if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then
|
||||
cat <<EOF >&2
|
||||
Usage: $0 [ --samples=<samples> ] [ --batch-size=<batch_size> ]
|
||||
[ --language=<lang> ] <wake_word>
|
||||
|
||||
--samples: The number of samples to generate for the wake word.
|
||||
Default: ${DEFAULT_SAMPLES}
|
||||
|
||||
--batch-size: How many samples should be generated at a time. The more
|
||||
samples, the more memory is needed.
|
||||
Default: ${DEFAULT_BATCH_SIZE}
|
||||
|
||||
--language: Language for TTS voice selection.
|
||||
"en" uses the multi-speaker LibriTTS-R generator.
|
||||
Other languages (e.g. "nl") use single-speaker ONNX
|
||||
voices and cycle between them for variety.
|
||||
Default: ${DEFAULT_LANGUAGE}
|
||||
|
||||
<wake_word> The word to generate samples for.
|
||||
Required.
|
||||
[ --language=<lang> ] [ --tts-mode=<modern|hybrid|piper> ]
|
||||
[ --tts-voice-count=<voices> ] <wake_word>
|
||||
|
||||
--samples: Number of samples to generate. Default: ${DEFAULT_SAMPLES}
|
||||
--batch-size: Generation batch size. Default: ${DEFAULT_BATCH_SIZE}
|
||||
--language: TTS language code. Default: ${DEFAULT_LANGUAGE}
|
||||
--tts-mode: modern, hybrid, or piper. Default: ${DEFAULT_TTS_MODE}
|
||||
--tts-voice-count: Deprecated compatibility option; direct generation ignores it.
|
||||
<wake_word> Required phrase to synthesize.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${DATA_DIR}/.venv/bin/activate"
|
||||
case "${TTS_MODE}" in
|
||||
modern|hybrid|piper) ;;
|
||||
*)
|
||||
echo "ERROR: --tts-mode must be modern, hybrid, or piper." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
WORK_DIR="${DATA_DIR}/work"
|
||||
mkdir -p "${WORK_DIR}" || :
|
||||
cd "${WORK_DIR}"
|
||||
|
||||
PSG="${DATA_DIR}/tools/piper-sample-generator"
|
||||
MODELS_DIR="${PSG}/models"
|
||||
VOICES_DIR="${PSG}/voices"
|
||||
SAMPLES_DIR="${WORK_DIR}/wake_word_samples"
|
||||
|
||||
mkdir -p "${SAMPLES_DIR}" || :
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build the --model argument(s) based on language
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -a MODEL_ARGS=()
|
||||
MODEL_TAG=""
|
||||
|
||||
if [ "${LANGUAGE}" == "en" ] ; then
|
||||
# English: use the multi-speaker LibriTTS-R generator (.pt)
|
||||
MODEL_NAME="en_US-libritts_r-medium.pt"
|
||||
MODEL_FILE="${MODELS_DIR}/${MODEL_NAME}"
|
||||
if [ ! -f "${MODEL_FILE}" ] ; then
|
||||
echo "ERROR: English model ${MODEL_FILE} not found. Run setup_python_venv first." >&2
|
||||
exit 1
|
||||
fi
|
||||
MODEL_ARGS=( --model "${MODEL_FILE}" )
|
||||
MODEL_TAG="${MODEL_NAME}"
|
||||
else
|
||||
# Non-English: find all ONNX voices matching the language prefix
|
||||
# e.g. LANGUAGE=nl matches nl_NL-pim-medium.onnx, nl_BE-nathalie-medium.onnx, etc.
|
||||
shopt -s nullglob
|
||||
voice_files=( "${VOICES_DIR}/${LANGUAGE}"_*.onnx )
|
||||
shopt -u nullglob
|
||||
|
||||
if [ ${#voice_files[@]} -eq 0 ] ; then
|
||||
echo "ERROR: No ONNX voice files found for language '${LANGUAGE}' in ${VOICES_DIR}/" >&2
|
||||
echo " Expected files matching: ${LANGUAGE}_*.onnx" >&2
|
||||
echo " Run setup_python_venv to download voice models." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Using ${#voice_files[@]} voice(s) for language '${LANGUAGE}':"
|
||||
MODEL_TAG="${LANGUAGE}"
|
||||
for vf in "${voice_files[@]}" ; do
|
||||
vname="$(basename "${vf}")"
|
||||
echo " - ${vname}"
|
||||
MODEL_ARGS+=( --model "${vf}" )
|
||||
MODEL_TAG="${MODEL_TAG}+${vname}"
|
||||
done
|
||||
fi
|
||||
|
||||
REGENERATE=false
|
||||
|
||||
if [ "${SAMPLES}" -eq 1 ] ; then
|
||||
echo "===== Generating ${SAMPLES} sample of '${WAKE_WORD}' (language=${LANGUAGE}) ====="
|
||||
wake_word_filename="${WAKE_WORD//[ \`~\!@#\$%^&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}"
|
||||
|
||||
mkdir -p "${WORK_DIR}/test_sample" || :
|
||||
"${PSG}/generate_samples.py" "${WAKE_WORD}" \
|
||||
"${MODEL_ARGS[@]}" \
|
||||
--max-samples ${SAMPLES} \
|
||||
--batch-size ${BATCH_SIZE} \
|
||||
--output-dir "${WORK_DIR}/test_sample" \
|
||||
--max-speakers 100 2>&1 | sed -r -e "s/(DEBUG|INFO):__main__:/ /g"
|
||||
mv "${WORK_DIR}/test_sample/0.wav" "${WORK_DIR}/test_sample/${wake_word_filename}.wav"
|
||||
echo "Sample available at ${WORK_DIR}/test_sample/${wake_word_filename}.wav"
|
||||
echo "Play it from your host."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grep -q "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" "${WORK_DIR}/last_wake_word" &>/dev/null || REGENERATE=true
|
||||
|
||||
# Double check that the number of existing samples matches SAMPLES
|
||||
existing_samples=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l)
|
||||
[ "${existing_samples}" -eq "${SAMPLES}" ] || REGENERATE=true
|
||||
mkdir -p "${WORK_DIR}"
|
||||
|
||||
START_TS=$EPOCHSECONDS
|
||||
echo "===== Generating ${SAMPLES} wake-word samples (language=${LANGUAGE}, tts=${TTS_MODE}) ====="
|
||||
|
||||
if ! ${REGENERATE} ; then
|
||||
echo "Sample generation not required"
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
python3 "${PROGDIR}/tts_generate_samples.py" "${WAKE_WORD}" \
|
||||
--samples="${SAMPLES}" \
|
||||
--batch-size="${BATCH_SIZE}" \
|
||||
--language="${LANGUAGE}" \
|
||||
--tts-mode="${TTS_MODE}" \
|
||||
--voice-count="${TTS_VOICE_COUNT}" \
|
||||
--data-dir="${DATA_DIR}" \
|
||||
--output-dir="${SAMPLES_DIR}"
|
||||
|
||||
echo -e "\n===== Generating ${SAMPLES} wake word samples in batches of ${BATCH_SIZE} (language=${LANGUAGE}) ====="
|
||||
export TF_CPP_MIN_LOG_LEVEL=9
|
||||
export TF_FORCE_GPU_ALLOW_GROWTH=true
|
||||
export TF_GPU_ALLOCATOR=cuda_malloc_async
|
||||
export TF_XLA_FLAGS="--tf_xla_auto_jit=0"
|
||||
export NVIDIA_TF32_OVERRIDE=1
|
||||
export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512
|
||||
export GLOG_minloglevel=2
|
||||
export GRPC_VERBOSITY=ERROR
|
||||
|
||||
echo " Generating samples"
|
||||
rm -rf "${SAMPLES_DIR}" || :
|
||||
mkdir -p "${SAMPLES_DIR}" || :
|
||||
python "${PROGDIR}/run_generator_with_progress.py" \
|
||||
--generator "${PSG}/generate_samples.py" \
|
||||
--output-dir "${SAMPLES_DIR}" \
|
||||
--max-samples ${SAMPLES} \
|
||||
-- \
|
||||
"${WAKE_WORD}" \
|
||||
"${MODEL_ARGS[@]}" \
|
||||
--max-samples ${SAMPLES} \
|
||||
--batch-size ${BATCH_SIZE} \
|
||||
--output-dir "${SAMPLES_DIR}"
|
||||
|
||||
generated_files=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l)
|
||||
generated_files=$(find "${SAMPLES_DIR}" -maxdepth 1 -name '*.wav' | wc -l)
|
||||
if [ "${generated_files}" -ne "${SAMPLES}" ] ; then
|
||||
echo "ERROR: only generated ${generated_files} files" >&2
|
||||
echo "ERROR: only generated ${generated_files} of ${SAMPLES} files" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" > "${WORK_DIR}/last_wake_word"
|
||||
echo
|
||||
|
||||
END_TS=$EPOCHSECONDS
|
||||
print_elapsed_time "${START_TS}" "${END_TS}" "Generated ${SAMPLES} wake word samples."
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
1199
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
330
frontend/src/TrainerApp.vue
Normal file
330
frontend/src/TrainerApp.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import AudioTrimModal from "./components/AudioTrimModal.vue";
|
||||
import type { JsonRecord } from "./api";
|
||||
import {
|
||||
autoLinked, captureTone, claimTater, clearSamples, copyWakeWord, deleteManagedData, describeFormat,
|
||||
disposeTrainer, ensureSupportedTtsMode, formatBytes, formatTimestamp, hasConsole, initializeTrainer,
|
||||
isBusy, itemAudioUrl, negativeCount, notify, personalCount, previewPhrase, refreshAuto,
|
||||
refreshCaptured, refreshManagedData, refreshSamples, refreshWakeWords, removeSample, revertSample, reviewCaptured,
|
||||
runAutoAction, saveAuto, selectFiles, selectedSamples, startSession, startTraining, stopSession, sttEngines,
|
||||
trainer, ttsRoute, unlinkTater, uploadSelectedFiles,
|
||||
} from "./trainerStore";
|
||||
import type { AudioItem, ManagedDataItem, SampleBucket, ViewName } from "./types";
|
||||
|
||||
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||
const consoleLog = ref<HTMLElement | null>(null);
|
||||
const consoleFollowing = ref(true);
|
||||
const linkUrl = ref("");
|
||||
const linkCode = ref("");
|
||||
const linkComplete = ref(false);
|
||||
const mascotUrl = "/static/images/tater-wake-word-trainer.png";
|
||||
const pageSize = 50;
|
||||
const tabs: Array<{ id: ViewName; label: string; short: string }> = [
|
||||
{ id: "trainer", label: "Trainer", short: "Train" },
|
||||
{ id: "auto", label: "Auto Training", short: "Auto" },
|
||||
{ id: "firmware", label: "Wake Words", short: "Words" },
|
||||
{ id: "captured", label: "Captured Audio", short: "Inbox" },
|
||||
{ id: "samples", label: "Samples", short: "Samples" },
|
||||
{ id: "data", label: "Data", short: "Data" },
|
||||
];
|
||||
|
||||
const pagedSamples = computed(() => {
|
||||
const page = trainer.samplePage[trainer.sampleBucket];
|
||||
return selectedSamples.value.slice(page * pageSize, (page + 1) * pageSize);
|
||||
});
|
||||
const samplePages = computed(() => Math.max(1, Math.ceil(selectedSamples.value.length / pageSize)));
|
||||
const autoState = computed(() => trainer.auto.state || {});
|
||||
const autoRuntime = computed(() => trainer.auto.runtime || {});
|
||||
const autoAudit = computed(() => {
|
||||
const state = autoState.value;
|
||||
const rows: string[] = [];
|
||||
if (state.last_review_result) rows.push(`Last review: ${String(state.last_review_result).replaceAll("_", " ")}`);
|
||||
if (state.last_review_file) rows.push(String(state.last_review_file));
|
||||
if (state.last_review_transcript) rows.push(`STT: “${state.last_review_transcript}”`);
|
||||
if (state.last_review_error) rows.push(`Error: ${state.last_review_error}`);
|
||||
if (state.last_stt_engine) rows.push(`STT engine: ${String(state.last_stt_engine).replaceAll("_", " ")}`);
|
||||
if (state.last_notify_at) rows.push(state.last_notify_error ? `Publish failed: ${state.last_notify_error}` : `Wake word published ${formatTimestamp(state.last_notify_at)}`);
|
||||
return rows.join(" · ") || "No automatic review has run yet.";
|
||||
});
|
||||
const trainingStatus = computed(() => {
|
||||
if (trainer.training.running) return { text: "Training running", tone: "warning" };
|
||||
if (trainer.training.exit_code === 0) return { text: "Training finished", tone: "success" };
|
||||
if (trainer.training.exit_code !== null) return { text: `Exit ${trainer.training.exit_code}`, tone: "error" };
|
||||
return { text: "Not started", tone: "neutral" };
|
||||
});
|
||||
const autoStatus = computed(() => {
|
||||
if (autoRuntime.value.review_running) return { text: `Transcribing ${autoRuntime.value.review_file || "wake"}`, tone: "warning" };
|
||||
if (trainer.training.running && trainer.auto.config?.enabled) return { text: "Training running", tone: "warning" };
|
||||
if (trainer.auto.config?.enabled) return { text: "Enabled", tone: "success" };
|
||||
return { text: "Disabled", tone: "neutral" };
|
||||
});
|
||||
const consoleLines = computed(() => trainer.training.log_lines?.length ? trainer.training.log_lines : ["No training output yet."]);
|
||||
const dataCategories = computed(() => {
|
||||
const groups = new Map<string, ManagedDataItem[]>();
|
||||
for (const item of trainer.managedData.items || []) {
|
||||
const rows = groups.get(item.category) || [];
|
||||
rows.push(item);
|
||||
groups.set(item.category, rows);
|
||||
}
|
||||
return Array.from(groups, ([name, items]) => ({ name, items }));
|
||||
});
|
||||
|
||||
watch(() => trainer.language, ensureSupportedTtsMode);
|
||||
watch(() => trainer.toast.serial, () => window.setTimeout(() => { trainer.toast.message = ""; }, 4500));
|
||||
watch(consoleLines, async () => {
|
||||
if (!consoleFollowing.value) return;
|
||||
await nextTick();
|
||||
if (consoleFollowing.value && consoleLog.value) {
|
||||
consoleLog.value.scrollTop = consoleLog.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
watch(() => trainer.consoleOpen, async (isOpen) => {
|
||||
if (!isOpen) return;
|
||||
consoleFollowing.value = true;
|
||||
await nextTick();
|
||||
scrollConsoleToBottom();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void initializeTrainer();
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposeTrainer();
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape") return;
|
||||
trainer.consoleOpen = false;
|
||||
trainer.taterLinkOpen = false;
|
||||
trainer.trimItem = null;
|
||||
}
|
||||
function onConsoleScroll(): void {
|
||||
const element = consoleLog.value;
|
||||
if (!element) return;
|
||||
const distanceFromBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
|
||||
consoleFollowing.value = distanceFromBottom <= 32;
|
||||
}
|
||||
function scrollConsoleToBottom(): void {
|
||||
const element = consoleLog.value;
|
||||
if (!element) return;
|
||||
consoleFollowing.value = true;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
function changeView(view: ViewName): void {
|
||||
trainer.activeView = view;
|
||||
const run = view === "auto" ? refreshAuto(false)
|
||||
: view === "captured" ? refreshCaptured()
|
||||
: view === "samples" ? refreshSamples()
|
||||
: view === "firmware" ? refreshWakeWords()
|
||||
: view === "data" ? refreshManagedData()
|
||||
: Promise.resolve();
|
||||
void run.catch((error) => notify(error instanceof Error ? error.message : "Refresh failed.", "error"));
|
||||
}
|
||||
function setBucket(bucket: SampleBucket): void { trainer.sampleBucket = bucket; }
|
||||
function openTrim(item: AudioItem, bucket: SampleBucket): void { trainer.trimBucket = bucket; trainer.trimItem = item; }
|
||||
function openLink(): void {
|
||||
linkUrl.value = trainer.autoForm.tater_url || "http://127.0.0.1:8501";
|
||||
linkCode.value = "";
|
||||
linkComplete.value = false;
|
||||
trainer.taterLinkOpen = true;
|
||||
void nextTick(() => (document.querySelector("#pairing-code") as HTMLInputElement | null)?.focus());
|
||||
}
|
||||
function formatLinkCode(): void {
|
||||
const raw = linkCode.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
|
||||
linkCode.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
|
||||
}
|
||||
async function submitLink(): Promise<void> {
|
||||
if (!linkUrl.value.trim() || !linkCode.value.trim()) { notify("Tater address and pairing code are required.", "warning"); return; }
|
||||
linkComplete.value = await claimTater(linkUrl.value, linkCode.value);
|
||||
}
|
||||
function metaRows(item: AudioItem): string[] {
|
||||
const rows: string[] = [];
|
||||
if (item.source_device) rows.push(String(item.source_device));
|
||||
if (item.wake_word) rows.push(String(item.wake_word));
|
||||
if (item.max_probability !== null && item.max_probability !== undefined) rows.push(`max ${item.max_probability}`);
|
||||
if (item.average_probability !== null && item.average_probability !== undefined) rows.push(`avg ${item.average_probability}`);
|
||||
if (item.detection_profile) rows.push(`profile ${String(item.detection_profile).replaceAll("_", " ")}`);
|
||||
if (item.auto_review_status) rows.push(`auto ${String(item.auto_review_status).replaceAll("_", " ")}`);
|
||||
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) rows.push(`VAD ${item.vad_max_probability}`);
|
||||
return rows;
|
||||
}
|
||||
function sampleSubtitle(item: AudioItem): string {
|
||||
const rows = [];
|
||||
if (item.original_name && item.original_name !== item.saved_as) rows.push(`From ${item.original_name}`);
|
||||
const timestamp = formatTimestamp(item.reviewed_at || item.received_at || item.created_at);
|
||||
if (timestamp) rows.push(`Saved ${timestamp}`);
|
||||
if (item.message) rows.push(String(item.message));
|
||||
if (item.auto_negative) rows.push("Auto-reviewed false positive");
|
||||
if (item.auto_positive) rows.push("Auto-promoted close miss");
|
||||
return rows.join(" · ") || "Training sample";
|
||||
}
|
||||
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
|
||||
function 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 class="field"><span>TTS source</span><select v-model="trainer.ttsMode" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')">
|
||||
<option value="hybrid" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Four-provider ensemble · recommended</option>
|
||||
<option value="modern" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.some((engine) => engine !== 'piper')">Modern only · no Piper</option>
|
||||
<option value="piper" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.includes('piper')">Piper only · legacy</option>
|
||||
</select><small>Models download once and stay cached.</small></label>
|
||||
</div>
|
||||
<div class="row form-actions"><button v-if="!trainer.session.safe_word" type="button" class="button primary" :disabled="isBusy('session') || !trainer.phrase.trim()" @click="startSession">{{ isBusy('session') ? "Starting…" : "Start session" }}</button><button v-else type="button" class="button danger" :disabled="isBusy('session')" @click="stopSession">{{ isBusy('session') ? "Stopping…" : (trainer.training.running ? "Stop session + training" : "Stop session") }}</button><button type="button" :disabled="!trainer.phrase.trim()" @click="previewPhrase">System preview</button></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><div class="number">2</div><div><h3>Train wake word</h3><p>Personal positives and reviewed false-wake negatives are automatically included.</p></div><span class="pill" :class="trainingStatus.tone">{{ trainingStatus.text }}</span></header>
|
||||
<div class="stats"><article><span>Positive samples</span><strong>{{ personalCount }}</strong></article><article><span>Negative samples</span><strong>{{ negativeCount }}</strong></article><article><span>Training format</span><strong class="format-value">16 kHz · mono · WAV</strong></article></div>
|
||||
<div class="train-action"><button type="button" class="button primary large" :disabled="!trainer.session.safe_word || trainer.training.running || isBusy('training-start')" @click="startTraining">{{ trainer.training.running ? "Training in progress" : "Start training" }}</button></div>
|
||||
<footer class="panel-footer"><span>Training opens the console automatically and continues if the window is closed.</span><button type="button" :disabled="!hasConsole" @click="trainer.consoleOpen = true">Open console</button></footer>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="trainer.activeView === 'auto'">
|
||||
<section class="hero auto-hero"><div><span class="eyebrow">False-positive loop</span><h2>Auto Training</h2><p>Transcribe captures, sort negatives, recover close misses, retrain on schedule, and publish through Tater.</p></div><span class="pill hero-pill" :class="autoStatus.tone">{{ autoStatus.text }}</span></section>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><div class="number">1</div><div><h3>Review rules</h3><p>Conservative local STT keeps uncertain clips in the manual inbox.</p></div></header>
|
||||
<div class="toggle-list">
|
||||
<label><input v-model="trainer.autoForm.enabled" type="checkbox" /><span><strong>Enable Auto Training</strong><small>Queue eligible wake triggers for local transcription.</small></span></label>
|
||||
<label><input v-model="trainer.autoForm.delete_confirmed_wakes" type="checkbox" /><span><strong>Delete confirmed good wakes</strong><small>Remove normal triggers when STT confirms the phrase.</small></span></label>
|
||||
<label><input v-model="trainer.autoForm.promote_close_misses" type="checkbox" /><span><strong>Promote confirmed close misses</strong><small>Move verified close misses into positive samples.</small></span></label>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>Wake phrase</span><input v-model="trainer.autoForm.wake_phrase" type="text" /></label>
|
||||
<label class="field"><span>STT language</span><input v-model="trainer.autoForm.language" type="text" /></label>
|
||||
<label class="field wide"><span>STT engine</span><select v-model="trainer.autoForm.stt_engine"><option v-for="engine in sttEngines" :key="engine.id || engine.value" :value="engine.id || engine.value">{{ engine.label || engine.name || engine.id }}</option></select><small>{{ sttEngines.find((row) => (row.id || row.value) === trainer.autoForm.stt_engine)?.description || "Runs locally on this trainer." }}</small></label>
|
||||
<label class="field"><span>Minimum transcript characters</span><input v-model.number="trainer.autoForm.minimum_transcript_chars" min="1" max="100" type="number" /></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><div class="number">2</div><div><h3>Training schedule</h3><p>A run starts only after enough newly reviewed negatives accumulate.</p></div></header>
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>Run training</span><select v-model.number="trainer.autoForm.schedule_hours"><option :value="0">Manually only</option><option :value="6">Every 6 hours</option><option :value="12">Every 12 hours</option><option :value="24">Every day</option><option :value="48">Every 2 days</option><option :value="168">Every week</option></select></label>
|
||||
<label class="field"><span>Minimum new negatives</span><input v-model.number="trainer.autoForm.minimum_new_negatives" min="1" max="10000" type="number" /></label>
|
||||
</div>
|
||||
<div class="stats"><article><span>Pending negatives</span><strong>{{ Number(autoState.pending_negative_count || 0) }}</strong></article><article><span>Next check</span><strong class="format-value">{{ autoState.next_run_at ? formatTimestamp(autoState.next_run_at) : "Manual" }}</strong></article><article><span>Last training</span><strong class="format-value">{{ autoState.last_train_finished_at ? formatTimestamp(autoState.last_train_finished_at) : "Never" }}</strong></article></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<header class="panel-head"><div class="number">3</div><div><h3>Publish to Tater</h3><p>Securely activate successful models across every connected satellite.</p></div></header>
|
||||
<div class="form-grid"><label class="field wide"><span>Trainer public URL</span><input v-model="trainer.autoForm.advertised_base_url" type="text" placeholder="Auto-detect LAN address" /><small>{{ trainer.autoForm.advertised_base_url ? `Configured: ${trainer.autoForm.advertised_base_url}` : `Detected: ${trainer.auto.advertised_base_url || "unavailable"}` }}</small></label><label class="field wide"><span>Tater URL</span><input v-model="trainer.autoForm.tater_url" type="text" /></label></div>
|
||||
<div class="link-row"><span class="pill" :class="autoLinked ? 'success' : 'warning'">{{ autoLinked ? `Linked${trainer.auto.trainer_link?.tater_name ? ` · ${trainer.auto.trainer_link.tater_name}` : ''}` : "Not linked" }}</span><button type="button" class="button primary" :disabled="isBusy('auto')" @click="openLink">{{ autoLinked ? "Relink Tater" : "Link Tater" }}</button><button v-if="autoLinked" type="button" class="button danger" :disabled="isBusy('auto')" @click="unlinkTater">Unlink</button></div>
|
||||
<div class="toggle-list compact"><label><input v-model="trainer.autoForm.notify_satellites" type="checkbox" /><span><strong>Activate after successful training</strong><small>Tater applies the new word globally.</small></span></label></div>
|
||||
</section>
|
||||
<section class="panel action-panel"><div class="action-grid"><button type="button" class="button primary" :disabled="isBusy('auto')" @click="saveAuto">Save Auto Training</button><button type="button" :disabled="isBusy('auto')" @click="runAutoAction('review_now')">Review inbox now</button><button type="button" :disabled="isBusy('auto') || trainer.training.running" @click="runAutoAction('train_now')">Train now</button><button type="button" :disabled="isBusy('auto') || !autoLinked" @click="runAutoAction('notify_now')">Publish current word</button></div><p class="audit">{{ autoAudit }}</p></section>
|
||||
</template>
|
||||
<template v-else-if="trainer.activeView === 'captured'">
|
||||
<section class="hero capture-hero"><div><span class="eyebrow">Capture review</span><h2>Captured Audio</h2><p>Listen to clips from your satellites and turn every real-world event into a better model.</p></div><span class="pill hero-pill" :class="trainer.captured.captured_count ? 'warning' : ''">{{ trainer.captured.captured_count ? `${trainer.captured.captured_count} waiting` : "Inbox idle" }}</span></section>
|
||||
<section class="panel"><header class="panel-head"><div class="number">1</div><div><h3>Review queue</h3><p>Approve good phrases, keep false positives as negatives, or discard noise.</p></div><button type="button" :disabled="isBusy('captured')" @click="refreshCaptured()">{{ isBusy('captured') ? "Refreshing…" : "Refresh inbox" }}</button></header><div class="stats"><article><span>Inbox</span><strong>{{ trainer.captured.captured_count }}</strong></article><article><span>Reviewed negatives</span><strong>{{ negativeCount }}</strong></article><article><span>Personal samples</span><strong>{{ personalCount }}</strong></article></div></section>
|
||||
<section class="panel"><header class="panel-head"><div class="number">2</div><div><h3>Listen + sort</h3><p>Metadata remains visible so borderline detections are easy to understand.</p></div></header>
|
||||
<div v-if="!trainer.captured.items?.length" class="empty-state">No captured audio yet. Clips sent by satellites will appear here.</div>
|
||||
<div v-else class="audio-list"><article v-for="item in trainer.captured.items" :key="item.saved_as" class="audio-card">
|
||||
<header><div><strong>{{ item.original_name || item.saved_as }}</strong><small>{{ formatTimestamp(item.captured_at || item.received_at) }} {{ item.message || "" }}</small></div><span class="pill" :class="captureTone(item).tone">{{ captureTone(item).label }}</span></header>
|
||||
<div v-if="metaRows(item).length" class="meta-row"><span v-for="row in metaRows(item)" :key="row">{{ row }}</span></div>
|
||||
<div v-if="item.transcript" class="transcript"><b>STT</b> {{ item.transcript }}</div><div v-if="item.auto_review_guided_transcript" class="transcript"><b>Guided wake check</b> {{ item.auto_review_guided_transcript }}</div>
|
||||
<audio controls preload="none" :src="itemAudioUrl(item, 'captured')" />
|
||||
<footer><span>{{ item.saved_as }} · {{ describeFormat(item.final_format) }}</span><div><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'approve_personal')">Add positive</button><button type="button" :disabled="isBusy('review')" @click="reviewCaptured(item, 'mark_negative')">Mark negative</button><button type="button" class="button danger ghost" :disabled="isBusy('review')" @click="reviewCaptured(item, 'discard')">Discard</button></div></footer>
|
||||
</article></div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="trainer.activeView === 'samples'">
|
||||
<section class="hero samples-hero"><div><span class="eyebrow">Sample library</span><h2>Current Training Samples</h2><p>Audit positives and negatives, trim recordings precisely, and import seed audio.</p></div><span class="pill hero-pill">{{ personalCount + negativeCount }} total</span></section>
|
||||
<section class="panel">
|
||||
<header class="panel-head sample-head"><div class="number">1</div><div><h3>Saved samples</h3><p>Personal clips are positives. Negative clips are false wakes and hard negatives.</p></div><div class="segment-control"><button type="button" :class="{ active: trainer.sampleBucket === 'personal' }" @click="setBucket('personal')">Personal <b>{{ personalCount }}</b></button><button type="button" :class="{ active: trainer.sampleBucket === 'negative' }" @click="setBucket('negative')">Negative <b>{{ negativeCount }}</b></button></div></header>
|
||||
<div class="row toolbar"><button type="button" :disabled="isBusy('samples')" @click="refreshSamples()">Refresh</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || personalCount === 0" @click="clearSamples('personal')">Clear positives</button><button type="button" class="button danger ghost" :disabled="isBusy('review') || negativeCount === 0" @click="clearSamples('negative')">Clear negatives</button></div>
|
||||
<div v-if="!selectedSamples.length" class="empty-state">No {{ trainer.sampleBucket }} samples saved yet.</div>
|
||||
<div v-else class="audio-list compact-list"><article v-for="item in pagedSamples" :key="item.saved_as" class="audio-card">
|
||||
<header><div><strong>{{ item.saved_as }}</strong><small>{{ sampleSubtitle(item) }}</small></div><div class="row"><span v-if="item.trimmed" class="pill warning">Trimmed</span><span class="pill" :class="trainer.sampleBucket === 'personal' ? 'success' : 'error'">{{ trainer.sampleBucket === "personal" ? "Positive" : "Negative" }}</span></div></header>
|
||||
<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 trainer’s 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
43
frontend/src/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
235
frontend/src/components/AudioTrimModal.vue
Normal file
235
frontend/src/components/AudioTrimModal.vue
Normal 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
11
frontend/src/main.ts
Normal 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
220
frontend/src/trainer.css
Normal 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; } }
|
||||
580
frontend/src/trainerStore.ts
Normal file
580
frontend/src/trainerStore.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { computed, reactive } from "vue";
|
||||
import { getJson, postJson, putJson, request, type JsonRecord } from "./api";
|
||||
import type {
|
||||
AudioItem,
|
||||
AutoTrainForm,
|
||||
AutoTrainPayload,
|
||||
CapturedPayload,
|
||||
LanguageOption,
|
||||
ManagedDataItem,
|
||||
ManagedDataPayload,
|
||||
SampleBucket,
|
||||
SamplesPayload,
|
||||
SessionPayload,
|
||||
ToastState,
|
||||
TrainingState,
|
||||
ViewName,
|
||||
WakeWordItem,
|
||||
} from "./types";
|
||||
|
||||
const emptyTraining = (): TrainingState => ({ running: false, exit_code: null, log_lines: [] });
|
||||
const emptySamples = (): SamplesPayload => ({ personal: [], negative: [], personal_count: 0, negative_count: 0 });
|
||||
const emptyCaptured = (): CapturedPayload => ({ items: [], captured_count: 0, personal_count: 0, negative_count: 0 });
|
||||
const emptyManagedData = (): ManagedDataPayload => ({ items: [], total_size_bytes: 0, total_file_count: 0 });
|
||||
|
||||
const defaultAutoForm = (): AutoTrainForm => ({
|
||||
enabled: false,
|
||||
wake_phrase: "",
|
||||
language: "en",
|
||||
stt_engine: "faster_whisper",
|
||||
minimum_transcript_chars: 2,
|
||||
delete_confirmed_wakes: false,
|
||||
promote_close_misses: false,
|
||||
schedule_hours: 24,
|
||||
minimum_new_negatives: 3,
|
||||
advertised_base_url: "",
|
||||
tater_url: "http://127.0.0.1:8501",
|
||||
notify_satellites: true,
|
||||
});
|
||||
|
||||
export const trainer = reactive({
|
||||
activeView: "trainer" as ViewName,
|
||||
initialized: false,
|
||||
busy: new Set<string>(),
|
||||
phrase: "",
|
||||
language: "en",
|
||||
ttsMode: "hybrid",
|
||||
languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[],
|
||||
session: {} as SessionPayload,
|
||||
samples: emptySamples(),
|
||||
captured: emptyCaptured(),
|
||||
training: emptyTraining(),
|
||||
auto: {} as AutoTrainPayload,
|
||||
autoForm: defaultAutoForm(),
|
||||
wakeWords: [] as WakeWordItem[],
|
||||
managedData: emptyManagedData(),
|
||||
selectedFiles: [] as File[],
|
||||
sampleBucket: "personal" as SampleBucket,
|
||||
samplePage: { personal: 0, negative: 0 },
|
||||
uploadProgress: 0,
|
||||
uploadLabel: "No upload in progress",
|
||||
uploadDetail: "Choose files and upload when you are ready.",
|
||||
consoleOpen: false,
|
||||
taterLinkOpen: false,
|
||||
trimItem: null as AudioItem | null,
|
||||
trimBucket: "personal" as SampleBucket,
|
||||
toast: { message: "", tone: "success", serial: 0 } as ToastState,
|
||||
});
|
||||
|
||||
let autoTimer = 0;
|
||||
let trainingTimer = 0;
|
||||
|
||||
export const personalCount = computed(() => Number(trainer.samples.personal_count ?? trainer.session.takes_received ?? 0));
|
||||
export const negativeCount = computed(() => Number(trainer.samples.negative_count ?? trainer.captured.negative_count ?? 0));
|
||||
export const currentLanguage = computed<LanguageOption>(() =>
|
||||
trainer.languages.find((item) => item.code === trainer.language) || trainer.languages[0],
|
||||
);
|
||||
export const ttsRoute = computed(() => {
|
||||
const engines = currentLanguage.value?.engines?.length ? currentLanguage.value.engines : ["omnivoice"];
|
||||
const selected = trainer.ttsMode === "piper"
|
||||
? engines.filter((engine) => engine === "piper")
|
||||
: trainer.ttsMode === "hybrid"
|
||||
? engines
|
||||
: engines.filter((engine) => engine !== "piper");
|
||||
const labels: Record<string, string> = { omnivoice: "OmniVoice", qwen3: "Qwen3", moss: "MOSS", piper: "Piper" };
|
||||
const quality = trainer.ttsMode === "piper" ? "Legacy" : titleCase(currentLanguage.value?.quality || "experimental");
|
||||
return `${selected.map((engine) => labels[engine] || engine).join(" + ") || "Unavailable"} · ${quality}`;
|
||||
});
|
||||
export const hasConsole = computed(() => Boolean(
|
||||
trainer.training.running || trainer.training.exit_code !== null || trainer.training.log_lines?.length,
|
||||
));
|
||||
export const selectedSamples = computed(() => trainer.samples[trainer.sampleBucket] || []);
|
||||
export const autoLinked = computed(() => Boolean(trainer.auto.trainer_link?.linked));
|
||||
export const sttEngines = computed<JsonRecord[]>(() => {
|
||||
const rows = trainer.auto.stt_engines;
|
||||
return Array.isArray(rows) && rows.length
|
||||
? rows
|
||||
: [{ id: "faster_whisper", label: "Faster Whisper" }, { id: "parakeet_onnx", label: "Parakeet ONNX" }];
|
||||
});
|
||||
|
||||
function titleCase(value: unknown): string {
|
||||
return String(value || "").replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
export function isBusy(name?: string): boolean {
|
||||
return name ? trainer.busy.has(name) : trainer.busy.size > 0;
|
||||
}
|
||||
|
||||
function setBusy(name: string, active: boolean): void {
|
||||
if (active) trainer.busy.add(name);
|
||||
else trainer.busy.delete(name);
|
||||
}
|
||||
|
||||
export function notify(message: unknown, tone: ToastState["tone"] = "success"): void {
|
||||
trainer.toast = { message: String(message || ""), tone, serial: trainer.toast.serial + 1 };
|
||||
}
|
||||
|
||||
function reportError(error: unknown, fallback: string): void {
|
||||
notify(error instanceof Error ? error.message : fallback, "error");
|
||||
}
|
||||
|
||||
function applySession(payload: SessionPayload): void {
|
||||
trainer.session = payload || {};
|
||||
if (Array.isArray(payload.available_languages) && payload.available_languages.length) {
|
||||
trainer.languages = payload.available_languages;
|
||||
}
|
||||
if (payload.raw_phrase) trainer.phrase = payload.raw_phrase;
|
||||
if (payload.language) trainer.language = payload.language;
|
||||
if (payload.tts_mode) trainer.ttsMode = payload.tts_mode;
|
||||
if (payload.training) trainer.training = payload.training;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<SessionPayload> {
|
||||
const payload = await getJson<SessionPayload>("/api/session");
|
||||
applySession(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startSession(): Promise<void> {
|
||||
if (!trainer.phrase.trim()) {
|
||||
notify("Enter a wake phrase first.", "warning");
|
||||
return;
|
||||
}
|
||||
setBusy("session", true);
|
||||
try {
|
||||
const payload = await postJson<SessionPayload>("/api/start_session", {
|
||||
phrase: trainer.phrase.trim(),
|
||||
language: trainer.language,
|
||||
tts_mode: trainer.ttsMode,
|
||||
});
|
||||
applySession(payload);
|
||||
notify(`Session ${payload.safe_word || "started"} is ready.`);
|
||||
} catch (error) {
|
||||
reportError(error, "Session failed to start.");
|
||||
} finally {
|
||||
setBusy("session", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopSession(): Promise<void> {
|
||||
const wasTraining = Boolean(trainer.training.running);
|
||||
if (wasTraining && !window.confirm("Training is running. Stop training cleanly and end this session?")) {
|
||||
return;
|
||||
}
|
||||
setBusy("session", true);
|
||||
if (trainingTimer) {
|
||||
window.clearInterval(trainingTimer);
|
||||
trainingTimer = 0;
|
||||
}
|
||||
try {
|
||||
const payload = await postJson<SessionPayload>("/api/stop_session");
|
||||
applySession(payload);
|
||||
notify(wasTraining ? "Training stopped cleanly and the session ended." : "Session ended. You can edit the wake phrase now.");
|
||||
} catch (error) {
|
||||
if (wasTraining) beginTrainingPoll();
|
||||
reportError(error, "Session could not be stopped.");
|
||||
} finally {
|
||||
setBusy("session", false);
|
||||
}
|
||||
}
|
||||
|
||||
export function previewPhrase(): void {
|
||||
if (!trainer.phrase.trim() || !("speechSynthesis" in window)) return;
|
||||
const utterance = new SpeechSynthesisUtterance(trainer.phrase.trim());
|
||||
utterance.lang = trainer.language;
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export function ensureSupportedTtsMode(): void {
|
||||
const engines = currentLanguage.value?.engines || [];
|
||||
const modern = engines.some((engine) => engine !== "piper");
|
||||
const piper = engines.includes("piper");
|
||||
if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper";
|
||||
if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper";
|
||||
if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern";
|
||||
}
|
||||
|
||||
export async function refreshSamples(quiet = false): Promise<SamplesPayload> {
|
||||
if (!quiet) setBusy("samples", true);
|
||||
try {
|
||||
const payload = await getJson<SamplesPayload>("/api/samples");
|
||||
trainer.samples = { ...emptySamples(), ...payload };
|
||||
for (const bucket of ["personal", "negative"] as const) {
|
||||
const lastPage = Math.max(0, Math.ceil((trainer.samples[bucket]?.length || 0) / 50) - 1);
|
||||
trainer.samplePage[bucket] = Math.min(trainer.samplePage[bucket], lastPage);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
if (!quiet) setBusy("samples", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshCaptured(quiet = false): Promise<CapturedPayload> {
|
||||
if (!quiet) setBusy("captured", true);
|
||||
try {
|
||||
const payload = await getJson<CapturedPayload>("/api/captured_audio");
|
||||
trainer.captured = { ...emptyCaptured(), ...payload };
|
||||
return payload;
|
||||
} finally {
|
||||
if (!quiet) setBusy("captured", false);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectFiles(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
trainer.selectedFiles = Array.from(input.files || []);
|
||||
}
|
||||
|
||||
function uploadOne(file: File, index: number, total: number): Promise<JsonRecord> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const data = new FormData();
|
||||
data.append("file", file, file.name);
|
||||
xhr.open("POST", "/api/upload_personal_sample");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
trainer.uploadProgress = Math.round(((index + event.loaded / event.total) / total) * 100);
|
||||
trainer.uploadLabel = `Uploading ${file.name} (${index + 1}/${total})`;
|
||||
trainer.uploadDetail = "Sending and normalizing the recording.";
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const body = xhr.response || {};
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
|
||||
else reject(new Error(body.error || `Upload failed for ${file.name}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error(`Upload failed for ${file.name}`));
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadSelectedFiles(input?: HTMLInputElement | null): Promise<void> {
|
||||
if (!trainer.session.safe_word) {
|
||||
notify("Start a trainer session before uploading samples.", "warning");
|
||||
return;
|
||||
}
|
||||
if (!trainer.selectedFiles.length) return;
|
||||
setBusy("upload", true);
|
||||
try {
|
||||
const files = [...trainer.selectedFiles];
|
||||
for (let index = 0; index < files.length; index += 1) await uploadOne(files[index], index, files.length);
|
||||
trainer.uploadProgress = 100;
|
||||
trainer.uploadLabel = "Upload complete";
|
||||
trainer.uploadDetail = `${files.length} sample${files.length === 1 ? "" : "s"} saved in the required training format.`;
|
||||
trainer.selectedFiles = [];
|
||||
if (input) input.value = "";
|
||||
await Promise.all([refreshSession(), refreshSamples(true)]);
|
||||
notify("Personal samples uploaded.");
|
||||
} catch (error) {
|
||||
trainer.uploadProgress = 0;
|
||||
reportError(error, "Sample upload failed.");
|
||||
} finally {
|
||||
setBusy("upload", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewCaptured(item: AudioItem, action: "approve_personal" | "mark_negative" | "discard"): Promise<void> {
|
||||
if (action === "discard" && !window.confirm(`Discard ${item.saved_as} from the captured-audio inbox?`)) return;
|
||||
setBusy("review", true);
|
||||
try {
|
||||
await postJson(`/api/captured_audio/${encodeURIComponent(item.saved_as)}/${action}`);
|
||||
await Promise.all([refreshSession(), refreshCaptured(true), refreshSamples(true)]);
|
||||
notify(action === "approve_personal" ? "Clip added to personal samples." : action === "mark_negative" ? "Clip marked negative." : "Clip discarded.");
|
||||
} catch (error) {
|
||||
reportError(error, "Review action failed.");
|
||||
} finally {
|
||||
setBusy("review", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
|
||||
if (!window.confirm(`Remove ${item.saved_as} from ${bucket} samples?`)) return;
|
||||
setBusy("review", true);
|
||||
try {
|
||||
await request(`/api/samples/${bucket}/${encodeURIComponent(item.saved_as)}`, { method: "DELETE" });
|
||||
await refreshSamples(true);
|
||||
notify("Sample removed.");
|
||||
} catch (error) {
|
||||
reportError(error, "Sample removal failed.");
|
||||
} finally {
|
||||
setBusy("review", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revertSample(item: AudioItem, bucket: SampleBucket): Promise<void> {
|
||||
if (!window.confirm(`Revert ${item.saved_as} to its pre-trim version?`)) return;
|
||||
const form = new FormData();
|
||||
form.append("bucket", bucket);
|
||||
form.append("file_name", item.saved_as);
|
||||
setBusy("review", true);
|
||||
try {
|
||||
await request("/api/samples/revert", { method: "POST", body: form });
|
||||
await refreshSamples(true);
|
||||
notify("Original sample restored.");
|
||||
} catch (error) {
|
||||
reportError(error, "Sample revert failed.");
|
||||
} finally {
|
||||
setBusy("review", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSamples(bucket: SampleBucket): Promise<void> {
|
||||
const count = bucket === "personal" ? personalCount.value : negativeCount.value;
|
||||
if (!count || !window.confirm(`Clear ${count} ${bucket} sample${count === 1 ? "" : "s"}?`)) return;
|
||||
setBusy("review", true);
|
||||
try {
|
||||
await postJson(bucket === "personal" ? "/api/reset_recordings" : "/api/reset_negative_samples");
|
||||
await Promise.all([refreshSession(), refreshSamples(true), refreshCaptured(true)]);
|
||||
notify(`${titleCase(bucket)} samples cleared.`);
|
||||
} catch (error) {
|
||||
reportError(error, "Samples could not be cleared.");
|
||||
} finally {
|
||||
setBusy("review", false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAuto(payload: AutoTrainPayload, populate: boolean): void {
|
||||
trainer.auto = payload || {};
|
||||
if (!populate) return;
|
||||
trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) };
|
||||
if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || "";
|
||||
if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en";
|
||||
}
|
||||
|
||||
export async function refreshAuto(populate = false): Promise<AutoTrainPayload> {
|
||||
const payload = await getJson<AutoTrainPayload>("/api/auto_train");
|
||||
applyAuto(payload, populate);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function saveAuto(): Promise<void> {
|
||||
setBusy("auto", true);
|
||||
try {
|
||||
const payload = await putJson<AutoTrainPayload>("/api/auto_train", trainer.autoForm);
|
||||
applyAuto(payload, true);
|
||||
notify(payload.config?.enabled ? "Auto Training saved and enabled." : "Auto Training saved.");
|
||||
} catch (error) {
|
||||
reportError(error, "Auto Training settings failed to save.");
|
||||
} finally {
|
||||
setBusy("auto", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutoAction(action: "review_now" | "train_now" | "notify_now"): Promise<void> {
|
||||
setBusy("auto", true);
|
||||
try {
|
||||
const payload = await postJson<AutoTrainPayload>("/api/auto_train/action", { action });
|
||||
applyAuto(payload, false);
|
||||
if (action === "train_now") {
|
||||
trainer.consoleOpen = true;
|
||||
beginTrainingPoll();
|
||||
}
|
||||
notify(action === "review_now" ? `${Number(payload.queued || 0)} clips queued for review.` : action === "train_now" ? "Training started." : "Wake word published.");
|
||||
} catch (error) {
|
||||
reportError(error, "Auto Training action failed.");
|
||||
} finally {
|
||||
setBusy("auto", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimTater(taterUrl: string, pairingCode: string): Promise<boolean> {
|
||||
setBusy("link", true);
|
||||
try {
|
||||
await postJson("/api/tater_link/claim", { tater_url: taterUrl.trim(), pairing_code: pairingCode.trim() });
|
||||
trainer.autoForm.tater_url = taterUrl.trim();
|
||||
await refreshAuto(false);
|
||||
notify("Trainer linked securely to Tater.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error, "Tater link failed.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy("link", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkTater(): Promise<void> {
|
||||
if (!window.confirm("Unlink this trainer from Tater?")) return;
|
||||
setBusy("auto", true);
|
||||
try {
|
||||
await postJson("/api/tater_link/unlink");
|
||||
await refreshAuto(false);
|
||||
notify("Trainer unlinked from Tater.", "warning");
|
||||
} catch (error) {
|
||||
reportError(error, "Tater unlink failed.");
|
||||
} finally {
|
||||
setBusy("auto", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWakeWords(quiet = false): Promise<void> {
|
||||
if (!quiet) setBusy("firmware", true);
|
||||
try {
|
||||
const payload = await getJson<JsonRecord>("/api/trained_wake_words/catalog");
|
||||
trainer.wakeWords = Array.isArray(payload.wake_words) ? payload.wake_words : [];
|
||||
} finally {
|
||||
if (!quiet) setBusy("firmware", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshManagedData(): Promise<ManagedDataPayload> {
|
||||
setBusy("data", true);
|
||||
try {
|
||||
const payload = await getJson<ManagedDataPayload>("/api/data");
|
||||
trainer.managedData = { ...emptyManagedData(), ...payload };
|
||||
return payload;
|
||||
} finally {
|
||||
setBusy("data", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteManagedData(item: ManagedDataItem): Promise<void> {
|
||||
if (!item.file_count) return;
|
||||
const details = `${formatBytes(item.size_bytes)} · ${Number(item.file_count).toLocaleString()} file${item.file_count === 1 ? "" : "s"}`;
|
||||
const rebuild = item.rebuild_note ? `\n\n${item.rebuild_note}` : "";
|
||||
if (!window.confirm(`Permanently delete ${item.label} (${details})?${rebuild}\n\nThis cannot be undone.`)) return;
|
||||
setBusy("data-delete", true);
|
||||
try {
|
||||
const payload = await request<ManagedDataPayload>(`/api/data/${encodeURIComponent(item.id)}`, { method: "DELETE" });
|
||||
trainer.managedData = { ...emptyManagedData(), ...payload };
|
||||
await Promise.allSettled([
|
||||
refreshSession(),
|
||||
refreshSamples(true),
|
||||
refreshCaptured(true),
|
||||
refreshWakeWords(true),
|
||||
]);
|
||||
notify(`${item.label} deleted. ${formatBytes(item.size_bytes)} released.`);
|
||||
} catch (error) {
|
||||
reportError(error, `${item.label} could not be deleted.`);
|
||||
} finally {
|
||||
setBusy("data-delete", false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyWakeWord(url: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
notify("Wake-word JSON URL copied.");
|
||||
} catch (error) {
|
||||
reportError(error, "Clipboard unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTraining(): Promise<void> {
|
||||
await Promise.all([refreshSession(), refreshSamples(true)]);
|
||||
let allowNoPersonal = false;
|
||||
if (!personalCount.value) {
|
||||
allowNoPersonal = window.confirm("No positive samples are saved. Train anyway without personal voices?");
|
||||
if (!allowNoPersonal) return;
|
||||
}
|
||||
setBusy("training-start", true);
|
||||
trainer.training = { running: true, exit_code: null, log_lines: ["Waiting for training output…"] };
|
||||
trainer.consoleOpen = true;
|
||||
try {
|
||||
await postJson("/api/train", { allow_no_personal: allowNoPersonal });
|
||||
beginTrainingPoll();
|
||||
} catch (error) {
|
||||
trainer.training = { running: false, exit_code: 1, log_lines: [error instanceof Error ? error.message : String(error)] };
|
||||
reportError(error, "Training could not start.");
|
||||
} finally {
|
||||
setBusy("training-start", false);
|
||||
}
|
||||
}
|
||||
|
||||
export function beginTrainingPoll(): void {
|
||||
if (trainingTimer) return;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await getJson<JsonRecord>("/api/train_status");
|
||||
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
|
||||
if (!trainer.training.running) {
|
||||
window.clearInterval(trainingTimer);
|
||||
trainingTimer = 0;
|
||||
await Promise.all([refreshSamples(true), refreshWakeWords(true)]);
|
||||
notify(trainer.training.exit_code === 0 ? "Training finished successfully." : `Training ended with exit ${trainer.training.exit_code}.`, trainer.training.exit_code === 0 ? "success" : "error");
|
||||
}
|
||||
} catch {
|
||||
// A temporary request failure should not stop the live poll.
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
trainingTimer = window.setInterval(() => void poll(), 1500);
|
||||
}
|
||||
|
||||
export async function initializeTrainer(): Promise<void> {
|
||||
setBusy("bootstrap", true);
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
refreshSession(),
|
||||
refreshSamples(true),
|
||||
refreshCaptured(true),
|
||||
refreshAuto(true),
|
||||
refreshWakeWords(true),
|
||||
]);
|
||||
ensureSupportedTtsMode();
|
||||
try {
|
||||
const payload = await getJson<JsonRecord>("/api/train_status");
|
||||
trainer.training = { ...emptyTraining(), ...(payload.training || {}) };
|
||||
if (trainer.training.running) {
|
||||
trainer.consoleOpen = true;
|
||||
beginTrainingPoll();
|
||||
}
|
||||
} catch {
|
||||
// Remaining panels can still function when status is temporarily unavailable.
|
||||
}
|
||||
autoTimer = window.setInterval(() => {
|
||||
if (trainer.activeView === "auto" && !isBusy("auto")) void refreshAuto(false).catch(() => undefined);
|
||||
}, 2500);
|
||||
trainer.initialized = true;
|
||||
} finally {
|
||||
setBusy("bootstrap", false);
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeTrainer(): void {
|
||||
window.clearInterval(autoTimer);
|
||||
window.clearInterval(trainingTimer);
|
||||
autoTimer = 0;
|
||||
trainingTimer = 0;
|
||||
}
|
||||
|
||||
export function formatTimestamp(value: unknown): string {
|
||||
if (!value) return "";
|
||||
const parsed = new Date(String(value));
|
||||
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatBytes(value: unknown): string {
|
||||
const bytes = Math.max(0, Number(value) || 0);
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let amount = bytes / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
|
||||
amount /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
export function describeFormat(info: JsonRecord | undefined): string {
|
||||
if (!info) return "16 kHz · mono · 16-bit WAV";
|
||||
const rate = Number(info.sample_rate || info.sample_rate_hz || 16000);
|
||||
const channels = Number(info.channels || 1) === 1 ? "mono" : `${info.channels} channels`;
|
||||
const bits = Number(info.bits_per_sample || info.sample_width_bits || 16);
|
||||
return `${Math.round(rate / 1000)} kHz · ${channels} · ${bits}-bit`;
|
||||
}
|
||||
|
||||
export function captureTone(item: AudioItem): { label: string; tone: string } {
|
||||
if (item.blocked_by_vad) return { label: "Blocked by VAD", tone: "warning" };
|
||||
const type = String(item.event_type || "").toLowerCase();
|
||||
if (type.includes("close")) return { label: item.capture_label || "Close miss", tone: "warning" };
|
||||
if (type.includes("false")) return { label: item.capture_label || "False trigger", tone: "error" };
|
||||
if (type.includes("wake") || type.includes("detect")) return { label: item.capture_label || "Wake trigger", tone: "success" };
|
||||
return { label: item.capture_label || "Captured", tone: "neutral" };
|
||||
}
|
||||
|
||||
export function itemAudioUrl(item: AudioItem, bucket: SampleBucket | "captured"): string {
|
||||
return item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`;
|
||||
}
|
||||
112
frontend/src/types.ts
Normal file
112
frontend/src/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { JsonRecord } from "./api";
|
||||
|
||||
export type ViewName = "trainer" | "auto" | "firmware" | "captured" | "samples" | "data";
|
||||
export type SampleBucket = "personal" | "negative";
|
||||
|
||||
export interface LanguageOption extends JsonRecord {
|
||||
code: string;
|
||||
label: string;
|
||||
engines?: string[];
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface TrainingState extends JsonRecord {
|
||||
running: boolean;
|
||||
exit_code: number | null;
|
||||
log_lines: string[];
|
||||
}
|
||||
|
||||
export interface SessionPayload extends JsonRecord {
|
||||
safe_word?: string;
|
||||
raw_phrase?: string;
|
||||
language?: string;
|
||||
tts_mode?: string;
|
||||
takes_received?: number;
|
||||
available_languages?: LanguageOption[];
|
||||
training?: TrainingState;
|
||||
}
|
||||
|
||||
export interface AudioItem extends JsonRecord {
|
||||
saved_as: string;
|
||||
original_name?: string;
|
||||
audio_url?: string;
|
||||
final_format?: JsonRecord;
|
||||
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;
|
||||
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
17
frontend/tsconfig.json
Normal 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
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(import.meta.dirname, "../static/ui"),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(import.meta.dirname, "src/main.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "trainer-ui.js",
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (assetInfo) =>
|
||||
assetInfo.name?.endsWith(".css") ? "trainer-ui.css" : "[name][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
BIN
static/images/tater-wake-word-trainer.png
Normal file
BIN
static/images/tater-wake-word-trainer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
3414
static/index.html
3414
static/index.html
File diff suppressed because it is too large
Load Diff
2
static/ui/trainer-ui.css
Normal file
2
static/ui/trainer-ui.css
Normal file
File diff suppressed because one or more lines are too long
4783
static/ui/trainer-ui.js
Normal file
4783
static/ui/trainer-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -292,13 +292,13 @@ 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):
|
||||
@@ -315,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):
|
||||
@@ -467,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
|
||||
@@ -566,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 (
|
||||
|
||||
83
tests/test_data_management.py
Normal file
83
tests/test_data_management.py
Normal 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()
|
||||
702
tests/test_modern_tts.py
Normal file
702
tests/test_modern_tts.py
Normal file
@@ -0,0 +1,702 @@
|
||||
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_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
116
tests/test_session_stop.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import signal
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import trainer_server as trainer
|
||||
|
||||
|
||||
class _FakeTrainingProcess:
|
||||
def __init__(self):
|
||||
self.pid = 5432
|
||||
self.returncode = None
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = -signal.SIGTERM
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = -signal.SIGTERM
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -signal.SIGKILL
|
||||
|
||||
|
||||
class _CompletedTrainingProcess:
|
||||
def __init__(self):
|
||||
self.pid = 6543
|
||||
self.returncode = 0
|
||||
self.stdout = io.StringIO("worker started\n")
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
|
||||
class SessionStopTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
trainer.TRAINING_STOP_EVENT.clear()
|
||||
|
||||
def test_session_stop_terminates_the_process_group_and_allows_another_run(self):
|
||||
proc = _FakeTrainingProcess()
|
||||
original_process = trainer.TRAINING_PROCESS
|
||||
original_thread = trainer.TRAINING_THREAD
|
||||
try:
|
||||
trainer.TRAINING_PROCESS = proc
|
||||
trainer.TRAINING_THREAD = None
|
||||
with (
|
||||
patch.object(trainer.os, "getpgid", return_value=proc.pid),
|
||||
patch.object(trainer.os, "getpgrp", return_value=999),
|
||||
patch.object(trainer.os, "killpg") as killpg,
|
||||
):
|
||||
self.assertTrue(trainer._stop_current_training(timeout=0.2))
|
||||
killpg.assert_called_once_with(proc.pid, signal.SIGTERM)
|
||||
self.assertFalse(trainer.TRAINING_STOP_EVENT.is_set())
|
||||
finally:
|
||||
trainer.TRAINING_PROCESS = original_process
|
||||
trainer.TRAINING_THREAD = original_thread
|
||||
|
||||
def test_reserved_running_state_starts_the_background_worker(self):
|
||||
original_process = trainer.TRAINING_PROCESS
|
||||
original_thread = trainer.TRAINING_THREAD
|
||||
original_raw_phrase = trainer.STATE.get("raw_phrase")
|
||||
original_training = dict(trainer.STATE["training"])
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
data_dir = Path(directory)
|
||||
process = _CompletedTrainingProcess()
|
||||
trainer.TRAINING_PROCESS = None
|
||||
trainer.TRAINING_THREAD = threading.current_thread()
|
||||
with trainer.STATE_LOCK:
|
||||
trainer.STATE["raw_phrase"] = "hey tater"
|
||||
trainer.STATE["training"]["running"] = True
|
||||
|
||||
with (
|
||||
patch.object(trainer, "DATA_DIR", data_dir),
|
||||
patch.object(trainer, "_ensure_training_venv"),
|
||||
patch.object(trainer, "_ensure_training_datasets"),
|
||||
patch.object(trainer.subprocess, "Popen", return_value=process) as popen,
|
||||
patch.object(trainer, "_normalize_output_artifacts"),
|
||||
):
|
||||
trainer._run_training_background(
|
||||
"hey_tater",
|
||||
"en",
|
||||
True,
|
||||
auto_run=False,
|
||||
tts_mode="modern",
|
||||
)
|
||||
|
||||
popen.assert_called_once()
|
||||
log_text = (data_dir / "recorder_training.log").read_text(encoding="utf-8")
|
||||
self.assertIn("Nvidia Docker Training Run", log_text)
|
||||
self.assertIn("worker started", log_text)
|
||||
self.assertFalse(trainer.STATE["training"]["running"])
|
||||
self.assertEqual(trainer.STATE["training"]["exit_code"], 0)
|
||||
self.assertIsNone(trainer.TRAINING_THREAD)
|
||||
finally:
|
||||
with trainer.STATE_LOCK:
|
||||
trainer.STATE["raw_phrase"] = original_raw_phrase
|
||||
trainer.STATE["training"].clear()
|
||||
trainer.STATE["training"].update(original_training)
|
||||
trainer.TRAINING_PROCESS = original_process
|
||||
trainer.TRAINING_THREAD = original_thread
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
61
tests/test_tts_config.py
Normal file
61
tests/test_tts_config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tts_config import (
|
||||
ENGINE_MOSS,
|
||||
ENGINE_OMNIVOICE,
|
||||
ENGINE_PIPER,
|
||||
ENGINE_QWEN3,
|
||||
distribute_samples,
|
||||
engines_for_language,
|
||||
language_for_engine,
|
||||
normalize_tts_mode,
|
||||
quality_for_engines,
|
||||
)
|
||||
|
||||
|
||||
class TtsConfigTests(unittest.TestCase):
|
||||
def test_recommended_languages_use_all_modern_engines(self) -> None:
|
||||
self.assertEqual(
|
||||
engines_for_language("en", "modern"),
|
||||
[ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS],
|
||||
)
|
||||
self.assertEqual(
|
||||
quality_for_engines(engines_for_language("fr", "modern")),
|
||||
"recommended",
|
||||
)
|
||||
|
||||
def test_broad_language_coverage_routes_through_omnivoice(self) -> None:
|
||||
self.assertEqual(engines_for_language("zu", "modern"), [ENGINE_OMNIVOICE])
|
||||
self.assertEqual(quality_for_engines([ENGINE_OMNIVOICE]), "experimental")
|
||||
|
||||
def test_hybrid_and_legacy_modes_require_available_piper(self) -> None:
|
||||
self.assertEqual(
|
||||
engines_for_language("en", "hybrid", piper_available=True),
|
||||
[ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS, ENGINE_PIPER],
|
||||
)
|
||||
self.assertEqual(engines_for_language("en", "piper"), [])
|
||||
self.assertEqual(
|
||||
engines_for_language("en", "piper", piper_available=True),
|
||||
[ENGINE_PIPER],
|
||||
)
|
||||
|
||||
def test_sample_distribution_is_exact_and_deterministic(self) -> None:
|
||||
self.assertEqual(
|
||||
distribute_samples(10, [ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS]),
|
||||
{ENGINE_OMNIVOICE: 4, ENGINE_QWEN3: 3, ENGINE_MOSS: 3},
|
||||
)
|
||||
self.assertEqual(sum(distribute_samples(50000, ["a", "b", "c"]).values()), 50000)
|
||||
|
||||
def test_invalid_mode_falls_back_to_four_provider_route(self) -> None:
|
||||
self.assertEqual(normalize_tts_mode("unknown"), "hybrid")
|
||||
|
||||
def test_common_language_aliases_use_model_catalog_ids(self) -> None:
|
||||
self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ar"), "arb")
|
||||
self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ne"), "npi")
|
||||
self.assertEqual(language_for_engine(ENGINE_MOSS, "ar"), "ar")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
126
tests/test_vue_ui.py
Normal file
126
tests/test_vue_ui.py
Normal 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()
|
||||
@@ -5,7 +5,7 @@ PROGPATH=$(realpath "$0")
|
||||
PROGDIR=$(dirname "${PROGPATH}")
|
||||
CLIDIR="${PROGDIR}/cli"
|
||||
|
||||
KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language )
|
||||
KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language tts-mode tts-voice-count )
|
||||
source "${CLIDIR}/shell.functions"
|
||||
WAKE_WORD=${POSITIONAL_ARGS[0]}
|
||||
|
||||
@@ -19,6 +19,8 @@ if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then
|
||||
Usage: train_wake_word [ --samples=<samples> ] [ --batch-size=<batch_size> ]
|
||||
[ --training-steps=<steps> ] [ --cleanup-work-dir ]
|
||||
[ --language=<lang> ]
|
||||
[ --tts-mode=<modern|hybrid|piper> ]
|
||||
[ --tts-voice-count=<voices> ]
|
||||
<wake_word> [ <wake_word_title> ]
|
||||
|
||||
Options:
|
||||
@@ -39,6 +41,12 @@ Options:
|
||||
--language: Language for TTS voice selection (e.g. "en", "nl").
|
||||
Default: ${DEFAULT_LANGUAGE}
|
||||
|
||||
--tts-mode: TTS source: modern (OmniVoice plus Qwen3/MOSS where
|
||||
supported), hybrid (modern plus Piper), or piper.
|
||||
Default: ${DEFAULT_TTS_MODE}
|
||||
|
||||
--tts-voice-count: Deprecated compatibility option; direct generation ignores it.
|
||||
|
||||
<wake_word> The word to train spelled phonetically.
|
||||
Required.
|
||||
|
||||
@@ -116,6 +124,8 @@ export GRPC_VERBOSITY=ERROR
|
||||
--samples=${SAMPLES} \
|
||||
--batch-size=${BATCH_SIZE} \
|
||||
--language="${LANGUAGE}" \
|
||||
--tts-mode="${TTS_MODE}" \
|
||||
--tts-voice-count="${TTS_VOICE_COUNT}" \
|
||||
--data-dir="${DATA_DIR}" "${WAKE_WORD}"
|
||||
|
||||
POST_GEN_TS=$EPOCHSECONDS
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
221
tts_config.py
Normal file
221
tts_config.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Shared modern-TTS catalog and routing helpers.
|
||||
|
||||
This module intentionally has no third-party dependencies. It is imported by
|
||||
the web server, the shell-facing generator, and unit tests before any of the
|
||||
large model environments have been installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
TTS_MODE_MODERN = "modern"
|
||||
TTS_MODE_HYBRID = "hybrid"
|
||||
TTS_MODE_PIPER = "piper"
|
||||
TTS_MODES = (TTS_MODE_MODERN, TTS_MODE_HYBRID, TTS_MODE_PIPER)
|
||||
DEFAULT_TTS_MODE = TTS_MODE_HYBRID
|
||||
|
||||
ENGINE_OMNIVOICE = "omnivoice"
|
||||
ENGINE_QWEN3 = "qwen3"
|
||||
ENGINE_MOSS = "moss"
|
||||
ENGINE_PIPER = "piper"
|
||||
MODERN_ENGINES = (ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS)
|
||||
|
||||
# Friendly/common codes whose OmniVoice IDs follow the model's catalog IDs.
|
||||
OMNIVOICE_LANGUAGE_ALIASES = {
|
||||
"ar": "arb", # Standard Arabic
|
||||
"ne": "npi", # Nepali
|
||||
}
|
||||
|
||||
|
||||
QWEN_LANGUAGES = {
|
||||
"zh": "Chinese",
|
||||
"en": "English",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"de": "German",
|
||||
"fr": "French",
|
||||
"ru": "Russian",
|
||||
"pt": "Portuguese",
|
||||
"es": "Spanish",
|
||||
"it": "Italian",
|
||||
}
|
||||
|
||||
# The upstream MOSS-TTS-Nano README calls this a 20-language list, although
|
||||
# the published table currently contains the 19 concrete entries below.
|
||||
MOSS_LANGUAGES = {
|
||||
"zh": "Chinese",
|
||||
"en": "English",
|
||||
"de": "German",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"ja": "Japanese",
|
||||
"it": "Italian",
|
||||
"hu": "Hungarian",
|
||||
"ko": "Korean",
|
||||
"ru": "Russian",
|
||||
"fa": "Persian (Farsi)",
|
||||
"ar": "Arabic",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"cs": "Czech",
|
||||
"da": "Danish",
|
||||
"sv": "Swedish",
|
||||
"el": "Greek",
|
||||
"tr": "Turkish",
|
||||
}
|
||||
|
||||
# Used when the live OmniVoice catalog has not been downloaded yet. The web
|
||||
# server expands this to the full upstream catalog (currently 646 languages)
|
||||
# and persists it under /data/.cache.
|
||||
COMMON_OMNIVOICE_LANGUAGES = {
|
||||
**QWEN_LANGUAGES,
|
||||
**MOSS_LANGUAGES,
|
||||
"af": "Afrikaans",
|
||||
"am": "Amharic",
|
||||
"as": "Assamese",
|
||||
"az": "Azerbaijani",
|
||||
"be": "Belarusian",
|
||||
"bg": "Bulgarian",
|
||||
"bn": "Bengali",
|
||||
"bs": "Bosnian",
|
||||
"ca": "Catalan",
|
||||
"cy": "Welsh",
|
||||
"et": "Estonian",
|
||||
"eu": "Basque",
|
||||
"fi": "Finnish",
|
||||
"fil": "Filipino",
|
||||
"gl": "Galician",
|
||||
"gu": "Gujarati",
|
||||
"he": "Hebrew",
|
||||
"hi": "Hindi",
|
||||
"hr": "Croatian",
|
||||
"hy": "Armenian",
|
||||
"id": "Indonesian",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"lt": "Lithuanian",
|
||||
"lv": "Latvian",
|
||||
"mk": "Macedonian",
|
||||
"ml": "Malayalam",
|
||||
"mr": "Marathi",
|
||||
"ms": "Malay",
|
||||
"my": "Burmese",
|
||||
"ne": "Nepali",
|
||||
"nl": "Dutch",
|
||||
"no": "Norwegian",
|
||||
"pa": "Punjabi",
|
||||
"ro": "Romanian",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"sq": "Albanian",
|
||||
"sr": "Serbian",
|
||||
"sw": "Swahili",
|
||||
"ta": "Tamil",
|
||||
"te": "Telugu",
|
||||
"th": "Thai",
|
||||
"uk": "Ukrainian",
|
||||
"ur": "Urdu",
|
||||
"vi": "Vietnamese",
|
||||
"yue": "Cantonese",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
}
|
||||
|
||||
QWEN_LANGUAGE_NAMES = {code: name for code, name in QWEN_LANGUAGES.items()}
|
||||
|
||||
|
||||
def parse_omnivoice_catalog(markdown: str) -> dict[str, dict[str, object]]:
|
||||
"""Parse the upstream Markdown language table without third-party packages."""
|
||||
|
||||
entries: dict[str, dict[str, object]] = {}
|
||||
for line in str(markdown or "").splitlines():
|
||||
if not line.lstrip().startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
||||
if len(cells) < 5 or not cells[0].isdigit():
|
||||
continue
|
||||
name, code, iso_code, duration_text = cells[1:5]
|
||||
code = code.strip().lower().replace("-", "_")
|
||||
if not code or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_" for character in code):
|
||||
continue
|
||||
try:
|
||||
duration_hours = float(duration_text.replace(",", ""))
|
||||
except ValueError:
|
||||
duration_hours = 0.0
|
||||
entries[code] = {
|
||||
"name": name or code.upper(),
|
||||
"iso_639_3": iso_code,
|
||||
"duration_hours": duration_hours,
|
||||
}
|
||||
return entries
|
||||
|
||||
|
||||
def normalize_tts_mode(value: object) -> str:
|
||||
token = str(value or DEFAULT_TTS_MODE).strip().lower().replace("-", "_")
|
||||
return token if token in TTS_MODES else DEFAULT_TTS_MODE
|
||||
|
||||
|
||||
def language_for_engine(engine: str, language: str) -> str:
|
||||
code = str(language or "en").strip().lower().replace("-", "_")
|
||||
if engine == ENGINE_OMNIVOICE:
|
||||
return OMNIVOICE_LANGUAGE_ALIASES.get(code, code)
|
||||
return code
|
||||
|
||||
|
||||
def modern_engines_for_language(language: str) -> list[str]:
|
||||
"""Return modern engines ordered from broadest to most specialized."""
|
||||
|
||||
code = str(language or "en").strip().lower().replace("-", "_")
|
||||
engines = [ENGINE_OMNIVOICE]
|
||||
if code in QWEN_LANGUAGES:
|
||||
engines.append(ENGINE_QWEN3)
|
||||
if code in MOSS_LANGUAGES:
|
||||
engines.append(ENGINE_MOSS)
|
||||
return engines
|
||||
|
||||
|
||||
def engines_for_language(
|
||||
language: str,
|
||||
mode: object = DEFAULT_TTS_MODE,
|
||||
*,
|
||||
piper_available: bool = False,
|
||||
) -> list[str]:
|
||||
selected_mode = normalize_tts_mode(mode)
|
||||
if selected_mode == TTS_MODE_PIPER:
|
||||
return [ENGINE_PIPER] if piper_available else []
|
||||
|
||||
engines = modern_engines_for_language(language)
|
||||
if selected_mode == TTS_MODE_HYBRID and piper_available:
|
||||
engines.append(ENGINE_PIPER)
|
||||
return engines
|
||||
|
||||
|
||||
def quality_for_engines(engines: Iterable[str]) -> str:
|
||||
engine_set = set(engines)
|
||||
if ENGINE_QWEN3 in engine_set and ENGINE_MOSS in engine_set:
|
||||
return "recommended"
|
||||
if ENGINE_MOSS in engine_set:
|
||||
return "supported"
|
||||
if ENGINE_OMNIVOICE in engine_set:
|
||||
return "experimental"
|
||||
return "legacy"
|
||||
|
||||
|
||||
def distribute_samples(total: int, engines: Iterable[str]) -> dict[str, int]:
|
||||
"""Distribute an exact sample total as evenly as possible."""
|
||||
|
||||
ordered = list(dict.fromkeys(str(engine) for engine in engines if engine))
|
||||
if total < 0:
|
||||
raise ValueError("total must be non-negative")
|
||||
if not ordered:
|
||||
if total:
|
||||
raise ValueError("at least one engine is required")
|
||||
return {}
|
||||
|
||||
quotient, remainder = divmod(total, len(ordered))
|
||||
return {
|
||||
engine: quotient + (1 if index < remainder else 0)
|
||||
for index, engine in enumerate(ordered)
|
||||
}
|
||||
Reference in New Issue
Block a user