mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
Release NVIDIA WakeWord Trainer v22
This commit is contained in:
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}"
|
||||
|
||||
1469
cli/tts_generate_samples.py
Executable file
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
99
cli/tts_moss_worker.py
Executable 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
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
|
||||
|
||||
Reference in New Issue
Block a user