refactor: metadata + markers unified in shared layer

- MarkerCollector: SRP extraction from executor (observation, not execution)
- Outro marker: executor now records outro as chapter marker
- Voice format: chapter voices [{provider, voice}], chunk voice {provider, voice}
- _finalize(): build_metadata_payload() + metadata.json + record_override_usage()
- ffmetadata: voices list → comma-separated string
- EPUB3: ChunkOverlay.voice = dict, _render_chunk_inline handles dict format
- 8 new tests: multi-speaker, ffmetadata format, EPUB3 format
- Updated existing tests for new voice format
This commit is contained in:
Artem Akymenko
2026-07-26 11:42:27 +03:00
parent 0b953d48e8
commit 6497e8c47a
7 changed files with 456 additions and 33 deletions
+130 -23
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import time import time
from contextlib import ExitStack from contextlib import ExitStack
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from abogen.application.conversion_models import ( from abogen.application.conversion_models import (
ConversionPlan, ConversionPlan,
@@ -40,6 +40,109 @@ from abogen.domain.output_paths import sanitize_filename_for_chapter
from abogen.infrastructure.subtitle_writer import make_subtitle_writer from abogen.infrastructure.subtitle_writer import make_subtitle_writer
# ─── MarkerCollector ───
class MarkerCollector:
"""Observes execution events and accumulates chapter/chunk markers.
Separates marker collection from synthesis logic.
"""
def __init__(self) -> None:
self._chapter_markers: List[Dict[str, Any]] = []
self._chunk_markers: List[Dict[str, Any]] = []
self._current_chapter_voices: Set[Tuple[str, str]] = set()
self._current_chapter_index: int = 0
self._current_chapter_title: str = ""
self._current_chapter_start: float = 0.0
def on_chapter_start(
self, index: int, title: str, start_time: float
) -> None:
"""Record chapter start."""
self._current_chapter_index = index
self._current_chapter_title = title
self._current_chapter_start = start_time
self._current_chapter_voices.clear()
def on_segment(
self,
provider: str,
voice: Any,
voice_spec: str,
speaker_id: str = "narrator",
) -> None:
"""Record a voice used in this chapter (for multi-speaker tracking)."""
self._current_chapter_voices.add((provider, voice_spec))
def on_chunk(
self,
chunk_id: str,
chapter_index: int,
chunk_index: int,
start: float,
end: float,
speaker_id: str,
provider: str,
voice_spec: str,
level: str,
characters: int,
) -> None:
"""Record a chunk marker."""
self._chunk_markers.append({
"id": chunk_id,
"chapter_index": chapter_index,
"chunk_index": chunk_index,
"start": start,
"end": end,
"speaker_id": speaker_id,
"voice": {"provider": provider, "voice": voice_spec},
"level": level,
"characters": characters,
})
def on_chapter_end(self, end_time: float) -> None:
"""Record chapter end and build chapter marker."""
voices = [
{"provider": p, "voice": v}
for p, v in sorted(self._current_chapter_voices)
]
self._chapter_markers.append({
"chapter_index": self._current_chapter_index,
"index": self._current_chapter_index + 1,
"title": self._current_chapter_title,
"start": self._current_chapter_start,
"end": end_time,
"voices": voices,
})
def on_outro(
self,
start_time: float,
end_time: float,
provider: str,
voice_spec: str,
) -> None:
"""Record outro chapter marker."""
self._chapter_markers.append({
"chapter_index": len(self._chapter_markers),
"index": len(self._chapter_markers) + 1,
"title": "Outro",
"start": start_time,
"end": end_time,
"voices": [{"provider": provider, "voice": voice_spec}],
})
@property
def chapter_markers(self) -> List[Dict[str, Any]]:
return self._chapter_markers
@property
def chunk_markers(self) -> List[Dict[str, Any]]:
return self._chunk_markers
def execute_conversion( def execute_conversion(
plan: ConversionPlan, plan: ConversionPlan,
events: ConversionEvents, events: ConversionEvents,
@@ -67,6 +170,7 @@ def execute_conversion(
""" """
request = plan.request request = plan.request
result = ConversionResult(metadata=plan.metadata) result = ConversionResult(metadata=plan.metadata)
collector = MarkerCollector()
# Determine cancellation checker # Determine cancellation checker
if check_cancelled is None: if check_cancelled is None:
@@ -192,6 +296,9 @@ def execute_conversion(
) )
chapter_backend = pipeline_provider.get(chapter_provider, request.language, request.use_gpu) chapter_backend = pipeline_provider.get(chapter_provider, request.language, request.use_gpu)
# Record chapter start for markers
collector.on_chapter_start(chapter_idx - 1, chapter.title, stats.current_time)
# Per-chapter sink # Per-chapter sink
chapter_sink: Optional[AudioSink] = None chapter_sink: Optional[AudioSink] = None
chapter_path = None chapter_path = None
@@ -285,8 +392,6 @@ def execute_conversion(
pending_heading_strip = True pending_heading_strip = True
# Process body segments # Process body segments
chapter_chunk_markers: List[Dict[str, Any]] = []
chapter_body_start = stats.current_time
for seg_idx, segment in enumerate(chapter.segments): for seg_idx, segment in enumerate(chapter.segments):
check_cancelled() check_cancelled()
@@ -317,6 +422,9 @@ def execute_conversion(
seg_speed = chapter_speed seg_speed = chapter_speed
seg_backend = chapter_backend seg_backend = chapter_backend
# Track voice for chapter marker
collector.on_segment(seg_provider, seg_voice, segment.voice_spec)
seg_start_time = stats.current_time seg_start_time = stats.current_time
local_segments, accumulated_tokens = synthesize_text( local_segments, accumulated_tokens = synthesize_text(
text=seg_text, text=seg_text,
@@ -353,17 +461,18 @@ def execute_conversion(
# Record chunk marker # Record chunk marker
if segment.source in ("chunk", "voice_marker"): if segment.source in ("chunk", "voice_marker"):
chapter_chunk_markers.append({ collector.on_chunk(
"id": segment.chunk_id, chunk_id=segment.chunk_id or "",
"chapter_index": chapter_idx - 1, chapter_index=chapter_idx - 1,
"chunk_index": segment.chunk_index or seg_idx, chunk_index=segment.chunk_index or seg_idx,
"start": seg_start_time, start=seg_start_time,
"end": stats.current_time, end=stats.current_time,
"speaker_id": segment.speaker_id, speaker_id=segment.speaker_id or "narrator",
"voice": segment.voice_spec, provider=seg_provider,
"level": segment.level or (request.chapter_chunk.chunk_level if request.chapter_chunk else "paragraph"), voice_spec=segment.voice_spec,
"characters": len(segment.text), level=segment.level or (request.chapter_chunk.chunk_level if request.chapter_chunk else "paragraph"),
}) characters=len(segment.text),
)
# Silence between chapters # Silence between chapters
if chapter_idx < len(plan.chapters) and request.silence_between_chapters > 0: if chapter_idx < len(plan.chapters) and request.silence_between_chapters > 0:
@@ -382,15 +491,8 @@ def execute_conversion(
if chapter_subtitle_writer: if chapter_subtitle_writer:
chapter_subtitle_writer.close() chapter_subtitle_writer.close()
# Add chapter marker # Record chapter end for markers
result.chapter_markers.append({ collector.on_chapter_end(stats.current_time)
"chapter_index": chapter_idx - 1,
"title": chapter.title,
"start": chapter_body_start,
"end": stats.current_time,
})
result.chunk_markers.extend(chapter_chunk_markers)
# Process outro # Process outro
if plan.outro and plan.outro.enabled and merge_chapters: if plan.outro and plan.outro.enabled and merge_chapters:
@@ -409,6 +511,7 @@ def execute_conversion(
stats=stats, stats=stats,
) )
outro_start = stats.current_time
synthesize_text( synthesize_text(
text=plan.outro.text, text=plan.outro.text,
params=synth, params=synth,
@@ -418,9 +521,13 @@ def execute_conversion(
chapter_sink=None, chapter_sink=None,
preview_callback=lambda text: events.log(f" {text[:80]}"), preview_callback=lambda text: events.log(f" {text[:80]}"),
) )
# Record outro marker
collector.on_outro(outro_start, stats.current_time, outro_provider, plan.outro.voice_spec)
events.log("Outro synthesized.") events.log("Outro synthesized.")
# Set result metadata # Set result metadata
result.chapter_markers = collector.chapter_markers
result.chunk_markers = collector.chunk_markers
result.total_chapters = len(plan.chapters) result.total_chapters = len(plan.chapters)
result.total_segments = sum(len(ch.segments) for ch in plan.chapters) result.total_segments = sum(len(ch.segments) for ch in plan.chapters)
result.total_characters = total_characters result.total_characters = total_characters
+33
View File
@@ -166,6 +166,39 @@ def _finalize(
else: else:
events.log("Skipped EPUB 3 generation: audio output unavailable.", level="warning") events.log("Skipped EPUB 3 generation: audio output unavailable.", level="warning")
# Build metadata payload and write metadata.json
if plan.output_layout and plan.output_layout.metadata_dir:
from abogen.domain.metadata_helpers import build_metadata_payload
metadata_payload = build_metadata_payload(
metadata=result.metadata,
chapter_markers=result.chapter_markers,
chunk_markers=result.chunk_markers,
chunk_level=request.chapter_chunk.chunk_level if request.chapter_chunk else None,
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else None,
speakers=request.chapter_chunk.speakers if request.chapter_chunk else None,
generate_epub3=bool(request.epub3_export),
)
metadata_dir = plan.output_layout.metadata_dir
metadata_dir.mkdir(parents=True, exist_ok=True)
metadata_file = metadata_dir / "metadata.json"
import json
metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8")
result.artifacts["metadata"] = metadata_file
events.log(f"Metadata written to {metadata_file}")
# Record override usage
if result.usage_counter:
try:
from abogen.normalization_settings import record_override_usage
record_override_usage(result.usage_counter)
except Exception as exc:
events.log(f"Failed to record override usage: {exc}", level="debug")
def _prepare_tts_context( def _prepare_tts_context(
request: ConversionRequest, request: ConversionRequest,
+8 -3
View File
@@ -22,7 +22,7 @@ class ChunkOverlay:
start: Optional[float] start: Optional[float]
end: Optional[float] end: Optional[float]
speaker_id: str speaker_id: str
voice: Optional[str] voice: Optional[Dict[str, str]]
level: Optional[str] = None level: Optional[str] = None
group_id: Optional[str] = None group_id: Optional[str] = None
@@ -273,7 +273,7 @@ class EPUB3PackageBuilder:
start=_safe_float(marker.get("start")), start=_safe_float(marker.get("start")),
end=_safe_float(marker.get("end")), end=_safe_float(marker.get("end")),
speaker_id=speaker_id, speaker_id=speaker_id,
voice=str(voice) if voice else None, voice=voice if isinstance(voice, dict) else None,
level=str(level) if level else None, level=str(level) if level else None,
group_id=normalized_group_id, group_id=normalized_group_id,
) )
@@ -696,7 +696,12 @@ def _group_chunks_for_render(chunks: Sequence[ChunkOverlay]) -> List[Tuple[Optio
def _render_chunk_inline(chunk: ChunkOverlay) -> str: def _render_chunk_inline(chunk: ChunkOverlay) -> str:
escaped_id = html.escape(chunk.id) escaped_id = html.escape(chunk.id)
speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else "" speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else ""
voice_attr = f" data-voice=\"{html.escape(chunk.voice)}\"" if chunk.voice else "" voice_str = None
if chunk.voice and isinstance(chunk.voice, dict):
name = chunk.voice.get("voice", "")
provider = chunk.voice.get("provider", "")
voice_str = f"{name}@{provider}" if name and provider else name or None
voice_attr = f" data-voice=\"{html.escape(voice_str)}\"" if voice_str else ""
level_attr = f" data-level=\"{html.escape(chunk.level)}\"" if chunk.level else "" level_attr = f" data-level=\"{html.escape(chunk.level)}\"" if chunk.level else ""
raw_text = chunk.text or "" raw_text = chunk.text or ""
escaped_text = html.escape(raw_text) escaped_text = html.escape(raw_text)
+8 -3
View File
@@ -84,9 +84,14 @@ class ExportService:
title = chapter.get("title") title = chapter.get("title")
if title: if title:
lines.append(f"title={self._escape_ffmetadata_value(title)}") lines.append(f"title={self._escape_ffmetadata_value(title)}")
voice = chapter.get("voice") voices = chapter.get("voices")
if voice: if voices and isinstance(voices, list):
lines.append(f"voice={self._escape_ffmetadata_value(voice)}") voice_str = ", ".join(
f"{v.get('voice', '')}@{v.get('provider', '')}"
for v in voices if v.get("voice")
)
if voice_str:
lines.append(f"voice={self._escape_ffmetadata_value(voice_str)}")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
+273
View File
@@ -622,3 +622,276 @@ class TestHeadingDedup:
# Both segments should be synthesized (heading + 2 body segments) # Both segments should be synthesized (heading + 2 body segments)
assert result.total_segments >= 2 assert result.total_segments >= 2
class TestMarkerCollector:
"""Tests for MarkerCollector."""
def test_chapter_marker_has_voices_list(self):
"""Chapter markers should have 'voices' as list of dicts."""
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(direct_text="Hello", voice="M1")
plan = ConversionPlan(
request=req,
metadata={},
chapters=[
ChapterPlan(
index=1,
title="Chapter 1",
original_title="Chapter 1",
body_text="Body text",
segments=[
SegmentPlan(
text="Body text",
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_markers) == 1
marker = result.chapter_markers[0]
assert "voices" in marker
assert isinstance(marker["voices"], list)
assert len(marker["voices"]) == 1
assert marker["voices"][0]["provider"] == "kokoro"
assert marker["voices"][0]["voice"] == "M1"
def test_outro_marker_recorded(self):
"""Outro should be recorded as a chapter marker."""
from abogen.application.conversion_models import IntroOutroSpec
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(direct_text="Hello", voice="M1")
plan = ConversionPlan(
request=req,
metadata={},
chapters=[
ChapterPlan(
index=1,
title="Chapter 1",
original_title="Chapter 1",
body_text="Body text",
segments=[
SegmentPlan(
text="Body text",
voice_spec="M1",
kind="body",
source="chapter",
),
],
voice_spec="M1",
)
],
outro=IntroOutroSpec(
enabled=True,
text="Thanks for listening",
voice_spec="M1",
kind="outro",
),
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)
# Should have chapter marker + outro marker
assert len(result.chapter_markers) == 2
outro_marker = result.chapter_markers[1]
assert outro_marker["title"] == "Outro"
assert "start" in outro_marker
assert "end" in outro_marker
assert outro_marker["end"] > outro_marker["start"]
def test_chunk_marker_voice_is_dict(self):
"""Chunk markers should have 'voice' as dict with provider."""
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(direct_text="Hello", voice="M1")
plan = ConversionPlan(
request=req,
metadata={},
chapters=[
ChapterPlan(
index=1,
title="Chapter 1",
original_title="Chapter 1",
body_text="Body text",
segments=[
SegmentPlan(
text="Body text",
voice_spec="M1",
kind="body",
source="chunk",
chunk_id="chunk_001",
chunk_index=0,
speaker_id="narrator",
level="paragraph",
),
],
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.chunk_markers) == 1
chunk = result.chunk_markers[0]
assert isinstance(chunk["voice"], dict)
assert chunk["voice"]["provider"] == "kokoro"
assert chunk["voice"]["voice"] == "M1"
def test_multi_speaker_collects_unique_voices(self):
"""Multi-speaker chapters should collect all unique voices."""
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(direct_text="Hello", voice="M1")
plan = ConversionPlan(
request=req,
metadata={},
chapters=[
ChapterPlan(
index=1,
title="Chapter 1",
original_title="Chapter 1",
body_text="Body text",
segments=[
SegmentPlan(
text="Narrator speaks",
voice_spec="M1",
kind="body",
source="chapter",
),
SegmentPlan(
text="Character speaks",
voice_spec="F1",
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)
marker = result.chapter_markers[0]
assert len(marker["voices"]) == 2
voice_specs = {v["voice"] for v in marker["voices"]}
assert "M1" in voice_specs
assert "F1" in voice_specs
class TestFfmetadataVoiceFormat:
"""Tests for ffmetadata rendering with new voice format."""
def test_render_ffmetadata_with_voices_list(self):
"""ffmetadata should render voices list as comma-separated string."""
from abogen.infrastructure.exporters import ExportService
svc = ExportService()
chapters = [
{
"title": "Chapter 1",
"start": 0.0,
"end": 60.0,
"voices": [
{"provider": "kokoro", "voice": "M1"},
{"provider": "kokoro", "voice": "F1"},
],
}
]
content = svc.render_ffmetadata({}, chapters)
assert "voice=M1@kokoro, F1@kokoro" in content
def test_render_ffmetadata_with_empty_voices(self):
"""ffmetadata should handle empty voices list."""
from abogen.infrastructure.exporters import ExportService
svc = ExportService()
chapters = [
{
"title": "Chapter 1",
"start": 0.0,
"end": 60.0,
"voices": [],
}
]
content = svc.render_ffmetadata({}, chapters)
assert "voice=" not in content
class TestEpub3VoiceFormat:
"""Tests for EPUB3 voice handling with new format."""
def test_chunk_overlay_voice_is_dict(self):
"""ChunkOverlay should accept voice as dict."""
from abogen.epub3.exporter import ChunkOverlay
overlay = ChunkOverlay(
id="test",
text="hello",
original_text=None,
start=0.0,
end=1.0,
speaker_id="narrator",
voice={"provider": "kokoro", "voice": "M1"},
)
assert isinstance(overlay.voice, dict)
assert overlay.voice["provider"] == "kokoro"
def test_render_chunk_inline_with_voice_dict(self):
"""_render_chunk_inline should render voice dict as data-voice attribute."""
from abogen.epub3.exporter import ChunkOverlay, _render_chunk_inline
overlay = ChunkOverlay(
id="chunk_001",
text="Hello world",
original_text=None,
start=0.0,
end=1.0,
speaker_id="narrator",
voice={"provider": "kokoro", "voice": "M1"},
)
html = _render_chunk_inline(overlay)
assert 'data-voice="M1@kokoro"' in html
+2 -2
View File
@@ -69,9 +69,9 @@ class TestRenderFfmetadata:
assert "title=Ch 1" in result assert "title=Ch 1" in result
def test_renders_voice_in_chapter(self): def test_renders_voice_in_chapter(self):
chapters = [{"start": 0.0, "end": 5.0, "voice": "af_heart"}] chapters = [{"start": 0.0, "end": 5.0, "voices": [{"provider": "kokoro", "voice": "af_heart"}]}]
result = self.svc.render_ffmetadata({}, chapters) result = self.svc.render_ffmetadata({}, chapters)
assert "voice=af_heart" in result assert "voice=af_heart@kokoro" in result
def test_skips_chapters_without_times(self): def test_skips_chapters_without_times(self):
chapters = [{"title": "No times"}] chapters = [{"title": "No times"}]
+2 -2
View File
@@ -14,7 +14,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
"publisher": "ACME=Corp", "publisher": "ACME=Corp",
} }
chapters = [ chapters = [
{"start": 0.0, "end": 5.0, "title": "Intro", "voice": "voice_a"}, {"start": 0.0, "end": 5.0, "title": "Intro", "voices": [{"provider": "kokoro", "voice": "voice_a"}]},
{"start": 5.0, "end": 12.345, "title": "Chapter 2"}, {"start": 5.0, "end": 12.345, "title": "Chapter 2"},
] ]
@@ -28,7 +28,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
assert rendered.count("[CHAPTER]") == 2 assert rendered.count("[CHAPTER]") == 2
assert "START=0" in rendered assert "START=0" in rendered
assert "END=5000" in rendered assert "END=5000" in rendered
assert "voice=voice_a" in rendered assert "voice=voice_a@kokoro" in rendered
audio_path = tmp_path / "book.m4b" audio_path = tmp_path / "book.m4b"
metadata_path = svc.write_ffmetadata_file(audio_path, metadata, chapters) metadata_path = svc.write_ffmetadata_file(audio_path, metadata, chapters)