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:
JB
2025-10-07 17:57:53 -07:00
parent bacf1b2f9e
commit 41f56a8491
18 changed files with 2844 additions and 14 deletions
+107
View File
@@ -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