- Create domain/text_utils.py with canonical calculate_text_length
(strips chapter markers, voice markers, metadata tags)
- Remove calculate_text_length from utils.py and subtitle_utils.py
- Update all imports to use domain.text_utils directly
- Create application/chapter_selection.py with build_chapter_payload()
(orchestrates preselection + character count + safety net)
- Replace manual chapter logic in form.py with build_chapter_payload()
- 22 new tests for text_utils and chapter_selection (1550 total)
Voice cache is now initialized lazily by domain's initialize_voice_cache()
in PipelinePool.get() — downloads only needed voices per request instead
of all ~100 kokoro voices at service startup. Reduces redundant downloads
and unifies initialization path for all UIs.
- Add expand_metadata_aliases() for concept fan-out (series→5, author→2,
description→2, tags→3 keys)
- Replace _normalize_metadata_tags in webui/service.py with
normalize_metadata_map from domain
- Replace _normalize_metadata in epub3/exporter.py with
normalize_metadata_map from domain
- Replace manual fan-out in form.py with expand_metadata_aliases()
- Rewrite metadata_overrides.py to use expand_metadata_aliases()
- Remove unused normalize_metadata_casefold import from exporters.py
- 19 new tests for expand_metadata_aliases (1528 total)
Move build_narrator_roster, build_speaker_roster, match_configured_speaker,
apply_speaker_config_to_roster, prepare_speaker_metadata from
webui/routes/utils/voice.py to domain/speaker_metadata.py.
- prepare_speaker_metadata now accepts optional inject_recommended callback
for UI-specific voice enrichment
- 47 new tests in test_domain_speaker_metadata.py
- All 1509 tests pass
- Add formula_from_profile, resolve_profile_voice, resolve_voice_setting, resolve_voice_choice to domain/voice_resolution.py
- Add build_voice_catalog, filter_voice_catalog to domain/voice_catalog.py
- Update webui/routes/utils/voice.py to import from domain
- Update webui/routes/utils/form.py and voices.py to import from domain directly
- Update synthesize.py to use domain resolve_voice
- Add 29 tests for voice resolution functions
- Move stored_integration_config(), build_audiobookshelf_config() to domain/settings_core.py
- Remove legacy fallback from stored_integration_config() (only config[integrations])
- Add load_audiobookshelf_config() as combined entry point
- PostConversionHooks reads config directly via stored_integration_config()
- WebUI imports from domain/settings_core instead of webui/routes/utils/settings
- Remove duplicate _build_abs_config() from PostConversionHooks
- Remove dead audiobookshelf code from infrastructure/exporters.py
- Remove duplicate audiobookshelf functions from webui/service.py
- Fix OutputFormat enum serialization in output_layout_service.py and conversion_executor.py
(f'{enum}' gave 'OutputFormat.WAV' instead of '.wav')
- Mock spaCy in test_returns_at_least_one_segment instead of loading real model
- Add 28 tests for PostConversionHooks and build_audiobookshelf_config
- synthesize.py: import to_float32 from domain.audio_helpers instead of duplicate
- Revert all log levels back to INFO (user controls verbosity via --log-level)
- form.py: convert language string to Language enum at all entry points
- conversion_request.py: add _coerce_enums() for defense-in-depth
- synthesize.py: fix NameError (lang -> language) in preview pipeline
- voice.py: render LANGUAGE_DESCRIPTIONS keys as .value strings for Jinja
- dashboard.js: open file picker on dropzone click
- api.py: add logging to preview endpoint for debugging
- Deleted _prepare_tts_context() (104 lines of duplicated logic)
- Replaced with direct build_tts_context() call in run_conversion()
- Fixed _finalize() to use raw fields (generate_epub3, epub3_book_id)
- Removed _MockJob antipattern
- Updated tests to use raw normalization_overrides field
- ConversionRequest uses raw fields instead of PronunciationConfig/Epub3ExportConfig
- Added speakers field to ConversionRequest
- Rewrote collect_required_voice_ids(), initialize_voice_cache(),
job_voice_fallback(), chapter_voice_spec(), chunk_voice_spec()
to accept ConversionRequest instead of job
- PipelinePool.get() now takes request= instead of job=
- Updated all tests to use ConversionRequest interface
- 1493 tests passing
- conversion_pipeline.py: new spacy_pre_tts_segmentation() function
- Handles: condition checks, language exclusion, split_pattern override
- Executor: integrated spaCy pre-TTS before each synthesize_text() call
- 6 new tests for condition checks and fallback behavior
- English excluded from pre-TTS (spaCy only for post-TTS subtitles)
- Fallback to regex when spaCy unavailable
- 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
- Import run_tts_segment_loop (used in pyqt/conversion.py)
- Use build_tts_context() instead of manual _MergeJob + TTSContext
- Drop unused AudioSink import
- Remove 77 unused imports across domain/application/runner files via ruff
- Add # noqa: F401 to re-exports used by tests and debug_tts_runner
- Fix build_tts_context: usage_counter uses 'is not None' instead of truthiness
- Fix test assertions: compiled rules use 'replacement' key not 'pronunciation'
- Add re-exports: _compile_pronunciation_rules, _merge_pronunciation_overrides
- Language enum: en-US, en-GB, es, fr, hi, it, ja, pt-BR, zh
- Engine-specific mappings (kokoro → single-letter) live in pipeline_factory and synthesize
- spacy_utils uses Language enum keys for model mapping
- split_pattern uses Language enum properties (is_cjk)
- Updated all tests to use ISO codes
- SubtitleFormat/SubtitleMode now only in domain/enums.py
- Added VTT to SubtitleFormat
- Renamed SENTENCE_HIGHLIGHTING → SENTENCE_HIGHLIGHT for consistency
- Infrastructure subtitle_writer imports from domain
- SubtitleMode, OutputFormat, SaveMode, SubtitleFormat, InputFormat
- Properties: dot_ext, is_lossless, is_book, is_subtitle
- from_str/from_path class methods with normalization
- Updated domain and application layers to use Enums
- 17 new tests for enum validation and properties
- Frozen dataclass in domain/conversion_engine.py with common params
- synthesize_text now takes params=SynthParams + unique kwargs
- Executor, PyQt legacy, WebUI legacy, and tests updated
- Adding new common params now only requires changing the dataclass
Two pre-existing bugs found during test coverage analysis:
1. sanitize_output_stem() only accepted 1 arg but resolve_project_layout
passed 2 args (name, index) via sanitize_fn parameter.
Fix: added optional index parameter to sanitize_output_stem.
2. audio_sink.py imported get_internal_cache_path from
abogen.infrastructure.cache which doesn't exist.
Fix: import from abogen.utils where the function lives.
Main orchestrator for the conversion flow. Both UIs call run_conversion().
Functions:
- run_conversion(request, events, pipeline_provider, voice_resolver) -> ConversionResult
- _prepare_tts_context(request, events) -> TTSContext
The service ties together planner, executor, and finalizers.
Converts WebUI Job to ConversionRequest for the application layer.
Functions:
- build_conversion_request_from_job(job) -> ConversionRequest
- WebJobEvents: wraps Job for logging, progress, cancellation
- WebPipelineProvider: wraps PipelinePool for TTS backends
- WebVoiceResolver: wraps voice resolution function
The adapter is the bridge between WebUI layer and application/domain.
Application layer never accesses Job directly.
7 tests for the unified conversion executor:
- simple text conversion
- multi-chapter with separate chapter output
- voice markers
- intro/outro
- cancellation behavior
- progress reporting
- metadata preservation
Uses FakeBackend, FakeAudioSink, FakeSubtitleWriter, FakeEvents,
FakePipelineProvider, FakeVoiceResolver to test without real TTS.
Pure function that takes ConversionRequest -> ConversionPlan.
Handles chapter parsing, voice markers, chunks, intro/outro, output layout.
Replaces duplicated planning logic in both PyQt and WebUI runners.
PyQt output path resolution now uses resolve_unique_path() from domain
instead of a hand-rolled counter loop. Output path logic is now fully
shared via domain functions.
Tests: 1253 passed
PyQt now calls process_subtitle_tokens() from domain instead of a thin
wrapper that just forwarded self.subtitle_mode/lang_code/use_spacy.
Tests: 1253 passed
Combines TTSContext.normalize() + run_tts_segment_loop() into a single
domain function. Both UIs call synthesize_text() instead of inlining
normalize → TTS loop. UI-specific concerns (provider resolution,
progress display, cancellation) stay in the UI layer.
Tests: 1253 passed
Bundles pronunciation_rules, heteronym_rules, normalization_overrides,
usage_counter, and split_pattern into a single TTSContext dataclass.
Both UIs create it once and use tts_context.normalize() instead of
threading 5 separate parameters through prepare_text_for_tts calls.
Tests: 1253 passed
- domain/intro_outro.py: resolve_intro(), resolve_outro() return IntroOutroSpec
- Both UIs call domain for text building + voice spec resolution
- PyQt uses resolve_intro/resolve_outro instead of direct calls
- infrastructure/subtitle_writer.py: resolve_subtitle_format(), make_subtitle_writer()
- Deleted duplicate _create_subtitle_writer() from WebUI
- Deleted duplicate _subtitle_alignment_from_format() from PyQt
- domain/conversion_engine.py: run_tts_segment_loop() for TTS iteration
- VoiceCache class in domain/voice_loader.py used by both UIs
- Tests: 1253 passed
- domain/voice_loader.py: VoiceCache class now used by both UIs;
resolve_voice() and load_voice_cached() accept VoiceCache or plain dict;
added hasattr(pipeline, 'load_single_voice') safety check from WebUI
- conversion_runner.py: replaced local _resolve_voice() with domain's
resolve_voice(); voice_cache changed from Dict to VoiceCache instance;
all cache access uses VoiceCache.get()/set() API
- pyqt/conversion.py: self.voice_cache changed from Dict to VoiceCache
- debug_tts_runner.py: imports resolve_voice from domain instead of
removed _resolve_voice from conversion_runner
- infrastructure/subtitle_writer.py: add resolve_subtitle_format() that
maps format strings (e.g. 'ass_centered_narrow') to (extension, alignment),
and make_subtitle_writer() convenience that resolves + creates writer or None
- conversion_runner.py: replace _create_subtitle_writer() with make_subtitle_writer()
- pyqt/conversion.py: replace _subtitle_alignment_from_format() and 3 manual
create_subtitle_writer() call sites with resolve_subtitle_format()/make_subtitle_writer()
- load_settings() now in domain/settings_core.py (shared by all UIs)
- settings.py delegates to domain instead of reimplementing
- settings.py: 456 → 430 lines
gui.py now reads defaults from all_settings_defaults() instead of
hardcoding values like 50, True, 'wav', etc. One source of truth
for all settings across Web UI and Desktop GUI.
- voice_formulas.py: add pairs_to_formula() as canonical implementation
- webui/routes/utils/voice.py: formula_from_profile() and pairs_to_formula()
now delegate to voice_formulas.pairs_to_formula()
- pyqt/gui.py: get_voice_formula() now uses voice_formulas.pairs_to_formula()
instead of inline string formatting
- Eliminates 3 duplicate implementations of voice*weight formula building
- +9 tests
- 1178 tests pass
- Replace get_pipeline() closure with PipelinePool from domain/pipeline_factory
- Replace resolve_voice_target() closure with domain function from voice_utils
- Remove dead _load_pipeline() function and unused is_plugin_registered import
- Add 33 tests for resolve_voice_target and PipelinePool
- Add 10 regression tests verifying domain extraction preserves behavior
- 1131 tests pass (+61 new)
Replace 2 inline 'if * in voice: get_new_voice(...)' patterns with
resolve_voice() from domain/voice_loader.py. Removes unused import
of get_new_voice.
PyQt had 7 inline float32 conversion patterns:
hasattr(x, 'numpy') ? x.numpy().astype('float32') : x.astype('float32')
spread across TTS loop, subtitle processing, and streaming.
All replaced with domain.audio_helpers.to_float32() which handles:
- None → zeros
- PyTorch tensors → .detach().cpu().numpy()
- Plain numpy → asarray(dtype=float32)
- reshape(-1) for consistent 1D output
The old inline code missed .detach() and .cpu() on GPU tensors,
causing potential crashes. Now both UIs use the same robust conversion.
1053 tests pass.
- Move pronunciation imports from inside run() to top-level imports
- Extract _FakeToken to module level (was redefined every loop iteration)
- use_spacy_segmentation now mirrors PyQt logic: pass the flag,
let process_subtitle_tokens filter by language internally
Before: WebUI wrote one subtitle entry per TTS segment (no sentence
grouping, no comma splitting, no karaoke highlighting). The subtitle
modes 'Sentence', 'Sentence + Comma', and 'Sentence + Highlighting'
produced broken output.
After: emit_text() accumulates tokens_with_timestamps from each
segment's .tokens attribute, then flushes them through
domain.subtitle_generation.process_subtitle_tokens() at the end.
This gives the WebUI the same subtitle quality as the PyQt desktop GUI:
- Sentence mode: groups tokens into sentences
- Sentence + Comma: splits on commas within sentences
- Sentence + Highlighting: karaoke timing per word
- Word-count mode: groups by N words
Also removed the duplicate _to_float32 function from synthesize.py
(now imports from domain.audio_helpers).
1053 tests pass.
PyQt desktop GUI now calls the shared normalization pipeline before
TTS synthesis, matching the Web UI's behavior:
1. Heteronym sentence rules (context-dependent pronunciation)
2. Pronunciation rules (token-level replacements)
3. Pipeline normalization (apostrophe handling, LLM)
Before: PyQt passed raw text to the backend — no normalization at all,
resulting in inferior audio quality compared to the Web UI.
The normalization rules are compiled once at the start of run() from
pronunciation_overrides and heteronym_overrides (currently None since
the PyQt GUI doesn't expose these settings yet — basic apostrophe
normalization still applies).
1053 tests pass.
Before:
- PyQt: inline ETR using chars-based formula
- WebUI: Job.estimated_time_remaining using progress-based formula
(different formulas → different ETR estimates)
After:
- Both UIs call domain.progress.calc_etr_str(elapsed, done, total)
- Same formula, same ETR, single source of truth
- WebUI now stores etr_str on Job and displays it directly
- Job.estimated_time_remaining property kept for backward compat
domain/progress.py: ProgressTracker class + calc_etr_str function
1053 tests pass.