Commit Graph
100 Commits
Author SHA1 Message Date
Artem Akymenko 98ab2d925e refactor: simplify _build_request, restore epub3_export as config object
Simplify _build_request() in WebUI
- Remove profiles loading (load_profiles, normalize_profile_entry)
- Remove PronunciationConfig/Epub3ExportConfig building
- Pass raw fields directly to ConversionRequest
- Clean up unused imports

Bugfix: epub3_export as Optional[Epub3ExportConfig] (None = disabled)
- Reverted generate_epub3: bool + epub3_book_id: str back to object pattern
- Consistent with word_substitution, subtitle_input, chapter_chunk
- Updated conversion_service.py _finalize() to use request.epub3_export
2026-07-27 12:41:29 +03:00
Artem Akymenko 625b6610e2 refactor: remove _prepare_tts_context, use build_tts_context directly
- 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
2026-07-27 12:19:59 +03:00
Artem Akymenko 0dc491e420 refactor: unify Language enum across all layers
- EngineConfig.language: Language (was lang_code: str = 'a')
- Engine owns _KOKORO_LANG_MAP, engine_language(), supported_languages()
- Engine provides language_for_voice_id() for voice catalog
- Plugins/kokoro/__init__.py calls engine_language() internally
- create_pipeline(plugin_id, language=Language) — no kokoro codes
- pipeline_factory.py clean of kokoro-specific code
- Domain functions raise TypeError if non-enum passed
- WebUI api.py: _parse_language() helper at API boundary
- Voice catalog returns ISO codes (lang.value)
- Constants: LANGUAGE_DESCRIPTIONS keyed by Language enum
- All tests updated for Language enum
- 1414 tests pass
2026-07-27 07:36:36 +00:00
Artem Akymenko 713abdfd73 refactor: voice resolution on ConversionRequest, PipelinePool without job param
- 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
2026-07-26 14:11:55 +03:00
Artem Akymenko 73f42e9563 refactor: voice fallback logging + base voice validation 2026-07-26 11:42:56 +03:00
Artem Akymenko 2c61f55f81 refactor: spaCy pre-TTS segmentation moved to shared layer
- 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
2026-07-26 11:42:43 +03:00
Artem Akymenko 6497e8c47a 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
2026-07-26 11:42:27 +03:00
Artem Akymenko 0b953d48e8 refactor: heading transforms + dedup moved to shared layer
- conversion_planner.py: caps normalization in _build_chapters()
- conversion_executor.py: heading dedup + state machine via headings_equivalent()
- Fixed seg_start_time → chapter_body_start bug in executor
- Removed getattr fallback defaults in both adapters
- Added 4 tests for caps normalization and heading dedup
- Updated ARCHITECTURE_REFACTOR_PLAN.md with deferred WebUI cleanup
2026-07-26 11:41:59 +03:00
Artem Akymenko f516cf1985 merge: resolve conflicts with origin/main
- Import run_tts_segment_loop (used in pyqt/conversion.py)
- Use build_tts_context() instead of manual _MergeJob + TTSContext
- Drop unused AudioSink import
2026-07-24 18:34:49 +00:00
Artem Akymenko 3857c27aae refactor: clean unused imports (ruff F401/F811), fix build_tts_context defaults
- 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
2026-07-24 21:26:36 +03:00
Artem Akymenko 7ed2addb11 refactor: add build_metadata_payload() to domain, unify metadata assembly in both UIs 2026-07-24 21:26:21 +03:00
Artem Akymenko cfc7de7abf refactor: unify PUNCTUATION constants in domain/split_pattern.py 2026-07-24 19:17:39 +03:00
Artem Akymenko 654c395943 refactor: move sanitize_name_for_os to domain/output_paths.py 2026-07-24 19:17:39 +03:00
Artem Akymenko d1a84cfb8b refactor: move voice marker functions to domain/voice_markers.py 2026-07-24 19:17:38 +03:00
Artem Akymenko 332934c0cf test: tests for apply_overrides, LLM mode, usage_counter, chunk_groups 2026-07-24 19:17:38 +03:00
Artem Akymenko c79838a5a1 fix: chunk_groups_by_chapter in planner, chunks assigned to correct chapter 2026-07-24 19:17:38 +03:00
Artem Akymenko d51a9118e4 fix: apply_overrides, LLM mode check, usage_counter in service 2026-07-24 19:17:38 +03:00
Artem Akymenko 3311bef2f7 feat: EPUB3 finalizer in service, extraction in ConversionPlan 2026-07-24 19:17:37 +03:00
Artem Akymenko 4123cadd87 feat: m4b finalizer in service (embed_m4b_metadata) 2026-07-24 19:17:37 +03:00
Artem Akymenko 2f83d10a1e feat: per-chapter subtitle writer in executor 2026-07-24 19:17:37 +03:00
Artem Akymenko a1241ee9ca refactor: word substitution in planner, add planner tests 2026-07-24 19:17:37 +03:00
Artem Akymenko 0ee5bb0496 refactor: config objects for feature toggles in ConversionRequest 2026-07-24 19:17:36 +03:00
Artem Akymenko 7d28b7eb52 test: mock subprocess.Popen in test_executor_m4b_forces_merge to eliminate ffmpeg dependency 2026-07-24 19:17:36 +03:00
Artem Akymenko fcec4e9fe5 fix: test_stretch_reduces_duration — remove stale self param, fix mock data size, fix atempo assertion 2026-07-22 15:32:18 +03:00
Artem Akymenko 72d5e3d1db fix: add keys() method to VoiceCache for resolve_intro compatibility 2026-07-22 15:31:43 +03:00
Artem Akymenko d3682e7672 refactor: dynamic ConversionRequest validation, remove 'or default' from adapters
- __post_init__: _apply_none_defaults() iterates dataclasses.fields() dynamically
- _NUMERIC_CONSTRAINTS and _ENUM_CONSTRAINTS dicts replace per-field if chains
- Both adapters pass values as-is (no 'or default' fallbacks)
- 18 validation tests + updated adapter tests for Enum assertions
2026-07-22 11:33:44 +00:00
Artem Akymenko 0805e9fdae refactor: Language Enum with ISO codes
- 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
2026-07-22 10:54:39 +00:00
Artem Akymenko 4aef73ff85 refactor: remove infrastructure enum duplicates
- 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
2026-07-22 09:16:40 +00:00
Artem Akymenko f6a8008f51 refactor: typed Enums for format/mode fields
- 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
2026-07-22 09:01:50 +00:00
Artem Akymenko dc5257252f refactor: run_tts_segment_loop also accepts SynthParams
- Reduces from 14 params to 5 unique params + SynthParams
- synthesize_text now passes params through cleanly
- PyQt intro/outro direct calls updated
2026-07-22 08:28:19 +00:00
Artem Akymenko c4cebb8822 refactor: SynthParams dataclass for synthesize_text
- 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
2026-07-22 08:21:45 +00:00
Artem Akymenko 93f5a46485 refactor: deduplicate synth params in executor
- Compute use_spacy and effective_subtitle_mode once instead of 6x each
- Reduces repeated ternary expressions across synthesize_text calls
2026-07-22 11:06:14 +03:00
Artem Akymenko 5f169a4921 refactor: replace executor _slugify with domain sanitize_filename_for_chapter
- Use existing domain function instead of duplicated local implementation
- Domain version includes OS-specific sanitization
2026-07-22 11:06:14 +03:00
Artem Akymenko df5705779e refactor: extract ConversionCancelled to conversion_ports
- Single definition in application layer
- Both adapters import from ports instead of defining locally
2026-07-22 11:06:14 +03:00
Artem Akymenko d0fe221176 refactor: ConversionPlan.request forward ref
- request type: Any → ConversionRequest via TYPE_CHECKING
2026-07-22 07:31:46 +00:00
Artem Akymenko 17700426fd clean: dead code removal + unused imports
- executor: remove dead subtitle_writer stub (lines 121-125)
- executor: replace getattr with direct field access on ResolvedVoice
- service: remove duplicate split_pattern import
- service: remove unused import time
- planner: remove unused import os
- adapters: remove unused import threading, time
2026-07-22 07:21:23 +00:00
Artem Akymenko 16b3f7d8a8 fix: clean direct_text in planner + remove redundant if/else
- _extract_source_text now applies clean_text() to direct_text (was skipped)
- _parse_chapters simplified: identical branches collapsed to single call
2026-07-22 07:05:50 +00:00
Artem Akymenko 1a3741ec50 fix: max_subtitle_words default 5 → 50
All UI layers use 50 (settings, Job, Thread, adapters). The value 5 was
incorrect and only masked by adapter fallbacks.
2026-07-21 14:58:58 +03:00
Artem Akymenko 7f317ca784 test: adapter field mapping + import layering tests
- 28 adapter tests (WebUI + PyQt): field mapping, events, provider, resolver
- 15 import/layering tests: no PyQt/WebUI in app layer, all models importable
2026-07-21 11:47:46 +00:00
Artem Akymenko d0e42ee691 fix: executor subtitle_writer leak + adapter Path/None-default fixes
- executor: manage subtitle_writer via ExitStack (stack.callback)
- executor: remove manual subtitle_writer.close()
- WebUI adapter: wrap source_path, output_folder, cover_image_path in Path()
- WebUI adapter: remove unused threading/time imports
- PyQt adapter: fix getattr(attr, None) or default for 4 fields
2026-07-21 11:47:36 +00:00
Artem Akymenko b1392084e1 chore: add .coverage to .gitignore 2026-07-21 09:35:36 +00:00
Artem Akymenko 71916aa39f fix: conversion_service import bug + coverage tests
Fix import error in _prepare_tts_context:
- apply_normalization_overrides doesn't exist in domain.normalization
- merge_pronunciation_overrides expects job-like object, not two lists
- Use _MockJob adapter to bridge ConversionRequest to existing API

