Files
abogen/abogen/webui/routes/utils/synthesize.py
T
Artem Akymenko 9bf4f8e809 feat: add voice resolution to domain layer, migrate WebUI imports
- Add formula_from_profile, resolve_profile_voice, resolve_voice_setting, resolve_voice_choice to domain/voice_resolution.py
- Add build_voice_catalog, filter_voice_catalog to domain/voice_catalog.py
- Update webui/routes/utils/voice.py to import from domain
- Update webui/routes/utils/form.py and voices.py to import from domain directly
- Update synthesize.py to use domain resolve_voice
- Add 29 tests for voice resolution functions
2026-07-30 10:58:23 +00:00

219 lines
7.3 KiB
Python

import io
import logging
import threading
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
import numpy as np
import soundfile as sf
from flask import current_app, send_file
from flask.typing import ResponseReturnValue
from abogen.domain.audio_helpers import to_float32
from abogen.domain.device import select_device as _select_device
from abogen.domain.enums import Language
from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.pronunciation import (
merge_pronunciation_overrides,
compile_pronunciation_rules,
apply_pronunciation_rules,
)
SAMPLE_RATE = 24000
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
_preview_pipeline_lock = threading.Lock()
def clear_preview_pipelines() -> None:
"""Dispose all cached preview pipelines and clear the cache."""
with _preview_pipeline_lock:
for pipeline in _preview_pipelines.values():
try:
pipeline.dispose()
except Exception:
pass
_preview_pipelines.clear()
def _resolve_pipeline(language: Language, use_gpu: bool) -> Tuple[Any, bool]:
devices: List[str] = ["cpu"]
if use_gpu:
preferred = _select_device()
if preferred != "cpu":
devices.insert(0, preferred)
last_error: Optional[Exception] = None
for device in devices:
try:
logging.info("[preview] Trying device=%s for language=%s", device, language)
return get_preview_pipeline(language, device), device != "cpu"
except Exception as exc:
last_error = exc
logging.warning("[preview] Device %s failed: %s", device, exc)
raise RuntimeError("Preview pipeline is unavailable") from last_error
def get_preview_pipeline(language: Language, device: str) -> Any:
key = (language, device)
with _preview_pipeline_lock:
pipeline = _preview_pipelines.get(key)
if pipeline is not None:
logging.info("[preview] Using cached pipeline for %s/%s", language, device)
return pipeline
from abogen.tts_plugin.utils import create_pipeline
logging.info("[preview] Creating pipeline: provider=kokoro language=%s device=%s", language, device)
pipeline = create_pipeline("kokoro", language=language, device=device)
_preview_pipelines[key] = pipeline
return pipeline
def generate_preview_audio(
text: str,
voice_spec: str,
language: Language,
speed: float,
use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
speakers: Optional[Mapping[str, Any]] = None,
) -> bytes:
if not text.strip():
raise ValueError("Preview text is required")
provider = (tts_provider or "kokoro").strip().lower()
# Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match
# before any downstream normalization potentially strips punctuation.
source_text = text
if pronunciation_overrides or manual_overrides or speakers:
try:
class _PreviewJob:
def __init__(self):
self.language = language
self.voice = voice_spec
self.speakers = speakers
self.manual_overrides = list(manual_overrides or [])
self.pronunciation_overrides = list(pronunciation_overrides or [])
job = _PreviewJob()
merged = merge_pronunciation_overrides(job)
rules = compile_pronunciation_rules(merged)
source_text = apply_pronunciation_rules(source_text, rules)
except Exception:
current_app.logger.exception("Preview override application failed; using raw text")
source_text = text
normalized_text = source_text
if provider != "supertonic":
try:
from abogen.kokoro_text_normalization import normalize_for_pipeline
normalized_text = normalize_for_pipeline(source_text)
except Exception:
current_app.logger.exception("Preview normalization failed; using raw text")
normalized_text = source_text
preview_split = get_split_pattern(language, "Disabled")
if provider == "supertonic":
from abogen.tts_plugin.utils import create_pipeline
pipeline = create_pipeline("supertonic", language=language)
segments = pipeline(
normalized_text,
voice=voice_spec,
speed=speed,
split_pattern=preview_split,
total_steps=supertonic_total_steps,
)
else:
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
if pipeline is None:
raise RuntimeError("Preview pipeline is unavailable")
voice_choice: Any = voice_spec
if voice_spec and "*" in voice_spec:
from abogen.domain.voice_loader import resolve_voice
voice_choice = resolve_voice(voice_spec, pipeline, pipeline_uses_gpu)
segments = pipeline(
normalized_text,
voice=voice_choice,
speed=speed,
split_pattern=preview_split,
)
audio_chunks: List[np.ndarray] = []
accumulated = 0
max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
for segment in segments:
graphemes = getattr(segment, "graphemes", "").strip()
if not graphemes:
continue
audio = to_float32(getattr(segment, "audio", None))
if audio.size == 0:
continue
remaining = max_samples - accumulated
if remaining <= 0:
break
if audio.shape[0] > remaining:
audio = audio[:remaining]
audio_chunks.append(audio)
accumulated += audio.shape[0]
if accumulated >= max_samples:
break
if not audio_chunks:
raise RuntimeError("Preview could not be generated")
audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
return buffer.getvalue()
def synthesize_preview(
text: str,
voice_spec: str,
language: Language,
speed: float,
use_gpu: bool,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
max_seconds: float = 8.0,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
speakers: Optional[Mapping[str, Any]] = None,
) -> ResponseReturnValue:
try:
audio_bytes = generate_preview_audio(
text=text,
voice_spec=voice_spec,
language=language,
speed=speed,
use_gpu=use_gpu,
tts_provider=tts_provider,
supertonic_total_steps=supertonic_total_steps,
max_seconds=max_seconds,
pronunciation_overrides=pronunciation_overrides,
manual_overrides=manual_overrides,
speakers=speakers,
)
except Exception as e:
raise e
buffer = io.BytesIO(audio_bytes)
response = send_file(
buffer,
mimetype="audio/wav",
as_attachment=False,
download_name="speaker_preview.wav",
)
response.headers["Cache-Control"] = "no-store"
return response