', first_paragraph_start)
+ first_paragraph = chapter_doc[first_paragraph_start:first_paragraph_end]
+ assert "First sentence." in first_paragraph
+ assert "Second sentence in same paragraph." in first_paragraph
\ No newline at end of file
diff --git a/tests/test_ffmetadata.py b/tests/test_ffmetadata.py
new file mode 100644
index 0000000..cc58cc8
--- /dev/null
+++ b/tests/test_ffmetadata.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from abogen.webui.conversion_runner import _render_ffmetadata, _write_ffmetadata_file
+
+
+def test_render_ffmetadata_includes_chapters(tmp_path):
+ metadata = {
+ "title": "Sample Book",
+ "artist": "Author Name",
+ "comment": "Line one\nLine two",
+ "publisher": "ACME=Corp",
+ }
+ chapters = [
+ {"start": 0.0, "end": 5.0, "title": "Intro", "voice": "voice_a"},
+ {"start": 5.0, "end": 12.345, "title": "Chapter 2"},
+ ]
+
+ rendered = _render_ffmetadata(metadata, chapters)
+
+ assert ";FFMETADATA1" in rendered
+ assert "title=Sample Book" in rendered
+ assert "artist=Author Name" in rendered
+ assert "comment=Line one\\nLine two" in rendered
+ assert "publisher=ACME\\=Corp" in rendered
+ assert rendered.count("[CHAPTER]") == 2
+ assert "START=0" in rendered
+ assert "END=5000" in rendered
+ assert "voice=voice_a" in rendered
+
+ audio_path = tmp_path / "book.m4b"
+ metadata_path = _write_ffmetadata_file(audio_path, metadata, chapters)
+ assert metadata_path is not None
+ assert metadata_path.exists()
+
+ content = metadata_path.read_text(encoding="utf-8")
+ assert "END=12345" in content
+
+ metadata_path.unlink()
diff --git a/tests/test_manual_overrides_applied_first.py b/tests/test_manual_overrides_applied_first.py
new file mode 100644
index 0000000..d21de5e
--- /dev/null
+++ b/tests/test_manual_overrides_applied_first.py
@@ -0,0 +1,51 @@
+from abogen.webui import conversion_runner
+
+
+class DummyJob:
+ def __init__(self):
+ self.language = "en"
+ self.voice = "M1"
+ self.speakers = None
+ self.manual_overrides = []
+ self.pronunciation_overrides = []
+
+
+def _apply(text: str, job: DummyJob) -> str:
+ merged = conversion_runner._merge_pronunciation_overrides(job)
+ rules = conversion_runner._compile_pronunciation_rules(merged)
+ return conversion_runner._apply_pronunciation_rules(text, rules)
+
+
+def test_manual_override_is_applied_even_if_pronunciation_overrides_stale():
+ job = DummyJob()
+ job.manual_overrides = [
+ {
+ "token": "Unfu*k",
+ "pronunciation": "Unfuck",
+ }
+ ]
+
+ out = _apply("He said Unfu*k loudly.", job)
+ assert "Unfuck" in out
+ assert "Unfu*k" not in out
+
+
+def test_manual_override_takes_precedence_over_existing_pronunciation_override():
+ job = DummyJob()
+ job.pronunciation_overrides = [
+ {
+ "token": "Unfu*k",
+ "normalized": "unfu*k",
+ "pronunciation": "WRONG",
+ }
+ ]
+ job.manual_overrides = [
+ {
+ "token": "Unfu*k",
+ "pronunciation": "RIGHT",
+ }
+ ]
+
+ out = _apply("Unfu*k.", job)
+ assert "RIGHT" in out
+ assert "WRONG" not in out
diff --git a/tests/test_opds_import_metadata_mapping.py b/tests/test_opds_import_metadata_mapping.py
new file mode 100644
index 0000000..1e7db36
--- /dev/null
+++ b/tests/test_opds_import_metadata_mapping.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from abogen.webui.routes.api import _opds_metadata_overrides
+
+
+def test_opds_metadata_overrides_maps_author_and_subtitle() -> None:
+ overrides = _opds_metadata_overrides(
+ {
+ "authors": ["Alexandre Dumas"],
+ "subtitle": "Unabridged",
+ "series": "Example",
+ "series_index": 2,
+ "tags": ["Fiction", "Classic"],
+ "summary": "Summary text",
+ }
+ )
+
+ assert overrides["authors"] == "Alexandre Dumas"
+ assert overrides["author"] == "Alexandre Dumas"
+ assert overrides["subtitle"] == "Unabridged"
+
+ # Existing behavior still present
+ assert overrides["series"] == "Example"
+ assert overrides["series_index"] == "2"
+ assert overrides["tags"] == "Fiction, Classic"
+ assert overrides["description"] == "Summary text"
+
+
+def test_opds_metadata_overrides_accepts_author_string() -> None:
+ overrides = _opds_metadata_overrides({"author": "Mary Shelley"})
+ assert overrides["authors"] == "Mary Shelley"
+ assert overrides["author"] == "Mary Shelley"
diff --git a/tests/test_output_paths.py b/tests/test_output_paths.py
new file mode 100644
index 0000000..a1d14a8
--- /dev/null
+++ b/tests/test_output_paths.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+
+from abogen.webui.conversion_runner import _build_output_path, _prepare_project_layout
+from abogen.webui.service import Job
+
+
+def _sample_job(tmp_path: Path) -> Job:
+ source = tmp_path / "sample.txt"
+ source.write_text("example", encoding="utf-8")
+ return Job(
+ id="job-1",
+ original_filename="Sample Title.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Use default save location",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+
+def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ job = _sample_job(tmp_path)
+ monkeypatch.setattr(
+ "abogen.webui.conversion_runner._output_timestamp_token",
+ lambda: "20250101-120000",
+ )
+
+ project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
+
+ assert project_root.name.startswith("20250101-120000_Sample_Title"), project_root.name
+ assert audio_dir == project_root
+ assert subtitle_dir == project_root
+ assert metadata_dir is None
+
+ output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
+ assert output_path == project_root / "Sample_Title.mp3"
+
+
+def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ job = _sample_job(tmp_path)
+ job.save_as_project = True
+ monkeypatch.setattr(
+ "abogen.webui.conversion_runner._output_timestamp_token",
+ lambda: "20250101-120500",
+ )
+
+ project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
+
+ assert audio_dir == project_root / "audio"
+ assert subtitle_dir == project_root / "subtitles"
+ assert metadata_dir == project_root / "metadata"
+ assert audio_dir.is_dir()
+ assert subtitle_dir.is_dir()
+ assert metadata_dir is not None and metadata_dir.is_dir()
+
+ output_path = _build_output_path(audio_dir, job.original_filename, "wav")
+ assert output_path == audio_dir / "Sample_Title.wav"
diff --git a/tests/test_prepare_form.py b/tests/test_prepare_form.py
new file mode 100644
index 0000000..4ea05eb
--- /dev/null
+++ b/tests/test_prepare_form.py
@@ -0,0 +1,121 @@
+from pathlib import Path
+
+from werkzeug.datastructures import MultiDict
+
+from abogen.webui.routes.utils.form import apply_prepare_form
+from abogen.webui.routes.utils.voice import resolve_voice_setting
+from abogen.webui.service import PendingJob
+
+
+def _make_pending_job() -> PendingJob:
+ return PendingJob(
+ id="pending",
+ original_filename="example.epub",
+ stored_path=Path("example.epub"),
+ language="a",
+ voice="af_nova",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="none",
+ output_format="mp3",
+ save_mode="save_next_to_input",
+ output_folder=None,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ total_characters=0,
+ save_chapters_separately=False,
+ merge_chapters_at_end=True,
+ separate_chapters_format="wav",
+ silence_between_chapters=2.0,
+ save_as_project=False,
+ voice_profile=None,
+ max_subtitle_words=50,
+ metadata_tags={},
+ chapters=[],
+ normalization_overrides={},
+ created_at=0.0,
+ read_title_intro=False,
+ normalize_chapter_opening_caps=True,
+ )
+
+
+def test_apply_prepare_form_handles_custom_mix_for_speakers():
+ pending = _make_pending_job()
+ pending.speakers = {
+ "hero": {
+ "id": "hero",
+ "label": "Hero",
+ }
+ }
+
+ form = MultiDict(
+ {
+ "chapter_intro_delay": "0.5",
+ "speaker-hero-voice": "__custom_mix",
+ "speaker-hero-formula": "af_nova*0.6+am_liam*0.4",
+ }
+ )
+
+ _, _, _, errors, *_ = apply_prepare_form(pending, form)
+
+ assert not errors
+ hero = pending.speakers["hero"]
+ assert hero["voice_formula"] == "af_nova*0.6+am_liam*0.4"
+ assert hero["resolved_voice"] == "af_nova*0.6+am_liam*0.4"
+ assert "voice" not in hero or hero["voice"] != "__custom_mix"
+
+
+def test_apply_prepare_form_accepts_saved_speaker_reference_for_voice():
+ pending = _make_pending_job()
+ pending.speakers = {
+ "hero": {
+ "id": "hero",
+ "label": "Hero",
+ }
+ }
+
+ form = MultiDict(
+ {
+ "chapter_intro_delay": "0.5",
+ "speaker-hero-voice": "speaker:Female HQ",
+ "speaker-hero-formula": "",
+ }
+ )
+
+ _, _, _, errors, *_ = apply_prepare_form(pending, form)
+
+ assert not errors
+ hero = pending.speakers["hero"]
+ assert hero["voice"] == "speaker:Female HQ"
+ assert hero["resolved_voice"] == "speaker:Female HQ"
+ assert "voice_formula" not in hero
+
+
+def test_resolve_voice_setting_handles_profile_reference():
+ profiles = {
+ "Blend": {
+ "language": "b",
+ "voices": [
+ ("af_nova", 1.0),
+ ("am_liam", 1.0),
+ ],
+ }
+ }
+
+ voice, profile_name, language = resolve_voice_setting("profile:Blend", profiles=profiles)
+
+ assert voice == "af_nova*0.5+am_liam*0.5"
+ assert profile_name == "Blend"
+ assert language == "b"
+
+
+def test_apply_prepare_form_updates_closing_outro_flag():
+ pending = _make_pending_job()
+ pending.read_closing_outro = True
+ form = MultiDict({
+ "read_closing_outro": "false",
+ })
+
+ apply_prepare_form(pending, form)
+
+ assert pending.read_closing_outro is False
diff --git a/tests/test_preview_applies_manual_overrides.py b/tests/test_preview_applies_manual_overrides.py
new file mode 100644
index 0000000..76998c4
--- /dev/null
+++ b/tests/test_preview_applies_manual_overrides.py
@@ -0,0 +1,49 @@
+from abogen.webui.routes.utils import preview
+
+
+def test_preview_applies_manual_override_before_normalization(monkeypatch):
+ # Don't run real TTS/normalization; just exercise the override stage by
+ # forcing provider=kokoro and then stubbing normalize_for_pipeline.
+
+ monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
+
+ # Stub normalize_for_pipeline to be identity; we only care that overrides run.
+ class _Norm:
+ @staticmethod
+ def normalize_for_pipeline(text):
+ return text
+
+ monkeypatch.setitem(__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm)
+
+ # And stub the kokoro pipeline path so generate_preview_audio won't proceed.
+ # We'll instead validate by calling the override logic through generate_preview_audio
+ # with provider=supertonic and stub SupertonicPipeline to capture input.
+ captured = {}
+
+ class DummyPipeline:
+ def __init__(self, **kwargs):
+ pass
+
+ def __call__(self, text, **kwargs):
+ captured["text"] = text
+ return iter(())
+
+ monkeypatch.setitem(__import__("sys").modules, "abogen.tts_supertonic", type("M", (), {"SupertonicPipeline": DummyPipeline}))
+
+ try:
+ preview.generate_preview_audio(
+ text="He said Unfu*k loudly.",
+ voice_spec="M1",
+ language="en",
+ speed=1.0,
+ use_gpu=False,
+ tts_provider="supertonic",
+ manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
+ )
+ except Exception:
+ # generate_preview_audio will raise because no audio chunks; that's fine.
+ pass
+
+ assert "text" in captured
+ assert "Unfuck" in captured["text"]
+ assert "Unfu*k" not in captured["text"]
diff --git a/tests/test_regression_fixes.py b/tests/test_regression_fixes.py
new file mode 100644
index 0000000..7bf0396
--- /dev/null
+++ b/tests/test_regression_fixes.py
@@ -0,0 +1,81 @@
+import pytest
+from unittest.mock import patch
+from abogen.kokoro_text_normalization import normalize_for_pipeline, DEFAULT_APOSTROPHE_CONFIG
+from abogen.normalization_settings import build_apostrophe_config, _SETTINGS_DEFAULTS
+
+def normalize(text, overrides=None):
+ settings = dict(_SETTINGS_DEFAULTS)
+ if overrides:
+ settings.update(overrides)
+
+ config = build_apostrophe_config(settings=settings, base=DEFAULT_APOSTROPHE_CONFIG)
+ return normalize_for_pipeline(text, config=config, settings=settings)
+
+def test_year_pronunciation():
+ # 1925 -> Nineteen Hundred Twenty Five
+ normalized = normalize("1925")
+ print(f"1925 -> {normalized}")
+ assert "nineteen hundred" in normalized.lower()
+ assert "five" in normalized.lower()
+
+ # 2025 -> Twenty Twenty Five
+ normalized = normalize("2025")
+ print(f"2025 -> {normalized}")
+ assert "twenty twenty" in normalized.lower()
+ assert "five" in normalized.lower()
+
+def test_currency_pronunciation():
+ # $1.00 -> One dollar (no zero cents)
+ normalized = normalize("$1.00")
+ print(f"$1.00 -> {normalized}")
+ assert "one dollar" in normalized.lower()
+ assert "zero cents" not in normalized.lower()
+
+ # $1.05 -> One dollar and five cents (or comma)
+ normalized = normalize("$1.05")
+ print(f"$1.05 -> {normalized}")
+ assert "one dollar" in normalized.lower()
+ assert "five cents" in normalized.lower()
+
+def test_url_pronunciation():
+ # https://www.amazon.com -> amazon dot com
+ normalized = normalize("https://www.amazon.com")
+ print(f"https://www.amazon.com -> {normalized}")
+ assert "amazon dot com" in normalized.lower()
+ assert "http" not in normalized.lower()
+ assert "www" not in normalized.lower()
+
+ # www.google.com -> google dot com
+ normalized = normalize("www.google.com")
+ print(f"www.google.com -> {normalized}")
+ assert "google dot com" in normalized.lower()
+
+def test_roman_numerals_world_war():
+ # World War I -> World War One
+ normalized = normalize("World War I")
+ print(f"World War I -> {normalized}")
+ assert "world war one" in normalized.lower()
+
+ # World War II -> World War Two
+ normalized = normalize("World War II")
+ print(f"World War II -> {normalized}")
+ assert "world war two" in normalized.lower()
+
+def test_footnote_removal():
+ # Bob is awesome1. -> Bob is awesome.
+ normalized = normalize("Bob is awesome1.")
+ print(f"Bob is awesome1. -> {normalized}")
+ assert "bob is awesome." in normalized.lower()
+ assert "1" not in normalized
+
+ # Citation needed[1]. -> Citation needed.
+ normalized = normalize("Citation needed[1].")
+ print(f"Citation needed[1]. -> {normalized}")
+ assert "citation needed." in normalized.lower()
+ assert "[1]" not in normalized
+
+def test_manual_override_normalization():
+ from abogen.entity_analysis import normalize_manual_override_token
+ assert normalize_manual_override_token("The") == "the"
+ assert normalize_manual_override_token(" A ") == "a"
+ assert normalize_manual_override_token("word") == "word"
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..07c61f1
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,290 @@
+from __future__ import annotations
+
+import io
+import time
+from abogen.webui.service import Job, JobStatus, build_service, _JOB_LOGGER, build_audiobookshelf_metadata
+
+
+def test_service_processes_job(tmp_path):
+ uploads = tmp_path / "uploads"
+ outputs = tmp_path / "outputs"
+ uploads.mkdir()
+ outputs.mkdir()
+
+ source = uploads / "sample.txt"
+ payload = "hello world"
+ source.write_text(payload, encoding="utf-8")
+
+ runner_invocations: list[str] = []
+
+ def runner(job):
+ runner_invocations.append(job.id)
+ job.add_log("processing")
+ job.progress = 1.0
+ job.processed_characters = job.total_characters or len(payload)
+ job.result.audio_path = outputs / f"{job.id}.wav"
+
+ service = build_service(
+ runner=runner,
+ output_root=outputs,
+ uploads_root=uploads,
+ )
+
+ job = service.enqueue(
+ original_filename="sample.txt",
+ stored_path=source,
+ language="a",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="wav",
+ save_mode="Save next to input file",
+ output_folder=outputs,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ total_characters=len(payload),
+ )
+
+ deadline = time.time() + 5
+ while time.time() < deadline and job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
+ time.sleep(0.05)
+
+ service.shutdown()
+
+ assert runner_invocations, "conversion runner was never called"
+ assert job.status is JobStatus.COMPLETED
+ assert job.progress == 1.0
+ assert job.result.audio_path == outputs / f"{job.id}.wav"
+ assert job.chunk_level == "paragraph"
+ assert job.speaker_mode == "single"
+ assert job.chunks == []
+ assert not job.generate_epub3
+
+
+def test_job_add_log_emits_to_stream(tmp_path):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("payload", encoding="utf-8")
+
+ job = Job(
+ id="job-test",
+ original_filename="sample.txt",
+ stored_path=sample,
+ language="a",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="wav",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+ captured_buffers = []
+ for handler in list(_JOB_LOGGER.handlers):
+ if not hasattr(handler, "setStream"):
+ continue
+ buffer = io.StringIO()
+ original_stream = getattr(handler, "stream", None)
+ handler.setStream(buffer) # type: ignore[attr-defined]
+ captured_buffers.append((handler, original_stream, buffer))
+
+ assert captured_buffers, "Expected job logger to have stream handlers"
+
+ try:
+ job.add_log("Test log line", level="error")
+ outputs = [buffer.getvalue() for _, _, buffer in captured_buffers]
+ finally:
+ for handler, original_stream, _ in captured_buffers:
+ if hasattr(handler, "setStream"):
+ handler.setStream(original_stream) # type: ignore[attr-defined]
+
+ assert any("Test log line" in output for output in outputs)
+ assert job.logs[-1].message == "Test log line"
+
+
+def test_job_add_log_handles_exception(tmp_path, capsys):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("payload", encoding="utf-8")
+
+ job = Job(
+ id="job-fail-test",
+ original_filename="sample.txt",
+ stored_path=sample,
+ language="a",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="wav",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+ # Mock the logger to raise an exception
+ original_log = _JOB_LOGGER.log
+
+ def side_effect(*args, **kwargs):
+ raise RuntimeError("Logger exploded")
+
+ _JOB_LOGGER.log = side_effect
+
+ try:
+ job.add_log("This should trigger fallback", level="info")
+ finally:
+ _JOB_LOGGER.log = original_log
+
+ captured = capsys.readouterr()
+ assert "Logging failed for job job-fail-test" in captured.err
+ assert "Logger exploded" in captured.err
+
+
+def test_retry_removes_failed_job(tmp_path):
+ uploads = tmp_path / "uploads"
+ outputs = tmp_path / "outputs"
+ uploads.mkdir()
+ outputs.mkdir()
+
+ source = uploads / "sample.txt"
+ source.write_text("hello", encoding="utf-8")
+
+ def failing_runner(job):
+ job.add_log("runner failing", level="error")
+ raise RuntimeError("boom")
+
+ service = build_service(
+ runner=failing_runner,
+ output_root=outputs,
+ uploads_root=uploads,
+ )
+
+ try:
+ job = service.enqueue(
+ original_filename="sample.txt",
+ stored_path=source,
+ language="a",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="wav",
+ save_mode="Save next to input file",
+ output_folder=outputs,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ total_characters=len("hello"),
+ )
+
+ deadline = time.time() + 5
+ while time.time() < deadline and job.status is not JobStatus.FAILED:
+ time.sleep(0.05)
+
+ assert job.status is JobStatus.FAILED
+
+ new_job = service.retry(job.id)
+ assert new_job is not None
+ assert new_job.id != job.id
+
+ job_ids = {entry.id for entry in service.list_jobs()}
+ assert job.id not in job_ids
+ assert new_job.id in job_ids
+ finally:
+ service.shutdown()
+
+
+def test_audiobookshelf_metadata_uses_book_number(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "book_number": "7",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesName"] == "Example Saga"
+ assert metadata["seriesSequence"] == "7"
+
+
+def test_audiobookshelf_metadata_normalizes_sequence_value(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs-normalize",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "series_index": "Book 7 of the Series",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesName"] == "Example Saga"
+ assert metadata["seriesSequence"] == "7"
+
+
+def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs-decimal",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "series_number": "Book 4.5",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesSequence"] == "4.5"
\ No newline at end of file
diff --git a/tests/test_settings_integrations_secrets.py b/tests/test_settings_integrations_secrets.py
new file mode 100644
index 0000000..96896fa
--- /dev/null
+++ b/tests/test_settings_integrations_secrets.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from abogen.utils import load_config, save_config
+from abogen.webui.app import create_app
+
+
+def test_settings_update_preserves_abs_api_token_when_blank(tmp_path):
+ # Seed config with stored integration secret.
+ save_config(
+ {
+ "language": "en",
+ "integrations": {
+ "audiobookshelf": {
+ "enabled": True,
+ "base_url": "https://abs.example",
+ "api_token": "SECRET_TOKEN",
+ "library_id": "lib1",
+ "folder_id": "fold1",
+ "verify_ssl": True,
+ },
+ "calibre_opds": {
+ "enabled": True,
+ "base_url": "https://opds.example",
+ "username": "user",
+ "password": "SECRET_PASS",
+ "verify_ssl": True,
+ },
+ },
+ }
+ )
+
+ 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:
+ # Emulate saving settings where integrations are present but secrets are blank
+ # (typical of masked password/token inputs).
+ resp = client.post(
+ "/settings/update",
+ data={
+ "language": "en",
+ "output_format": "mp3",
+ # ABS integration fields (token blank)
+ "audiobookshelf_enabled": "on",
+ "audiobookshelf_base_url": "https://abs.example",
+ "audiobookshelf_api_token": "",
+ "audiobookshelf_library_id": "lib1",
+ "audiobookshelf_folder_id": "fold1",
+ "audiobookshelf_verify_ssl": "on",
+ # Calibre OPDS integration fields (password blank)
+ "calibre_opds_enabled": "on",
+ "calibre_opds_base_url": "https://opds.example",
+ "calibre_opds_username": "user",
+ "calibre_opds_password": "",
+ "calibre_opds_verify_ssl": "on",
+ },
+ follow_redirects=False,
+ )
+ assert resp.status_code in {302, 303}
+
+ cfg = load_config() or {}
+ integrations = cfg.get("integrations") or {}
+
+ assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN"
+ assert integrations["calibre_opds"]["password"] == "SECRET_PASS"
+
+
+def test_settings_update_preserves_secrets_when_fields_missing(tmp_path):
+ save_config(
+ {
+ "language": "en",
+ "integrations": {
+ "audiobookshelf": {"api_token": "SECRET_TOKEN"},
+ "calibre_opds": {"password": "SECRET_PASS"},
+ },
+ }
+ )
+
+ 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:
+ # Post unrelated changes; omit integration fields completely.
+ resp = client.post(
+ "/settings/update",
+ data={
+ "language": "en",
+ "output_format": "wav",
+ },
+ follow_redirects=False,
+ )
+ assert resp.status_code in {302, 303}
+
+ cfg = load_config() or {}
+ integrations = cfg.get("integrations") or {}
+ assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN"
+ assert integrations["calibre_opds"]["password"] == "SECRET_PASS"
diff --git a/tests/test_speaker_analysis.py b/tests/test_speaker_analysis.py
new file mode 100644
index 0000000..88885a5
--- /dev/null
+++ b/tests/test_speaker_analysis.py
@@ -0,0 +1,93 @@
+from abogen.speaker_analysis import analyze_speakers
+
+
+def _chapters():
+ return [
+ {
+ "id": "0001",
+ "index": 0,
+ "title": "Test",
+ "text": "",
+ "enabled": True,
+ }
+ ]
+
+
+def _chunk(text: str, idx: int) -> dict:
+ return {
+ "id": f"chunk-{idx}",
+ "chapter_index": 0,
+ "chunk_index": idx,
+ "text": text,
+ }
+
+
+def test_analyze_speakers_infers_gender_from_pronouns():
+ chunks = [
+ _chunk("\"Greetings,\" said John. He adjusted his hat as he smiled.", 0),
+ _chunk("\"Hello,\" said Mary. She straightened her dress as she introduced herself.", 1),
+ _chunk("\"Nice to meet you,\" said Alex.", 2),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
+
+ john = analysis.speakers.get("john")
+ mary = analysis.speakers.get("mary")
+ alex = analysis.speakers.get("alex")
+
+ assert john is not None
+ assert mary is not None
+ assert alex is not None
+
+ assert john.gender == "male"
+ assert mary.gender == "female"
+ assert alex.gender == "unknown"
+
+
+def test_analyze_speakers_ignores_leading_stopwords():
+ chunks = [
+ _chunk('But Volescu said, "We march at dawn."', 0),
+ _chunk('Then Blue Leader shouted, "Hold the perimeter."', 1),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
+
+ speakers = analysis.speakers
+ assert "volescu" in speakers
+ assert speakers["volescu"].label == "Volescu"
+ assert "blue_leader" in speakers
+ assert speakers["blue_leader"].label == "Blue Leader"
+ assert "but_volescu" not in speakers
+ assert "then_blue_leader" not in speakers
+
+
+def test_analyze_speakers_applies_threshold_suppression():
+ chunks = [
+ _chunk("\"Hello there,\" said Narrator.", 0),
+ _chunk("\"It is lying,\" said Green.", 1),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=3, max_speakers=0)
+
+ green = analysis.speakers.get("green")
+ assert green is not None
+ assert green.suppressed is True
+ assert "green" in analysis.suppressed
+
+
+def test_sample_excerpt_includes_context_paragraphs():
+ chunks = [
+ _chunk("The hallway was quiet as footsteps approached.", 0),
+ _chunk('\"Open the door,\" said John as he reached for the handle.', 1),
+ _chunk("Mary watched him closely, unsure of his intent.", 2),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
+
+ john = analysis.speakers.get("john")
+ assert john is not None
+ assert john.sample_quotes, "Expected John to have at least one sample quote"
+ excerpt = john.sample_quotes[0]["excerpt"]
+ assert "The hallway was quiet" in excerpt
+ assert "\"Open the door,\" said John" in excerpt
+ assert "Mary watched him closely" in excerpt
diff --git a/tests/test_text_extractor.py b/tests/test_text_extractor.py
new file mode 100644
index 0000000..f63b67f
--- /dev/null
+++ b/tests/test_text_extractor.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+
+from ebooklib import epub
+
+from abogen.text_extractor import extract_from_path
+from abogen.utils import calculate_text_length
+
+
+ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub")
+
+
+def test_epub_character_counts_align_with_calculated_total():
+ result = extract_from_path(ASSET)
+
+ combined_total = calculate_text_length(result.combined_text)
+ chapter_total = sum(chapter.characters for chapter in result.chapters)
+
+ assert result.total_characters == combined_total == chapter_total
+
+
+def test_epub_metadata_composer_matches_artist():
+ result = extract_from_path(ASSET)
+
+ composer = result.metadata.get("composer") or result.metadata.get("COMPOSER")
+ artist = result.metadata.get("artist") or result.metadata.get("ARTIST")
+
+ assert composer
+ assert composer == artist
+ assert composer != "Narrator"
+
+
+def test_epub_series_metadata_extracted_from_opf_meta(tmp_path):
+ book = epub.EpubBook()
+ book.set_identifier("id")
+ book.set_title("Example Title")
+ book.set_language("en")
+ book.add_author("Example Author")
+
+ # Calibre-style series metadata
+ book.add_metadata("OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"})
+ book.add_metadata("OPF", "meta", "", {"name": "calibre:series_index", "content": "2"})
+
+ chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en")
+ chapter.content = "
Chapter 1
Hello
"
+ book.add_item(chapter)
+ book.spine = ["nav", chapter]
+ book.add_item(epub.EpubNcx())
+ book.add_item(epub.EpubNav())
+
+ path = tmp_path / "example.epub"
+ epub.write_epub(str(path), book)
+
+ result = extract_from_path(path)
+
+ assert result.metadata.get("series") == "Example Saga"
+ assert result.metadata.get("series_index") == "2"
diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py
new file mode 100644
index 0000000..156fccf
--- /dev/null
+++ b/tests/test_text_normalization.py
@@ -0,0 +1,343 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import patch
+
+from abogen.kokoro_text_normalization import (
+ DEFAULT_APOSTROPHE_CONFIG,
+ normalize_for_pipeline,
+ normalize_roman_numeral_titles,
+)
+from abogen.normalization_settings import (
+ apply_overrides as apply_normalization_overrides,
+ build_apostrophe_config,
+ get_runtime_settings,
+)
+from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions
+
+
+SPACY_RESOLVER_AVAILABLE = bool(resolve_ambiguous_contractions("It's been a long time."))
+
+
+def _normalize_text(text: str, *, normalization_overrides: dict[str, object] | None = None) -> str:
+ runtime_settings = get_runtime_settings()
+ if normalization_overrides:
+ runtime_settings = apply_normalization_overrides(runtime_settings, normalization_overrides)
+ config = build_apostrophe_config(settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG)
+ return normalize_for_pipeline(text, config=config, settings=runtime_settings)
+
+
+def test_title_abbreviations_are_expanded():
+ text = "Dr. Watson met Mr. Holmes and Ms. Hudson."
+ normalized = _normalize_text(text)
+ assert "Doctor" in normalized
+ assert "Mister" in normalized
+ assert "Miz" in normalized
+
+
+def test_suffix_abbreviations_are_expanded_with_case_preserved():
+ text = "John Doe Jr. spoke to JANE DOE SR. about the estate."
+ normalized = _normalize_text(text)
+ assert "John Doe Junior" in normalized
+ assert "JANE DOE SENIOR" in normalized
+
+
+def test_missing_terminal_punctuation_is_added():
+ normalized = _normalize_text("Chapter 1")
+ assert normalized.endswith(".")
+
+
+def test_terminal_punctuation_respects_closing_quotes():
+ normalized = _normalize_text('"Chapter 1"')
+ compact = normalized.replace(" ", "")
+ assert compact.endswith('."')
+
+
+def test_normalization_preserves_spacing_around_quotes_and_hyphen():
+ sample = "“Still,” said Château-Renaud, “Dr. d’Avrigny, who attends my mother, declares he is in despair about it."
+ normalized = _normalize_text(sample)
+
+ assert normalized.startswith(
+ "“Still,” said Château-Renaud, “Doctor d'Avrigny, who attends my mother, declares he is in despair about it."
+ )
+ assert " " not in normalized
+ assert "Château-Renaud" in normalized
+ assert "Doctor d'Avrigny" in normalized
+
+
+def test_normalize_roman_titles_converts_when_majority() -> None:
+ titles = ["I: Opening", "II: Rising Action", "III: Climax"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ assert normalized == ["1: Opening", "2: Rising Action", "3: Climax"]
+
+
+def test_normalize_roman_titles_skips_when_not_majority() -> None:
+ titles = ["Preface", "I: Opening", "Acknowledgements"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ assert normalized == titles
+
+
+def test_normalize_roman_titles_preserves_separators() -> None:
+ titles = [" IV. The Trial", "V - The Verdict", "VI\nAftermath"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ assert normalized[0] == " 4. The Trial"
+ assert normalized[1] == "5 - The Verdict"
+ assert normalized[2].startswith("6\nAftermath")
+
+
+def test_grouped_numbers_are_spelled_out() -> None:
+ normalized = _normalize_text("The vault holds 35,000 credits")
+ assert "thirty-five thousand" in normalized.lower()
+
+
+def test_numeric_ranges_are_spoken_with_to() -> None:
+ normalized = _normalize_text("Chapters 1-3")
+ assert "one to three" in normalized.lower()
+
+
+def test_simple_fractions_are_spoken() -> None:
+ normalized = _normalize_text("Add 1/2 cup of sugar")
+ assert "one half" in normalized.lower()
+
+
+def test_plain_numbers_are_spelled_out() -> None:
+ normalized = _normalize_text("He rolled a 42.")
+ assert "forty-two" in normalized.lower()
+
+
+def test_decimal_numbers_include_point() -> None:
+ normalized = _normalize_text("Book 4.5 of the series.")
+ assert "four point five" in normalized.lower()
+
+
+def test_space_separated_numbers_become_ranges() -> None:
+ normalized = _normalize_text("Read pages 12 14 tonight.")
+ assert "pages twelve to fourteen" in normalized.lower()
+
+
+def test_year_like_numbers_use_common_pronunciation() -> None:
+ normalized = _normalize_text("In 1924 the journey began")
+ folded = normalized.lower().replace("-", " ")
+ assert "nineteen hundred" in folded
+ assert "twenty four" in folded
+
+
+def test_early_century_years_use_hundred_format() -> None:
+ normalized = _normalize_text("In 1204 the city fell")
+ assert "twelve hundred" in normalized.lower()
+ assert "oh four" in normalized.lower()
+
+
+def test_roman_numerals_in_titles_are_converted() -> None:
+ normalized = _normalize_text("Chapter IV begins now")
+ assert "chapter four" in normalized.lower()
+
+
+def test_roman_numeral_suffixes_use_ordinals() -> None:
+ normalized = _normalize_text("Bob Smith II arrived late")
+ assert "bob smith the second" in normalized.lower()
+
+
+def test_lowercase_roman_after_part_converts_to_cardinal() -> None:
+ normalized = _normalize_text("We studied part iii of the manuscript.")
+ assert "part three" in normalized.lower()
+
+
+def test_hyphenated_phase_with_roman_is_converted() -> None:
+ normalized = _normalize_text("They executed phase-IV without delay.")
+ assert "phase four" in normalized.lower()
+
+
+def test_all_caps_quotes_are_sentence_cased() -> None:
+ normalized = _normalize_text('"THIS IS A TEST."')
+ cleaned = normalized.replace('" ', '"')
+ assert '"This is a test."' in cleaned
+
+
+def test_caps_quote_preserves_acronyms() -> None:
+ normalized = _normalize_text("“THE NASA TEAM ARRIVED.”")
+ assert "“The NASA team arrived.”" in normalized
+
+
+def test_caps_quote_normalization_respects_override() -> None:
+ normalized = _normalize_text(
+ '"KEEP SHOUTING."',
+ normalization_overrides={"normalization_caps_quotes": False},
+ )
+ cleaned = normalized.replace('" ', '"')
+ assert '"KEEP SHOUTING."' in cleaned
+
+
+def test_recent_years_split_twenty_style() -> None:
+ normalized = _normalize_text("In 2025 we planned ahead")
+ folded = normalized.lower().replace("-", " ")
+ assert "twenty twenty five" in folded
+
+
+def test_two_thousands_use_two_thousand_prefix() -> None:
+ normalized = _normalize_text("In 2005 we celebrated")
+ assert "two thousand five" in normalized.lower()
+
+
+def test_year_style_can_be_disabled() -> None:
+ normalized = _normalize_text(
+ "In 2025 we planned ahead",
+ normalization_overrides={"normalization_numbers_year_style": "off"},
+ )
+ folded = normalized.lower().replace("-", " ")
+ assert "twenty twenty five" not in folded
+
+
+def test_contractions_can_be_kept_when_override_disabled() -> None:
+ normalized = _normalize_text(
+ "It's a good day.",
+ normalization_overrides={"normalization_apostrophes_contractions": False},
+ )
+ assert "It's" in normalized
+
+
+def test_sibilant_possessives_remain_when_marking_disabled() -> None:
+ normalized = _normalize_text(
+ "The boss's chair wobbled.",
+ normalization_overrides={"normalization_apostrophes_sibilant_possessives": False},
+ )
+ assert "boss's" in normalized
+ assert "boss iz" not in normalized.lower()
+
+
+def test_decades_can_skip_expansion_when_disabled() -> None:
+ normalized = _normalize_text(
+ "Classic hits from the '90s filled the hall.",
+ normalization_overrides={"normalization_apostrophes_decades": False},
+ )
+ assert "'90s" in normalized
+
+
+def test_abbreviated_decades_expand_to_spoken_form() -> None:
+ normalized = _normalize_text("She loved music from the '80s.")
+ assert "eighties" in normalized.lower()
+
+
+def test_currency_under_one_dollar_uses_cents() -> None:
+ normalized = _normalize_text("It cost $0.99.")
+ folded = normalized.lower().replace("-", " ")
+ assert "zero dollars" not in folded
+ assert "cents" in folded
+
+
+def test_iso_dates_use_locale_order_and_ordinals(monkeypatch) -> None:
+ monkeypatch.setenv("LC_TIME", "en_US.UTF-8")
+ normalized = _normalize_text("The date is 2025/12/15.")
+ folded = normalized.lower().replace("-", " ")
+ assert "december" in folded
+ assert "fifteenth" in folded
+
+
+def test_times_and_acronyms_do_not_say_dot() -> None:
+ normalized = _normalize_text("Meet at 5 p.m. near the U.S.A. border.")
+ folded = normalized.lower()
+ assert " dot " not in folded
+
+
+def test_internet_slang_expansion_is_configurable() -> None:
+ normalized = _normalize_text(
+ "pls knock before entering.",
+ normalization_overrides={"normalization_internet_slang": True},
+ )
+ assert "please" in normalized.lower()
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_it_has_from_context() -> None:
+ normalized = _normalize_text("It's been a long time.")
+ assert "It has been a long time." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_it_is_from_context() -> None:
+ normalized = _normalize_text("It's cold outside.")
+ assert "It is cold outside." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_she_had() -> None:
+ normalized = _normalize_text("She'd left before dawn.")
+ assert "She had left before dawn." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_she_would() -> None:
+ normalized = _normalize_text("She'd go if invited.")
+ assert "She would go if invited." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_sample_sentence_handles_complex_contractions() -> None:
+ sample = "I've heard the captain'll arrive by dusk, but they'd said the same yesterday."
+ normalized = _normalize_text(sample)
+ assert (
+ "I have heard the captain will arrive by dusk, but they had said the same yesterday." == normalized
+ )
+
+
+def test_modal_will_contractions_can_be_disabled() -> None:
+ sample = "The captain'll arrive at dawn."
+ normalized = _normalize_text(
+ sample,
+ normalization_overrides={"normalization_contraction_modal_will": False},
+ )
+ assert "captain'll" in normalized
+
+
+@pytest.fixture(autouse=True)
+def mock_settings():
+ defaults = {
+ "normalization_numbers": True,
+ "normalization_titles": True,
+ "normalization_terminal": True,
+ "normalization_phoneme_hints": True,
+ "normalization_caps_quotes": True,
+ "normalization_apostrophes_contractions": True,
+ "normalization_apostrophes_plural_possessives": True,
+ "normalization_apostrophes_sibilant_possessives": True,
+ "normalization_apostrophes_decades": True,
+ "normalization_apostrophes_leading_elisions": True,
+ "normalization_apostrophe_mode": "spacy",
+ "normalization_contraction_aux_be": True,
+ "normalization_contraction_aux_have": True,
+ "normalization_contraction_modal_will": True,
+ "normalization_contraction_modal_would": True,
+ "normalization_contraction_negation_not": True,
+ "normalization_contraction_let_us": True,
+ "normalization_currency": True,
+ "normalization_footnotes": True,
+ "normalization_numbers_year_style": "american",
+ }
+ with patch("tests.test_text_normalization.get_runtime_settings", return_value=defaults):
+ yield
+
+def test_currency_magnitude():
+ cases = [
+ ("$2 million", "two million dollars"),
+ ("$2.5 million", "two point five million dollars"),
+ ("$100 billion", "one hundred billion dollars"),
+ ("$1.2 trillion", "one point two trillion dollars"),
+ ("$2.55 million", "two point five five million dollars"),
+ ("$1 million", "one million dollars"),
+ ("$0.5 million", "zero point five million dollars"),
+ ("$2.50", "two dollars, fifty cents"),
+ ("$100", "one hundred dollars"),
+ ]
+
+ settings = {
+ "normalization_numbers": True,
+ "normalization_currency": True,
+ "normalization_apostrophe_mode": "spacy"
+ }
+
+ for input_text, expected in cases:
+ normalized = _normalize_text(input_text, normalization_overrides=settings)
+ assert expected.lower() in normalized.lower(), f"Failed for {input_text}: got '{normalized}'"
diff --git a/tests/test_tts_supertonic_unsupported_chars.py b/tests/test_tts_supertonic_unsupported_chars.py
new file mode 100644
index 0000000..c08ca2c
--- /dev/null
+++ b/tests/test_tts_supertonic_unsupported_chars.py
@@ -0,0 +1,53 @@
+import numpy as np
+
+from abogen.tts_supertonic import SupertonicPipeline
+
+
+class _DummyTTS:
+ def get_voice_style(self, voice_name: str):
+ return {"voice": voice_name}
+
+ def synthesize(
+ self,
+ *,
+ text: str,
+ voice_style,
+ total_steps: int,
+ speed: float,
+ max_chunk_length: int,
+ silence_duration: float,
+ verbose: bool,
+ ):
+ if "•" in text:
+ raise ValueError("Found 1 unsupported character(s): ['•']")
+ # Return 50ms of audio at 24kHz.
+ sr = 24000
+ audio = np.zeros(int(0.05 * sr), dtype="float32")
+ return audio, 0.05
+
+
+def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
+ # Avoid importing/initializing real supertonic by manually constructing the pipeline.
+ pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
+ pipeline.sample_rate = 24000
+ pipeline.total_steps = 5
+ pipeline.max_chunk_length = 1000
+ pipeline._tts = _DummyTTS()
+
+ segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
+ assert len(segs) == 1
+ assert segs[0].graphemes == "Hello world" or segs[0].graphemes == "Hello world"
+ assert isinstance(segs[0].audio, np.ndarray)
+ assert segs[0].audio.dtype == np.float32
+ assert segs[0].audio.size > 0
+
+
+def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
+ pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
+ pipeline.sample_rate = 24000
+ pipeline.total_steps = 5
+ pipeline.max_chunk_length = 1000
+ pipeline._tts = _DummyTTS()
+
+ segs = list(pipeline("•", voice="M1", speed=1.0))
+ assert segs == []
diff --git a/tests/test_utils_cache.py b/tests/test_utils_cache.py
new file mode 100644
index 0000000..2d35d18
--- /dev/null
+++ b/tests/test_utils_cache.py
@@ -0,0 +1,55 @@
+import os
+import sys
+from pathlib import Path
+from typing import Iterable
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+
+@pytest.fixture(autouse=True)
+def clear_utils_cache():
+ import abogen.utils as utils
+
+ getattr(utils.get_user_cache_root, "cache_clear")()
+ yield
+ getattr(utils.get_user_cache_root, "cache_clear")()
+
+
+def _clear_env(monkeypatch: pytest.MonkeyPatch, keys: Iterable[str]) -> None:
+ for key in keys:
+ monkeypatch.delenv(key, raising=False)
+
+
+def test_abogen_temp_dir_configures_hf_cache(monkeypatch, tmp_path):
+ import abogen.utils as utils
+
+ cache_root = tmp_path / "cache-root"
+ home_dir = tmp_path / "home"
+
+ monkeypatch.setenv("ABOGEN_TEMP_DIR", str(cache_root))
+ monkeypatch.setenv("HOME", str(home_dir))
+ _clear_env(
+ monkeypatch,
+ (
+ "XDG_CACHE_HOME",
+ "HF_HOME",
+ "HUGGINGFACE_HUB_CACHE",
+ "TRANSFORMERS_CACHE",
+ "ABOGEN_INTERNAL_CACHE_ROOT",
+ ),
+ )
+
+ root = utils.get_user_cache_root()
+
+ expected_root = os.path.abspath(str(cache_root))
+ expected_hf = os.path.join(expected_root, "huggingface")
+
+ assert root == expected_root
+ assert os.environ["XDG_CACHE_HOME"] == expected_root
+ assert os.environ["HF_HOME"] == expected_hf
+ assert os.environ["HUGGINGFACE_HUB_CACHE"] == expected_hf
+ assert os.environ["TRANSFORMERS_CACHE"] == expected_hf
diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py
new file mode 100644
index 0000000..b0aa2ca
--- /dev/null
+++ b/tests/test_voice_cache.py
@@ -0,0 +1,65 @@
+from types import SimpleNamespace
+from typing import cast
+
+import pytest
+
+from abogen.constants import VOICES_INTERNAL
+from abogen.voice_cache import LocalEntryNotFoundError, _CACHED_VOICES, ensure_voice_assets
+from abogen.webui.conversion_runner import _collect_required_voice_ids
+from abogen.webui.service import Job
+
+
+@pytest.fixture(autouse=True)
+def clear_voice_cache():
+ _CACHED_VOICES.clear()
+ yield
+ _CACHED_VOICES.clear()
+
+
+def test_ensure_voice_assets_downloads_missing(monkeypatch):
+ recorded = []
+
+ cached = set()
+
+ def fake_download(**kwargs):
+ filename = kwargs["filename"]
+ if kwargs.get("local_files_only"):
+ if filename in cached:
+ return f"/tmp/{filename}"
+ raise LocalEntryNotFoundError(f"{filename} missing")
+
+ recorded.append(filename)
+ cached.add(filename)
+ return f"/tmp/{filename}"
+
+ monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
+
+ downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
+
+ assert downloaded == {"af_nova", "am_liam"}
+ assert errors == {}
+ assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
+
+ recorded.clear()
+ downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
+
+ assert downloaded_again == set()
+ assert errors_again == {}
+ assert recorded == []
+
+
+def test_collect_required_voice_ids_includes_all():
+ job = SimpleNamespace(
+ voice="af_nova",
+ chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
+ chunks=[{"voice": "am_michael"}],
+ speakers={
+ "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
+ "narrator": {"voice": "af_nova"},
+ },
+ )
+
+ voices = _collect_required_voice_ids(cast(Job, job))
+
+ assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
+ assert voices.issuperset(VOICES_INTERNAL)
diff --git a/tests/test_voice_formula_resolution.py b/tests/test_voice_formula_resolution.py
new file mode 100644
index 0000000..2bc3430
--- /dev/null
+++ b/tests/test_voice_formula_resolution.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
+from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
+
+
+def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
+ # This can happen when a previously-saved Kokoro mix formula is present
+ # but the active provider is SuperTonic (no Kokoro pipeline object).
+ formula = "af_heart*0.5+af_sky*0.5"
+ resolved = _resolve_voice(None, formula, use_gpu=False)
+ assert resolved == formula
+
+
+def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
+ # When a stale Kokoro mix formula is present, SuperTonic should not receive it.
+ chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
+ assert chosen in DEFAULT_SUPERTONIC_VOICES