mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement speaker analysis and EPUB 3 export functionality
- Added speaker analysis module to infer speaker identities from text chunks. - Introduced SpeakerGuess and SpeakerAnalysis data classes for managing speaker data. - Developed functions for analyzing speaker occurrences and confidence levels. - Created EPUB 3 exporter to generate EPUB packages with synchronized narration and media overlays. - Implemented configurable chunking options for TTS synthesis and EPUB alignment. - Enhanced JavaScript for speaker preview functionality in the web interface. - Added comprehensive tests for chunking and EPUB exporting features. - Documented upgrade plan for transitioning to EPUB 3 with multi-speaker support.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from abogen.web.conversion_runner import _chunk_voice_spec, _group_chunks_by_chapter
|
||||
|
||||
|
||||
def test_group_chunks_by_chapter_orders_and_groups() -> None:
|
||||
chunks = [
|
||||
{"chapter_index": "0", "chunk_index": "5", "text": "tail"},
|
||||
{"chapter_index": 0, "chunk_index": 1, "text": "body"},
|
||||
{"chapter_index": 1, "chunk_index": 0, "text": "next"},
|
||||
]
|
||||
|
||||
grouped = _group_chunks_by_chapter(chunks)
|
||||
|
||||
assert [entry["text"] for entry in grouped[0]] == ["body", "tail"]
|
||||
assert grouped[1][0]["text"] == "next"
|
||||
|
||||
|
||||
def test_chunk_voice_spec_prefers_chunk_overrides() -> None:
|
||||
job = SimpleNamespace(voice="base_voice", speakers={})
|
||||
chunk = {"voice": "override_voice", "speaker_id": "narrator"}
|
||||
|
||||
assert _chunk_voice_spec(job, chunk, "fallback") == "override_voice"
|
||||
|
||||
|
||||
def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None:
|
||||
job = SimpleNamespace(voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}})
|
||||
chunk = {"speaker_id": "narrator"}
|
||||
|
||||
assert _chunk_voice_spec(job, chunk, "fallback") == "speaker_voice"
|
||||
|
||||
|
||||
def test_chunk_voice_spec_uses_fallback_when_no_overrides() -> None:
|
||||
job = SimpleNamespace(voice="base_voice", speakers={})
|
||||
chunk = {"speaker_id": "unknown"}
|
||||
|
||||
assert _chunk_voice_spec(job, chunk, "fallback") == "fallback"
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
|
||||
from abogen.epub3.exporter import build_epub3_package
|
||||
from abogen.text_extractor import ExtractedChapter, ExtractionResult
|
||||
|
||||
|
||||
def _make_sample_extraction() -> ExtractionResult:
|
||||
return ExtractionResult(
|
||||
chapters=[
|
||||
ExtractedChapter(title="Chapter 1", text="Hello world."),
|
||||
ExtractedChapter(title="Chapter 2", text="Another passage."),
|
||||
],
|
||||
metadata={"title": "Sample Book", "artist": "Test Author", "language": "en"},
|
||||
)
|
||||
|
||||
|
||||
def test_build_epub3_package_creates_expected_structure(tmp_path) -> None:
|
||||
extraction = _make_sample_extraction()
|
||||
chunks = [
|
||||
{
|
||||
"id": "chap0000_p0000",
|
||||
"chapter_index": 0,
|
||||
"chunk_index": 0,
|
||||
"text": "Hello world.",
|
||||
"speaker_id": "narrator",
|
||||
},
|
||||
{
|
||||
"id": "chap0001_p0000",
|
||||
"chapter_index": 1,
|
||||
"chunk_index": 0,
|
||||
"text": "Another passage.",
|
||||
"speaker_id": "narrator",
|
||||
},
|
||||
]
|
||||
chunk_markers = [
|
||||
{"id": "chap0000_p0000", "chapter_index": 0, "chunk_index": 0, "start": 0.0, "end": 1.2},
|
||||
{"id": "chap0001_p0000", "chapter_index": 1, "chunk_index": 0, "start": 1.2, "end": 2.4},
|
||||
]
|
||||
chapter_markers = [
|
||||
{"index": 1, "title": "Chapter 1", "start": 0.0, "end": 1.2},
|
||||
{"index": 2, "title": "Chapter 2", "start": 1.2, "end": 2.4},
|
||||
]
|
||||
metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"}
|
||||
|
||||
audio_path = tmp_path / "sample.mp3"
|
||||
audio_path.write_bytes(b"ID3 test audio")
|
||||
|
||||
output_path = tmp_path / "output.epub"
|
||||
result_path = build_epub3_package(
|
||||
output_path=output_path,
|
||||
book_id="job-123",
|
||||
extraction=extraction,
|
||||
metadata_tags=metadata_tags,
|
||||
chapter_markers=chapter_markers,
|
||||
chunk_markers=chunk_markers,
|
||||
chunks=chunks,
|
||||
audio_path=audio_path,
|
||||
speaker_mode="single",
|
||||
)
|
||||
|
||||
assert result_path == output_path
|
||||
assert output_path.exists()
|
||||
|
||||
with zipfile.ZipFile(output_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "mimetype" in names
|
||||
assert archive.read("mimetype") == b"application/epub+zip"
|
||||
assert "META-INF/container.xml" in names
|
||||
assert "OEBPS/content.opf" in names
|
||||
assert "OEBPS/nav.xhtml" in names
|
||||
assert "OEBPS/audio/sample.mp3" in names
|
||||
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
|
||||
assert "Hello world." in chapter_doc
|
||||
smil_doc = archive.read("OEBPS/smil/chapter_0001.smil").decode("utf-8")
|
||||
assert "clipBegin=\"00:00:00.000\"" in smil_doc
|
||||
opf_doc = archive.read("OEBPS/content.opf").decode("utf-8")
|
||||
assert "media-overlay" in opf_doc
|
||||
assert "media:duration" in opf_doc
|
||||
assert "abogen:speakerMode" in opf_doc
|
||||
|
||||
|
||||
def test_build_epub3_package_handles_missing_markers(tmp_path) -> None:
|
||||
extraction = _make_sample_extraction()
|
||||
metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"}
|
||||
audio_path = tmp_path / "audio.mp3"
|
||||
audio_path.write_bytes(b"ID3 audio")
|
||||
output_path = tmp_path / "output.epub"
|
||||
|
||||
result_path = build_epub3_package(
|
||||
output_path=output_path,
|
||||
book_id="job-456",
|
||||
extraction=extraction,
|
||||
metadata_tags=metadata_tags,
|
||||
chapter_markers=[],
|
||||
chunk_markers=[],
|
||||
chunks=[],
|
||||
audio_path=audio_path,
|
||||
speaker_mode="single",
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(result_path) as archive:
|
||||
nav_doc = archive.read("OEBPS/nav.xhtml").decode("utf-8")
|
||||
assert "Chapter 1" in nav_doc
|
||||
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
|
||||
assert "Hello world." in chapter_doc
|
||||
@@ -54,4 +54,8 @@ def test_service_processes_job(tmp_path):
|
||||
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.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
|
||||
Reference in New Issue
Block a user