3 Commits
v24 ... v27

Author SHA1 Message Date
MasterPhooey
13f229e451 Release NVIDIA WakeWord Trainer v27 2026-08-10 07:06:44 -05:00
MasterPhooey
68c4227cb7 Release NVIDIA WakeWord Trainer v26 2026-08-04 06:25:09 -05:00
MasterPhooey
bd71567a4f Release NVIDIA WakeWord Trainer v25 2026-08-03 19:18:06 -05:00
17 changed files with 787 additions and 272 deletions

View File

@@ -1 +1 @@
24 27

View File

@@ -1,3 +1 @@
- Added an ESPHome section to the Wake Words tab with a dedicated, copyable micro_wake_word JSON URL for every trained model. - Added English accent emphasis for Mixed English, Australian, American, British, Canadian, Irish, Scottish, New Zealand, Indian, and South African voices. Qwen shapes the selected accent and MOSS carries it into cloned references, with the setting available in both manual and automatic training.
- Kept the full Tater Native package unchanged while serving a separate strict ESPHome v2 manifest that references the same TFLite model.
- Added regression coverage for ESPHome manifest generation and the shared Vue interface.

View File

@@ -12,6 +12,7 @@ DEFAULT_SAMPLES=50000
DEFAULT_BATCH_SIZE=100 DEFAULT_BATCH_SIZE=100
DEFAULT_TRAINING_STEPS=40000 DEFAULT_TRAINING_STEPS=40000
DEFAULT_LANGUAGE=en DEFAULT_LANGUAGE=en
DEFAULT_ENGLISH_ACCENT=mixed
DEFAULT_TTS_MODE=hybrid DEFAULT_TTS_MODE=hybrid
DEFAULT_TTS_VOICE_COUNT=128 DEFAULT_TTS_VOICE_COUNT=128
@@ -21,6 +22,7 @@ DEFAULT_TTS_VOICE_COUNT=128
: "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}" : "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}"
: "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}" : "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}"
: "${LANGUAGE:=${DEFAULT_LANGUAGE}}" : "${LANGUAGE:=${DEFAULT_LANGUAGE}}"
: "${ENGLISH_ACCENT:=${DEFAULT_ENGLISH_ACCENT}}"
: "${TTS_MODE:=${DEFAULT_TTS_MODE}}" : "${TTS_MODE:=${DEFAULT_TTS_MODE}}"
: "${TTS_VOICE_COUNT:=${DEFAULT_TTS_VOICE_COUNT}}" : "${TTS_VOICE_COUNT:=${DEFAULT_TTS_VOICE_COUNT}}"
: "${CLEANUP_WORK_DIR:=false}" : "${CLEANUP_WORK_DIR:=false}"

View File

