mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Add debug TTS functionality with EPUB generation, WAV artifact creation, and settings integration
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
MARKER_PREFIX = "[[ABOGEN-DBG:"
|
||||||
|
MARKER_SUFFIX = "]]"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DebugTTSSample:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG_TTS_SAMPLES: Sequence[DebugTTSSample] = (
|
||||||
|
DebugTTSSample(
|
||||||
|
code="APOS_001",
|
||||||
|
label="Apostrophes & contractions",
|
||||||
|
text="It's a beautiful day, isn't it? Let's see what we'll do.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="POS_001",
|
||||||
|
label="Plural possessives",
|
||||||
|
text="The dogs' bowls were empty, but the boss's office was quiet.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="NUM_001",
|
||||||
|
label="Grouped numbers",
|
||||||
|
text="There are 1,234 apples, 56 oranges, and 7.89 liters of juice.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="YEAR_001",
|
||||||
|
label="Years and decades",
|
||||||
|
text="In 1999, people said the '90s were over.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="DATE_001",
|
||||||
|
label="ISO dates",
|
||||||
|
text="On 2023-01-01, we celebrated the new year.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="CUR_001",
|
||||||
|
label="Currency symbols",
|
||||||
|
text="The price is $10.50, but it was £8.00 yesterday.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="TITLE_001",
|
||||||
|
label="Titles and abbreviations",
|
||||||
|
text="Dr. Smith lives on Elm St. near the U.S. border.",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="PUNC_001",
|
||||||
|
label="Terminal punctuation",
|
||||||
|
text="This sentence ends without punctuation",
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="QUOTE_001",
|
||||||
|
label="ALL CAPS inside quotes",
|
||||||
|
text='He shouted, "THIS IS IMPORTANT!" and then whispered, "ok."',
|
||||||
|
),
|
||||||
|
DebugTTSSample(
|
||||||
|
code="FOOT_001",
|
||||||
|
label="Footnote indicators",
|
||||||
|
text="This is a sentence with a footnote[1] and another[12].",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def marker_for(code: str) -> str:
|
||||||
|
return f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> Path:
|
||||||
|
"""Create a tiny EPUB containing all debug samples.
|
||||||
|
|
||||||
|
The text includes stable marker codes so developers can report failures
|
||||||
|
precisely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dest_path = Path(dest_path)
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
chapter_lines: List[str] = [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"<html xmlns=\"http://www.w3.org/1999/xhtml\">",
|
||||||
|
"<head>",
|
||||||
|
f" <title>{title}</title>",
|
||||||
|
" <meta charset=\"utf-8\" />",
|
||||||
|
"</head>",
|
||||||
|
"<body>",
|
||||||
|
f" <h1>{title}</h1>",
|
||||||
|
" <p>Each paragraph begins with a stable debug code marker.</p>",
|
||||||
|
]
|
||||||
|
|
||||||
|
for sample in DEBUG_TTS_SAMPLES:
|
||||||
|
safe_label = sample.label.replace("&", "and")
|
||||||
|
chapter_lines.append(f" <h2>{safe_label}</h2>")
|
||||||
|
chapter_lines.append(
|
||||||
|
" <p><strong>" + marker_for(sample.code) + "</strong> " + sample.text + "</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
chapter_lines += ["</body>", "</html>"]
|
||||||
|
chapter_xhtml = "\n".join(chapter_lines)
|
||||||
|
|
||||||
|
container_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>
|
||||||
|
"""
|
||||||
|
|
||||||
|
content_opf = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="bookid">abogen-debug-samples</dc:identifier>
|
||||||
|
<dc:title>abogen debug samples</dc:title>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
|
||||||
|
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
<itemref idref="chapter" />
|
||||||
|
</spine>
|
||||||
|
</package>
|
||||||
|
"""
|
||||||
|
|
||||||
|
nav_xhtml = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title>Navigation</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav epub:type="toc" id="toc">
|
||||||
|
<h2>Table of Contents</h2>
|
||||||
|
<ol>
|
||||||
|
<li><a href="chapter.xhtml">Debug samples</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
(tmp_path / "mimetype").write_text("application/epub+zip", encoding="utf-8")
|
||||||
|
meta_inf = tmp_path / "META-INF"
|
||||||
|
meta_inf.mkdir(parents=True, exist_ok=True)
|
||||||
|
(meta_inf / "container.xml").write_text(container_xml, encoding="utf-8")
|
||||||
|
oebps = tmp_path / "OEBPS"
|
||||||
|
oebps.mkdir(parents=True, exist_ok=True)
|
||||||
|
(oebps / "content.opf").write_text(content_opf, encoding="utf-8")
|
||||||
|
(oebps / "chapter.xhtml").write_text(chapter_xhtml, encoding="utf-8")
|
||||||
|
(oebps / "nav.xhtml").write_text(nav_xhtml, encoding="utf-8")
|
||||||
|
|
||||||
|
# Per EPUB spec: mimetype must be the first entry and stored (no compression).
|
||||||
|
with zipfile.ZipFile(dest_path, "w") as zf:
|
||||||
|
zf.write(tmp_path / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||||
|
for source in (meta_inf / "container.xml", oebps / "content.opf", oebps / "chapter.xhtml", oebps / "nav.xhtml"):
|
||||||
|
arcname = str(source.relative_to(tmp_path)).replace("\\", "/")
|
||||||
|
zf.write(source, arcname, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
|
||||||
|
return dest_path
|
||||||
|
|
||||||
|
|
||||||
|
def iter_expected_codes() -> Iterable[str]:
|
||||||
|
for sample in DEBUG_TTS_SAMPLES:
|
||||||
|
yield sample.code
|
||||||
@@ -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 flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
from abogen.web.routes.utils.settings import (
|
from abogen.web.routes.utils.settings import (
|
||||||
@@ -7,11 +9,16 @@ from abogen.web.routes.utils.settings import (
|
|||||||
save_settings,
|
save_settings,
|
||||||
coerce_bool,
|
coerce_bool,
|
||||||
coerce_int,
|
coerce_int,
|
||||||
|
SAVE_MODE_LABELS,
|
||||||
|
llm_ready,
|
||||||
_NORMALIZATION_BOOLEAN_KEYS,
|
_NORMALIZATION_BOOLEAN_KEYS,
|
||||||
_NORMALIZATION_STRING_KEYS,
|
_NORMALIZATION_STRING_KEYS,
|
||||||
_DEFAULT_ANALYSIS_THRESHOLD,
|
_DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
)
|
)
|
||||||
from abogen.web.routes.utils.voice import template_options
|
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__)
|
settings_bp = Blueprint("settings", __name__)
|
||||||
|
|
||||||
@@ -147,10 +154,71 @@ def settings_page() -> str | ResponseReturnValue:
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return update_settings()
|
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(
|
return render_template(
|
||||||
"settings.html",
|
"settings.html",
|
||||||
settings=load_settings(),
|
settings=load_settings(),
|
||||||
integrations=load_integration_settings(),
|
integrations=load_integration_settings(),
|
||||||
options=template_options(),
|
options=template_options(),
|
||||||
normalization_samples=_NORMALIZATION_SAMPLES,
|
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{% 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="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="integrations">Integrations</button>
|
||||||
|
<button type="button" class="settings-nav__item" data-section="debug">Debug</button>
|
||||||
</nav>
|
</nav>
|
||||||
<form action="{{ url_for('settings.update_settings') }}" method="post" class="settings__form">
|
<form action="{{ url_for('settings.update_settings') }}" method="post" class="settings__form">
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
@@ -472,6 +473,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
<div class="modal" data-role="contraction-modal" hidden>
|
<div class="modal" data-role="contraction-modal" hidden>
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES, MARKER_PREFIX, MARKER_SUFFIX, iter_expected_codes
|
||||||
|
from abogen.kokoro_text_normalization import HAS_NUM2WORDS, normalize_for_pipeline
|
||||||
|
from abogen.normalization_settings import build_apostrophe_config
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
|
from abogen.web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_epub_contains_all_codes():
|
||||||
|
epub_path = Path("tests/fixtures/abogen_debug_tts_samples.epub")
|
||||||
|
assert epub_path.exists()
|
||||||
|
|
||||||
|
extraction = extract_from_path(epub_path)
|
||||||
|
combined = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
|
||||||
|
|
||||||
|
for code in iter_expected_codes():
|
||||||
|
marker = f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}"
|
||||||
|
assert marker in combined
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_samples_normalize_smoke():
|
||||||
|
# Use the same defaults as the web UI.
|
||||||
|
from abogen.web.routes.utils.settings import settings_defaults
|
||||||
|
|
||||||
|
settings = settings_defaults()
|
||||||
|
apostrophe = build_apostrophe_config(settings=settings)
|
||||||
|
runtime = dict(settings)
|
||||||
|
|
||||||
|
normalized = {
|
||||||
|
sample.code: normalize_for_pipeline(sample.text, config=apostrophe, settings=runtime)
|
||||||
|
for sample in DEBUG_TTS_SAMPLES
|
||||||
|
}
|
||||||
|
|
||||||
|
# Contractions should expand under defaults.
|
||||||
|
assert "it is" in normalized["APOS_001"].lower()
|
||||||
|
|
||||||
|
# Titles should expand.
|
||||||
|
assert "doctor" in normalized["TITLE_001"].lower()
|
||||||
|
|
||||||
|
# Footnotes should be removed.
|
||||||
|
assert "[1]" not in normalized["FOOT_001"]
|
||||||
|
|
||||||
|
# Terminal punctuation should be added.
|
||||||
|
assert normalized["PUNC_001"].strip()[-1] in {".", "!", "?"}
|
||||||
|
|
||||||
|
if HAS_NUM2WORDS:
|
||||||
|
# Currency and numbers should expand to words when num2words is available.
|
||||||
|
assert "dollar" in normalized["CUR_001"].lower()
|
||||||
|
assert "thousand" in normalized["NUM_001"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch):
|
||||||
|
# Avoid pulling Kokoro models in tests: stub the pipeline.
|
||||||
|
from abogen.web import debug_tts_runner as runner
|
||||||
|
|
||||||
|
class _Seg:
|
||||||
|
def __init__(self, audio):
|
||||||
|
self.audio = audio
|
||||||
|
|
||||||
|
class DummyPipeline:
|
||||||
|
def __call__(self, text, **kwargs):
|
||||||
|
# 100ms of audio per call, deterministic.
|
||||||
|
audio = np.zeros(int(0.1 * runner.SAMPLE_RATE), dtype="float32")
|
||||||
|
audio[::100] = 0.1
|
||||||
|
yield _Seg(audio)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline())
|
||||||
|
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test",
|
||||||
|
"OUTPUT_FOLDER": str(tmp_path),
|
||||||
|
"UPLOAD_FOLDER": str(tmp_path / "uploads"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post("/settings/debug/run")
|
||||||
|
assert resp.status_code in {302, 303}
|
||||||
|
location = resp.headers.get("Location", "")
|
||||||
|
assert "debug_run_id=" in location
|
||||||
|
|
||||||
|
# Extract run id from query string.
|
||||||
|
run_id = location.split("debug_run_id=")[1].split("&")[0].split("#")[0]
|
||||||
|
manifest_path = tmp_path / "debug" / run_id / "manifest.json"
|
||||||
|
assert manifest_path.exists()
|
||||||
|
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
filenames = {item["filename"] for item in manifest.get("artifacts", [])}
|
||||||
|
assert "overall.wav" in filenames
|
||||||
|
assert any(name.startswith("case_") and name.endswith(".wav") for name in filenames)
|
||||||
Reference in New Issue
Block a user