feat: Enhance voice formula parsing and validation, implement voice asset caching, and add tests for new functionality

This commit is contained in:
JB
2025-10-09 13:37:36 -07:00
parent 6bd301b707
commit f0b6976d12
7 changed files with 404 additions and 44 deletions
+68 -2
View File
@@ -12,12 +12,13 @@ from collections import defaultdict
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, cast
import numpy as np
import soundfile as sf
import static_ffmpeg
from abogen.constants import VOICES_INTERNAL
from abogen.epub3.exporter import build_epub3_package
from abogen.kokoro_text_normalization import (
ApostropheConfig,
@@ -36,7 +37,8 @@ from abogen.utils import (
load_config,
load_numpy_kpipeline,
)
from abogen.voice_formulas import get_new_voice
from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice
from .service import Job, JobStatus
@@ -69,6 +71,69 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool:
return bool(value)
def _spec_to_voice_ids(spec: Any) -> Set[str]:
text = str(spec or "").strip()
if not text:
return set()
if "*" in text:
try:
return set(extract_voice_ids(text))
except ValueError:
return set()
if text in VOICES_INTERNAL:
return {text}
return set()
def _collect_required_voice_ids(job: Job) -> Set[str]:
voices: Set[str] = set()
voices.update(_spec_to_voice_ids(job.voice))
for chapter in getattr(job, "chapters", []) or []:
if not isinstance(chapter, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(chapter.get(key)))
for chunk in getattr(job, "chunks", []) or []:
if not isinstance(chunk, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(chunk.get(key)))
speakers = getattr(job, "speakers", {})
if isinstance(speakers, dict):
for payload in speakers.values() or []:
if not isinstance(payload, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(payload.get(key)))
voices.update(VOICES_INTERNAL)
return voices
def _initialize_voice_cache(job: Job) -> None:
try:
targets = _collect_required_voice_ids(job)
downloaded, errors = ensure_voice_assets(
targets,
on_progress=lambda message: job.add_log(message, level="debug"),
)
except RuntimeError as exc:
job.add_log(f"Voice cache unavailable: {exc}", level="warning")
return
if downloaded:
job.add_log(
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
level="info",
)
for voice_id, error in errors.items():
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500}
_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160}
_STRUCTURAL_KEYWORDS = (
@@ -631,6 +696,7 @@ def run_conversion_job(job: Job) -> None:
active_chapter_configs: List[Dict[str, Any]] = []
try:
pipeline = _load_pipeline(job)
_initialize_voice_cache(job)
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
+28 -20
View File
@@ -58,7 +58,7 @@ from abogen.voice_profiles import (
serialize_profiles,
)
from abogen.voice_formulas import get_new_voice
from abogen.voice_formulas import get_new_voice, parse_formula_terms
from abogen.speaker_analysis import analyze_speakers
from abogen.speaker_configs import (
delete_config,
@@ -656,6 +656,8 @@ def _apply_prepare_form(
pending.applied_speaker_config = selected_config or None
errors: List[str] = []
if isinstance(pending.speakers, dict):
for speaker_id, payload in list(pending.speakers.items()):
if not isinstance(payload, dict):
@@ -669,12 +671,34 @@ def _apply_prepare_form(
payload.pop("pronunciation", None)
voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
formula_key = f"speaker-{speaker_id}-formula"
formula_value = (form.get(formula_key) or "").strip()
has_formula = False
if formula_value:
try:
_parse_voice_formula(formula_value)
except ValueError as exc:
label = payload.get("label") or speaker_id.replace("_", " ").title()
errors.append(f"Invalid custom mix for {label}: {exc}")
else:
payload["voice_formula"] = formula_value
payload["resolved_voice"] = formula_value
payload.pop("voice_profile", None)
has_formula = True
else:
payload.pop("voice_formula", None)
if voice_value == "__custom_mix":
voice_value = ""
if voice_value:
payload["voice"] = voice_value
payload["resolved_voice"] = voice_value
if not has_formula:
payload["resolved_voice"] = voice_value
else:
payload.pop("voice", None)
payload.pop("resolved_voice", None)
if not has_formula:
payload.pop("resolved_voice", None)
lang_key = f"speaker-{speaker_id}-languages"
languages: List[str] = []
@@ -689,7 +713,6 @@ def _apply_prepare_form(
payload["config_languages"] = languages
profiles = serialize_profiles()
errors: List[str] = []
raw_delay = form.get("chapter_intro_delay")
if raw_delay is not None:
raw_normalized = raw_delay.strip()
@@ -1135,22 +1158,7 @@ def _persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Opt
def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
parts = [segment.strip() for segment in formula.split("+") if segment.strip()]
voices: List[tuple[str, float]] = []
for part in parts:
if "*" not in part:
raise ValueError("Each component must be in the form voice*weight")
name, weight_str = part.split("*", 1)
name = name.strip()
if name not in VOICES_INTERNAL:
raise ValueError(f"Unknown voice '{name}'")
try:
weight = float(weight_str.strip())
except ValueError as exc: # pragma: no cover - validated via form
raise ValueError(f"Invalid weight for {name}") from exc
if weight <= 0:
raise ValueError(f"Weight for {name} must be positive")
voices.append((name, weight))
voices = parse_formula_terms(formula)
total = sum(weight for _, weight in voices)
if total <= 0:
raise ValueError("Voice weights must sum to a positive value")
+19
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping
from abogen.utils import get_internal_cache_path, get_user_settings_dir
from abogen.voice_cache import bootstrap_voice_cache
def _create_set_event() -> threading.Event:
@@ -262,6 +263,7 @@ class ConversionService:
self._pending_jobs: Dict[str, PendingJob] = {}
self._state_path = self._determine_state_path()
self._ensure_directories()
self._bootstrap_voice_cache()
self._load_state()
# Public API ---------------------------------------------------------
@@ -562,6 +564,23 @@ class ConversionService:
self._uploads_root.mkdir(parents=True, exist_ok=True)
self._state_path.parent.mkdir(parents=True, exist_ok=True)
def _bootstrap_voice_cache(self) -> None:
try:
downloaded, errors = bootstrap_voice_cache(
on_progress=lambda msg: _JOB_LOGGER.debug("[voice cache] %s", msg)
)
except RuntimeError as exc:
_JOB_LOGGER.warning("Voice cache bootstrap skipped: %s", exc)
return
if downloaded:
count = len(downloaded)
suffix = "s" if count != 1 else ""
_JOB_LOGGER.info("Voice cache ready: downloaded %d new asset%s.", count, suffix)
if errors:
for voice_id, message in errors.items():
_JOB_LOGGER.warning("Voice cache failed for %s: %s", voice_id, message)
def _ensure_worker(self) -> None:
with self._lock:
if self._worker_thread and self._worker_thread.is_alive():