@@ -16,6 +16,7 @@ import math
import os import os
import random import random
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import wave import wave
@@ -30,20 +31,24 @@ if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR)) sys.path.insert(0, str(ROOT_DIR))
from tts_config import ( # noqa: E402 from tts_config import ( # noqa: E402
DEFAULT_ENGLISH_ACCENT,
DEFAULT_TTS_MODE, DEFAULT_TTS_MODE,
ENGLISH_ACCENTS,
ENGINE_MOSS, ENGINE_MOSS,
ENGINE_OMNIVOICE, ENGINE_OMNIVOICE,
ENGINE_PIPER, ENGINE_PIPER,
ENGINE_QWEN3, ENGINE_QWEN3,
MIXED_ENGLISH_ACCENTS,
QWEN_LANGUAGE_NAMES, QWEN_LANGUAGE_NAMES,
distribute_samples, distribute_samples,
engines_for_language, engines_for_language,
language_for_engine, language_for_engine,
normalize_english_accent,
normalize_tts_mode, normalize_tts_mode,
) )
GENERATOR_VERSION = "modern-tts-v15-four-provider-direct-corpus-safe-limits" GENERATOR_VERSION = "modern-tts-v17-four-provider-direct-corpus-safe-limits-english-accent-emphasis"
VOICE_BANK_VERSION = "modern-tts-voice-bank-v1-native-random-qualified-single-utterance" VOICE_BANK_VERSION = "modern-tts-voice-bank-v1-native-random-qualified-single-utterance"
COMPATIBLE_VOICE_BANK_VERSIONS = { COMPATIBLE_VOICE_BANK_VERSIONS = {
VOICE_BANK_VERSION, VOICE_BANK_VERSION,
@@ -65,6 +70,8 @@ DIRECT_CANDIDATE_FACTORS = {
ENGINE_MOSS: 1.25, ENGINE_MOSS: 1.25,
ENGINE_PIPER: 1.05, ENGINE_PIPER: 1.05,
} }
NORMALIZATION_TIMEOUT_SECONDS = 30.0
NORMALIZATION_PROGRESS_INTERVAL = 100
CARRIER_PROMPT_TEMPLATES = { CARRIER_PROMPT_TEMPLATES = {
"ar": "بصوت هادئ وطبيعي أقول {phrase} بوضوح، ثم أواصل الحديث بإيقاع ثابت.", "ar": "بصوت هادئ وطبيعي أقول {phrase} بوضوح، ثم أواصل الحديث بإيقاع ثابت.",
@@ -122,6 +129,38 @@ def run_with_batch_retry(
run(retry_command, env=env) run(retry_command, env=env)
def run_normalization_ffmpeg(command: list[str], timeout: float) -> int | None:
"""Run one conversion without allowing a stuck file read to block training."""
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
try:
return process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Do not use subprocess.run(timeout=...) here. On POSIX it performs an
# unbounded wait after killing the child, which can still freeze the
# trainer when a process is stuck in filesystem I/O.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except OSError:
try:
process.kill()
except ProcessLookupError:
pass
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
# The process may remain in uninterruptible I/O until the kernel
# releases it. The next candidate can still be processed safely.
pass
return None
def write_jsonl(path: Path, entries: list[dict]) -> None: def write_jsonl(path: Path, entries: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream: with path.open("w", encoding="utf-8") as stream:
@@ -164,7 +203,11 @@ def stable_prompt_text(phrase: str, language: str = "en") -> str:
return clean + "." return clean + "."
def qwen_descriptions(language_name: str, count: int) -> list[str]: def qwen_descriptions(
language_name: str,
count: int,
english_accent: str = DEFAULT_ENGLISH_ACCENT,
) -> list[str]:
genders = ("female", "male") genders = ("female", "male")
ages = ("child", "teenager", "young adult", "middle-aged adult", "elderly adult") ages = ("child", "teenager", "young adult", "middle-aged adult", "elderly adult")
pitches = ("low pitch", "medium pitch", "high pitch") pitches = ("low pitch", "medium pitch", "high pitch")
@@ -180,16 +223,30 @@ def qwen_descriptions(language_name: str, count: int) -> list[str]:
weights = ("light", "balanced", "compact", "full-bodied", "resonant") weights = ("light", "balanced", "compact", "full-bodied", "resonant")
combinations = list(product(genders, ages, pitches, deliveries, textures, paces, weights)) combinations = list(product(genders, ages, pitches, deliveries, textures, paces, weights))
descriptions = [] descriptions = []
accent_cycle: tuple[str, ...] = ()
if language_name == "English":
selected_accent = normalize_english_accent(english_accent, "en")
accent_cycle = (
MIXED_ENGLISH_ACCENTS
if selected_accent == DEFAULT_ENGLISH_ACCENT
else (selected_accent,)
)
# Walking the Cartesian product sequentially clusters the leading traits # Walking the Cartesian product sequentially clusters the leading traits
# (the first 375 combinations are all female). A coprime stride retains a # (the first 375 combinations are all female). A coprime stride retains a
# deterministic, non-repeating order while balancing every trait early. # deterministic, non-repeating order while balancing every trait early.
for index in range(count): for index in range(count):
combination_index = (index * VOICE_PROFILE_STRIDE) % len(combinations) combination_index = (index * VOICE_PROFILE_STRIDE) % len(combinations)
gender, age, pitch, delivery, texture, pace, weight = combinations[combination_index] gender, age, pitch, delivery, texture, pace, weight = combinations[combination_index]
language_style = f"native {language_name}"
if accent_cycle:
selected_accent = accent_cycle[index % len(accent_cycle)]
language_style = (
f"English with a natural {ENGLISH_ACCENTS[selected_accent]} accent"
)
descriptions.append( descriptions.append(
f"A distinct {age} {gender} speaker with a {texture} timbre, " f"A distinct {age} {gender} speaker with a {texture} timbre, "
f"{pitch}, {weight} vocal weight, and {delivery}, speaking native " f"{pitch}, {weight} vocal weight, and {delivery}, speaking "
f"{language_name} at a {pace} pace. Say only the supplied text once." f"{language_style} at a {pace} pace. Say only the supplied text once."
) )
return descriptions return descriptions
@@ -248,6 +305,10 @@ def valid_sample(path: Path) -> bool:
class Generator: class Generator:
def __init__(self, args: argparse.Namespace): def __init__(self, args: argparse.Namespace):
self.args = args self.args = args
self.english_accent = normalize_english_accent(
getattr(args, "english_accent", DEFAULT_ENGLISH_ACCENT),
args.language,
)
self.spoken_phrase = args.phrase.replace("_", " ").strip() self.spoken_phrase = args.phrase.replace("_", " ").strip()
self.data_dir = args.data_dir.resolve() self.data_dir = args.data_dir.resolve()
self.output_dir = args.output_dir.resolve() self.output_dir = args.output_dir.resolve()
@@ -304,6 +365,7 @@ class Generator:
"generator_version": GENERATOR_VERSION, "generator_version": GENERATOR_VERSION,
"phrase": self.args.phrase, "phrase": self.args.phrase,
"language": self.args.language, "language": self.args.language,
"english_accent": self.english_accent,
"tts_mode": self.args.tts_mode, "tts_mode": self.args.tts_mode,
"samples": self.args.samples, "samples": self.args.samples,
"engines": engines, "engines": engines,
@@ -1029,7 +1091,11 @@ class Generator:
self.direct_attempt[engine] += count self.direct_attempt[engine] += count
rng = random.Random(24051984 + start + sum(ord(ch) for ch in engine + prefix)) rng = random.Random(24051984 + start + sum(ord(ch) for ch in engine + prefix))
descriptions = ( descriptions = (
qwen_descriptions(QWEN_LANGUAGE_NAMES[self.args.language], start + count)[start:] qwen_descriptions(
QWEN_LANGUAGE_NAMES[self.args.language],
start + count,
self.english_accent,
)[start:]
if engine == ENGINE_QWEN3 if engine == ENGINE_QWEN3
else [] else []
) )
@@ -1247,7 +1313,9 @@ class Generator:
def normalize(self, paths: list[Path], start_index: int, limit: int) -> list[Path]: def normalize(self, paths: list[Path], start_index: int, limit: int) -> list[Path]:
accepted = [] accepted = []
self.final_dir.mkdir(parents=True, exist_ok=True) self.final_dir.mkdir(parents=True, exist_ok=True)
for path in paths: candidate_count = len(paths)
log(f"→ Normalizing up to {limit} accepted clip(s) from {candidate_count} candidate(s)")
for processed, path in enumerate(paths, start=1):
if len(accepted) >= limit: if len(accepted) >= limit:
break break
final_path = self.final_dir / f"{start_index + len(accepted)}.wav" final_path = self.final_dir / f"{start_index + len(accepted)}.wav"
@@ -1258,6 +1326,7 @@ class Generator:
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"error", "error",
"-nostdin",
"-y", "-y",
"-i", "-i",
str(path), str(path),
@@ -1272,18 +1341,39 @@ class Generator:
"pcm_s16le", "pcm_s16le",
str(temp_path), str(temp_path),
] ]
try: return_code = run_normalization_ffmpeg(
subprocess.run(command, check=True) command,
except subprocess.CalledProcessError: timeout=NORMALIZATION_TIMEOUT_SECONDS,
)
converted = return_code == 0
if return_code is None:
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
continue log(
digest = hashlib.sha256(temp_path.read_bytes()).hexdigest() if temp_path.is_file() else "" f"⚠️ Normalization timed out after "
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes: f"{NORMALIZATION_TIMEOUT_SECONDS:g}s; skipping {path.name}"
temp_path.replace(final_path) )
self.accepted_hashes.add(digest) elif return_code != 0:
accepted.append(final_path)
else:
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
log(f"⚠️ ffmpeg rejected {path.name} (exit {return_code}); skipping it")
if converted:
digest = hashlib.sha256(temp_path.read_bytes()).hexdigest() if temp_path.is_file() else ""
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes:
temp_path.replace(final_path)
self.accepted_hashes.add(digest)
accepted.append(final_path)
else:
temp_path.unlink(missing_ok=True)
if (
processed % NORMALIZATION_PROGRESS_INTERVAL == 0
or processed == candidate_count
or len(accepted) >= limit
):
log(
f"Normalization progress: {len(accepted)}/{limit} accepted "
f"({processed}/{candidate_count} candidate(s) checked)"
)
return accepted return accepted
def generate(self) -> None: def generate(self) -> None:
@@ -1301,6 +1391,8 @@ class Generator:
self.final_dir.mkdir(parents=True, exist_ok=True) self.final_dir.mkdir(parents=True, exist_ok=True)
plan = distribute_samples(self.args.samples, engines) plan = distribute_samples(self.args.samples, engines)
log(f"===== Direct TTS corpus plan ({self.args.tts_mode}, {self.args.language}) =====") log(f"===== Direct TTS corpus plan ({self.args.tts_mode}, {self.args.language}) =====")
if self.args.language == "en" and ENGINE_QWEN3 in plan:
log(f" English accent emphasis: {self.english_accent}")
for engine, count in plan.items(): for engine, count in plan.items():
log(f" {engine}: {count} sample(s)") log(f" {engine}: {count} sample(s)")
log( log(
@@ -1388,6 +1480,7 @@ class Generator:
"reusable_profile_bank": False, "reusable_profile_bank": False,
"moss_unique_accepted_carriers": True, "moss_unique_accepted_carriers": True,
"piper_all_model_speakers": True, "piper_all_model_speakers": True,
"english_accent_emphasis": self.english_accent,
}, },
"qa": { "qa": {
"audio_format": "16 kHz mono PCM16 WAV", "audio_format": "16 kHz mono PCM16 WAV",
@@ -1416,6 +1509,10 @@ def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser() result = argparse.ArgumentParser()
result.add_argument("phrase") result.add_argument("phrase")
result.add_argument("--language", default="en") result.add_argument("--language", default="en")
result.add_argument(
"--english-accent",
default=os.environ.get("MWW_ENGLISH_ACCENT", DEFAULT_ENGLISH_ACCENT),
)
result.add_argument("--tts-mode", default=DEFAULT_TTS_MODE) result.add_argument("--tts-mode", default=DEFAULT_TTS_MODE)
result.add_argument("--samples", type=int, default=50000) result.add_argument("--samples", type=int, default=50000)
result.add_argument("--batch-size", type=int, default=8) result.add_argument("--batch-size", type=int, default=8)
@@ -1435,6 +1532,7 @@ def parser() -> argparse.ArgumentParser:
def main() -> int: def main() -> int:
args = parser().parse_args() args = parser().parse_args()
args.language = args.language.strip().lower().replace("-", "_") args.language = args.language.strip().lower().replace("-", "_")
args.english_accent = normalize_english_accent(args.english_accent, args.language)
args.tts_mode = normalize_tts_mode(args.tts_mode) args.tts_mode = normalize_tts_mode(args.tts_mode)
if args.samples < 1: if args.samples < 1:
raise SystemExit("--samples must be positive") raise SystemExit("--samples must be positive")

View File

@@ -69,7 +69,6 @@ def main() -> int:
text=str(item["text"]), text=str(item["text"]),
output_audio_path=str(output_path), output_audio_path=str(output_path),
mode="voice_clone", mode="voice_clone",
prompt_text=str(item["ref_text"]),
prompt_audio_path=str(item["ref_audio"]), prompt_audio_path=str(item["ref_audio"]),
reference_audio_path=None, reference_audio_path=None,
text_tokenizer_path=None, text_tokenizer_path=None,

View File

@@ -4,7 +4,7 @@ set -euo pipefail
PROGPATH="$(realpath "$0")" PROGPATH="$(realpath "$0")"
PROGDIR="$(dirname "${PROGPATH}")" PROGDIR="$(dirname "${PROGPATH}")"
KNOWN_ARGS=( samples batch-size data-dir language tts-mode tts-voice-count ) KNOWN_ARGS=( samples batch-size data-dir language english-accent tts-mode tts-voice-count )
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "${PROGDIR}/shell.functions" source "${PROGDIR}/shell.functions"
WAKE_WORD="${POSITIONAL_ARGS[0]:-}" WAKE_WORD="${POSITIONAL_ARGS[0]:-}"
@@ -17,12 +17,14 @@ fi
if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then
cat <<EOF >&2 cat <<EOF >&2
Usage: $0 [ --samples=<samples> ] [ --batch-size=<batch_size> ] Usage: $0 [ --samples=<samples> ] [ --batch-size=<batch_size> ]
[ --language=<lang> ] [ --tts-mode=<modern|hybrid|piper> ] [ --language=<lang> ] [ --english-accent=<accent> ]
[ --tts-mode=<modern|hybrid|piper> ]
[ --tts-voice-count=<voices> ] <wake_word> [ --tts-voice-count=<voices> ] <wake_word>
--samples: Number of samples to generate. Default: ${DEFAULT_SAMPLES} --samples: Number of samples to generate. Default: ${DEFAULT_SAMPLES}
--batch-size: Generation batch size. Default: ${DEFAULT_BATCH_SIZE} --batch-size: Generation batch size. Default: ${DEFAULT_BATCH_SIZE}
--language: TTS language code. Default: ${DEFAULT_LANGUAGE} --language: TTS language code. Default: ${DEFAULT_LANGUAGE}
--english-accent: English accent emphasis. Default: ${DEFAULT_ENGLISH_ACCENT}
--tts-mode: modern, hybrid, or piper. Default: ${DEFAULT_TTS_MODE} --tts-mode: modern, hybrid, or piper. Default: ${DEFAULT_TTS_MODE}
--tts-voice-count: Deprecated compatibility option; direct generation ignores it. --tts-voice-count: Deprecated compatibility option; direct generation ignores it.
<wake_word> Required phrase to synthesize. <wake_word> Required phrase to synthesize.
@@ -38,17 +40,31 @@ case "${TTS_MODE}" in
;; ;;
esac esac
LANGUAGE="$(echo "${LANGUAGE}" | tr '[:upper:]' '[:lower:]')"
ENGLISH_ACCENT="$(echo "${ENGLISH_ACCENT}" | tr '[:upper:] -' '[:lower:]__')"
if [ "${LANGUAGE}" != "en" ]; then
ENGLISH_ACCENT="mixed"
fi
case "${ENGLISH_ACCENT}" in
mixed|australian|american|british|canadian|irish|scottish|new_zealand|indian|south_african) ;;
*)
echo "ERROR: unsupported --english-accent '${ENGLISH_ACCENT}'." >&2
exit 2
;;
esac
WORK_DIR="${DATA_DIR}/work" WORK_DIR="${DATA_DIR}/work"
SAMPLES_DIR="${WORK_DIR}/wake_word_samples" SAMPLES_DIR="${WORK_DIR}/wake_word_samples"
mkdir -p "${WORK_DIR}" mkdir -p "${WORK_DIR}"
START_TS=$EPOCHSECONDS START_TS=$EPOCHSECONDS
echo "===== Generating ${SAMPLES} wake-word samples (language=${LANGUAGE}, tts=${TTS_MODE}) =====" echo "===== Generating ${SAMPLES} wake-word samples (language=${LANGUAGE}, accent=${ENGLISH_ACCENT}, tts=${TTS_MODE}) ====="
python3 "${PROGDIR}/tts_generate_samples.py" "${WAKE_WORD}" \ python3 "${PROGDIR}/tts_generate_samples.py" "${WAKE_WORD}" \
--samples="${SAMPLES}" \ --samples="${SAMPLES}" \
--batch-size="${BATCH_SIZE}" \ --batch-size="${BATCH_SIZE}" \
--language="${LANGUAGE}" \ --language="${LANGUAGE}" \
--english-accent="${ENGLISH_ACCENT}" \
--tts-mode="${TTS_MODE}" \ --tts-mode="${TTS_MODE}" \
--voice-count="${TTS_VOICE_COUNT}" \ --voice-count="${TTS_VOICE_COUNT}" \
--data-dir="${DATA_DIR}" \ --data-dir="${DATA_DIR}" \

View File

@@ -70,7 +70,7 @@ const dataCategories = computed(() => {
return Array.from(groups, ([name, items]) => ({ name, items })); return Array.from(groups, ([name, items]) => ({ name, items }));
}); });
watch(() => trainer.language, ensureSupportedTtsMode); watch([() => trainer.language, () => trainer.ttsMode], ensureSupportedTtsMode);
watch(() => trainer.toast.serial, () => window.setTimeout(() => { trainer.toast.message = ""; }, 4500)); watch(() => trainer.toast.serial, () => window.setTimeout(() => { trainer.toast.message = ""; }, 4500));
watch(consoleLines, async () => { watch(consoleLines, async () => {
if (!consoleFollowing.value) return; if (!consoleFollowing.value) return;
@@ -197,6 +197,7 @@ function consoleTone(line: string): string {
<div class="form-grid phrase-form"> <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 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>Language</span><select v-model="trainer.language" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')"><option v-for="item in trainer.languages" :key="item.code" :value="item.code">{{ item.label }}</option></select><small>{{ ttsRoute }}</small></label>
<label v-if="trainer.language === 'en' && trainer.ttsMode !== 'piper'" class="field"><span>English accent emphasis</span><select v-model="trainer.englishAccent" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')"><option v-for="accent in trainer.englishAccents" :key="accent.code" :value="accent.code">{{ accent.label }}</option></select><small>Qwen emphasizes this accent; MOSS carries it through accepted references. OmniVoice and Piper keep broad English coverage.</small></label>
<label class="field"><span>TTS source</span><select v-model="trainer.ttsMode" :disabled="Boolean(trainer.session.safe_word) || isBusy('session')"> <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="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="modern" :disabled="!trainer.languages.find((item) => item.code === trainer.language)?.engines?.some((engine) => engine !== 'piper')">Modern only · no Piper</option>
@@ -224,6 +225,7 @@ function consoleTone(line: string): string {
<div class="form-grid"> <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>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"><span>STT language</span><input v-model="trainer.autoForm.language" type="text" /></label>
<label v-if="String(trainer.autoForm.language).toLowerCase().startsWith('en')" class="field"><span>English accent emphasis</span><select v-model="trainer.autoForm.english_accent"><option v-for="accent in trainer.englishAccents" :key="accent.code" :value="accent.code">{{ accent.label }}</option></select><small>Used when Auto Training needs to regenerate English TTS.</small></label>
<label class="field wide"><span>STT engine</span><select v-model="trainer.autoForm.stt_engine"><option v-for="engine in sttEngines" :key="engine.id || engine.value" :value="engine.id || engine.value">{{ engine.label || engine.name || engine.id }}</option></select><small>{{ sttEngines.find((row) => (row.id || row.value) === trainer.autoForm.stt_engine)?.description || "Runs locally on this trainer." }}</small></label> <label class="field 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> <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> </div>
@@ -266,6 +268,7 @@ function consoleTone(line: string): string {
<div v-if="!selectedSamples.length" class="empty-state">No {{ trainer.sampleBucket }} samples saved yet.</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"> <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> <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)" /> <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> <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> </article></div>

View File

@@ -1,6 +1,7 @@
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { getJson, postJson, putJson, request, type JsonRecord } from "./api"; import { getJson, postJson, putJson, request, type JsonRecord } from "./api";
import type { import type {
AccentOption,
AudioItem, AudioItem,
AutoTrainForm, AutoTrainForm,
AutoTrainPayload, AutoTrainPayload,
@@ -26,6 +27,7 @@ const defaultAutoForm = (): AutoTrainForm => ({
enabled: false, enabled: false,
wake_phrase: "", wake_phrase: "",
language: "en", language: "en",
english_accent: "mixed",
stt_engine: "faster_whisper", stt_engine: "faster_whisper",
minimum_transcript_chars: 2, minimum_transcript_chars: 2,
delete_confirmed_wakes: false, delete_confirmed_wakes: false,
@@ -43,8 +45,21 @@ export const trainer = reactive({
busy: new Set<string>(), busy: new Set<string>(),
phrase: "", phrase: "",
language: "en", language: "en",
englishAccent: "mixed",
ttsMode: "hybrid", ttsMode: "hybrid",
languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[], languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[],
englishAccents: [
{ code: "mixed", label: "Mixed English" },
{ code: "australian", label: "Australian" },
{ code: "american", label: "American" },
{ code: "british", label: "British" },
{ code: "canadian", label: "Canadian" },
{ code: "irish", label: "Irish" },
{ code: "scottish", label: "Scottish" },
{ code: "new_zealand", label: "New Zealand" },
{ code: "indian", label: "Indian" },
{ code: "south_african", label: "South African" },
] as AccentOption[],
session: {} as SessionPayload, session: {} as SessionPayload,
samples: emptySamples(), samples: emptySamples(),
captured: emptyCaptured(), captured: emptyCaptured(),
@@ -123,8 +138,12 @@ function applySession(payload: SessionPayload): void {
if (Array.isArray(payload.available_languages) && payload.available_languages.length) { if (Array.isArray(payload.available_languages) && payload.available_languages.length) {
trainer.languages = payload.available_languages; trainer.languages = payload.available_languages;
} }
if (Array.isArray(payload.available_english_accents) && payload.available_english_accents.length) {
trainer.englishAccents = payload.available_english_accents;
}
if (payload.raw_phrase) trainer.phrase = payload.raw_phrase; if (payload.raw_phrase) trainer.phrase = payload.raw_phrase;
if (payload.language) trainer.language = payload.language; if (payload.language) trainer.language = payload.language;
if (payload.english_accent) trainer.englishAccent = payload.english_accent;
if (payload.tts_mode) trainer.ttsMode = payload.tts_mode; if (payload.tts_mode) trainer.ttsMode = payload.tts_mode;
if (payload.training) trainer.training = payload.training; if (payload.training) trainer.training = payload.training;
} }
@@ -145,6 +164,7 @@ export async function startSession(): Promise<void> {
const payload = await postJson<SessionPayload>("/api/start_session", { const payload = await postJson<SessionPayload>("/api/start_session", {
phrase: trainer.phrase.trim(), phrase: trainer.phrase.trim(),
language: trainer.language, language: trainer.language,
english_accent: trainer.englishAccent,
tts_mode: trainer.ttsMode, tts_mode: trainer.ttsMode,
}); });
applySession(payload); applySession(payload);
@@ -193,6 +213,7 @@ export function ensureSupportedTtsMode(): void {
if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper"; if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper";
if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper"; if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper";
if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern"; if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern";
if (trainer.language !== "en" || trainer.ttsMode === "piper") trainer.englishAccent = "mixed";
} }
export async function refreshSamples(quiet = false): Promise<SamplesPayload> { export async function refreshSamples(quiet = false): Promise<SamplesPayload> {
@@ -340,6 +361,8 @@ function applyAuto(payload: AutoTrainPayload, populate: boolean): void {
trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) }; trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) };
if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || ""; if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || "";
if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en"; if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en";
if (!trainer.autoForm.english_accent) trainer.autoForm.english_accent = trainer.session.english_accent || "mixed";
if (!String(trainer.autoForm.language).toLowerCase().startsWith("en")) trainer.autoForm.english_accent = "mixed";
} }
export async function refreshAuto(populate = false): Promise<AutoTrainPayload> { export async function refreshAuto(populate = false): Promise<AutoTrainPayload> {

View File

@@ -10,6 +10,11 @@ export interface LanguageOption extends JsonRecord {
quality?: string; quality?: string;
} }
export interface AccentOption extends JsonRecord {
code: string;
label: string;
}
export interface TrainingState extends JsonRecord { export interface TrainingState extends JsonRecord {
running: boolean; running: boolean;
exit_code: number | null; exit_code: number | null;
@@ -20,9 +25,11 @@ export interface SessionPayload extends JsonRecord {
safe_word?: string; safe_word?: string;
raw_phrase?: string; raw_phrase?: string;
language?: string; language?: string;
english_accent?: string;
tts_mode?: string; tts_mode?: string;
takes_received?: number; takes_received?: number;
available_languages?: LanguageOption[]; available_languages?: LanguageOption[];
available_english_accents?: AccentOption[];
training?: TrainingState; training?: TrainingState;
} }
@@ -31,6 +38,11 @@ export interface AudioItem extends JsonRecord {
original_name?: string; original_name?: string;
audio_url?: string; audio_url?: string;
final_format?: JsonRecord; 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 { export interface SamplesPayload extends JsonRecord {
@@ -51,6 +63,7 @@ export interface AutoTrainForm extends JsonRecord {
enabled: boolean; enabled: boolean;
wake_phrase: string; wake_phrase: string;
language: string; language: string;
english_accent: string;
stt_engine: string; stt_engine: string;
minimum_transcript_chars: number; minimum_transcript_chars: number;
delete_confirmed_wakes: boolean; delete_confirmed_wakes: boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -315,6 +315,10 @@ class AutoTrainTests(unittest.TestCase):
self.assertEqual(metadata["transcript"], "turn on the kitchen lights") self.assertEqual(metadata["transcript"], "turn on the kitchen lights")
self.assertEqual(metadata["auto_review_stt_engine"], "faster_whisper") self.assertEqual(metadata["auto_review_stt_engine"], "faster_whisper")
self.assertEqual(metadata["auto_review_stt_model"], "small.en") 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) self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 1)
def test_matching_phrase_stays_in_manual_review_inbox(self): def test_matching_phrase_stays_in_manual_review_inbox(self):
@@ -467,9 +471,30 @@ class AutoTrainTests(unittest.TestCase):
self.assertTrue(metadata["auto_positive"]) self.assertTrue(metadata["auto_positive"])
self.assertEqual(metadata["review_status"], "auto_approved_personal") self.assertEqual(metadata["review_status"], "auto_approved_personal")
self.assertEqual(metadata["transcript"], "hey tater") 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.assertFalse(list(trainer.NEGATIVE_DIR.glob("*.wav")))
self.assertEqual(trainer.AUTO_TRAIN_STATE["pending_negative_count"], 0) 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): def test_close_miss_without_phrase_stays_in_inbox(self):
audio_path = self.add_capture(event_type="close_miss") audio_path = self.add_capture(event_type="close_miss")
trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True trainer.AUTO_TRAIN_CONFIG["promote_close_misses"] = True

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ast
import importlib.util import importlib.util
import json import json
import math import math
import shutil import shutil
import signal
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
@@ -69,6 +71,44 @@ class ModernTtsTests(unittest.TestCase):
["--position_temperature", "5.0", "--class_temperature", "0.0"], ["--position_temperature", "5.0", "--class_temperature", "0.0"],
) )
def test_qwen_accent_emphasis_supports_specific_and_mixed_english(self) -> None:
australian = generator_module.qwen_descriptions("English", 4, "australian")
self.assertTrue(all("natural Australian accent" in row for row in australian))
mixed = generator_module.qwen_descriptions("English", 9, "mixed")
for label in (
"Australian",
"American",
"British",
"Canadian",
"Irish",
"Scottish",
"New Zealand",
"Indian",
"South African",
):
self.assertTrue(any(f"natural {label} accent" in row for row in mixed))
german = generator_module.qwen_descriptions("German", 1, "australian")
self.assertIn("speaking native German", german[0])
self.assertNotIn("accent", german[0])
def test_moss_voice_clone_uses_audio_without_disallowed_prompt_text(self) -> None:
worker_path = REPO_ROOT / "cli" / "tts_moss_worker.py"
tree = ast.parse(worker_path.read_text(encoding="utf-8"))
inference_calls = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "inference"
]
self.assertEqual(len(inference_calls), 1)
keywords = {keyword.arg for keyword in inference_calls[0].keywords}
self.assertIn("prompt_audio_path", keywords)
self.assertNotIn("prompt_text", keywords)
def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None: def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
data_dir = Path(temp_dir) data_dir = Path(temp_dir)
@@ -586,6 +626,87 @@ class ModernTtsTests(unittest.TestCase):
self.assertTrue((output_dir / ".generation_manifest.json").is_file()) self.assertTrue((output_dir / ".generation_manifest.json").is_file())
self.assertTrue(instance.cache_hit()) 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: def test_docker_and_ui_are_wired_for_modern_tts(self) -> None:
for dockerfile in ("dockerfile", "dockerfile.blackwell"): for dockerfile in ("dockerfile", "dockerfile.blackwell"):
source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8") source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8")

View File

@@ -10,6 +10,7 @@ from tts_config import (
distribute_samples, distribute_samples,
engines_for_language, engines_for_language,
language_for_engine, language_for_engine,
normalize_english_accent,
normalize_tts_mode, normalize_tts_mode,
quality_for_engines, quality_for_engines,
) )
@@ -51,6 +52,12 @@ class TtsConfigTests(unittest.TestCase):
def test_invalid_mode_falls_back_to_four_provider_route(self) -> None: def test_invalid_mode_falls_back_to_four_provider_route(self) -> None:
self.assertEqual(normalize_tts_mode("unknown"), "hybrid") self.assertEqual(normalize_tts_mode("unknown"), "hybrid")
def test_english_accent_aliases_and_non_english_fallback(self) -> None:
self.assertEqual(normalize_english_accent("Australia", "en"), "australian")
self.assertEqual(normalize_english_accent("new-zealand", "en_US"), "new_zealand")
self.assertEqual(normalize_english_accent("unknown", "en"), "mixed")
self.assertEqual(normalize_english_accent("australian", "fr"), "mixed")
def test_common_language_aliases_use_model_catalog_ids(self) -> None: 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, "ar"), "arb")
self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ne"), "npi") self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ne"), "npi")

View File

@@ -80,6 +80,14 @@ class VueTrainerUiTests(unittest.TestCase):
self.assertIn('@scroll.passive="onConsoleScroll"', app) self.assertIn('@scroll.passive="onConsoleScroll"', app)
self.assertIn("Jump to latest", 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: def test_wake_word_card_uses_explicit_json_catalog_url(self) -> None:
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")

View File

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

View File

@@ -39,6 +39,7 @@ ROOT_DIR = Path(__file__).resolve().parent
from tts_config import ( from tts_config import (
COMMON_OMNIVOICE_LANGUAGES, COMMON_OMNIVOICE_LANGUAGES,
DEFAULT_ENGLISH_ACCENT,
DEFAULT_TTS_MODE, DEFAULT_TTS_MODE,
ENGINE_MOSS, ENGINE_MOSS,
ENGINE_OMNIVOICE, ENGINE_OMNIVOICE,
@@ -47,6 +48,8 @@ from tts_config import (
MOSS_LANGUAGES, MOSS_LANGUAGES,
OMNIVOICE_LANGUAGE_ALIASES, OMNIVOICE_LANGUAGE_ALIASES,
QWEN_LANGUAGES, QWEN_LANGUAGES,
english_accent_options,
normalize_english_accent,
normalize_tts_mode, normalize_tts_mode,
parse_omnivoice_catalog, parse_omnivoice_catalog,
quality_for_engines, quality_for_engines,
@@ -115,6 +118,10 @@ TRAIN_CMD = os.environ.get(
) )
DEFAULT_LANGUAGE = os.environ.get("MWW_LANGUAGE", "en") DEFAULT_LANGUAGE = os.environ.get("MWW_LANGUAGE", "en")
DEFAULT_SERVER_TTS_MODE = normalize_tts_mode(os.environ.get("MWW_TTS_MODE", DEFAULT_TTS_MODE)) DEFAULT_SERVER_TTS_MODE = normalize_tts_mode(os.environ.get("MWW_TTS_MODE", DEFAULT_TTS_MODE))
DEFAULT_SERVER_ENGLISH_ACCENT = normalize_english_accent(
os.environ.get("MWW_ENGLISH_ACCENT", DEFAULT_ENGLISH_ACCENT),
DEFAULT_LANGUAGE,
)
TAKES_PER_SPEAKER_DEFAULT = int(os.environ.get("REC_TAKES_PER_SPEAKER", "10")) TAKES_PER_SPEAKER_DEFAULT = int(os.environ.get("REC_TAKES_PER_SPEAKER", "10"))
SPEAKERS_TOTAL_DEFAULT = int(os.environ.get("REC_SPEAKERS_TOTAL", "1")) SPEAKERS_TOTAL_DEFAULT = int(os.environ.get("REC_SPEAKERS_TOTAL", "1"))
@@ -157,6 +164,7 @@ AUTO_TRAIN_DEFAULT_CONFIG: Dict[str, Any] = {
"enabled": False, "enabled": False,
"wake_phrase": "", "wake_phrase": "",
"language": DEFAULT_LANGUAGE, "language": DEFAULT_LANGUAGE,
"english_accent": DEFAULT_SERVER_ENGLISH_ACCENT,
"stt_engine": DEFAULT_STT_ENGINE, "stt_engine": DEFAULT_STT_ENGINE,
"minimum_transcript_chars": 2, "minimum_transcript_chars": 2,
"delete_confirmed_wakes": False, "delete_confirmed_wakes": False,
@@ -213,6 +221,7 @@ STATE: Dict[str, Any] = {
"raw_phrase": None, "raw_phrase": None,
"safe_word": None, "safe_word": None,
"language": DEFAULT_LANGUAGE, "language": DEFAULT_LANGUAGE,
"english_accent": DEFAULT_SERVER_ENGLISH_ACCENT,
"tts_mode": DEFAULT_SERVER_TTS_MODE, "tts_mode": DEFAULT_SERVER_TTS_MODE,
# multi-speaker # multi-speaker
@@ -787,6 +796,9 @@ def _normalize_auto_train_config(values: Dict[str, Any] | None, *, base: Dict[st
"enabled": _config_bool(source.get("enabled")), "enabled": _config_bool(source.get("enabled")),
"wake_phrase": str(source.get("wake_phrase") or "").strip(), "wake_phrase": str(source.get("wake_phrase") or "").strip(),
"language": language, "language": language,
"english_accent": normalize_english_accent(
source.get("english_accent"), language
),
"stt_engine": _normalize_stt_engine(source.get("stt_engine")), "stt_engine": _normalize_stt_engine(source.get("stt_engine")),
"minimum_transcript_chars": _bounded_int(source.get("minimum_transcript_chars"), 2, 1, 100), "minimum_transcript_chars": _bounded_int(source.get("minimum_transcript_chars"), 2, 1, 100),
"delete_confirmed_wakes": _config_bool(source.get("delete_confirmed_wakes")), "delete_confirmed_wakes": _config_bool(source.get("delete_confirmed_wakes")),
@@ -1725,6 +1737,9 @@ def _start_auto_training() -> Dict[str, Any]:
safe_word = safe_name(wake_phrase) safe_word = safe_name(wake_phrase)
available_languages = _available_languages() available_languages = _available_languages()
language = _normalize_language(str(config.get("language") or DEFAULT_LANGUAGE)) language = _normalize_language(str(config.get("language") or DEFAULT_LANGUAGE))
english_accent = normalize_english_accent(
config.get("english_accent"), language
)
tts_mode = _resolve_tts_mode_for_language( tts_mode = _resolve_tts_mode_for_language(
DEFAULT_SERVER_TTS_MODE, DEFAULT_SERVER_TTS_MODE,
language, language,
@@ -1737,6 +1752,7 @@ def _start_auto_training() -> Dict[str, Any]:
STATE["raw_phrase"] = wake_phrase STATE["raw_phrase"] = wake_phrase
STATE["safe_word"] = safe_word STATE["safe_word"] = safe_word
STATE["language"] = language STATE["language"] = language
STATE["english_accent"] = english_accent
STATE["tts_mode"] = tts_mode STATE["tts_mode"] = tts_mode
STATE["training"]["running"] = True STATE["training"]["running"] = True
with AUTO_TRAIN_LOCK: with AUTO_TRAIN_LOCK:
@@ -1744,7 +1760,9 @@ def _start_auto_training() -> Dict[str, Any]:
AUTO_TRAIN_RUNTIME["training_pending_consumed"] = int(AUTO_TRAIN_STATE.get("pending_negative_count") or 0) AUTO_TRAIN_RUNTIME["training_pending_consumed"] = int(AUTO_TRAIN_STATE.get("pending_negative_count") or 0)
_save_auto_train_state_locked() _save_auto_train_state_locked()
try: try:
_start_training_thread(safe_word, language, True, True, tts_mode) _start_training_thread(
safe_word, language, True, True, tts_mode, english_accent
)
except Exception as exc: except Exception as exc:
with STATE_LOCK: with STATE_LOCK:
STATE["training"]["running"] = False STATE["training"]["running"] = False
@@ -1754,6 +1772,7 @@ def _start_auto_training() -> Dict[str, Any]:
"started": True, "started": True,
"safe_word": safe_word, "safe_word": safe_word,
"language": language, "language": language,
"english_accent": english_accent,
"tts_mode": tts_mode, "tts_mode": tts_mode,
} }
@@ -2711,6 +2730,9 @@ def _sample_item_from_path(audio_path: Path, bucket: str) -> Dict[str, Any]:
"message": meta.get("message") or "", "message": meta.get("message") or "",
"transcript": meta.get("transcript") or "", "transcript": meta.get("transcript") or "",
"transcribed_at": meta.get("transcribed_at") or "", "transcribed_at": meta.get("transcribed_at") or "",
"auto_review_guided_transcript": meta.get("auto_review_guided_transcript") or "",
"auto_review_stt_engine": meta.get("auto_review_stt_engine") or "",
"auto_review_stt_model": meta.get("auto_review_stt_model") or "",
"auto_negative": bool(meta.get("auto_negative")), "auto_negative": bool(meta.get("auto_negative")),
"auto_positive": bool(meta.get("auto_positive")), "auto_positive": bool(meta.get("auto_positive")),
"auto_review_reason": meta.get("auto_review_reason") or "", "auto_review_reason": meta.get("auto_review_reason") or "",
@@ -3070,11 +3092,19 @@ def _start_training_thread(
allow_no_personal: bool, allow_no_personal: bool,
auto_run: bool, auto_run: bool,
tts_mode: str, tts_mode: str,
english_accent: str = DEFAULT_SERVER_ENGLISH_ACCENT,
) -> threading.Thread: ) -> threading.Thread:
global TRAINING_THREAD global TRAINING_THREAD
thread = threading.Thread( thread = threading.Thread(
target=_run_training_background, target=_run_training_background,
args=(safe_word, language, allow_no_personal, auto_run, tts_mode), args=(
safe_word,
language,
allow_no_personal,
auto_run,
tts_mode,
english_accent,
),
daemon=True, daemon=True,
name="wake-word-training", name="wake-word-training",
) )
@@ -3118,10 +3148,12 @@ def _run_training_background(
allow_no_personal: bool, allow_no_personal: bool,
auto_run: bool = False, auto_run: bool = False,
tts_mode: str = DEFAULT_SERVER_TTS_MODE, tts_mode: str = DEFAULT_SERVER_TTS_MODE,
english_accent: str = DEFAULT_SERVER_ENGLISH_ACCENT,
): ):
global TRAINING_PROCESS, TRAINING_THREAD global TRAINING_PROCESS, TRAINING_THREAD
language = (language or DEFAULT_LANGUAGE).strip().lower() or DEFAULT_LANGUAGE language = (language or DEFAULT_LANGUAGE).strip().lower() or DEFAULT_LANGUAGE
tts_mode = normalize_tts_mode(tts_mode) tts_mode = normalize_tts_mode(tts_mode)
english_accent = normalize_english_accent(english_accent, language)
rc = 999 rc = 999
proc: subprocess.Popen | None = None proc: subprocess.Popen | None = None
with STATE_LOCK: with STATE_LOCK:
@@ -3167,7 +3199,12 @@ def _run_training_background(
except Exception as error: except Exception as error:
_append_train_log(f"⚠️ Piper is unavailable for hybrid mode; using modern TTS only: {error}") _append_train_log(f"⚠️ Piper is unavailable for hybrid mode; using modern TTS only: {error}")
command_args = [f"--language={language}", f"--tts-mode={tts_mode}", safe_word] command_args = [
f"--language={language}",
f"--english-accent={english_accent}",
f"--tts-mode={tts_mode}",
safe_word,
]
if wake_word_title: if wake_word_title:
command_args.append(wake_word_title) command_args.append(wake_word_title)
cmd_str = f"{TRAIN_CMD} " + " ".join(shlex.quote(argument) for argument in command_args) cmd_str = f"{TRAIN_CMD} " + " ".join(shlex.quote(argument) for argument in command_args)
@@ -3177,6 +3214,8 @@ def _run_training_background(
_append_train_log("===== Training (train_wake_word) =====") _append_train_log("===== Training (train_wake_word) =====")
_append_train_log(f"→ Running: {cmd_str}") _append_train_log(f"→ Running: {cmd_str}")
if language == "en":
_append_train_log(f"→ English accent emphasis: {english_accent}")
with open(log_path, "a", encoding="utf-8") as lf: with open(log_path, "a", encoding="utf-8") as lf:
proc = subprocess.Popen( proc = subprocess.Popen(
@@ -3408,6 +3447,9 @@ def start_session(payload: Dict[str, Any]):
language, language,
available_languages, available_languages,
) )
english_accent = normalize_english_accent(
payload.get("english_accent", DEFAULT_SERVER_ENGLISH_ACCENT), language
)
speakers_total = max(1, min(10, speakers_total)) speakers_total = max(1, min(10, speakers_total))
takes_per_speaker = max(1, min(50, takes_per_speaker)) takes_per_speaker = max(1, min(50, takes_per_speaker))
@@ -3416,6 +3458,7 @@ def start_session(payload: Dict[str, Any]):
STATE["raw_phrase"] = raw STATE["raw_phrase"] = raw
STATE["safe_word"] = safe STATE["safe_word"] = safe
STATE["language"] = language STATE["language"] = language
STATE["english_accent"] = english_accent
STATE["tts_mode"] = tts_mode STATE["tts_mode"] = tts_mode
STATE["speakers_total"] = speakers_total STATE["speakers_total"] = speakers_total
STATE["takes_per_speaker"] = takes_per_speaker STATE["takes_per_speaker"] = takes_per_speaker
@@ -3429,6 +3472,7 @@ def start_session(payload: Dict[str, Any]):
"raw_phrase": raw, "raw_phrase": raw,
"safe_word": safe, "safe_word": safe,
"language": language, "language": language,
"english_accent": english_accent,
"tts_mode": tts_mode, "tts_mode": tts_mode,
"speakers_total": speakers_total, "speakers_total": speakers_total,
"takes_per_speaker": takes_per_speaker, "takes_per_speaker": takes_per_speaker,
@@ -3436,6 +3480,7 @@ def start_session(payload: Dict[str, Any]):
"takes_received": len(takes), "takes_received": len(takes),
"takes": takes, "takes": takes,
"available_languages": available_languages, "available_languages": available_languages,
"available_english_accents": english_accent_options(),
"personal_dir": str(PERSONAL_DIR), "personal_dir": str(PERSONAL_DIR),
"data_dir": str(DATA_DIR), "data_dir": str(DATA_DIR),
} }
@@ -3465,6 +3510,9 @@ def stop_session():
STATE["training"]["safe_word"] = None STATE["training"]["safe_word"] = None
training = dict(STATE["training"]) training = dict(STATE["training"])
language = _normalize_language(STATE["language"]) language = _normalize_language(STATE["language"])
english_accent = normalize_english_accent(
STATE.get("english_accent"), language
)
tts_mode = normalize_tts_mode(STATE.get("tts_mode")) tts_mode = normalize_tts_mode(STATE.get("tts_mode"))
return { return {
"ok": True, "ok": True,
@@ -3473,11 +3521,13 @@ def stop_session():
"raw_phrase": None, "raw_phrase": None,
"safe_word": None, "safe_word": None,
"language": language, "language": language,
"english_accent": english_accent,
"tts_mode": tts_mode, "tts_mode": tts_mode,
"takes_received": len(takes), "takes_received": len(takes),
"takes": list(takes), "takes": list(takes),
"training": training, "training": training,
"available_languages": available_languages, "available_languages": available_languages,
"available_english_accents": english_accent_options(),
} }
@@ -3488,13 +3538,18 @@ def get_session():
with STATE_LOCK: with STATE_LOCK:
current_language = _normalize_language(STATE["language"]) current_language = _normalize_language(STATE["language"])
current_tts_mode = normalize_tts_mode(STATE.get("tts_mode")) current_tts_mode = normalize_tts_mode(STATE.get("tts_mode"))
current_english_accent = normalize_english_accent(
STATE.get("english_accent"), current_language
)
STATE["language"] = current_language STATE["language"] = current_language
STATE["english_accent"] = current_english_accent
STATE["tts_mode"] = current_tts_mode STATE["tts_mode"] = current_tts_mode
return { return {
"ok": True, "ok": True,
"raw_phrase": STATE["raw_phrase"], "raw_phrase": STATE["raw_phrase"],
"safe_word": STATE["safe_word"], "safe_word": STATE["safe_word"],
"language": current_language, "language": current_language,
"english_accent": current_english_accent,
"tts_mode": current_tts_mode, "tts_mode": current_tts_mode,
"speakers_total": STATE["speakers_total"], "speakers_total": STATE["speakers_total"],
"takes_per_speaker": STATE["takes_per_speaker"], "takes_per_speaker": STATE["takes_per_speaker"],
@@ -3502,6 +3557,7 @@ def get_session():
"takes": list(takes), "takes": list(takes),
"training": dict(STATE["training"]), "training": dict(STATE["training"]),
"available_languages": available_languages, "available_languages": available_languages,
"available_english_accents": english_accent_options(),
} }
@@ -4014,6 +4070,9 @@ def train_now(payload: Dict[str, Any] = None):
with STATE_LOCK: with STATE_LOCK:
safe_word = STATE["safe_word"] safe_word = STATE["safe_word"]
language = (STATE.get("language") or DEFAULT_LANGUAGE) language = (STATE.get("language") or DEFAULT_LANGUAGE)
english_accent = normalize_english_accent(
STATE.get("english_accent"), language
)
tts_mode = normalize_tts_mode(STATE.get("tts_mode")) tts_mode = normalize_tts_mode(STATE.get("tts_mode"))
takes_received = int(STATE["takes_received"]) takes_received = int(STATE["takes_received"])
speakers_total = int(STATE["speakers_total"]) speakers_total = int(STATE["speakers_total"])
@@ -4043,7 +4102,14 @@ def train_now(payload: Dict[str, Any] = None):
with STATE_LOCK: with STATE_LOCK:
STATE["training"]["running"] = True STATE["training"]["running"] = True
try: try:
_start_training_thread(safe_word, language, allow_no_personal, False, tts_mode) _start_training_thread(
safe_word,
language,
allow_no_personal,
False,
tts_mode,
english_accent,
)
except Exception as exc: except Exception as exc:
with STATE_LOCK: with STATE_LOCK:
STATE["training"]["running"] = False STATE["training"]["running"] = False
@@ -4057,6 +4123,7 @@ def train_now(payload: Dict[str, Any] = None):
"started": True, "started": True,
"safe_word": safe_word, "safe_word": safe_word,
"language": language, "language": language,
"english_accent": english_accent,
"tts_mode": tts_mode, "tts_mode": tts_mode,
"personal_samples_used": takes_received > 0, "personal_samples_used": takes_received > 0,
"allow_no_personal": allow_no_personal, "allow_no_personal": allow_no_personal,

View File

@@ -16,6 +16,26 @@ TTS_MODE_PIPER = "piper"
TTS_MODES = (TTS_MODE_MODERN, TTS_MODE_HYBRID, TTS_MODE_PIPER) TTS_MODES = (TTS_MODE_MODERN, TTS_MODE_HYBRID, TTS_MODE_PIPER)
DEFAULT_TTS_MODE = TTS_MODE_HYBRID DEFAULT_TTS_MODE = TTS_MODE_HYBRID
# English is one TTS language, while these values control the accent mix used
# by providers that can follow a style instruction or clone a reference. The
# remaining providers continue contributing their available English voices.
DEFAULT_ENGLISH_ACCENT = "mixed"
ENGLISH_ACCENTS = {
"mixed": "Mixed English",
"australian": "Australian",
"american": "American",
"british": "British",
"canadian": "Canadian",
"irish": "Irish",
"scottish": "Scottish",
"new_zealand": "New Zealand",
"indian": "Indian",
"south_african": "South African",
}
MIXED_ENGLISH_ACCENTS = tuple(
code for code in ENGLISH_ACCENTS if code != DEFAULT_ENGLISH_ACCENT
)
ENGINE_OMNIVOICE = "omnivoice" ENGINE_OMNIVOICE = "omnivoice"
ENGINE_QWEN3 = "qwen3" ENGINE_QWEN3 = "qwen3"
ENGINE_MOSS = "moss" ENGINE_MOSS = "moss"
@@ -157,6 +177,39 @@ def normalize_tts_mode(value: object) -> str:
return token if token in TTS_MODES else DEFAULT_TTS_MODE return token if token in TTS_MODES else DEFAULT_TTS_MODE
def normalize_english_accent(value: object, language: object = "en") -> str:
"""Return a supported English accent emphasis or the mixed default."""
language_code = str(language or "en").strip().lower().replace("-", "_")
if language_code.split("_", 1)[0] != "en":
return DEFAULT_ENGLISH_ACCENT
token = str(value or DEFAULT_ENGLISH_ACCENT).strip().lower().replace("-", "_").replace(" ", "_")
aliases = {
"all": "mixed",
"none": "mixed",
"us": "american",
"usa": "american",
"uk": "british",
"gb": "british",
"australia": "australian",
"canada": "canadian",
"ireland": "irish",
"scotland": "scottish",
"new_zealand_english": "new_zealand",
"south_africa": "south_african",
}
token = aliases.get(token, token)
return token if token in ENGLISH_ACCENTS else DEFAULT_ENGLISH_ACCENT
def english_accent_options() -> list[dict[str, str]]:
return [
{"code": code, "label": label}
for code, label in ENGLISH_ACCENTS.items()
]
def language_for_engine(engine: str, language: str) -> str: def language_for_engine(engine: str, language: str) -> str:
code = str(language or "en").strip().lower().replace("-", "_") code = str(language or "en").strip().lower().replace("-", "_")
if engine == ENGINE_OMNIVOICE: if engine == ENGINE_OMNIVOICE: