Release NVIDIA WakeWord Trainer v22

This commit is contained in:
MasterPhooey
2026-08-02 20:46:04 -05:00
parent 2b1320f1f3
commit 2a88090b85
37 changed files with 11685 additions and 3628 deletions

113
cli/setup_modern_tts_envs Executable file
View File

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

View File

@@ -12,6 +12,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}"

1469
cli/tts_generate_samples.py Executable file

File diff suppressed because it is too large Load Diff

99
cli/tts_moss_worker.py Executable file
View File

@@ -0,0 +1,99 @@
#!/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_text=str(item["ref_text"]),
prompt_audio_path=str(item["ref_audio"]),
reference_audio_path=None,
text_tokenizer_path=None,
audio_tokenizer_type=MOSS_AUDIO_TOKENIZER_TYPE,
audio_tokenizer_pretrained_name_or_path=args.audio_tokenizer,
device=device,
nq=None,
max_new_frames=64,
voice_clone_max_text_tokens=32,
voice_clone_max_memory_per_sample_gb=1.0,
do_sample=True,
use_kv_cache=True,
text_temperature=1.0,
text_top_p=1.0,
text_top_k=50,
audio_temperature=0.7,
audio_top_p=0.9,
audio_top_k=25,
audio_repetition_penalty=1.3,
)
if index % 10 == 0 or index == len(entries):
print(f"MOSS generated {index}/{len(entries)}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

151
cli/tts_qwen_worker.py Executable file
View File

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

374
cli/tts_reference_qa.py Normal file
View File

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

View File

@@ -1,12 +1,13 @@
#!/bin/bash
set -e
set -euo pipefail
PROGPATH=$(realpath "$0")
PROGDIR=$(dirname "${PROGPATH}")
PROGPATH="$(realpath "$0")"
PROGDIR="$(dirname "${PROGPATH}")"
KNOWN_ARGS=( samples batch-size data-dir language )
KNOWN_ARGS=( samples batch-size data-dir language 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