mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add debug TTS functionality with EPUB generation, WAV artifact creation, and settings integration
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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.web.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", 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 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)
|
||||
|
||||
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 "en").strip() or "en"
|
||||
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)
|
||||
|
||||
# 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) -> np.ndarray:
|
||||
normalized = normalize_for_pipeline(
|
||||
text,
|
||||
config=apostrophe_config,
|
||||
settings=normalization_settings,
|
||||
)
|
||||
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)
|
||||
|
||||
# Per sample
|
||||
for code, snippet in cases:
|
||||
if not snippet:
|
||||
continue
|
||||
audio = synth(snippet)
|
||||
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))
|
||||
overall_audio.append(audio)
|
||||
|
||||
# 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))
|
||||
|
||||
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
|
||||
@@ -1,4 +1,6 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, render_template, request, redirect, url_for, flash, send_file, abort
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.web.routes.utils.settings import (
|
||||
@@ -7,11 +9,16 @@ from abogen.web.routes.utils.settings import (
|
||||
save_settings,
|
||||
coerce_bool,
|
||||
coerce_int,
|
||||
SAVE_MODE_LABELS,
|
||||
llm_ready,
|
||||
_NORMALIZATION_BOOLEAN_KEYS,
|
||||
_NORMALIZATION_STRING_KEYS,
|
||||
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||
)
|
||||
from abogen.web.routes.utils.voice import template_options
|
||||
from abogen.web.debug_tts_runner import run_debug_tts_wavs
|
||||
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
|
||||
from abogen.utils import get_user_output_path
|
||||
|
||||
settings_bp = Blueprint("settings", __name__)
|
||||
|
||||
@@ -147,10 +154,71 @@ def settings_page() -> str | ResponseReturnValue:
|
||||
if request.method == "POST":
|
||||
return update_settings()
|
||||
|
||||
debug_run_id = (request.args.get("debug_run_id") or "").strip()
|
||||
debug_manifest = None
|
||||
if debug_run_id:
|
||||
run_dir = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) / "debug" / debug_run_id
|
||||
manifest_path = run_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
import json
|
||||
|
||||
debug_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
debug_manifest = None
|
||||
|
||||
save_locations = [{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()]
|
||||
default_output_dir = str(Path(get_user_output_path()).resolve())
|
||||
|
||||
return render_template(
|
||||
"settings.html",
|
||||
settings=load_settings(),
|
||||
integrations=load_integration_settings(),
|
||||
options=template_options(),
|
||||
normalization_samples=_NORMALIZATION_SAMPLES,
|
||||
save_locations=save_locations,
|
||||
default_output_dir=default_output_dir,
|
||||
llm_ready=llm_ready(load_settings()),
|
||||
debug_samples=DEBUG_TTS_SAMPLES,
|
||||
debug_manifest=debug_manifest,
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.post("/debug/run")
|
||||
def run_debug_wavs() -> ResponseReturnValue:
|
||||
settings = load_settings()
|
||||
output_root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web"))
|
||||
try:
|
||||
manifest = run_debug_tts_wavs(output_root=output_root, settings=settings)
|
||||
except Exception as exc:
|
||||
flash(f"Debug WAV generation failed: {exc}", "error")
|
||||
return redirect(url_for("settings.settings_page", _anchor="debug"))
|
||||
|
||||
flash("Debug WAV generation completed.", "success")
|
||||
return redirect(
|
||||
url_for(
|
||||
"settings.settings_page",
|
||||
debug_run_id=str(manifest.get("run_id") or ""),
|
||||
_anchor="debug",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.get("/debug/<run_id>/<filename>")
|
||||
def download_debug_wav(run_id: str, filename: str) -> ResponseReturnValue:
|
||||
safe_run = (run_id or "").strip()
|
||||
safe_name = (filename or "").strip()
|
||||
if not safe_run or not safe_name or "/" in safe_name or "\\" in safe_name:
|
||||
abort(404)
|
||||
if not safe_name.lower().endswith(".wav") and safe_name != "manifest.json":
|
||||
abort(404)
|
||||
|
||||
root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web"))
|
||||
path = (root / "debug" / safe_run / safe_name).resolve()
|
||||
if not path.exists() or not path.is_file():
|
||||
abort(404)
|
||||
# Ensure path is within root/debug/run_id
|
||||
expected_dir = (root / "debug" / safe_run).resolve()
|
||||
if expected_dir not in path.parents:
|
||||
abort(404)
|
||||
return send_file(path, as_attachment=True, download_name=path.name)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button>
|
||||
<button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button>
|
||||
<button type="button" class="settings-nav__item" data-section="integrations">Integrations</button>
|
||||
<button type="button" class="settings-nav__item" data-section="debug">Debug</button>
|
||||
</nav>
|
||||
<form action="{{ url_for('settings.update_settings') }}" method="post" class="settings__form">
|
||||
<div class="settings-panels">
|
||||
@@ -472,6 +473,42 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" data-section="debug">
|
||||
<fieldset class="settings__section">
|
||||
<legend>Debug · TTS transformations</legend>
|
||||
<p class="hint">Generate a set of WAV files from a purpose-built EPUB containing code-tagged examples. When something sounds wrong, report the code (e.g. <code>NUM_001</code>) to pinpoint the failing transformation.</p>
|
||||
|
||||
<div class="field field--stack">
|
||||
<button type="submit" class="button" formaction="{{ url_for('settings.run_debug_wavs') }}" formmethod="post">Generate debug WAVs</button>
|
||||
<p class="hint">Uses your current Settings defaults (voice, language, speed, GPU).</p>
|
||||
</div>
|
||||
|
||||
{% if debug_manifest and debug_manifest.artifacts %}
|
||||
<div class="field field--wide">
|
||||
<label>Latest debug WAVs</label>
|
||||
<ul>
|
||||
{% for item in debug_manifest.artifacts %}
|
||||
<li>
|
||||
<a href="{{ url_for('settings.download_debug_wav', run_id=debug_manifest.run_id, filename=item.filename) }}">{{ item.label }} · {{ item.filename }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if debug_samples %}
|
||||
<div class="field field--wide">
|
||||
<label>Included examples</label>
|
||||
<ul>
|
||||
{% for sample in debug_samples %}
|
||||
<li><strong>{{ sample.code }}</strong> — {{ sample.label }}: <span class="muted">{{ sample.text }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-role="contraction-modal" hidden>
|
||||
|
||||
Reference in New Issue
Block a user