mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Language enum expanded from 9 to 33 languages:
- Added 24 new ISO 639-1 languages: AR, BG, CS, DA, DE, EL, ET, FI,
HR, HU, ID, KO, LT, LV, NL, PL, RO, RU, SK, SL, SV, TR, UK, VI
- Updated display_name, is_cjk (added KO)
Supertonic language mapping (32 languages, no ZH):
- engine.py: _SUPERTONIC_LANG_MAP, engine_language(), supported_languages()
- __init__.py: create_engine() passes config.language to pipeline
- pipeline.py: __init__() accepts language, resolves to ISO code;
__call__() passes lang= to TTS.synthesize()
total_steps propagation:
- tts_segments(): +total_steps param, conditionally passed to backend
- synthesize_text(): +total_steps param
- run_tts_segment_loop(): +total_steps param
- executor: all 5 synthesize_text() calls pass total_steps
Integration:
- pipeline_factory: create_pipeline_for_job() passes language to supertonic
- preview path: create_pipeline('supertonic', language=language)
Tests updated to accept total_steps in FakeBackend.__call__
754 lines
26 KiB
Python
754 lines
26 KiB
Python
"""Tests for conversion_service.py, output_layout_service.py, and executor gaps.
|
|
|
|
Covers the remaining untested code in the application layer.
|
|
"""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, List, Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from abogen.application.conversion_config import (
|
|
CoverConfig,
|
|
PronunciationConfig,
|
|
SaveConfig,
|
|
SubtitleConfig,
|
|
)
|
|
from abogen.application.conversion_request import ConversionRequest
|
|
from abogen.application.conversion_models import (
|
|
ChapterPlan,
|
|
ConversionPlan,
|
|
IntroOutroSpec,
|
|
OutputLayout,
|
|
SegmentPlan,
|
|
)
|
|
from abogen.application.conversion_ports import ResolvedVoice
|
|
from abogen.domain.normalization import TTSContext
|
|
|
|
|
|
# ─── Fake implementations (shared with executor tests) ─────────────
|
|
|
|
|
|
class FakeAudioSink:
|
|
def __init__(self):
|
|
self.written: List[np.ndarray] = []
|
|
self.closed = False
|
|
|
|
def write(self, audio: np.ndarray) -> None:
|
|
self.written.append(audio)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.close()
|
|
|
|
|
|
class FakeBackend:
|
|
def __init__(self):
|
|
self.synthesized: List[str] = []
|
|
|
|
def __call__(self, text: str, *, voice: Any, speed: float = 1.0, split_pattern: str = "", **kwargs: Any) -> List:
|
|
self.synthesized.append(text)
|
|
|
|
class FakeSegment:
|
|
def __init__(self, text: str):
|
|
self.graphemes = text
|
|
self.audio = np.zeros(2400, dtype=np.float32)
|
|
self.tokens = []
|
|
|
|
return [FakeSegment(text)]
|
|
|
|
|
|
class FakeEvents:
|
|
def __init__(self):
|
|
self.logs = []
|
|
self.progress_calls = []
|
|
self.cancelled = False
|
|
|
|
def log(self, message: str, level: str = "info") -> None:
|
|
self.logs.append((message, level))
|
|
|
|
def progress(self, pct: int, etr: str) -> None:
|
|
self.progress_calls.append((pct, etr))
|
|
|
|
def check_cancelled(self) -> None:
|
|
if self.cancelled:
|
|
raise RuntimeError("Conversion cancelled")
|
|
|
|
|
|
class FakePipelineProvider:
|
|
def __init__(self):
|
|
self.backends = {}
|
|
|
|
def get(self, provider: str, language: str, use_gpu: bool) -> FakeBackend:
|
|
key = f"{provider}:{language}"
|
|
if key not in self.backends:
|
|
self.backends[key] = FakeBackend()
|
|
return self.backends[key]
|
|
|
|
def dispose_all(self) -> None:
|
|
self.backends.clear()
|
|
|
|
|
|
class FakeVoiceResolver:
|
|
def __init__(self):
|
|
self.resolved_specs = []
|
|
|
|
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
|
self.resolved_specs.append(voice_spec)
|
|
return ResolvedVoice(
|
|
provider="kokoro",
|
|
resolved_spec=voice_spec,
|
|
voice=voice_spec,
|
|
speed=1.0,
|
|
supertonic_steps=5,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_pool_and_resolver():
|
|
"""Mock PipelinePool and _create_voice_resolver for all service tests."""
|
|
fake_pool = FakePipelineProvider()
|
|
fake_resolver = FakeVoiceResolver()
|
|
with patch(
|
|
"abogen.domain.pipeline_factory.PipelinePool",
|
|
return_value=fake_pool,
|
|
), patch(
|
|
"abogen.domain.voice_loader.VoiceCache",
|
|
), patch(
|
|
"abogen.application.conversion_service._create_voice_resolver",
|
|
return_value=fake_resolver,
|
|
):
|
|
yield
|
|
|
|
|
|
# ─── Tests for conversion_service.py ───────────────────────────────
|
|
|
|
|
|
class TestConversionService:
|
|
"""Tests for the ConversionService.run_conversion function."""
|
|
|
|
def test_simple_conversion(self):
|
|
"""Simple text conversion through the service."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello world",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
|
|
assert result is not None
|
|
assert result.audio_path is not None
|
|
assert result.audio_path.exists()
|
|
|
|
def test_service_logs_pipeline_preparation(self):
|
|
"""Service logs pipeline preparation step."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
|
|
log_messages = [msg for msg, _ in events.logs]
|
|
assert any("Preparing conversion pipeline" in msg for msg in log_messages)
|
|
assert any("Building conversion plan" in msg for msg in log_messages)
|
|
assert any("Starting conversion" in msg for msg in log_messages)
|
|
assert any("Conversion complete" in msg for msg in log_messages)
|
|
|
|
def test_service_handles_cancellation(self):
|
|
"""Service propagates cancellation from events."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
events = FakeEvents()
|
|
events.cancelled = True
|
|
|
|
with pytest.raises(RuntimeError, match="Conversion cancelled"):
|
|
run_conversion(req, events)
|
|
|
|
def test_service_handles_empty_text(self):
|
|
"""Service raises ValueError for empty text."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
req = ConversionRequest(direct_text="", voice="M1")
|
|
events = FakeEvents()
|
|
|
|
with pytest.raises(ValueError, match="No text content"):
|
|
run_conversion(req, events)
|
|
|
|
def test_service_multi_chapter(self):
|
|
"""Service handles multi-chapter conversion."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
|
|
assert result.total_chapters == 2
|
|
|
|
def test_service_with_intro_outro(self):
|
|
"""Service handles intro/outro."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Body text",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
read_title_intro=True,
|
|
read_closing_outro=True,
|
|
metadata_tags={"title": "Test Book", "author": "Author"},
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
|
|
assert result is not None
|
|
|
|
def test_service_error_logs_failure(self):
|
|
"""Service logs error when conversion fails."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
req = ConversionRequest(direct_text="Hello", voice="M1")
|
|
events = FakeEvents()
|
|
|
|
# Mock build_conversion_plan to raise an error
|
|
with patch("abogen.application.conversion_service.build_conversion_plan", side_effect=RuntimeError("Test error")):
|
|
with pytest.raises(RuntimeError, match="Test error"):
|
|
run_conversion(req, events)
|
|
|
|
log_messages = [msg for msg, _ in events.logs]
|
|
assert any("Conversion failed" in msg for msg in log_messages)
|
|
|
|
def test_tts_context_applies_normalization_overrides(self):
|
|
"""Service applies normalization_overrides from request to apostrophe config."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
pronunciation=PronunciationConfig(
|
|
normalization_overrides={"normalization_numbers": False},
|
|
),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
assert result is not None
|
|
|
|
def test_tts_context_rejects_unconfigured_llm_mode(self):
|
|
"""Service raises RuntimeError if LLM apostrophe mode is selected but unconfigured."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
pronunciation=PronunciationConfig(
|
|
normalization_overrides={"normalization_apostrophe_mode": "llm"},
|
|
),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
with pytest.raises(RuntimeError, match="LLM.*apostrophe"):
|
|
run_conversion(req, events)
|
|
|
|
def test_usage_counter_populated_in_result(self):
|
|
"""usage_counter is created and accessible in result."""
|
|
from abogen.application.conversion_service import run_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
events = FakeEvents()
|
|
|
|
result = run_conversion(req, events)
|
|
assert hasattr(result, "usage_counter")
|
|
assert isinstance(result.usage_counter, dict)
|
|
|
|
|
|
# ─── Tests for output_layout_service.py ─────────────────────────────
|
|
|
|
|
|
class TestOutputLayoutService:
|
|
"""Tests for the output_layout_service module."""
|
|
|
|
def test_resolve_output_layout_custom_folder(self):
|
|
"""Output layout with custom folder."""
|
|
from abogen.application.output_layout_service import resolve_output_layout
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
layout = resolve_output_layout(req)
|
|
|
|
assert layout.parent_dir == Path(tmpdir)
|
|
assert layout.audio_dir == Path(tmpdir)
|
|
|
|
def test_resolve_output_layout_source_path(self):
|
|
"""Output layout from source path."""
|
|
from abogen.application.output_layout_service import resolve_output_layout
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
source = Path(tmpdir) / "test.txt"
|
|
source.write_text("Hello")
|
|
req = ConversionRequest(
|
|
source_path=source,
|
|
voice="M1",
|
|
save=SaveConfig(mode="save_next_to_input"),
|
|
)
|
|
layout = resolve_output_layout(req)
|
|
|
|
assert layout.parent_dir == Path(tmpdir)
|
|
|
|
def test_resolve_output_layout_project(self):
|
|
"""Output layout with save_as_project."""
|
|
from abogen.application.output_layout_service import resolve_output_layout
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(
|
|
mode="custom_folder",
|
|
output_folder=Path(tmpdir),
|
|
save_as_project=True,
|
|
),
|
|
original_filename="test.wav",
|
|
)
|
|
layout = resolve_output_layout(req)
|
|
|
|
assert layout.project_root is not None
|
|
assert layout.audio_dir is not None
|
|
|
|
def test_resolve_merged_path(self):
|
|
"""Resolve merged output path."""
|
|
from abogen.application.output_layout_service import resolve_merged_path
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
layout = OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
)
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
original_filename="book.wav",
|
|
output_format="wav",
|
|
)
|
|
path = resolve_merged_path(layout, req)
|
|
|
|
assert path.name == "book.wav"
|
|
assert path.parent == Path(tmpdir)
|
|
|
|
def test_resolve_chapter_path(self):
|
|
"""Resolve chapter output path."""
|
|
from abogen.application.output_layout_service import resolve_chapter_path
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
layout = OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
)
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(separate_chapters_format="wav"),
|
|
)
|
|
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
|
|
|
|
assert "01" in path.name
|
|
assert path.suffix == ".wav"
|
|
|
|
def test_resolve_chapter_path_empty_title(self):
|
|
"""Resolve chapter path with empty title."""
|
|
from abogen.application.output_layout_service import resolve_chapter_path
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
layout = OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
)
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(separate_chapters_format="wav"),
|
|
)
|
|
path = resolve_chapter_path(layout, req, "", 3)
|
|
|
|
assert "chapter_3" in path.name
|
|
|
|
def test_should_merge_output_m4b(self):
|
|
"""m4b format forces merge."""
|
|
from abogen.application.output_layout_service import should_merge_output
|
|
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
output_format="m4b",
|
|
save=SaveConfig(merge_chapters_at_end=False),
|
|
)
|
|
assert should_merge_output(req) is True
|
|
|
|
def test_should_merge_output_no_separate(self):
|
|
"""No separate chapters means merge."""
|
|
from abogen.application.output_layout_service import should_merge_output
|
|
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(save_chapters_separately=False),
|
|
)
|
|
assert should_merge_output(req) is True
|
|
|
|
def test_should_merge_output_separate_and_merge(self):
|
|
"""Separate chapters + merge_at_end means merge."""
|
|
from abogen.application.output_layout_service import should_merge_output
|
|
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(
|
|
save_chapters_separately=True,
|
|
merge_chapters_at_end=True,
|
|
),
|
|
)
|
|
assert should_merge_output(req) is True
|
|
|
|
def test_should_merge_output_separate_no_merge(self):
|
|
"""Separate chapters + no merge_at_end means no merge."""
|
|
from abogen.application.output_layout_service import should_merge_output
|
|
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(
|
|
save_chapters_separately=True,
|
|
merge_chapters_at_end=False,
|
|
),
|
|
)
|
|
assert should_merge_output(req) is False
|
|
|
|
|
|
# ─── Tests for executor gaps ────────────────────────────────────────
|
|
|
|
|
|
class TestExecutorGaps:
|
|
"""Tests for uncovered executor branches."""
|
|
|
|
def test_executor_no_layout_raises(self):
|
|
"""Executor raises ValueError without output_layout."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
req = ConversionRequest(direct_text="Hello", voice="M1")
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[],
|
|
output_layout=None,
|
|
)
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FakeVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
with pytest.raises(ValueError, match="output_layout"):
|
|
execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
@patch("subprocess.Popen")
|
|
def test_executor_m4b_forces_merge(self, mock_popen):
|
|
"""Executor forces merge for m4b format."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
mock_proc = mock_popen.return_value
|
|
mock_proc.returncode = 0
|
|
mock_proc.wait.return_value = 0
|
|
mock_proc.stdin = MagicMock()
|
|
mock_proc.communicate.return_value = (b"", b"")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(
|
|
mode="custom_folder",
|
|
output_folder=Path(tmpdir),
|
|
save_chapters_separately=True,
|
|
merge_chapters_at_end=False,
|
|
),
|
|
output_format="m4b",
|
|
)
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[
|
|
ChapterPlan(
|
|
index=1,
|
|
title="text",
|
|
original_title="text",
|
|
body_text="Hello",
|
|
segments=[
|
|
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
)
|
|
],
|
|
output_layout=OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
),
|
|
)
|
|
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FakeVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
assert result.audio_path is not None
|
|
assert result.audio_path.suffix == ".m4b"
|
|
|
|
def test_executor_separate_chapters(self):
|
|
"""Executor creates separate chapter files."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(
|
|
mode="custom_folder",
|
|
output_folder=Path(tmpdir),
|
|
save_chapters_separately=True,
|
|
merge_chapters_at_end=True,
|
|
),
|
|
)
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[
|
|
ChapterPlan(
|
|
index=1,
|
|
title="Chapter 1",
|
|
original_title="Chapter 1",
|
|
body_text="Text A",
|
|
segments=[
|
|
SegmentPlan(text="Text A", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
),
|
|
ChapterPlan(
|
|
index=2,
|
|
title="Chapter 2",
|
|
original_title="Chapter 2",
|
|
body_text="Text B",
|
|
segments=[
|
|
SegmentPlan(text="Text B", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
),
|
|
],
|
|
output_layout=OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
),
|
|
)
|
|
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FakeVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
assert len(result.chapter_paths) == 2
|
|
|
|
def test_executor_no_intro_outro(self):
|
|
"""Executor works without intro/outro."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[
|
|
ChapterPlan(
|
|
index=1,
|
|
title="text",
|
|
original_title="text",
|
|
body_text="Hello",
|
|
segments=[
|
|
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
)
|
|
],
|
|
intro=None,
|
|
outro=None,
|
|
output_layout=OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
),
|
|
)
|
|
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FakeVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
assert result is not None
|
|
log_messages = [msg for msg, _ in events.logs]
|
|
assert not any("Title intro" in msg for msg in log_messages)
|
|
assert not any("Closing outro" in msg for msg in log_messages)
|
|
|
|
def test_executor_voice_fallback_on_error(self):
|
|
"""Executor falls back to base voice on resolution error."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
class FailingVoiceResolver:
|
|
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
|
if voice_spec == "F1":
|
|
raise ValueError("Voice not found")
|
|
return ResolvedVoice(
|
|
provider="kokoro",
|
|
resolved_spec=voice_spec,
|
|
voice=voice_spec,
|
|
speed=1.0,
|
|
supertonic_steps=5,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
)
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[
|
|
ChapterPlan(
|
|
index=1,
|
|
title="text",
|
|
original_title="text",
|
|
body_text="Hello",
|
|
segments=[
|
|
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
)
|
|
],
|
|
output_layout=OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
),
|
|
)
|
|
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FailingVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
assert result is not None
|
|
|
|
def test_executor_silence_between_chapters(self):
|
|
"""Executor adds silence between chapters."""
|
|
from abogen.application.conversion_executor import execute_conversion
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
req = ConversionRequest(
|
|
direct_text="Hello",
|
|
voice="M1",
|
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
|
silence_between_chapters=1.0,
|
|
)
|
|
plan = ConversionPlan(
|
|
request=req,
|
|
metadata={},
|
|
chapters=[
|
|
ChapterPlan(
|
|
index=1,
|
|
title="Ch1",
|
|
original_title="Ch1",
|
|
body_text="Text A",
|
|
segments=[
|
|
SegmentPlan(text="Text A", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
),
|
|
ChapterPlan(
|
|
index=2,
|
|
title="Ch2",
|
|
original_title="Ch2",
|
|
body_text="Text B",
|
|
segments=[
|
|
SegmentPlan(text="Text B", voice_spec="M1", kind="body", source="chapter")
|
|
],
|
|
voice_spec="M1",
|
|
),
|
|
],
|
|
output_layout=OutputLayout(
|
|
parent_dir=Path(tmpdir),
|
|
audio_dir=Path(tmpdir),
|
|
),
|
|
)
|
|
|
|
events = FakeEvents()
|
|
pipeline = FakePipelineProvider()
|
|
resolver = FakeVoiceResolver()
|
|
tts_context = TTSContext()
|
|
|
|
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
|
|
|
assert result is not None
|
|
# Check that audio was written (silence + speech)
|
|
assert len(pipeline.backends) > 0
|