mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Enhance voice formula parsing and validation, implement voice asset caching, and add tests for new functionality
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user