mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f229e451 | ||
|
|
68c4227cb7 | ||
|
|
bd71567a4f | ||
|
|
293318ad20 | ||
|
|
1f16f6f916 |
20
WHATS_NEW.md
20
WHATS_NEW.md
@@ -1,19 +1 @@
|
|||||||
- Fixed the training console so scrolling up pauses auto-follow and provides a Jump to latest control.
|
- 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.
|
||||||
- Fixed trained wake-word cards and Copy URL to use the explicit JSON package URL instead of producing `undefined`.
|
|
||||||
- Replaced the 128-profile clone pipeline with direct final-corpus generation from Qwen, OmniVoice, and Piper; MOSS now uses a different accepted carrier for each take.
|
|
||||||
- Added strict provider-specific rejection for static, broadband/high-frequency noise, silence, clipping, excessive duration/rambling, and exact duplicate audio.
|
|
||||||
- Capped Qwen and MOSS decoding for a single short utterance, fixed OmniVoice to bounded wake-phrase durations, and let safer providers fill every rejected share.
|
|
||||||
- Expanded Qwen to 18,750 balanced combinations across gender, age, pitch, delivery, timbre, pace, and vocal weight before an instruction repeats.
|
|
||||||
- Shifted the reactive trainer UI from blue-black surfaces to Tater's graphite-grey and orange visual theme.
|
|
||||||
- Rebuilt the trainer interface as a reactive Vue 3 + TypeScript application using the same typed UI pattern as Tater.
|
|
||||||
- Preserved session setup, multilingual TTS routing, sample review/import/trim, Auto Training, secure Tater pairing, live logs, and wake-word publishing in the new component-driven UI.
|
|
||||||
- Updated the standard CUDA and Blackwell Dockerfiles to copy the complete prebuilt UI bundle; Node.js is not installed or required in the runtime image.
|
|
||||||
- Made OmniVoice, Qwen3-TTS, MOSS-TTS-Nano, and Piper the recommended four-provider route where a compatible Piper model is present.
|
|
||||||
- Added the live 646-language OmniVoice catalog, language quality tiers, exact per-engine routing, and normalized acoustic QA.
|
|
||||||
- Added persistent per-engine environments and Hugging Face caches that keep conflicting TTS dependencies separate from wake-word training.
|
|
||||||
- Improved automatic review accuracy for short wake phrases that STT initially hears as similar-sounding words.
|
|
||||||
- Added a conservative Faster Whisper confirmation pass that uses the currently configured wake phrase only when the unbiased transcript is already phonetically close.
|
|
||||||
- Kept unconfirmed close transcripts in the manual review inbox instead of allowing them to become harmful negative training samples.
|
|
||||||
- Added visible guided-transcript and review-reason details, plus retry support for ambiguous clips through Review Now.
|
|
||||||
- Locked the wake phrase, language, and TTS route while a session is active, and added Stop Session with clean full-process-tree training cancellation.
|
|
||||||
- Added a Data tab with per-dataset disk usage and file counts, plus confirmed, training-safe deletion for recordings, downloads, generated caches, speech models, and training results.
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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,11 +1341,22 @@ 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(
|
||||||
|
f"⚠️ Normalization timed out after "
|
||||||
|
f"{NORMALIZATION_TIMEOUT_SECONDS:g}s; skipping {path.name}"
|
||||||
|
)
|
||||||
|
elif return_code != 0:
|
||||||
|
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 ""
|
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:
|
if valid_sample(temp_path) and digest and digest not in self.accepted_hashes:
|
||||||
temp_path.replace(final_path)
|
temp_path.replace(final_path)
|
||||||
@@ -1284,6 +1364,16 @@ class Generator:
|
|||||||
accepted.append(final_path)
|
accepted.append(final_path)
|
||||||
else:
|
else:
|
||||||
temp_path.unlink(missing_ok=True)
|
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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}" \
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -162,6 +162,7 @@ function sampleSubtitle(item: AudioItem): string {
|
|||||||
return rows.join(" · ") || "Training sample";
|
return rows.join(" · ") || "Training sample";
|
||||||
}
|
}
|
||||||
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
|
function wordJsonUrl(item: JsonRecord): string { return String(item.json_url || item.url || item.jsonUrl || ""); }
|
||||||
|
function wordEsphomeJsonUrl(item: JsonRecord): string { return String(item.esphome_json_url || item.esphomeJsonUrl || ""); }
|
||||||
function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); }
|
function wordModelUrl(item: JsonRecord): string { return String(item.model_url || item.modelUrl || ""); }
|
||||||
function consoleTone(line: string): string {
|
function consoleTone(line: string): string {
|
||||||
const value = line.trim().toLowerCase();
|
const value = line.trim().toLowerCase();
|
||||||
@@ -196,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>
|
||||||
@@ -223,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>
|
||||||
@@ -265,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>
|
||||||
@@ -301,6 +305,11 @@ function consoleTone(line: string): string {
|
|||||||
<div v-if="!trainer.wakeWords.length" class="empty-state">Train a wake word and its package will appear here.</div>
|
<div v-if="!trainer.wakeWords.length" class="empty-state">Train a wake word and its package will appear here.</div>
|
||||||
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="word.key || wordJsonUrl(word)"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordJsonUrl(word)" :href="wordJsonUrl(word)" target="_blank" rel="noreferrer">JSON · {{ wordJsonUrl(word) }}</a><span v-else class="muted">JSON package URL unavailable</span><a v-if="wordModelUrl(word)" :href="wordModelUrl(word)" target="_blank" rel="noreferrer">Model · {{ wordModelUrl(word) }}</a><div class="meta-row"><span v-if="word.language">{{ word.language }}</span><span v-if="word.trained_at">{{ formatTimestamp(word.trained_at) }}</span><span v-if="word.recall !== undefined">recall {{ word.recall }}</span></div></div><button type="button" :disabled="!wordJsonUrl(word)" @click="copyWakeWord(wordJsonUrl(word))">Copy URL</button></article></div>
|
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="word.key || wordJsonUrl(word)"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordJsonUrl(word)" :href="wordJsonUrl(word)" target="_blank" rel="noreferrer">JSON · {{ wordJsonUrl(word) }}</a><span v-else class="muted">JSON package URL unavailable</span><a v-if="wordModelUrl(word)" :href="wordModelUrl(word)" target="_blank" rel="noreferrer">Model · {{ wordModelUrl(word) }}</a><div class="meta-row"><span v-if="word.language">{{ word.language }}</span><span v-if="word.trained_at">{{ formatTimestamp(word.trained_at) }}</span><span v-if="word.recall !== undefined">recall {{ word.recall }}</span></div></div><button type="button" :disabled="!wordJsonUrl(word)" @click="copyWakeWord(wordJsonUrl(word))">Copy URL</button></article></div>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="native-notice esphome-notice"><strong>ESPHome</strong><span>Strict micro_wake_word manifest without Tater Native or calibration extensions.</span></div>
|
||||||
|
<section class="panel compatibility-panel"><header class="panel-head"><div class="number">ESP</div><div><h3>ESPHome JSON</h3><p>Use this URL as the model in an ESPHome micro_wake_word configuration.</p></div></header>
|
||||||
|
<div v-if="!trainer.wakeWords.length" class="empty-state">ESPHome links appear after a wake word is trained.</div>
|
||||||
|
<div v-else class="word-list"><article v-for="word in trainer.wakeWords" :key="`esphome-${word.key || wordEsphomeJsonUrl(word)}`"><div><strong>{{ word.label || word.name || "Trained wake word" }}</strong><a v-if="wordEsphomeJsonUrl(word)" :href="wordEsphomeJsonUrl(word)" target="_blank" rel="noreferrer">ESPHome JSON · {{ wordEsphomeJsonUrl(word) }}</a><span v-else class="muted">ESPHome package URL unavailable</span><div class="meta-row"><span>Schema v2</span><span>Same TFLite model</span></div></div><button type="button" :disabled="!wordEsphomeJsonUrl(word)" @click="copyWakeWord(wordEsphomeJsonUrl(word))">Copy ESPHome URL</button></article></div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ button:disabled { opacity: .43; cursor: not-allowed; }
|
|||||||
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--orange), var(--violet)); transition: width .2s ease; }
|
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--orange), var(--violet)); transition: width .2s ease; }
|
||||||
.native-notice { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; }
|
.native-notice { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; }
|
||||||
.native-notice strong { color: var(--green); }
|
.native-notice strong { color: var(--green); }
|
||||||
|
.esphome-notice strong { color: var(--orange-2); }
|
||||||
|
.compatibility-panel { padding-top: 19px; }
|
||||||
|
.compatibility-panel .panel-head { margin-bottom: 15px; }
|
||||||
.word-list article { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18,18,19,.64); }
|
.word-list article { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18,18,19,.64); }
|
||||||
.word-list article > div { display: grid; min-width: 0; gap: 6px; }
|
.word-list article > div { display: grid; min-width: 0; gap: 6px; }
|
||||||
.word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; }
|
.word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; }
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -76,6 +89,8 @@ export interface WakeWordItem extends JsonRecord {
|
|||||||
url?: string;
|
url?: string;
|
||||||
json_url?: string;
|
json_url?: string;
|
||||||
jsonUrl?: string;
|
jsonUrl?: string;
|
||||||
|
esphome_json_url?: string;
|
||||||
|
esphomeJsonUrl?: string;
|
||||||
model_url?: string;
|
model_url?: string;
|
||||||
modelUrl?: string;
|
modelUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -583,6 +608,56 @@ class AutoTrainTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(rows), 1)
|
self.assertEqual(len(rows), 1)
|
||||||
self.assertEqual(rows[0]["url"], rows[0]["json_url"])
|
self.assertEqual(rows[0]["url"], rows[0]["json_url"])
|
||||||
self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json"))
|
self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json"))
|
||||||
|
self.assertTrue(
|
||||||
|
rows[0]["esphome_json_url"].endswith(
|
||||||
|
"/api/trained_wake_words/hey_tater.esphome.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_esphome_manifest_route_removes_tater_extensions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
trained_dir = Path(directory)
|
||||||
|
(trained_dir / "hey_tater.tflite").write_bytes(b"model")
|
||||||
|
metadata = {
|
||||||
|
"type": "micro",
|
||||||
|
"wake_word": "hey tater",
|
||||||
|
"label": "Hey Tater",
|
||||||
|
"author": "Tater Totterson",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"model": "hey_tater.tflite",
|
||||||
|
"trained_languages": ["en"],
|
||||||
|
"version": 2,
|
||||||
|
"model_format": "tflite_stream_state_internal_quant",
|
||||||
|
"quantization": "int8",
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"micro": {
|
||||||
|
"probability_cutoff": 0.97,
|
||||||
|
"sliding_window_size": 5,
|
||||||
|
"feature_step_size": 10,
|
||||||
|
"tensor_arena_size": 30000,
|
||||||
|
"minimum_esphome_version": "2024.7.0",
|
||||||
|
},
|
||||||
|
"tater_native": {"format_version": 1},
|
||||||
|
"calibration": {"recall": 0.99},
|
||||||
|
}
|
||||||
|
(trained_dir / "hey_tater.json").write_text(
|
||||||
|
json.dumps(metadata),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir),
|
||||||
|
patch.object(trainer, "_sync_trained_wake_word_artifacts"),
|
||||||
|
):
|
||||||
|
response = trainer.trained_wake_word_artifact(
|
||||||
|
"hey_tater.esphome.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
self.assertEqual(set(payload), set(trainer.ESPHOME_MANIFEST_KEYS))
|
||||||
|
self.assertEqual(payload["micro"], metadata["micro"])
|
||||||
|
self.assertNotIn("label", payload)
|
||||||
|
self.assertNotIn("tater_native", payload)
|
||||||
|
self.assertNotIn("calibration", payload)
|
||||||
|
|
||||||
def test_tater_notification_fails_when_trained_word_is_missing(self):
|
def test_tater_notification_fails_when_trained_word_is_missing(self):
|
||||||
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
|
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import signal
|
import signal
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import trainer_server as trainer
|
import trainer_server as trainer
|
||||||
@@ -26,6 +30,19 @@ class _FakeTrainingProcess:
|
|||||||
self.returncode = -signal.SIGKILL
|
self.returncode = -signal.SIGKILL
|
||||||
|
|
||||||
|
|
||||||
|
class _CompletedTrainingProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.pid = 6543
|
||||||
|
self.returncode = 0
|
||||||
|
self.stdout = io.StringIO("worker started\n")
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
|
||||||
class SessionStopTests(unittest.TestCase):
|
class SessionStopTests(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
trainer.TRAINING_STOP_EVENT.clear()
|
trainer.TRAINING_STOP_EVENT.clear()
|
||||||
@@ -49,6 +66,51 @@ class SessionStopTests(unittest.TestCase):
|
|||||||
trainer.TRAINING_PROCESS = original_process
|
trainer.TRAINING_PROCESS = original_process
|
||||||
trainer.TRAINING_THREAD = original_thread
|
trainer.TRAINING_THREAD = original_thread
|
||||||
|
|
||||||
|
def test_reserved_running_state_starts_the_background_worker(self):
|
||||||
|
original_process = trainer.TRAINING_PROCESS
|
||||||
|
original_thread = trainer.TRAINING_THREAD
|
||||||
|
original_raw_phrase = trainer.STATE.get("raw_phrase")
|
||||||
|
original_training = dict(trainer.STATE["training"])
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
data_dir = Path(directory)
|
||||||
|
process = _CompletedTrainingProcess()
|
||||||
|
trainer.TRAINING_PROCESS = None
|
||||||
|
trainer.TRAINING_THREAD = threading.current_thread()
|
||||||
|
with trainer.STATE_LOCK:
|
||||||
|
trainer.STATE["raw_phrase"] = "hey tater"
|
||||||
|
trainer.STATE["training"]["running"] = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(trainer, "DATA_DIR", data_dir),
|
||||||
|
patch.object(trainer, "_ensure_training_venv"),
|
||||||
|
patch.object(trainer, "_ensure_training_datasets"),
|
||||||
|
patch.object(trainer.subprocess, "Popen", return_value=process) as popen,
|
||||||
|
patch.object(trainer, "_normalize_output_artifacts"),
|
||||||
|
):
|
||||||
|
trainer._run_training_background(
|
||||||
|
"hey_tater",
|
||||||
|
"en",
|
||||||
|
True,
|
||||||
|
auto_run=False,
|
||||||
|
tts_mode="modern",
|
||||||
|
)
|
||||||
|
|
||||||
|
popen.assert_called_once()
|
||||||
|
log_text = (data_dir / "recorder_training.log").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Nvidia Docker Training Run", log_text)
|
||||||
|
self.assertIn("worker started", log_text)
|
||||||
|
self.assertFalse(trainer.STATE["training"]["running"])
|
||||||
|
self.assertEqual(trainer.STATE["training"]["exit_code"], 0)
|
||||||
|
self.assertIsNone(trainer.TRAINING_THREAD)
|
||||||
|
finally:
|
||||||
|
with trainer.STATE_LOCK:
|
||||||
|
trainer.STATE["raw_phrase"] = original_raw_phrase
|
||||||
|
trainer.STATE["training"].clear()
|
||||||
|
trainer.STATE["training"].update(original_training)
|
||||||
|
trainer.TRAINING_PROCESS = original_process
|
||||||
|
trainer.TRAINING_THREAD = original_thread
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -89,6 +97,15 @@ class VueTrainerUiTests(unittest.TestCase):
|
|||||||
self.assertNotIn("copyWakeWord(word.url)", app)
|
self.assertNotIn("copyWakeWord(word.url)", app)
|
||||||
self.assertIn("json_url?: string", types)
|
self.assertIn("json_url?: string", types)
|
||||||
|
|
||||||
|
def test_wake_words_tab_exposes_esphome_manifest_urls(self) -> None:
|
||||||
|
app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8")
|
||||||
|
types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("ESPHome JSON", app)
|
||||||
|
self.assertIn("wordEsphomeJsonUrl", app)
|
||||||
|
self.assertIn("Copy ESPHome URL", app)
|
||||||
|
self.assertIn("esphome_json_url?: string", types)
|
||||||
|
|
||||||
def test_runtime_packaging_uses_bundle_without_node(self) -> None:
|
def test_runtime_packaging_uses_bundle_without_node(self) -> None:
|
||||||
dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"]
|
dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"]
|
||||||
for dockerfile in dockerfiles:
|
for dockerfile in dockerfiles:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -581,6 +590,24 @@ def _metadata_int(value: Any) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
ESPHOME_MANIFEST_SUFFIX = ".esphome.json"
|
||||||
|
ESPHOME_MANIFEST_KEYS = (
|
||||||
|
"type",
|
||||||
|
"wake_word",
|
||||||
|
"author",
|
||||||
|
"website",
|
||||||
|
"model",
|
||||||
|
"trained_languages",
|
||||||
|
"version",
|
||||||
|
"micro",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _esphome_manifest(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Return only fields accepted by ESPHome's micro_wake_word v2 schema."""
|
||||||
|
return {key: metadata[key] for key in ESPHOME_MANIFEST_KEYS if key in metadata}
|
||||||
|
|
||||||
|
|
||||||
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
|
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
|
||||||
_sync_trained_wake_word_artifacts()
|
_sync_trained_wake_word_artifacts()
|
||||||
base = str(base_url or "").rstrip("/")
|
base = str(base_url or "").rstrip("/")
|
||||||
@@ -619,9 +646,11 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
|
|||||||
recall = _metadata_float(calibration.get("recall"))
|
recall = _metadata_float(calibration.get("recall"))
|
||||||
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
|
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
|
||||||
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
|
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
|
||||||
|
esphome_json_url = f"/api/trained_wake_words/{quote(safe + ESPHOME_MANIFEST_SUFFIX)}"
|
||||||
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
||||||
if base:
|
if base:
|
||||||
json_url = f"{base}{json_url}"
|
json_url = f"{base}{json_url}"
|
||||||
|
esphome_json_url = f"{base}{esphome_json_url}"
|
||||||
model_url = f"{base}{model_url}"
|
model_url = f"{base}{model_url}"
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
@@ -634,6 +663,7 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
|
|||||||
# New consumers should prefer the explicit `json_url` field.
|
# New consumers should prefer the explicit `json_url` field.
|
||||||
"url": json_url,
|
"url": json_url,
|
||||||
"json_url": json_url,
|
"json_url": json_url,
|
||||||
|
"esphome_json_url": esphome_json_url,
|
||||||
"model_url": model_url,
|
"model_url": model_url,
|
||||||
"json_file": json_path.name,
|
"json_file": json_path.name,
|
||||||
"model_file": model_path.name,
|
"model_file": model_path.name,
|
||||||
@@ -766,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")),
|
||||||
@@ -1704,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,
|
||||||
@@ -1716,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:
|
||||||
@@ -1723,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
|
||||||
@@ -1733,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2690,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 "",
|
||||||
@@ -3049,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",
|
||||||
)
|
)
|
||||||
@@ -3097,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:
|
||||||
@@ -3110,8 +3163,9 @@ def _run_training_background(
|
|||||||
|
|
||||||
with DATA_MANAGEMENT_LOCK:
|
with DATA_MANAGEMENT_LOCK:
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
if STATE["training"]["running"]:
|
# The API or auto-training scheduler reserves the run by setting
|
||||||
return JSONResponse({"ok": False, "error": "Training already running"}, status_code=400)
|
# this flag before the thread starts. Duplicate starts are already
|
||||||
|
# rejected there and by _start_training_thread's runtime lock.
|
||||||
STATE["training"]["running"] = True
|
STATE["training"]["running"] = True
|
||||||
STATE["training"]["exit_code"] = None
|
STATE["training"]["exit_code"] = None
|
||||||
STATE["training"]["log_lines"] = []
|
STATE["training"]["log_lines"] = []
|
||||||
@@ -3145,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)
|
||||||
@@ -3155,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(
|
||||||
@@ -3386,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))
|
||||||
@@ -3394,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
|
||||||
@@ -3407,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,
|
||||||
@@ -3414,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),
|
||||||
}
|
}
|
||||||
@@ -3443,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,
|
||||||
@@ -3451,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3466,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"],
|
||||||
@@ -3480,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3959,6 +4037,24 @@ def trained_wake_word_artifact(filename: str):
|
|||||||
if not safe_filename or Path(safe_filename).suffix.lower() not in {".json", ".tflite"}:
|
if not safe_filename or Path(safe_filename).suffix.lower() not in {".json", ".tflite"}:
|
||||||
return JSONResponse({"ok": False, "error": "Unsupported wake word artifact."}, status_code=400)
|
return JSONResponse({"ok": False, "error": "Unsupported wake word artifact."}, status_code=400)
|
||||||
_sync_trained_wake_word_artifacts()
|
_sync_trained_wake_word_artifacts()
|
||||||
|
if safe_filename.endswith(ESPHOME_MANIFEST_SUFFIX):
|
||||||
|
source_stem = safe_filename[: -len(ESPHOME_MANIFEST_SUFFIX)]
|
||||||
|
source_path = TRAINED_WAKE_WORDS_DIR / f"{source_stem}.json"
|
||||||
|
if not source_stem or not source_path.is_file():
|
||||||
|
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)
|
||||||
|
try:
|
||||||
|
metadata = json.loads(source_path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "error": "Wake word package is invalid."}, status_code=422)
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return JSONResponse({"ok": False, "error": "Wake word package is invalid."}, status_code=422)
|
||||||
|
model_name = Path(str(metadata.get("model") or f"{source_stem}.tflite")).name
|
||||||
|
if not (TRAINED_WAKE_WORDS_DIR / model_name).is_file():
|
||||||
|
return JSONResponse({"ok": False, "error": "Wake word model not found."}, status_code=404)
|
||||||
|
return JSONResponse(
|
||||||
|
_esphome_manifest(metadata),
|
||||||
|
headers={"Cache-Control": "no-store, max-age=0"},
|
||||||
|
)
|
||||||
artifact_path = TRAINED_WAKE_WORDS_DIR / safe_filename
|
artifact_path = TRAINED_WAKE_WORDS_DIR / safe_filename
|
||||||
if not artifact_path.exists() or not artifact_path.is_file():
|
if not artifact_path.exists() or not artifact_path.is_file():
|
||||||
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)
|
return JSONResponse({"ok": False, "error": "Wake word artifact not found."}, status_code=404)
|
||||||
@@ -3974,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"])
|
||||||
@@ -4003,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
|
||||||
@@ -4017,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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user