Add test_application_coverage.py (23 tests):
- ConversionService: simple, logs, cancellation, empty text, multi-chapter, intro/outro, error
- OutputLayoutService: custom folder, source path, project, merged path, chapter path, should_merge
- Executor gaps: no layout, m4b, separate chapters, no intro/outro, voice fallback, silence

Coverage: 80% -> 92%
2026-07-21 09:34:47 +00:00
Artem Akymenko 4c4434c309 fix: sanitize_output_stem signature + audio_sink import
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.
2026-07-21 09:18:51 +00:00
Artem Akymenko 7973de3868 feat: ConversionService
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.
2026-07-21 11:30:15 +03:00
Artem Akymenko fd659d0f4f feat: PyQt adapter
Converts PyQt ConversionThread to ConversionRequest for the application layer.

Functions:
- build_conversion_request_from_thread(thread) -> ConversionRequest
- PyQtEvents: wraps thread signals for logging, progress, cancellation
- PyQtPipelineProvider: wraps existing backend
- PyQtVoiceResolver: wraps load_voice_cached

Subtitle file/timestamp special paths remain in ConversionThread.run().
2026-07-21 11:29:47 +03:00
Artem Akymenko e53251ef81 feat: WebUI adapter
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.
2026-07-21 11:29:38 +03:00
Artem Akymenko cd3cc9bce7 refactor: extract OutputLayoutService
Extract output path resolution from conversion_planner.py into
application/output_layout_service.py as a standalone service.

Functions:
- resolve_output_layout(request) -> OutputLayout
- resolve_merged_path(layout, request) -> Path
- resolve_chapter_path(layout, request, title, index) -> Path
- should_merge_output(request) -> bool

Planner now imports from output_layout_service instead of inline logic.
2026-07-21 11:17:28 +03:00
Artem Akymenko 75a3ad517a test: executor tests with fake backend/sink/ports
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.
2026-07-21 11:16:41 +03:00
Artem Akymenko a6b7ce69aa feat: unified conversion executor (execute_conversion)
Takes ConversionPlan + ports, executes TTS conversion, returns ConversionResult.
- Opens/closes audio sinks and subtitle writers
- Processes intro/outro
- Executes chapter loop with heading + body segments
- Collects chapter_markers and chunk_markers
- Uses domain functions only (no UI imports)
2026-07-21 11:16:41 +03:00
Artem Akymenko 680418fa1d test: planner tests + domain regression tests
51 tests for the unified conversion planner:
- build_conversion_plan: direct text, voice markers, chunks, chapters, intro/outro, output layout
- Domain regression: chapter parsing, voice markers, TTSContext, voice resolution, intro/outro, output paths, subtitles
- All tests use domain functions only (no UI, no TTS, no audio I/O)
2026-07-21 11:16:41 +03:00
Artem Akymenko 53b850ef41 feat: unified conversion planner (build_conversion_plan)
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.
2026-07-21 11:16:41 +03:00
Artem Akymenko 7ed4eca68c feat: application layer models and ports for conversion unification
- application/conversion_models.py: SegmentPlan, ChapterPlan, ConversionPlan, OutputLayout, IntroOutroSpec
- application/conversion_request.py: ConversionRequest (normalized input)
- application/conversion_result.py: ConversionResult, ConversionError (normalized output)
- application/conversion_ports.py: protocols (ConversionEvents, PipelineProvider, VoiceResolver, SubtitleWriter, AudioSink)

These are pure data models and interfaces. No implementation yet.
2026-07-21 11:16:41 +03:00
Artem Akymenko e1e49e8a0f test: regression tests for conversion flow unification
Three new test files describing expected behavior before refactoring:
- test_conversion_planner.py: chapter parsing, voice markers, TTSContext, intro/outro, output paths, subtitles (30 tests)
- test_conversion_request.py: settings, context building, chapter selection, cancellation/logging protocols (15 tests)
- test_conversion_executor.py: synthesize_text, process_and_write_subtitles, full pipeline with fake backend/sink (12 tests)

All 1310 tests pass (1253 existing + 57 new).
2026-07-21 11:16:40 +03:00
Artem Akymenko 28998e1e5c refactor: delete redundant _prepare_project_layout wrapper
resolve_project_layout() from domain already handles mkdir.
Callers now use the domain function directly.

Tests: 1253 passed
2026-07-20 10:10:50 +00:00
Artem Akymenko 8a220a936c refactor: extract extract_metadata_for_file() domain function, delete PyQt wrapper
domain/metadata_extraction.py gains extract_metadata_for_file() combining
read_text_for_metadata + extract_metadata_from_text. PyQt _extract_metadata_dict
deleted, calls replaced with domain function.

Tests: 1253 passed
2026-07-20 09:49:33 +00:00
Artem Akymenko ccc2cdb166 refactor: replace manual suffix loop with resolve_unique_path (#4)
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
2026-07-20 09:27:31 +00:00
Artem Akymenko 79ff7e4682 refactor: delete _process_subtitle_tokens wrapper, use domain function directly (#6)
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
2026-07-20 09:22:31 +00:00
Artem Akymenko 2a54b8fdf1 refactor: extract synthesize_text() domain function (#2)
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
2026-07-20 09:12:40 +00:00
Artem Akymenko 68e5adb091 refactor: consolidate voice resolution via resolve_voice_choice (#3)
Chapter and chunk loops now call resolve_voice_choice() instead of
inlining _resolve_voice_target + cache check + resolve_voice.
Reduces 3 duplicated voice resolution blocks to 1 closure.

Tests: 1253 passed
2026-07-20 09:05:26 +00:00
Artem Akymenko c4870eece6 refactor: extract TTSContext dataclass for normalization parameters (#5)
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
2026-07-20 09:00:55 +00:00
Artem Akymenko 8144a7a507 refactor: unify intro/outro through domain; extract subtitle writer creation
- 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
2026-07-20 08:32:55 +00:00
Artem Akymenko f38700025a unify voice caching: VoiceCache class used by both WebUI and PyQt
- 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
2026-07-20 08:02:34 +00:00
Artem Akymenko 804517f5b2 extract subtitle writer creation: resolve_subtitle_format() + make_subtitle_writer()
- 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()
2026-07-20 07:52:15 +00:00
Artem Akymenko 5d30903149 extract conversion_engine: shared TTS segment iteration loop for WebUI and PyQt
- domain/conversion_engine.py: run_tts_segment_loop() with CancelChecker,
  SegmentStats, SegmentInfo protocols; on_segment callback for per-segment
  subtitle processing; process_and_write_subtitles() helper
- conversion_runner.py: emit_text() delegates TTS iteration to engine
- pyqt/conversion.py: inner tts_segments loop replaced with engine call,
  on_segment handles dual merged+chapter subtitle writers
- routes/utils/settings.py: re-exports load_settings, coerce_int/float,
  llm_ready, settings_defaults from domain for backward compat
2026-07-20 07:35:58 +00:00
Artem Akymenko 476063bc3d refactor: move load_settings() to domain, simplify settings.py
- 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
2026-07-20 06:56:42 +00:00
Artem Akymenko 079e185108 refactor: simplify normalize_setting_value() via Setting.normalizer
- Added normalizer callable to Setting dataclass
- Moved special-case logic (_norm_save_mode, _norm_voice_spec, etc.)
  into registry entries as normalizers
- normalize_setting_value() reduced from 25 lines to 10 lines
- Single dispatch: normalizer → coerce → fallback
2026-07-20 06:48:06 +00:00
Artem Akymenko 69c398ebf0 refactor(pyqt): replace hardcoded config defaults with SETTINGS_REGISTRY
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.
2026-07-19 15:57:49 +00:00
Artem Akymenko dbe73254a4 refactor: add SETTINGS_REGISTRY contract to domain/settings_core.py
- Setting dataclass: key, type, default, min/max, valid_values, scope
- 72 settings total: 54 shared, 18 PyQt-only
- validate_setting() checks types and ranges
- Setting.coerce() handles type conversion with bounds
- settings_defaults() / all_settings_defaults() derived from registry
- BOOLEAN_SETTINGS, FLOAT_SETTINGS, INT_SETTINGS now auto-derived
- 17 tests validating schema, coercion, and validation
2026-07-19 13:24:52 +00:00
Artem Akymenko 64e8a8f4e6 refactor: extract shared settings_core and Flask route logic to domain/services 2026-07-19 15:52:35 +03:00
Artem Akymenko aec3462f1f refactor: both UIs use shared tts_segments() from domain/conversion_pipeline.py
- domain/conversion_pipeline.py: add tts_segments() for pre-normalized text;
  emit_text_segments() now delegates to tts_segments() internally
- pyqt/conversion.py: inner TTS loop replaced with tts_segments() iterator;
  removed FakeToken import (handled by domain)
- webui/conversion_runner.py: emit_text() inner loop replaced with
  tts_segments() iterator; removed FakeToken import
- Both UIs now share the same TTS emission logic — normalization + backend
  invocation + segment iteration + token extraction
- +3 tests for tts_segments (no-normalization, chunk_start, basic)
- 1188 tests pass
2026-07-19 11:47:14 +00:00
Artem Akymenko 1193185833 refactor: create domain/conversion_pipeline.py with shared TTS emission loop
- domain/conversion_pipeline.py: emit_text_segments() — generator yielding
  SegmentResult for each TTS segment; emit_text_to_sinks() — convenience
  wrapper handling audio writing + token accumulation + subtitle flushing
- Both WebUI and PyQt can call these instead of reimplementing the TTS loop
- Caller provides backend, voice, speed, split_pattern; domain handles
  normalization, TTS invocation, token extraction
- +7 tests (segment yielding, empty audio skip, chunk_start, tokens, fallback)
- 1185 tests pass
2026-07-19 10:31:58 +00:00
Artem Akymenko a99cf58c79 refactor: consolidate voice formula building into voice_formulas.py
- 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
2026-07-19 10:02:14 +00:00
Artem Akymenko fe62b6b44c refactor: extract book metadata logic from PyQt to domain
- domain/metadata_extraction.py: add format_metadata_tags(),
  extract_book_metadata_epub(), extract_book_metadata_pdf(),
  extract_book_metadata_markdown(), _save_cover_to_cache()
- pyqt/book_handler.py: _extract_book_metadata() reduced from ~165 lines
  to ~10 lines by delegating to domain; _format_metadata_tags() reduced
  from ~55 lines to ~15 lines; ebooklib/fitz imports moved to domain
- +16 tests (format_metadata_tags, save_cover, markdown extraction)
- 1169 tests pass
2026-07-19 09:51:50 +00:00
Artem Akymenko 0e216f3786 refactor: extract _process_subtitle_file domain logic to shared modules
- domain/subtitle_processor.py: parse_subtitle_file, format_time_range,
  speed_up_audio, fit_audio_to_duration (moved from audio_buffer),
  process_subtitle_entries (core TTS loop with cancel/log/progress callbacks)
- domain/audio_buffer.py: add fit_audio_to_duration, ffmpeg_time_stretch
- domain/output_paths.py: add resolve_unique_path (collision-safe filename)
- pyqt/conversion.py: _process_subtitle_file reduced from ~350 to ~100 lines
  by delegating to domain functions; removed 4 unused subtitle parser imports
- +33 tests (8 resolve_unique_path, 14 subtitle_processor, 8 audio_buffer,
  3 format_time_range)
- 1153 tests pass
2026-07-19 09:34:17 +00:00
Artem Akymenko 380cdee0cb refactor(pyqt): use create_pipeline_for_job() in LoadPipelineThread
Replace direct create_pipeline() call with domain function for
consistent provider validation and device resolution.
2026-07-19 07:57:09 +00:00
Artem Akymenko c76cf74efc refactor: unify duplicated logic between WebUI and PyQt
domain/output_paths.py:
- Add sanitize_filename_for_chapter() with OS safety + smart truncation
- Keep existing slugify() for backward compatibility

domain/text_chapters.py (NEW):
- parse_chapters_from_text() combines intro preservation (PyQt) + clean_text (WebUI)

PyQt/conversion.py:
- Replace 3x copy-pasted ASS/SRT headers with create_subtitle_writer()
- Replace inline M4B muxing with ExportService.embed_m4b_metadata()
- Replace inline chapter splitting with parse_chapters_from_text()
- Replace inline chapter filename sanitization with sanitize_filename_for_chapter()
- Remove unused _CHAPTER_MARKER_SEARCH_PATTERN import

Tests: 1152 passed (+21 new)
2026-07-19 07:50:02 +00:00
Artem Akymenko a299947bb1 refactor(webui): replace inline get_pipeline/resolve_voice_target closures with domain modules
- 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)
2026-07-18 14:13:17 +03:00
Artem Akymenko 957c6778f6 refactor(pyt): dedup voice formula resolution in PyQt
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.
2026-07-18 14:13:16 +03:00
Artem Akymenko fcdaf2b2a8 refactor(domain): extract FakeToken to domain/tokens.py
Shared token stub used by both WebUI and PyQt for languages
without per-word token support.
2026-07-18 14:12:18 +03:00
Artem Akymenko d8634f812d refactor(domain): extract audio sink abstraction to domain layer
- New domain/audio_sink.py: AudioSink context manager + open_audio_sink() factory
  - Supports WAV/FLAC (soundfile) and MP3/Opus/M4B (ffmpeg pipe)
  - cancel_check, extra_ffmpeg_args, ffmpeg_cmd parameters
  - 17 tests in test_domain_audio_sink.py

- WebUI: replaced local AudioSink + _open_audio_sink with domain module
  - Removed 35 lines, 3 call sites now use open_audio_sink()

- PyQt: replaced 3 inline audio output setups with domain module
  - New _open_merged_sink() helper encapsulates m4b cover art logic
  - ExitStack for automatic cleanup in main conversion
  - Removed ~140 lines of duplicated ffmpeg/soundfile boilerplate
2026-07-18 14:12:01 +03:00
Artem Akymenko 85b5851786 refactor(pyt): replace all inline float32 conversion with to_float32()
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.
2026-07-18 06:58:21 +00:00
Artem Akymenko e77c8b3372 fix: review cleanup — imports, _FakeToken, use_spacy_segmentation
- 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
2026-07-18 06:52:28 +00:00
Artem Akymenko 294069e53e refactor(webui): token-level subtitle processing via process_subtitle_tokens — P0
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.
2026-07-18 06:36:19 +00:00
Artem Akymenko 4ff09be664 refactor(pyt): add text normalization via prepare_text_for_tts — P0
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.
2026-07-18 09:28:53 +03:00
Artem Akymenko a1d93820b1 refactor(domain): unify ETR calculation — both UIs now use calc_etr_str
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.
2026-07-16 09:31:26 +00:00
Artem Akymenko 0c1a3c1904 refactor(webui): use shared get_split_pattern instead of hardcoded \\n+
All three Web UI consumers now call domain.split_pattern.get_split_pattern()
which selects the correct split pattern based on language and subtitle mode.

Before: WebUI always split on \\n+ regardless of language (CJK missed
punctuation-based splitting that PyQt already had).
After: Both UIs share identical language-aware splitting logic.

1038 tests pass.
2026-07-16 09:12:46 +00:00
Artem Akymenko 2228f37c06 refactor(domain): add prepare_text_for_tts — unified normalization pipeline
New function chains all three normalization stages:
  1. Heteronym sentence rules (context-dependent pronunciation)
  2. Pronunciation rules (token-level replacements)
  3. Pipeline normalization (apostrophe, LLM)

This is the single entry point that both Web UI and PyQt should call
before TTS synthesis. Currently only Web UI uses it; PyQt has NO
normalization — this unlocks that capability.

Updated conversion_runner.emit_text to use the new function.
1038 tests pass.
2026-07-16 08:53:15 +00:00
Artem Akymenko 832e2c5197 refactor(domain): extract chapter classification heuristics from form.py
Moved supplement_score, should_preselect_chapter, and
ensure_at_least_one_chapter_enabled to domain/chapter_classification.py.

Also moved coerce_bool from settings.py to common.py to break circular
import introduced in previous commit.

1025 tests pass.
2026-07-16 08:13:37 +00:00
Artem Akymenko 17229b2390 refactor(webui): deduplicate _extract_checkbox (3 copies → 1)
Extracted extract_checkbox from settings.py, form.py (x2) into
webui/routes/utils/common.py. Moved coerce_bool from settings.py
to common.py to break circular import.

Fixed bug in second form.py copy (was missing __contains__ check).
1006 tests pass.
2026-07-16 07:46:51 +00:00
Artem Akymenko c2c584e741 refactor(domain): deduplicate metadata helpers across 3 layers
Extracted 8 metadata functions from service.py, exporters.py, and
audiobookshelf.py into domain/metadata_helpers.py:
- normalize_metadata_casefold, split_people_field, split_simple_list
- first_nonempty, extract_year, normalize_series_sequence
- build_audiobookshelf_metadata, load_audiobookshelf_chapters

service.py, exporters.py, and audiobookshelf.py now import from domain
instead of maintaining separate copies. Thin wrappers adapt to layer
interfaces (Job objects, etc.).

Net -231 lines. 1006 tests pass.
2026-07-16 07:34:58 +00:00
Artem Akymenko 8ccdc85ccb refactor(webui): rename preview.py to synthesize.py and remove dead code
- Rename abogen/webui/routes/utils/preview.py → synthesize.py
  The file contains the core TTS synthesis pipeline (generate_preview_audio,
  synthesize_preview), not just preview logic. Name now matches responsibility.
- Remove dead code from voice.py: get_preview_pipeline(), synthesize_audio_from_normalized(),
  _preview_pipeline_lock, _preview_pipelines, and unused imports (threading, numpy,
  create_pipeline, get_new_voice, _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN).
  These were never called — identical logic lives in synthesize.py.
- Update imports in api.py, voices.py, and test_preview_applies_manual_overrides.py
- 7 new tests in test_synthesize_module.py enforce file naming and import rules
- 7 tests in test_domain_imports.py updated for renamed module
2026-07-16 10:19:01 +03:00
Artem Akymenko ef07a8b5b2 fix(tests): mock spacy in plugin tests to fix externally-managed-environment failures 2026-07-16 10:02:01 +03:00
Artem Akymenko 1268a83cff fix(webui): voice.py imports from domain modules instead of conversion_runner
Replace private imports (_select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN)
from abogen.webui.conversion_runner with proper domain imports:
- select_device from abogen.domain.device
- to_float32, SAMPLE_RATE from abogen.domain.audio_helpers
- SPLIT_PATTERN defined locally (r'\n+')

Also verifies preview.py already uses domain imports correctly.

7 new tests in tests/test_domain_imports.py enforce the architecture rule.
2026-07-16 06:57:12 +00:00
Artem Akymenko ef6faff2e8 refactor: extract metadata processing logic to domain layer
- Replace manual metadata extraction with regex in pyqt/conversion.py
  with calls to domain/metadata_extraction.py functions
- Remove duplicate _embed_m4b_metadata and _apply_m4b_chapters_with_mutagen
  functions from webui/conversion_runner.py
- Use ExportService.embed_m4b_metadata for m4b metadata embedding
- Reduce code duplication between PyQt and WebUI interfaces
2026-07-15 20:44:19 +03:00
Artem Akymenko da9d5e7eb9 fix(tests): audio_buffer and subtitle_generation tests
- Fix mix_audio to return target buffer (was not modifying in-place)
- Fix samples_for_duration to return 0 for negative durations
- Fix test assertions for numpy 2.x compatibility (share_memory -> shares_memory)
- Adjust subtitle_generation tests to match actual behavior
2026-07-15 20:26:31 +03:00
Artem Akymenko acb000b9e6 refactor: extract voice loading logic to domain layer
- Add abogen/domain/voice_loader.py with:
  - VoiceCache class: unified cache for loaded voices
  - resolve_voice(): load voice with optional caching
  - load_voice_cached(): compatibility wrapper for PyQt

- Update abogen/pyqt/conversion.py:
  - Replace load_voice_cached method body with call to domain function
  - Maintain backward compatibility with existing interface

- Add tests/test_voice_loader.py with unit tests for VoiceCache and voice loading
2026-07-15 20:20:18 +03:00
Artem Akymenko d6c66dc18a refactor: extract subtitle token processing to domain layer
- Add abogen/domain/subtitle_generation.py with:
  - process_subtitle_tokens(): main function for converting TTS tokens to subtitles
  - Support for all subtitle modes: Line, Sentence, Sentence + Comma, Sentence + Highlighting
  - Support for word-count based grouping (e.g., '5' for 5 words per entry)
  - spaCy integration for English sentence boundary detection
  - Karaoke highlighting tags for Sentence + Highlighting mode
  - Punctuation constants for sentence splitting

- Update abogen/pyqt/conversion.py:
  - Replace _process_subtitle_tokens method body with call to domain function
  - Remove ~260 lines of duplicate logic

- Add tests/test_subtitle_generation.py with comprehensive unit tests
2026-07-15 20:14:02 +03:00
Artem Akymenko 0d46076bf6 refactor: extract audio buffer operations to domain layer
- Add abogen/domain/audio_buffer.py with core audio operations:
  - create_silence(): create silence audio buffer
  - mix_audio(): mix source into target buffer with auto-resize
  - normalize_audio(): normalize to prevent clipping
  - ensure_buffer_size(): extend buffer to minimum size
  - concatenate_audio(): join multiple audio buffers
  - audio_duration(): calculate duration from samples
  - samples_for_duration(): calculate samples from duration
  - SAMPLE_RATE constant (24000)

- Update abogen/pyqt/conversion.py:
  - Import and use create_silence for chapter silence
  - Use mix_audio for subtitle file mixing
  - Use normalize_audio for clipping prevention
  - Use create_silence for padding in subtitle processing

- Update abogen/webui/conversion_runner.py:
  - Import and use create_silence in append_silence
  - Replace np.zeros with domain function

- Add tests/test_audio_buffer.py with comprehensive unit tests
2026-07-15 20:02:33 +03:00
Artem Akymenko 7fef9c1d93 extract normalize_text_for_pipeline to domain/normalization.py 2026-07-15 15:19:01 +00:00
Artem Akymenko 56cfd0810d extract resolve_fallback_voice_spec to domain/voice_resolution.py; fix missing get_default_voice import and __custom_mix reset bug 2026-07-15 15:01:17 +00:00