Files
abogen/abogen/webui/debug_tts_runner.py
T
Artem Akymenko f079373821 refactor: migrate preview and conversion code to use TTSBackendRegistry
Migrate all preview/debug/conversion pipeline creation to use
TTSBackendRegistry.create_backend() instead of direct imports:

- debug_tts_runner._load_pipeline(): Kokoro via registry
- preview.get_preview_pipeline(): Kokoro via registry
- preview.generate_preview_audio(): Supertonic via registry
- voice.get_preview_pipeline(): Kokoro via registry
- conversion_runner._load_pipeline(): both backends via registry
- conversion_runner inline pipeline creation: both via registry
- test: update mock to target tts_backend_registry.create_backend
2026-07-06 15:59:22 +03:00

251 lines
8.5 KiB
Python

from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
import numpy as np
from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes
from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.tts_backend_registry import create_backend
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@dataclass(frozen=True)
class DebugWavArtifact:
label: str
filename: str
code: Optional[str] = None
text: Optional[str] = None
def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
"""Resolve settings voice strings into a pipeline-ready voice spec.
Supports "profile:<name>" by converting it into a concrete voice formula.
Returns (resolved_voice_spec, profile_name, profile_language).
"""
from abogen.webui.routes.utils.voice import resolve_voice_setting
return resolve_voice_setting(value)
def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu"
if use_gpu:
device = _select_device()
return create_backend("kokoro", lang_code=language, device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
raw = str(text or "")
matches = list(_MARKER_RE.finditer(raw))
cases: List[Tuple[str, str]] = []
if not matches:
return cases
for idx, match in enumerate(matches):
code = match.group("code")
start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
snippet = raw[start:end]
# Keep it small and predictable: collapse whitespace.
snippet = " ".join(snippet.strip().split())
cases.append((code, snippet))
return cases
def _spoken_id(code: str) -> str:
# Make IDs pronounceable and stable (avoid reading as a word).
out: List[str] = []
for ch in str(code or ""):
if ch == "_":
out.append(" ")
elif ch.isalnum():
out.append(ch)
else:
out.append(" ")
# Add spaces between alnum to encourage letter-by-letter reading.
spaced = " ".join("".join(out).split())
return spaced
def run_debug_tts_wavs(
*,
output_root: Path,
settings: Mapping[str, Any],
epub_path: Optional[Path] = None,
) -> Dict[str, Any]:
"""Generate WAV artifacts for the debug EPUB samples.
Writes:
- overall.wav: concatenation of all samples
- case_<CODE>.wav: each sample rendered separately
- manifest.json: metadata + file list
"""
output_root = Path(output_root)
output_root.mkdir(parents=True, exist_ok=True)
run_id = uuid.uuid4().hex
run_dir = output_root / "debug" / run_id
run_dir.mkdir(parents=True, exist_ok=True)
if epub_path is None:
epub_path = run_dir / "abogen_debug_samples.epub"
build_debug_epub(epub_path)
else:
epub_path = Path(epub_path)
extraction = extract_from_path(epub_path)
combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
cases = _extract_cases_from_text(combined_text)
# Prefer the canonical sample catalog for text (EPUB extraction may include headings).
try:
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
except Exception:
sample_text_by_code = {}
expected = list(iter_expected_codes())
found_codes = {code for code, _ in cases}
missing = [code for code in expected if code not in found_codes]
if missing:
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
language = str(settings.get("language") or "a").strip() or "a"
# Kokoro's KPipeline expects short language codes like "a" (American English),
# but older settings may store ISO-like values such as "en".
language_aliases = {
"en": "a",
"en-us": "a",
"en_us": "a",
"en-gb": "b",
"en_gb": "b",
"es": "e",
"es-es": "e",
"fr": "f",
"fr-fr": "f",
"hi": "h",
"it": "i",
"pt": "p",
"pt-br": "p",
"ja": "j",
"jp": "j",
"zh": "z",
"zh-cn": "z",
}
language = language_aliases.get(language.lower(), language)
voice_spec = str(settings.get("default_voice") or "").strip()
use_gpu = bool(settings.get("use_gpu", False))
speed = float(settings.get("default_speed", 1.0) or 1.0)
# Settings may store "profile:<name>" which is not a Kokoro voice ID.
# Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
# doesn't attempt to download a non-existent "voices/profile:<name>.pt".
try:
resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
if resolved_voice:
voice_spec = resolved_voice
if profile_language:
language = str(profile_language).strip() or language
except Exception:
# Voice profile resolution is best-effort; fall back to raw voice_spec.
pass
# Best-effort voice caching (only for known Kokoro internal voices).
voice_ids = _spec_to_voice_ids(voice_spec)
if voice_ids:
try:
ensure_voice_assets(voice_ids)
except Exception:
# Network / optional dependency variance; debug runner can still proceed.
pass
pipeline = _load_pipeline(language, use_gpu)
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
apostrophe_config = build_apostrophe_config(settings=settings)
normalization_settings = dict(settings)
artifacts: List[DebugWavArtifact] = []
overall_path = run_dir / "overall.wav"
overall_audio: List[np.ndarray] = []
def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
normalized = (
normalize_for_pipeline(
text,
config=apostrophe_config,
settings=normalization_settings,
)
if apply_normalization
else str(text or "")
)
parts: List[np.ndarray] = []
for segment in pipeline(
normalized,
voice=voice_choice,
speed=speed,
split_pattern=SPLIT_PATTERN,
):
audio = _to_float32(getattr(segment, "audio", None))
if audio.size:
parts.append(audio)
if not parts:
return np.zeros(0, dtype="float32")
return np.concatenate(parts).astype("float32", copy=False)
pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
# Per sample
for code, snippet in cases:
snippet = sample_text_by_code.get(code, snippet)
if not snippet:
continue
id_audio = synth(_spoken_id(code), apply_normalization=False)
text_audio = synth(snippet, apply_normalization=True)
audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
filename = f"case_{code}.wav"
path = run_dir / filename
# Write float32 PCM WAV.
import soundfile as sf
sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
overall_audio.append(audio)
overall_audio.append(between_cases)
# Overall
if overall_audio:
combined = np.concatenate(overall_audio).astype("float32", copy=False)
else:
combined = np.zeros(0, dtype="float32")
import soundfile as sf
sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
manifest = {
"run_id": run_id,
"epub": str(epub_path),
"artifacts": [artifact.__dict__ for artifact in artifacts],
"sample_rate": SAMPLE_RATE,
}
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest