Commit Graph
806 Commits
Author SHA1 Message Date
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