Compare commits

..
Author SHA1 Message Date
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
Artem Akymenko 7bd3177241 extract select_device to domain/device.py; fix bug where conversion_runner didn't check torch availability 2026-07-15 14:45:38 +00:00
Artem AkymenkoandGitHub d5c2a81733 Merge pull request #191 from hydraxman/fix/large-chapter-form-limits
fix(webui): allow large chapter forms
2026-07-15 17:35:52 +03:00
Artem Akymenko 514e29a761 extract apply_chapter_text_transforms to domain/chapter_titles.py 2026-07-15 14:29:29 +00:00
Artem Akymenko 86042a3315 fix bugs, remove dead code and unused imports in conversion_runner.py 2026-07-15 13:50:18 +00:00
Artem Akymenko 50d75eb2fc standardize m4b encoding to VBR -q:a 2; replace remaining ffmpeg blocks in desktop GUI with domain modules 2026-07-15 13:28:21 +00:00
Artem Akymenko ae9ab70421 refactor: extract audio helpers to domain/audio_helpers.py
- Extract build_ffmpeg_command, to_float32, apply_m4b_chapters_with_mutagen
- _apply_m4b_chapters_with_mutagen becomes thin wrapper with error handling
- Add tests/test_audio_helpers.py (12 tests)
- conversion_runner.py: 1410 → 1320 lines
- All tests pass
2026-07-15 12:15:10 +00:00
Artem Akymenko 4364276a5b refactor: extract output path utilities to domain/output_paths.py
- Extract slugify, sanitize_output_stem, output_timestamp_token, build_output_path
- Extract apply_newline_policy, resolve_output_directory, resolve_project_layout
- _prepare_output_dir and _prepare_project_layout become thin wrappers with mkdir
- Add tests/test_output_paths.py (21 tests)
- conversion_runner.py: 1443 → 1410 lines
- All tests pass
2026-07-15 11:56:06 +00:00
Artem Akymenko 914e77de46 refactor: wire up domain/voice_utils.py and remove duplicates
- Import supertonic_voice_from_spec, split_speaker_reference, formula_from_kokoro_entry
- Import infer_provider_from_spec, coerce_truthy from domain/voice_utils.py
- Remove duplicate function bodies from conversion_runner.py
- conversion_runner.py: 1518 → 1443 lines
- All tests pass
2026-07-15 11:06:29 +00:00
Artem Akymenko 1d7a2aeed6 refactor: extract chunk utils to domain/chunk_utils.py
- Extract safe_int, group_chunks_by_chapter, record_override_usage, chunk_text_for_tts
- Add tests/test_chunk_utils.py (15 tests)
- Update test_chunk_helpers.py and test_chunk_text_for_tts_prefers_raw.py imports
- conversion_runner.py: 1574 → 1518 lines
- All tests pass
2026-07-15 11:00:18 +00:00
Artem Akymenko a26e02b017 refactor: wire up domain/chapter_overrides.py and domain/metadata_merge.py
- Update chapter_overrides.py to return tuple matching original signature
- Import apply_chapter_overrides and merge_metadata from domain modules
- Remove old function bodies from conversion_runner.py
- Add tests/test_chapter_merge_normalize.py (19 tests)
- conversion_runner.py: 1677 → 1574 lines
- All tests pass
2026-07-15 10:36:31 +00:00
Artem Akymenko c94347b33b refactor: extract voice resolution to domain/voice_resolution.py
- Extract spec_to_voice_ids, job_voice_fallback, collect_required_voice_ids
- Extract initialize_voice_cache, chapter_voice_spec, chunk_voice_spec
- Add tests/test_voice_resolution.py (29 tests)
- conversion_runner.py: 1822 → 1677 lines
- All tests pass
2026-07-15 10:20:58 +00:00
Artem Akymenko b7a48e3204 refactor: extract pronunciation rules to domain/pronunciation.py
- Extract compile_pronunciation_rules, compile_heteronym_sentence_rules
- Extract apply_pronunciation_rules, apply_heteronym_sentence_rules
- Extract merge_pronunciation_overrides
- Add tests/test_pronunciation.py (31 tests)
- All tests pass
2026-07-14 18:27:22 +00:00
Artem Akymenko feb38a24ec fix: create missing domain/file_type.py from previous incomplete refactoring 2026-07-14 18:27:13 +00:00
Artem Akymenko f63590932d refactor: extract voice utils to domain/voice_utils.py
- Extract infer_provider_from_spec, supertonic_voice_from_spec, split_speaker_reference, formula_from_kokoro_entry, coerce_truthy to domain/voice_utils.py
- Add tests/test_voice_utils.py with 24 tests
- All tests match old behavior
2026-07-14 11:02:34 +00:00
Artem Akymenko 7777e58f1d refactor: extract title/outro builders into domain/title_builder.py
- Extract build_title_intro_text and build_outro_text into domain/title_builder.py
- Uses metadata_helpers for metadata processing
- Remove _build_title_intro_text and _build_outro_text from conversion_runner.py
- Add tests/test_title_builder.py with 12 tests
- All tests match old behavior
2026-07-14 10:27:48 +00:00
Artem Akymenko 364c179bd6 refactor: extract metadata helpers into domain/metadata_helpers.py
- Extract normalize_metadata_map, format_author_sentence, ensure_sentence
- Extract normalize_series_number, extract_series_metadata, format_series_sentence
- Remove _SERIES_NAME_KEYS, _SERIES_NUMBER_KEYS, _SERIES_NUMBER_RE from conversion_runner.py
- Add tests/test_metadata_helpers.py with 32 tests
- All tests match old behavior
2026-07-14 10:26:05 +00:00
Artem Akymenko 60ba01557e refactor: extract chapter title processing into domain/chapter_titles.py
- Extract simplify_heading_text, headings_equivalent, strip_duplicate_heading_line
- Extract normalize_caps_word, normalize_chapter_opening_caps
- Extract format_spoken_chapter_title
- Remove _HEADING_SANITIZE_RE, _HEADING_NUMBER_PREFIX_RE, _ACRONYM_ALLOWLIST, _ROMAN_NUMERAL_CHARS, _CAPS_WORD_RE from conversion_runner.py
- Add tests/test_chapter_titles.py with 31 tests
- All tests match old behavior
2026-07-14 10:17:20 +00:00
Artem Akymenko 39eac9b032 refactor: replace _srt_time/_ass_time with _format_timestamp from infrastructure/subtitle_writer.py
- Remove _srt_time() and _ass_time() methods from ConversionThread
- Use _format_timestamp() from infrastructure/subtitle_writer.py instead
- Supports both SRT (ass=False) and ASS (ass=True) formats
- All existing tests pass
2026-07-14 10:14:58 +00:00
Artem Akymenko 1499a3b426 refactor: extract _get_split_pattern into domain/split_pattern.py
- Extract unified split pattern logic to domain/split_pattern.py
- Add get_split_pattern() function with language and subtitle_mode support
- Remove duplicated logic from pyqt/conversion.py
- Update pyqt/conversion.py to use domain.split_pattern.get_split_pattern
- Add tests/test_split_pattern.py with 20 tests covering English, CJK, Spanish, French, and pattern structure
2026-07-14 10:11:52 +00:00
Artem Akymenko 013c80b92c refactor: migrate FFmpeg metadata functions to infrastructure/exporters.py
- Extract FFmpeg metadata functions to infrastructure/exporters.py as ExportService
- _escape_ffmetadata_value → _escape_ffmetadata_value
- _render_ffmetadata → render_ffmetadata
- _write_ffmetadata_file → write_ffmetadata_file
- _metadata_to_ffmpeg_args → _metadata_to_ffmpeg_args
- _apply_m4b_chapters_with_mutagen → _apply_m4b_chapters_mutagen
- _embed_m4b_metadata → embed_m4b_metadata
- Add tests/test_exporters.py with 28 tests for ExportService
- Update tests/test_ffmetadata.py to use ExportService
- Update conversion_runner.py to use ExportService
- All tests pass with new implementation matching old behavior
2026-07-14 10:09:27 +00:00
Artem Akymenko 62f42a9f79 refactor: migrate SubtitleWriter to infrastructure/subtitle_writer.py
- Extract SubtitleWriter classes (SrtWriter, AssWriter, VttWriter) to infrastructure/subtitle_writer.py
- Add create_subtitle_writer() factory function
- Remove old SubtitleWriter class and _format_timestamp from conversion_runner.py
- Use create_subtitle_writer() factory from infrastructure layer
- Add tests/test_subtitle_writer.py with 28 tests covering SrtWriter, AssWriter, VttWriter
- All tests match old _format_timestamp behavior
2026-07-14 10:06:51 +00:00
Bryan Nathan 2c4d13bf56 fix(webui): allow large chapter forms 2026-07-14 08:51:40 +08:00
Artem Akymenko b7026a666d refactor(shutdown): move shutdown logic in one place 2026-07-12 20:19:33 +03:00
Artem Akymenko c380a58496 tts: fix kokoro AlbertModel import for transformers 5.x (plugin architecture)
- Monkey-patch transformers.AlbertModel in plugins/kokoro/__init__.py before kokoro imports it
- Works with transformers 4.x (no-op) and 5.x (adds moved symbol)
- No pinning, forks, or extra files needed
2026-07-12 20:17:25 +03:00
Artem AkymenkoandGitHub b8386b43f7 Merge pull request #190 from denizsafak/tts-plugin-refactor
refactor(tts)!: replace legacy backend with plugin architecture
2026-07-12 18:29:54 +03:00
Artem Akymenko 26e71cc2ac chore: add .gitattributes for consistent LF line endings
- Add .gitattributes with text=auto eol=lf for all text file types
- Ensures consistent line endings across platforms
- Prevents future CRLF/LF diffs in pull requests
2026-07-12 16:20:44 +03:00
Artem Akymenko d8fcfb1cce chore: normalize line endings to LF, add .gitattributes
- Add .gitattributes with text=auto eol=lf for all text files
- Renormalize all files in index to LF line endings
- Fixes massive whitespace-only diffs between main and feature branch
2026-07-12 16:20:42 +03:00
Artem Akymenko c85ea9d64f refactor(tests): add auto-discovery test system for TTS plugins
- Create tests/plugins/ with auto-discovery fixtures and generic tests
- Add conftest.py with plugin_ids, loaded_plugin, host_context fixtures
- Add test_all_plugins.py with 3 test classes:
  - TestAllPluginsManifest: validates manifest structure
  - TestAllPluginsEngine: validates engine lifecycle contract
  - TestAllPluginsCapabilities: validates capability implementation
- Update docs/testing.md with auto-discovery documentation
- Plugin-specific tests remain in tests/test_*_plugin.py for integration

New plugins in plugins/ are now automatically tested without manual test creation.
2026-07-12 16:20:30 +03:00
Artem Akymenko f151a1ae0d docs: archive historical plans, remove duplicates
- Move migration-roadmap.md, epub3_upgrade_plan.md, entities_step_overhaul_plan.md to docs/archive/
- Remove duplicate tts-plugin-architecture.md
2026-07-12 16:20:30 +03:00
Artem Akymenko 096ea58d74 docs: rewrite developer-guide as architectural reference
- Remove implementation details that will rot (code templates, tutorials)
- Keep only stable contracts: ownership, lifecycle, protocols, error semantics
- 270 lines → reference that only changes when architecture changes
2026-07-12 16:20:28 +03:00
Artem Akymenko 65cb0c75e5 fix(tests): pre-existing SuperTonic plugin test mock
Fix _make_mock_engine() to return 10 voices matching manifest and raise EngineError after dispose.
All 528 tests now pass.
2026-07-12 16:20:20 +03:00
Artem Akymenko 780e9bd780 refactor(cleanup): remove Legacy TTS Architecture
Delete legacy backend infrastructure:
- abogen/tts_backend.py (TTSBackend protocol, TTSBackendMetadata)
- abogen/tts_backend_registry.py (TTSBackendRegistry, global singleton, register_backend)
- abogen/tts_backends/ (kokoro.py, supertonic.py, __init__.py)

Delete legacy tests:
- tests/test_tts_backend.py
- tests/test_kokoro_backend.py
- tests/test_voice_formula_resolution.py
- tests/test_tts_supertonic_unsupported_chars.py

Production code now uses only Plugin Architecture via create_pipeline().
All contract, behavioral, and integration tests pass.
2 pre-existing failures in test_supertonic_plugin.py (mock engine mismatch).
2026-07-12 16:20:20 +03:00
Artem Akymenko c094b94704 feat(tts-plugin): complete Plugin Architecture refactor
- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device)
- EngineConfig: add lang_code field per Architecture Amendment #1
- Kokoro plugin reads config.lang_code (fixes functional regression)
- Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister)
- get_voices() reads from manifest without creating Engine
- Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic
- Clean up unused imports and dead code in engine implementations
- Fix test expectations for VoiceLister (mock overrides)
- Add clear_preview_pipelines() for resource management
2026-07-12 16:20:20 +03:00
Artem Akymenko 735098d7cd feat: add static voice catalog to PluginManifest
- Add  to PluginManifest
  - None = not declared (use VoiceLister fallback)
  - () = explicitly no static voices
  - Non-empty = static catalog available without Engine instantiation

- Update get_voices() to check manifest first, fall back to Engine
- Declare 54 Kokoro voices and 10 SuperTonic voices in manifests
- Remove hardcoded voice lists from engine.py files
- Engine.listVoices() now returns [] (manifest is source of truth)

- Clean up dead create_pipeline() kwargs (sample_rate, auto_download, total_steps)
  - SuperTonic plugin uses internal defaults
  - total_steps is per-request parameter via Pipeline.__call__() kwargs

- Add clear_preview_pipelines() for resource cleanup
- Fix test mocks to override listVoices()
- Update Architecture Amendment #1 doc
2026-07-12 16:20:16 +03:00
Artem Akymenko 5d1e7165bb feat: finalize behavioral regression suite
- Add 96 behavioral regression tests parametrized for both Kokoro and SuperTonic
- Remove legacy TTSBackendRegistry tests (13) from behavioral suite
- Remove mock-only capability tests (Preview, Streaming, Cancellation) not implemented by either plugin
- Fix get_voices() to pass required args to create_engine() + error handling
- All 598 tests pass
2026-07-12 16:20:06 +03:00
Artem Akymenko 9150a80459 refactor: eliminate remaining legacy dependencies from production code
Task 1: Replace hardcoded VoiceLister bypass in get_voices()
- Use PluginManager → Engine → VoiceLister instead of direct imports
- No more hardcoded imports of plugins.kokoro.engine / plugins.supertonic.engine

Task 2: Remove SuperTonic Plugin dependency on legacy backend
- Create self-contained plugins/supertonic/pipeline.py
- Plugin no longer imports from abogen.tts_backends

Production code now has zero imports from:
- abogen.tts_backend
- abogen.tts_backend_registry
- abogen.tts_backends
2026-07-12 16:20:06 +03:00
Artem Akymenko a76d338931 refactor: remove compatibility layer, use Plugin Architecture directly
- Delete abogen/tts_plugin/compat.py (CompatBackend, create_backend, get_metadata, etc.)
- Add abogen/tts_plugin/utils.py with direct Plugin Manager functions:
  get_voices, get_default_voice, is_plugin_registered, resolve_voice_to_plugin, create_pipeline
- Update all 16 consumer files to import from utils instead of compat
- Update __init__.py to re-export utils instead of compat
- Update 5 test files and add TestNoCompatLayer regression tests
- All 493 tests pass
2026-07-12 16:20:06 +03:00
Artem Akymenko 985e16f1f8 feat: migrate remaining consumers to new Plugin Architecture
- Add compatibility functions to tts_plugin/compat.py:
  - get_metadata(): returns TTSBackendMetadata with voices
  - is_registered_backend(): checks if plugin is loaded
  - resolve_backend_for_voice(): resolves backend for voice spec
  - get_default_voice(): gets default voice for backend

- Update tts_plugin/__init__.py to export new functions

- Migrate all consumers from old tts_backend_registry:
  - WebUI: conversion_runner, debug_tts_runner, routes/api, routes/utils/*
  - PyQt UI: gui, predownload_gui, voice_formula_gui
  - Voice utilities: voice_cache, voice_formulas, voice_profiles
  - Other: subtitle_utils, utils, predownload_gui (root)

- Update tests to use new plugin architecture

Old architecture remains intact as fallback.
2026-07-12 16:20:06 +03:00
Artem Akymenko 25d45ffd36 refactor: extract EngineContractMixin base class for plugin tests
- Add tests/contracts/engine_contract.py with shared Engine/Session tests
- TestKokoroEngineContract and TestSuperTonicEngineContract inherit from it
- Eliminates protocol test duplication between plugins
- Any new plugin just inherits EngineContractMixin to verify compliance
2026-07-12 16:20:06 +03:00
Artem Akymenko 6284c501ed feat: add SuperTonic TTS plugin
- Add plugins/supertonic/ with Engine and EngineSession implementations
- Reuse existing SupertonicPipeline from abogen.tts_backends.supertonic
- Implement VoiceLister capability (M1-M5, F1-F5 voices)
- Declare no streaming support via capabilities
- Add 28 tests: plugin loading, protocol compliance, lifecycle, voice listing, parameters, errors
- All 237 tests pass
2026-07-12 16:20:06 +03:00
Artem Akymenko 23f1efcc62 refactor: rename integration test file, remove PR reference from docstring 2026-07-12 16:20:05 +03:00
Artem Akymenko a05357bab9 feat: add PluginManager, compat adapter, and consumer migration
- Add PluginManager singleton for plugin discovery and engine caching
- Add CompatBackend adapter wrapping Engine/EngineSession into old create_backend() API
- Update tts_plugin/__init__.py with public exports
- Migrate preview.py and its test to use compat.create_backend
- Add integration and plugin manager contract tests
2026-07-12 16:20:05 +03:00
Artem Akymenko d129b0abe8 feat: add Kokoro plugin vertical slice
Implement first TTS plugin using new Plugin Architecture:
- plugins/kokoro/: Plugin package with manifest and entry point
- plugins/kokoro/engine.py: KokoroEngine and KokoroSession adapters
- Wraps existing KokoroBackend without modifying it
- Implements VoiceLister capability
- Satisfies Engine/EngineSession protocol
- Passes all 163 contract tests

Tests:
- Plugin loading through Plugin Loader
- Manifest validation
- Engine creation and lifecycle
- Session synthesis and dispose
- VoiceLister capability

15 new tests for Kokoro plugin.
2026-07-12 16:20:05 +03:00
Artem Akymenko 6eda8516cc feat: add plugin loader infrastructure
Implement plugin loading and validation:
- loader.py: discover, import, validate plugins
- validate PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- validate api_version compatibility (major must match)
- validate capabilities (reject unknown)
- diagnostic messages for all error cases
- no partial registration after error

Test plugins:
- fake_plugin: minimal valid plugin for testing
- missing_manifest: no PLUGIN_MANIFEST
- invalid_api_version: major version mismatch
- invalid_capabilities: unknown capabilities
- missing_create_engine: no create_engine function
- import_error: raises ImportError during import
- missing_model_requirements: no MODEL_REQUIREMENTS

39 new tests covering all loader functionality.
2026-07-12 16:20:05 +03:00
Artem Akymenko 0f568120f4 feat: add contract test suite for Plugin API
Create reusable contract tests for TTS Plugin Architecture:
- conftest.py: shared fixtures and stubs (FakeEngine, FakeSession, etc.)
- test_types_contract.py: value object contracts (frozen, immutability, equality)
- test_errors_contract.py: error hierarchy contracts
- test_manifest_contract.py: manifest type contracts
- test_engine_contract.py: Engine protocol contracts (lifecycle, dispose)
- test_session_contract.py: EngineSession protocol contracts
- test_capabilities_contract.py: capability protocol contracts
- test_host_context_contract.py: HostContext contracts
- test_plugin_contract.py: plugin contract (exports, create_engine)

124 tests covering all public API contracts.
2026-07-12 16:20:05 +03:00
Artem Akymenko 79b3d26f66 feat: add frozen Plugin API skeleton
Create public API structure for TTS Plugin Architecture:
- types.py: immutable value objects (AudioFormat, Duration, VoiceSelection, etc.)
- errors.py: EngineError hierarchy (7 typed exceptions)
- manifest.py: plugin manifest dataclasses (PluginManifest, EngineManifest, etc.)
- engine.py: Engine and EngineSession protocols
- capabilities.py: optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
- host_context.py: HostContext and HttpClient protocol
- plugin.py: plugin contract (create_engine signature)
- __init__.py: public API exports

All interfaces are fully defined but contain no business logic.
API is frozen and ready for implementation in subsequent PRs.
2026-07-12 16:20:05 +03:00
Artem AkymenkoandGitHub f1cc6deae8 Merge pull request #164 from yashupadhyayy1/main
Refactor segment processing with overflow error handling
2026-07-09 19:25:23 +03:00
Artem Akymenko 6f25fc06d0 ci: add UV_LINK_MODE=copy to suppress Windows hardlink warning 2026-07-09 06:58:59 +00:00
Artem Akymenko 32c4d533c9 ci: disable uv cache pruning to preserve wheel files 2026-07-09 06:06:10 +00:00
Artem Akymenko 146000886d ci: add uv cache prune to optimize cache size 2026-07-08 21:14:15 +00:00
Artem Akymenko 31f95137dd ci: replace pip with uv for faster dependency installation 2026-07-08 18:34:35 +00:00
Artem Akymenko 6f02fda41c fix(ci): set QT_QPA_PLATFORM=offscreen for headless PyQt6 tests 2026-07-08 17:36:59 +00:00
Artem Akymenko a3c3462348 fix(ci): install libegl1 on Ubuntu and normalize line endings in epub test
- Add system dependency step for libegl1 to fix PyQt6 import on headless CI
- Normalize CRLF to LF in epub exporter whitespace test for Windows CI
2026-07-08 17:16:33 +00:00
Artem AkymenkoandGitHub 79332204d3 Merge pull request #189 from denizsafak/feat/registry-voice-resolution
refactor: Move backend resolution by voice spec into registry
2026-07-08 20:03:19 +03:00
Artem Akymenko 6deec3b9b6 refactor: move backend resolution by voice spec into registry
- Add resolve_backend_for_voice() to TTSBackendRegistry
- Add module-level wrapper resolve_backend_for_voice()
- Simplify _infer_provider_from_spec() to use registry API
- Simplify _supertonic_voice_from_spec() to only normalize
- Add 11 test cases for the new method

Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (* or +) -> kokoro
3. Exact voice ID match -> backend id
4. Unknown voice -> fallback
2026-07-08 17:02:33 +00:00
Artem Akymenko c4d14112d4 refactor: replace hardcoded backend ID sets with registry checks
Add TTSBackendRegistry.is_registered() and module-level
is_registered_backend() to validate backend IDs dynamically.
Replace all Category A hardcoded sets (validation-only) in
voice_profiles, api routes, conversion_runner, and form utils.
2026-07-08 16:33:16 +00:00
Artem Akymenko f4cb2c2329 ci: add pytest, use actions/cache@v6 2026-07-08 19:26:42 +03:00
Artem AkymenkoandGitHub 783738882f Merge pull request #188 from denizsafak/refactor/move-kokoro-voices-into-backend
refactor: move VOICES_INTERNAL into KokoroBackend module
2026-07-08 19:23:33 +03:00
Artem Akymenko e94ba5257e refactor: move VOICES_INTERNAL into KokoroBackend module
Make the Kokoro voice list an internal implementation detail of the
backend instead of a shared constant. The rest of the project already
accesses voices via get_metadata('kokoro').voices.

- Move VOICES_INTERNAL from constants.py to kokoro.py as _VOICES_INTERNAL
- Update tests to use get_metadata('kokoro').voices instead of importing
  the constant directly
2026-07-08 16:19:34 +00:00
Artem AkymenkoandGitHub 49d66839dc Merge pull request #186 from denizsafak/refactor/migrate-remaining-voice-metadata-consumers
refactor: migrate remaining consumers to get_metadata API
2026-07-08 19:01:20 +03:00
Artem AkymenkoandGitHub d0e316ea7b Merge pull request #187 from denizsafak/refactor/migrate-pyqt-to-backend-metadata
refactor(pyqt): migrate from VOICES_INTERNAL to get_metadata API
2026-07-08 19:01:02 +03:00
Artem Akymenko bb96ae502c refactor: migrate remaining consumers to get_metadata API
Replace direct VOICES_INTERNAL imports with get_metadata('kokoro').voices:
- abogen/predownload_gui.py
- abogen/subtitle_utils.py
2026-07-08 15:58:51 +00:00
Artem Akymenko a4d25accc1 refactor(pyqt): migrate from VOICES_INTERNAL to get_metadata API
Replace direct VOICES_INTERNAL imports with get_metadata('kokoro').voices
from tts_backend_registry in all PyQt modules:
- abogen/pyqt/gui.py
- abogen/pyqt/predownload_gui.py
- abogen/pyqt/voice_formula_gui.py
2026-07-08 15:57:18 +00:00
Artem AkymenkoandGitHub 66964bfd0b Merge pull request #185 from denizsafak/refactor/use-backend-metadata-in-webui
refactor(webui): replace direct VOICES_INTERNAL/DEFAULT_SUPERTONIC_VOICES with get_metadata API
2026-07-08 18:49:21 +03:00
Artem Akymenko f8f72624f8 refactor(webui): replace direct VOICES_INTERNAL/DEFAULT_SUPERTONIC_VOICES with get_metadata API
- Add get_default_voice() helper to tts_backend_registry
- Replace all VOICES_INTERNAL imports in WebUI with get_metadata().voices
- Replace all DEFAULT_SUPERTONIC_VOICES imports in conversion_runner with get_metadata().voices
- Remove unused VOICES_INTERNAL import from voices.py

Core modules (voice_profiles, voice_formulas, voice_cache) already used
get_metadata(). This completes the WebUI migration.
2026-07-08 15:42:49 +00:00
Artem Akymenko e7a88a513a ci: fix duplicate triggers, pin macos-14 to avoid migration warning 2026-07-08 16:52:26 +03:00
Artem AkymenkoandGitHub 2277f16d0a Merge pull request #184 from denizsafak/refactor/use-backend-metadata-for-voice-lists
refactor: migrate core modules to use TTSBackendMetadata.voices via registry
2026-07-08 16:51:58 +03:00
Artem Akymenko 1d50429b87 refactor: migrate core modules to use TTSBackendMetadata.voices via registry
Replace direct imports of VOICES_INTERNAL and DEFAULT_SUPERTONIC_VOICES
in voice_profiles, voice_formulas, and voice_cache with get_metadata()
from TTSBackendRegistry. Adds get_metadata() top-level function to
tts_backend_registry as symmetric counterpart to register_backend() and
create_backend().
2026-07-08 13:43:52 +00:00
Artem Akymenko 29681a5fbb ci: update actions to v7/v6, add pip caching, optimize Dockerfile layer order 2026-07-08 16:22:52 +03:00
Artem AkymenkoandGitHub 50fa2e5b9e Merge pull request #183 from denizsafak/refactor/store-supported-voices-in-backend-metadata
feat: store supported voices in TTSBackendMetadata
2026-07-08 16:02:57 +03:00
Artem Akymenko 5816feb6da feat: store supported voices in TTSBackendMetadata
Add voices field to TTSBackendMetadata so each backend's supported
voice list is part of its metadata rather than external constants.

- Add voices: tuple[str, ...] = () to TTSBackendMetadata
- Create _KOKORO_METADATA / _SUPERTONIC_METADATA as single source
  of truth for both metadata property and registry registration
- Update KokoroBackend.get_available_voices() to use self.metadata.voices
- Update SupertonicBackend.get_available_voices() to use self.metadata.voices
- Add tests for voices field, metadata voice content, and unified instance identity
2026-07-06 17:40:49 +00:00
Artem AkymenkoandGitHub b95df8f217 Merge pull request #182 from denizsafak/refactor/add-kokoro-backend
feat: add KokoroBackend implementing TTSBackend protocol
2026-07-06 17:29:49 +03:00
Artem AkymenkoandGitHub 245e67284e Merge pull request #181 from denizsafak/refactor/add-supertonic-backend
feat: add SupertonicBackend implementing TTSBackend protocol
2026-07-06 17:29:22 +03:00
Artem Akymenko e2557d961b feat: add KokoroBackend implementing TTSBackend protocol
- Create KokoroBackend class implementing TTSBackend protocol
- Move all KPipeline interaction inside KokoroBackend
- Update LoadPipelineThread to create backend via create_backend()
- Update ConversionThread and VoicePreviewThread to accept backend
- Replace np_module/kpipeline_class parameters with single backend
- Add 24 unit tests for KokoroBackend
- KPipeline is now an internal implementation detail of KokoroBackend
2026-07-06 14:10:54 +00:00
Artem Akymenko 9c6b3774b4 feat: add SupertonicBackend implementing TTSBackend protocol
Encapsulate SupertonicPipeline as an internal detail of
SupertonicBackend. The factory create_supertonic_backend() now
returns a SupertonicBackend instance instead of a raw
SupertonicPipeline, satisfying the TTSBackend protocol with
metadata, synthesize, get_available_voices, get_supported_formats,
and get_info methods. Backward-compatible __call__ delegates to
the internal pipeline.
2026-07-06 14:09:30 +00:00
Artem AkymenkoandGitHub fd9fe5579a Merge pull request #180 from k0sm0naft/refactor/use-registry-for-preview
refactor: migrate preview and conversion code to use TTSBackendRegistry
2026-07-06 16:53:28 +03:00
Artem Akymenko f079373821 refactor: migrate preview and conversion code to use TTSBackendRegistry
Migrate all preview/debug/conversion pipeline creation to use
TTSBackendRegistry.create_backend() instead of direct imports:

- debug_tts_runner._load_pipeline(): Kokoro via registry
- preview.get_preview_pipeline(): Kokoro via registry
- preview.generate_preview_audio(): Supertonic via registry
- voice.get_preview_pipeline(): Kokoro via registry
- conversion_runner._load_pipeline(): both backends via registry
- conversion_runner inline pipeline creation: both via registry
- test: update mock to target tts_backend_registry.create_backend
2026-07-06 15:59:22 +03:00
Deniz ŞafakandGitHub fbb5d4e368 Merge pull request #179 from k0sm0naft/refactor/backend-registry
Add TTS backend registry and automatic backend registration
2026-07-06 15:14:47 +03:00
Artem Akymenko 57fec453e2 feat: auto-register existing TTS backends
- Add create_kokoro_backend() factory in kokoro.py
- Add create_supertonic_backend() factory in supertonic.py
- Auto-discover backend modules in __init__.py via pkgutil
- Both backends register themselves on import
- Tests verify registration and factory callables
2026-07-06 15:04:49 +03:00
Artem Akymenko 58fe22e3d5 feat: add TTSBackendRegistry for backend registration and creation
- TTSBackendRegistry class with register(), list_backends(), get_metadata(), create_backend()
- Global registry singleton with register_backend() and create_backend() convenience functions
- Unit tests for registry operations
2026-07-06 15:04:49 +03:00
Deniz ŞafakandGitHub ab8cbc4911 Merge pull request #178 from k0sm0naft/refactor/backend-package
refactor: move backend implementations to tts_backends package
2026-07-06 14:50:16 +03:00
Deniz ŞafakandGitHub 5e2048072a Merge pull request #177 from k0sm0naft/refactor/tts-backend-interface
feat: Add TTSBackendMetadata model
2026-07-06 14:49:35 +03:00
Artem Akymenko 66ed2a202d refactor: move backend implementations to tts_backends package
Moved SupertonicPipeline from abogen/tts_supertonic.py to
abogen/tts_backends/supertonic.py and load_numpy_kpipeline from
abogen/utils.py to abogen/tts_backends/kokoro.py.

Git correctly detects the Supertonic file as a rename (R),
preserving full commit history.

- New package: abogen/tts_backends/
  - __init__.py (package marker)
  - supertonic.py (SupertonicPipeline, moved from tts_supertonic.py)
  - kokoro.py (load_numpy_kpipeline, moved from utils.py)
- abogen/utils.py: re-exports load_numpy_kpipeline for backward compat
- All imports updated to new canonical paths
2026-07-06 14:04:51 +03:00
Artem Akymenko 45e859dac4 feat: Add TTSBackendMetadata model 2026-07-06 12:58:06 +03:00
Deniz ŞafakandGitHub 56d3e414b3 Merge pull request #176 from k0sm0naft/feat/voice-metadata
feat: add VoiceMetadata data model for TTS backends
2026-07-06 11:01:22 +03:00
Deniz ŞafakandGitHub b942bcb820 Merge pull request #175 from k0sm0naft/refactor/tts-backend-interface
refactor: Switch TTSBackend from ABC to Protocol
2026-07-06 11:00:46 +03:00
Artem Akymenko 47efcb4420 feat: add VoiceMetadata data model for TTS backends 2026-07-05 19:07:57 +00:00
Artem Akymenko 7b3f9d8615 Merge branch 'main' into refactor/tts-backend-interface 2026-07-05 16:53:40 +03:00
Artem Akymenko 9833bb0843 refactor: Switch TTSBackend from ABC to Protocol 2026-07-05 13:47:31 +00:00
Deniz ŞafakandGitHub cbc05ead42 Merge pull request #173 from k0sm0naft/refactor/tts-backend-interface
refactor: introduce TTS backend abstraction
2026-07-03 21:04:47 +03:00
Artem Akymenko 50b4d6872a feat: Add minimal TTSBackend interface for future extensibility
- Create TTSBackend abstract base class with minimal contract
- Implement KokoroTTSBackend that maintains existing behavior
- Update conversion_runner.py to use new interface
- No behavioral changes, GUI unchanged, no new features
2026-07-03 01:25:41 +03:00
YashandGitHub da68f38b9b Refactor segment processing with overflow error handling
fix: catch OverflowError in emit_text for very large numbers

Fixes #145

When text contains a very large number (e.g. a long decimal from a binary
hash), misaki's pipeline calls num2words() which raises OverflowError and
crashes the entire job. Wrap the segment iterator in try/except so the
chunk is skipped gracefully with a warning log instead of terminating.
2026-05-22 10:07:38 +05:30
176 changed files with 14832 additions and 2873 deletions
+15
View File
@@ -0,0 +1,15 @@
*.py text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.json text eol=lf
*.txt text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.sh text eol=lf
*.cfg text eol=lf
*.ini text eol=lf
*.svg text eol=lf
*.j2 text eol=lf
+31 -11
View File
@@ -1,7 +1,9 @@
name: pip install name: CI
run-name: pip install run-name: CI
on: on:
push: push:
branches: [main]
paths: paths:
- '**.py' - '**.py'
- 'pyproject.toml' - 'pyproject.toml'
@@ -11,23 +13,41 @@ on:
- 'pyproject.toml' - 'pyproject.toml'
- '.github/workflows/**' - '.github/workflows/**'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
install-and-run: test:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-14, windows-latest]
python-version: ['3.12'] python-version: ['3.12']
fail-fast: false fail-fast: false
continue-on-error: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install from repository
run: python -m pip install . - name: Install uv
#- name: Run abogen uses: astral-sh/setup-uv@v8.3.1
# run: abogen with:
enable-cache: true
prune-cache: false
cache-dependency-glob: pyproject.toml
- name: Install system dependencies (Ubuntu)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libegl1
- name: Install dependencies
run: uv pip install --system .[dev]
env:
UV_LINK_MODE: copy
- name: Run tests
env:
QT_QPA_PLATFORM: offscreen
run: pytest tests/ -v --tb=short
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Login to Github Container Registry - name: Login to Github Container Registry
# Only if we need to push an image # Only if we need to push an image
+1 -6
View File
@@ -38,8 +38,6 @@ This method handles everything automatically - installing all dependencies inclu
#### <b>OPTION 2: Install using uv</b> #### <b>OPTION 2: Install using uv</b>
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
The CUDA extras install both GPU-accelerated Kokoro (via PyTorch) and Supertonic (via onnxruntime-gpu).
```bash ```bash
# For NVIDIA GPUs (CUDA 12.8) - Recommended # For NVIDIA GPUs (CUDA 12.8) - Recommended
uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
@@ -67,9 +65,6 @@ venv\Scripts\activate
# We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628 # We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
# Also install onnxruntime-gpu for Supertonic GPU acceleration:
pip install onnxruntime-gpu
# For AMD GPUs: # For AMD GPUs:
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU. # Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
@@ -178,7 +173,7 @@ Abogen offers **two interfaces**, but currently they have different feature sets
| Command | Interface | Features | | Command | Interface | Features |
|---------|-----------|----------| |---------|-----------|----------|
| `abogen` | PyQt6 Desktop GUI | Stable core features + **Supertonic TTS**| | `abogen` | PyQt6 Desktop GUI | Stable core features |
| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! | | `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience. > **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
-14
View File
@@ -323,13 +323,6 @@ if /I "%IS_NVIDIA%"=="true" (
pause pause
exit /b exit /b
) )
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
if errorlevel 1 (
echo Failed to install onnxruntime-gpu.
pause
exit /b
)
) else ( ) else (
echo CUDA is available on NVIDIA GPU. echo CUDA is available on NVIDIA GPU.
) )
@@ -355,13 +348,6 @@ if /I "%IS_NVIDIA%"=="true" (
pause pause
exit /b exit /b
) )
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
if errorlevel 1 (
echo Failed to install onnxruntime-gpu.
pause
exit /b
)
) )
) )
Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

-109
View File
@@ -29,57 +29,6 @@ LANGUAGE_DESCRIPTIONS = {
"z": "Mandarin Chinese", "z": "Mandarin Chinese",
} }
# Mapping from Kokoro single-letter language codes to ISO 3166-1 alpha-2 country codes
# Used for loading flag icons
KOKORO_LANG_TO_COUNTRY = {
"a": "us", # American English -> United States
"b": "gb", # British English -> United Kingdom
"e": "es", # Spanish -> Spain
"f": "fr", # French -> France
"h": "in", # Hindi -> India
"i": "it", # Italian -> Italy
"j": "jp", # Japanese -> Japan
"p": "br", # Brazilian Portuguese -> Brazil
"z": "cn", # Mandarin Chinese -> China
}
# Mapping from Supertonic ISO 639-1 language codes to ISO 3166-1 alpha-2 country codes
# Used for loading flag icons in the Supertonic language picker
SUPERTONIC_LANG_TO_COUNTRY = {
"en": "gb",
"ko": "kr",
"ja": "jp",
"ar": "ae",
"bg": "bg",
"cs": "cz",
"da": "dk",
"de": "de",
"el": "gr",
"es": "es",
"et": "ee",
"fi": "fi",
"fr": "fr",
"hi": "in",
"hr": "hr",
"hu": "hu",
"id": "id",
"it": "it",
"lt": "lt",
"lv": "lv",
"nl": "nl",
"pl": "pl",
"pt": "pt",
"ro": "ro",
"ru": "ru",
"sk": "sk",
"sl": "si",
"sv": "se",
"tr": "tr",
"uk": "ua",
"vi": "vn",
"na": "na",
}
# Supported sound formats # Supported sound formats
SUPPORTED_SOUND_FORMATS = [ SUPPORTED_SOUND_FORMATS = [
"wav", "wav",
@@ -114,64 +63,6 @@ SUPPORTED_INPUT_FORMATS = [
# 384 if self.lang_code in 'ab': # 384 if self.lang_code in 'ab':
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys()) SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
# Voice and sample text constants
VOICES_INTERNAL = [
"af_alloy",
"af_aoede",
"af_bella",
"af_heart",
"af_jessica",
"af_kore",
"af_nicole",
"af_nova",
"af_river",
"af_sarah",
"af_sky",
"am_adam",
"am_echo",
"am_eric",
"am_fenrir",
"am_liam",
"am_michael",
"am_onyx",
"am_puck",
"am_santa",
"bf_alice",
"bf_emma",
"bf_isabella",
"bf_lily",
"bm_daniel",
"bm_fable",
"bm_george",
"bm_lewis",
"ef_dora",
"em_alex",
"em_santa",
"ff_siwis",
"hf_alpha",
"hf_beta",
"hm_omega",
"hm_psi",
"if_sara",
"im_nicola",
"jf_alpha",
"jf_gongitsune",
"jf_nezumi",
"jf_tebukuro",
"jm_kumo",
"pf_dora",
"pm_alex",
"pm_santa",
"zf_xiaobei",
"zf_xiaoni",
"zf_xiaoxiao",
"zf_xiaoyi",
"zm_yunjian",
"zm_yunxi",
"zm_yunxia",
"zm_yunyang",
]
# Voice and sample text mapping # Voice and sample text mapping
SAMPLE_VOICE_TEXTS = { SAMPLE_VOICE_TEXTS = {
"a": "This is a sample of the selected voice.", "a": "This is a sample of the selected voice.",
+118
View File
@@ -0,0 +1,118 @@
"""Audio helper utilities.
Functions for building ffmpeg commands, converting audio formats,
and applying chapter metadata to MP4 files.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
SAMPLE_RATE = 24000
def build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
from abogen.infrastructure.exporters import ExportService
base = [
"ffmpeg",
"-y",
"-f",
"f32le",
"-ar",
str(SAMPLE_RATE),
"-ac",
"1",
"-i",
"pipe:0",
]
if fmt == "mp3":
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
elif fmt == "opus":
base += ["-c:a", "libopus", "-b:a", "24000"]
elif fmt == "m4b":
base += ["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]
else:
base += ["-c:a", "copy"]
if metadata:
svc = ExportService()
base.extend(svc._metadata_to_ffmpeg_args(metadata))
base.append(str(path))
return base
def to_float32(audio_segment) -> np.ndarray:
if audio_segment is None:
return np.zeros(0, dtype="float32")
tensor = audio_segment
if hasattr(tensor, "detach"):
tensor = tensor.detach()
if hasattr(tensor, "cpu"):
try:
tensor = tensor.cpu()
except Exception:
pass
if hasattr(tensor, "numpy"):
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
return np.asarray(tensor, dtype="float32").reshape(-1)
def apply_m4b_chapters_with_mutagen(
audio_path: Path,
chapters: List[Dict[str, Any]],
) -> bool:
"""Apply chapter atoms to an MP4/M4B file using mutagen.
Returns True if chapters were written, False otherwise.
Raises ImportError if mutagen is not installed.
"""
if not chapters:
return False
from fractions import Fraction
from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import]
mp4 = MP4(str(audio_path))
chapter_objects: List[MP4Chapter] = []
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
start_raw = entry.get("start")
if start_raw is None:
continue
try:
start_seconds = max(0.0, float(start_raw))
except (TypeError, ValueError):
continue
title_value = entry.get("title")
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
chapter_atom = MP4Chapter(start_fraction, title_text)
end_raw = entry.get("end")
if end_raw is not None:
try:
end_seconds = float(end_raw)
except (TypeError, ValueError):
end_seconds = None
if end_seconds is not None and end_seconds > start_seconds:
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
chapter_objects.append(chapter_atom)
if not chapter_objects:
return False
from typing import cast
mp4.chapters = cast(Any, chapter_objects)
mp4.save()
return True
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from abogen.text_extractor import ExtractedChapter
from abogen.domain.voice_utils import coerce_truthy
def apply_chapter_overrides(
extracted: List[ExtractedChapter],
overrides: List[Dict[str, Any]],
) -> Tuple[List[ExtractedChapter], Dict[str, str], List[str]]:
if not overrides:
return [], {}, []
selected: List[ExtractedChapter] = []
metadata_updates: Dict[str, str] = {}
diagnostics: List[str] = []
for position, payload in enumerate(overrides):
if not isinstance(payload, dict):
diagnostics.append(
f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}."
)
continue
enabled = coerce_truthy(payload.get("enabled", True))
payload["enabled"] = enabled
if not enabled:
continue
metadata_payload = payload.get("metadata") or {}
if isinstance(metadata_payload, dict):
for key, value in metadata_payload.items():
if value is None:
continue
metadata_updates[str(key)] = str(value)
base: Optional[ExtractedChapter] = None
idx_candidate = payload.get("index")
idx_normalized: Optional[int] = None
if isinstance(idx_candidate, int):
idx_normalized = idx_candidate
elif isinstance(idx_candidate, str):
try:
idx_normalized = int(idx_candidate)
except ValueError:
idx_normalized = None
if idx_normalized is not None and 0 <= idx_normalized < len(extracted):
base = extracted[idx_normalized]
payload["index"] = idx_normalized
if base is None:
source_title = payload.get("source_title")
if isinstance(source_title, str):
base = next((chapter for chapter in extracted if chapter.title == source_title), None)
if base is None:
candidate_title = payload.get("title")
if isinstance(candidate_title, str):
base = next((chapter for chapter in extracted if chapter.title == candidate_title), None)
text_override = payload.get("text")
if text_override is not None:
text_value = str(text_override)
elif base is not None:
text_value = base.text
else:
diagnostics.append(
f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found."
)
continue
title_override = payload.get("title")
if title_override is not None:
title_value = str(title_override)
elif base is not None:
title_value = base.title
else:
title_value = f"Chapter {position + 1}"
if base and not payload.get("source_title"):
payload["source_title"] = base.title
payload["title"] = title_value
payload["text"] = text_value
payload["characters"] = len(text_value)
payload.setdefault("order", payload.get("order", position))
selected.append(ExtractedChapter(title=title_value, text=text_value))
return selected, metadata_updates, diagnostics
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import re
from typing import List, Tuple
_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
_HEADING_NUMBER_PREFIX_RE = re.compile(
r"^\s*(?P<number>(?:\d+|[ivxlcdm]+))(?P<suffix>(?:[\s.:;-].*)?)$",
re.IGNORECASE,
)
_ACRONYM_ALLOWLIST = {
"AI", "API", "CPU", "DIY", "GPU", "HTML", "HTTP", "HTTPS", "ID",
"JSON", "MP3", "MP4", "M4B", "NASA", "OCR", "PDF", "SQL", "TV",
"TTS", "UK", "UN", "UFO", "OK", "URL", "USA", "US", "VR",
}
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
def simplify_heading_text(text: str) -> str:
raw = str(text or "").strip().lower()
if not raw:
return ""
simplified = _HEADING_SANITIZE_RE.sub("", raw)
if simplified.startswith("chapter"):
simplified = simplified[7:]
return simplified
def headings_equivalent(left: str, right: str) -> bool:
simple_left = simplify_heading_text(left)
simple_right = simplify_heading_text(right)
if not simple_left or not simple_right:
return False
if simple_left == simple_right:
return True
if simple_right.startswith(simple_left):
return True
if simple_left.startswith(simple_right):
return True
if len(simple_left) > 5 and simple_left in simple_right:
return True
return False
def strip_duplicate_heading_line(text: str, heading: str) -> Tuple[str, bool]:
source_text = str(text or "")
if not source_text:
return source_text, False
normalized_heading = simplify_heading_text(heading)
if not normalized_heading:
return source_text, False
lines = source_text.splitlines()
new_lines: List[str] = []
removed = False
for line in lines:
stripped = line.strip()
if not removed and stripped:
if headings_equivalent(stripped, heading):
removed = True
continue
new_lines.append(line)
if not removed:
return source_text, False
while new_lines and not new_lines[0].strip():
new_lines.pop(0)
return "\n".join(new_lines), True
def normalize_caps_word(word: str) -> str:
upper = word.upper()
letters = [char for char in upper if char.isalpha()]
if not letters:
return word
if upper in _ACRONYM_ALLOWLIST:
return word
if len(letters) <= 1:
return word
if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7:
return word
parts = re.split(r"(['\-\u2019])", word)
normalized_parts: List[str] = []
for part in parts:
if part in {"'", "-", "\u2019"}:
normalized_parts.append(part)
continue
if not part:
continue
normalized_parts.append(part[0].upper() + part[1:].lower())
return "".join(normalized_parts) or word
def normalize_chapter_opening_caps(text: str) -> Tuple[str, bool]:
if not text:
return text, False
leading_len = len(text) - len(text.lstrip())
leading = text[:leading_len]
working = text[leading_len:]
if not working:
return text, False
builder: List[str] = []
pos = 0
changed = False
while pos < len(working):
char = working[pos]
if char in "\r\n":
builder.append(working[pos:])
pos = len(working)
break
if char.isspace():
builder.append(char)
pos += 1
continue
if char.islower():
builder.append(working[pos:])
pos = len(working)
break
if not char.isalpha():
builder.append(char)
pos += 1
continue
match = _CAPS_WORD_RE.match(working, pos)
if not match:
builder.append(char)
pos += 1
continue
word = match.group(0)
if any(ch.islower() for ch in word):
builder.append(working[pos:])
pos = len(working)
break
normalized = normalize_caps_word(word)
if normalized != word:
changed = True
builder.append(normalized)
pos = match.end()
if pos < len(working):
builder.append(working[pos:])
if not changed:
return text, False
return leading + "".join(builder), True
def format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str:
base = str(title or "").strip()
if not base:
return f"Chapter {index}" if apply_prefix else ""
if not apply_prefix:
return base
lowered = base.lower()
if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()):
return base
match = _HEADING_NUMBER_PREFIX_RE.match(base)
if match:
number = match.group("number") or ""
suffix = match.group("suffix") or ""
cleaned_suffix = suffix.lstrip(" .,:;-_ \t\u2013\u2014\u00b7\u2022")
if cleaned_suffix:
return f"Chapter {number}. {cleaned_suffix}"
return f"Chapter {number}"
return base
def apply_chapter_text_transforms(
text: str,
*,
heading_text: str,
raw_title: str,
strip_heading: bool,
normalize_caps: bool,
) -> Tuple[str, bool, bool]:
"""Strip duplicate heading and normalize opening caps.
Returns ``(text, heading_removed, caps_changed)``.
The caller is responsible for state updates (pending flags, logging,
dict mutation, ``continue``).
"""
heading_removed = False
caps_changed = False
if strip_heading and heading_text:
text, heading_removed = strip_duplicate_heading_line(text, heading_text)
if not heading_removed and raw_title:
match = _HEADING_NUMBER_PREFIX_RE.match(raw_title)
if match:
number = match.group("number")
if number:
text, heading_removed = strip_duplicate_heading_line(text, number)
if normalize_caps and text:
text, caps_changed = normalize_chapter_opening_caps(text)
return text, heading_removed, caps_changed
+75
View File
@@ -0,0 +1,75 @@
"""Chunk processing utilities.
Functions for grouping chunks, recording override usage, and selecting
text for TTS synthesis.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, Iterable, Mapping, Optional
from abogen.pronunciation_store import increment_usage
def safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for entry in chunks or []:
if not isinstance(entry, dict):
continue
try:
chapter_index = int(entry.get("chapter_index", 0))
except (TypeError, ValueError):
chapter_index = 0
grouped[chapter_index].append(dict(entry))
for chapter_index, items in grouped.items():
items.sort(key=lambda payload: safe_int(payload.get("chunk_index")))
return grouped
def record_override_usage(
job: Any,
usage_counter: Mapping[str, int],
token_map: Mapping[str, str],
) -> None:
if not usage_counter:
return
language = getattr(job, "language", "") or "a"
for normalized, amount in usage_counter.items():
if amount <= 0:
continue
token_value = token_map.get(normalized, normalized)
try:
increment_usage(language=language, token=token_value, amount=int(amount))
except Exception: # pragma: no cover - defensive logging
job.add_log(f"Failed to record usage for override {token_value}", level="warning")
def chunk_text_for_tts(entry: Mapping[str, Any]) -> str:
"""Choose the best source text for synthesis.
We must prefer the raw chunk text (``text`` / ``original_text``) so
manual/pronunciation overrides can match against the original tokens
(e.g. censored words like ``Unfu*k``). ``normalized_text`` may have
already been run through ``normalize_for_pipeline``, which can remove
punctuation and prevent overrides from triggering.
"""
if not isinstance(entry, Mapping):
return ""
return str(
entry.get("text")
or entry.get("original_text")
or entry.get("normalized_text")
or ""
).strip()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import platform as _platform
def select_device() -> str:
"""Return the best available compute device (``"mps"``, ``"cuda"``, or ``"cpu"``).
Checks ``torch`` availability at runtime so this can be called from
any context without requiring torch at import time.
"""
try:
import torch # type: ignore[import-not-found]
except Exception:
return "cpu"
system = _platform.system()
if system == "Darwin" and _platform.processor() == "arm":
try:
if torch.backends.mps.is_available(): # type: ignore[union-attr]
return "mps"
except Exception:
pass
return "cpu"
try:
if torch.cuda.is_available(): # type: ignore[union-attr]
return "cuda"
except Exception:
pass
return "cpu"
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Tuple
from abogen.text_extractor import ExtractedChapter
_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500}
_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160}
_STRUCTURAL_KEYWORDS = (
"preface",
"prologue",
"introduction",
"foreword",
"epilogue",
"afterword",
"appendix",
"acknowledgment",
"acknowledgement",
)
_STRUCTURAL_MIN_LENGTH = 120
_MAX_SHORT_CHAPTERS = 2
@dataclass
class ChapterFilterResult:
kept: List[ExtractedChapter]
skipped: List[Tuple[str, int]]
def infer_file_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".epub":
return "epub"
if suffix in {".md", ".markdown"}:
return "markdown"
if suffix == ".pdf":
return "pdf"
if suffix == ".txt":
return "text"
return suffix.lstrip(".") or "text"
def looks_structural(title: str) -> bool:
lowered = title.strip().lower()
if not lowered:
return False
return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS)
def chapter_label(file_type: str) -> str:
return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages"
def auto_select_relevant_chapters(
chapters: List[ExtractedChapter],
file_type: str,
) -> ChapterFilterResult:
if not chapters:
return ChapterFilterResult(kept=[], skipped=[])
normalized = file_type.lower()
threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0)
min_short = _MIN_SHORT_CONTENT.get(normalized, 0)
kept: List[ExtractedChapter] = []
skipped: List[Tuple[str, int]] = []
short_kept = 0
for chapter in chapters:
stripped = chapter.text.strip()
length = len(stripped)
if length == 0:
skipped.append((chapter.title, length))
continue
keep = False
if threshold == 0:
keep = True
elif length >= threshold:
keep = True
elif not kept:
keep = True
elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS:
keep = True
short_kept += 1
elif looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH:
keep = True
if keep:
kept.append(chapter)
else:
skipped.append((chapter.title, length))
if kept:
return ChapterFilterResult(kept=kept, skipped=skipped)
longest_idx = None
longest_length = 0
for idx, chapter in enumerate(chapters):
stripped = chapter.text.strip()
if stripped and len(stripped) > longest_length:
longest_length = len(stripped)
longest_idx = idx
if longest_idx is not None:
longest = chapters[longest_idx]
fallback_skipped = [
(chapter.title, len(chapter.text.strip()))
for idx, chapter in enumerate(chapters)
if idx != longest_idx and chapter.text.strip()
]
return ChapterFilterResult(kept=[longest], skipped=fallback_skipped)
return ChapterFilterResult(kept=[], skipped=skipped)
def update_metadata_for_chapter_count(
metadata: Dict[str, Any], count: int, file_type: str
) -> None:
if not metadata or count <= 0:
return
label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages"
metadata["chapter_count"] = str(count)
pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)")
replacement = f"({count} {label})"
for key in ("album", "ALBUM"):
value = metadata.get(key)
if not isinstance(value, str):
continue
metadata[key] = pattern.sub(replacement, value)
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple
_SERIES_NAME_KEYS = (
"series",
"series_name",
"series_title",
)
_SERIES_NUMBER_KEYS = (
"series_index",
"series_position",
"series_sequence",
"book_number",
"series_number",
)
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
def normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
normalized: Dict[str, str] = {}
if not values:
return normalized
for key, value in values.items():
if value is None:
continue
text = str(value).strip()
if not text:
continue
normalized[str(key).casefold()] = text
return normalized
def format_author_sentence(raw: Optional[str]) -> str:
if raw is None:
return ""
normalized = str(raw).strip()
if not normalized:
return ""
lowered = normalized.casefold()
if lowered in {"unknown", "various"}:
return ""
working = normalized.replace("&", " and ")
segments = [segment.strip() for segment in working.split(",") if segment.strip()]
tokens: List[str] = []
if segments:
for segment in segments:
parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()]
if parts:
tokens.extend(parts)
else:
tokens.append(segment)
else:
parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()]
tokens.extend(parts or [normalized])
cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}]
if not cleaned:
return ""
if len(cleaned) == 1:
return f"By {cleaned[0]}"
if len(cleaned) == 2:
return f"By {cleaned[0]} and {cleaned[1]}"
return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}"
def ensure_sentence(text: str) -> str:
cleaned = text.strip()
if not cleaned:
return ""
if cleaned[-1] in ".!?":
return cleaned
return f"{cleaned}."
def normalize_series_number(value: Any) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
candidate = text.replace(",", ".")
if candidate.replace(".", "", 1).isdigit():
if "." in candidate:
normalized = candidate.rstrip("0").rstrip(".")
return normalized or "0"
try:
return str(int(candidate))
except ValueError:
pass
match = _SERIES_NUMBER_RE.search(candidate)
if not match:
return None
normalized = match.group(0)
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or "0"
try:
return str(int(normalized))
except ValueError:
return normalized
def extract_series_metadata(values: Mapping[str, str]) -> Tuple[Optional[str], Optional[str]]:
series_name: Optional[str] = None
for key in _SERIES_NAME_KEYS:
raw = values.get(key)
if raw:
cleaned = str(raw).strip()
if cleaned:
series_name = cleaned
break
series_number: Optional[str] = None
for key in _SERIES_NUMBER_KEYS:
raw = values.get(key)
if raw is None:
continue
normalized = normalize_series_number(raw)
if normalized:
series_number = normalized
break
return series_name, series_number
def format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
if not series_name or not series_number:
return ""
name = series_name.strip()
number = series_number.strip()
if not name or not number:
return ""
article = "the " if not name.lower().startswith("the ") else ""
phrase = f"Book {number} of {article}{name}"
return re.sub(r"\s+", " ", phrase).strip()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Any, Dict, Optional
def merge_metadata(
extracted: Optional[Dict[str, Any]],
overrides: Optional[Dict[str, Any]],
) -> Dict[str, str]:
merged: Dict[str, str] = {}
if extracted:
for key, value in extracted.items():
if value is None:
continue
merged[str(key)] = str(value)
if overrides:
for key, value in overrides.items():
key_str = str(key)
if value is None:
merged.pop(key_str, None)
else:
merged[key_str] = str(value)
return merged
+30
View File
@@ -0,0 +1,30 @@
"""Text normalization convenience helpers."""
from __future__ import annotations
from typing import Any, Mapping, Optional
from abogen.kokoro_text_normalization import (
ApostropheConfig,
normalize_for_pipeline as _normalize_for_pipeline,
)
from abogen.normalization_settings import (
build_apostrophe_config,
get_runtime_settings,
apply_overrides as _apply_overrides,
)
_BASE_APOSTROPHE_CONFIG = ApostropheConfig()
def normalize_text_for_pipeline(
text: str,
*,
normalization_overrides: Optional[Mapping[str, Any]] = None,
) -> str:
"""Normalize text using runtime settings with optional overrides."""
runtime_settings = get_runtime_settings()
if normalization_overrides:
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
return _normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings)
+91
View File
@@ -0,0 +1,91 @@
"""Output path resolution utilities.
Pure functions for resolving output directories, building file paths,
and computing project folder layouts.
"""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from abogen.text_extractor import ExtractedChapter
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
def slugify(title: str, index: int) -> str:
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
if not sanitized:
sanitized = f"chapter_{index:02d}"
return sanitized[:80]
def sanitize_output_stem(name: str) -> str:
base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
return sanitized or "output"
def output_timestamp_token() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
def build_output_path(directory: Path, original_name: str, extension: str) -> Path:
sanitized = sanitize_output_stem(original_name)
return directory / f"{sanitized}.{extension}"
def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
if not replace_single_newlines:
return
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
for chapter in chapters:
chapter.text = newline_regex.sub(" ", chapter.text)
def resolve_output_directory(
*,
save_mode: str,
stored_path: Path,
output_folder: Optional[str],
desktop_dir: Optional[Path],
user_output_path: Optional[Path],
user_cache_outputs: Optional[Path],
) -> Path:
if save_mode == "Save to Desktop" and desktop_dir:
return desktop_dir
if save_mode == "Save next to input file":
return stored_path.parent
if save_mode == "Choose output folder" and output_folder:
return Path(output_folder)
if save_mode == "Use default save location" and user_output_path:
return user_output_path
return user_cache_outputs or Path(".")
def resolve_project_layout(
*,
original_filename: str,
save_as_project: bool,
base_dir: Path,
timestamp_fn: Callable[[], str] = output_timestamp_token,
sanitize_fn: Callable[[str, int], str] = sanitize_output_stem,
) -> Tuple[Path, Path, Path, Optional[Path]]:
sanitized = sanitize_fn(original_filename, 0)
folder_name = f"{timestamp_fn()}_{sanitized}"
project_root = base_dir / folder_name
project_root.mkdir(parents=True, exist_ok=True)
if save_as_project:
audio_dir = project_root / "audio"
subtitle_dir = project_root / "subtitles"
metadata_dir = project_root / "metadata"
for directory in (audio_dir, subtitle_dir, metadata_dir):
directory.mkdir(parents=True, exist_ok=True)
return project_root, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None
+261
View File
@@ -0,0 +1,261 @@
"""Pronunciation rule compilation and application.
Pure functions for compiling token-level and sentence-level pronunciation
overrides into regex patterns, applying them to text, and merging multiple
override sources with precedence rules.
"""
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List, Mapping, Optional
from abogen.entity_analysis import normalize_token as normalize_entity_token
from abogen.entity_analysis import normalize_manual_override_token
def compile_pronunciation_rules(
overrides: Optional[Iterable[Mapping[str, Any]]],
) -> List[Dict[str, Any]]:
if not overrides:
return []
candidates: List[Dict[str, Any]] = []
seen: set[str] = set()
for entry in overrides:
if not isinstance(entry, Mapping):
continue
pronunciation_value = str(entry.get("pronunciation") or "").strip()
if not pronunciation_value:
continue
token_values: List[str] = []
token_raw = entry.get("token")
if token_raw:
token_value = str(token_raw).strip()
if token_value:
token_values.append(token_value)
normalized_raw = entry.get("normalized")
if normalized_raw:
normalized_value = str(normalized_raw).strip()
if normalized_value:
token_values.append(normalized_value)
if token_raw and not token_values:
fallback = normalize_entity_token(str(token_raw))
if fallback:
token_values.append(fallback)
if not token_values:
continue
usage_normalized = str(entry.get("normalized") or "").strip()
if not usage_normalized and token_values:
usage_normalized = normalize_entity_token(token_values[0]) or token_values[0]
usage_token = str(entry.get("token") or token_values[0])
for token_value in token_values:
key = token_value.casefold()
if key in seen:
continue
seen.add(key)
candidates.append(
{
"token": token_value,
"normalized": usage_normalized,
"replacement": pronunciation_value,
}
)
if not candidates:
return []
candidates.sort(key=lambda item: len(item["token"]), reverse=True)
compiled: List[Dict[str, Any]] = []
for candidate in candidates:
token_value = candidate["token"]
pronunciation_value = candidate["replacement"]
escaped = re.escape(token_value)
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
compiled.append(
{
"pattern": pattern,
"replacement": pronunciation_value,
"normalized": candidate.get("normalized") or token_value,
"token": candidate.get("token") or token_value,
}
)
return compiled
def compile_heteronym_sentence_rules(
overrides: Optional[Iterable[Mapping[str, Any]]],
) -> List[Dict[str, Any]]:
if not overrides:
return []
compiled: List[Dict[str, Any]] = []
seen: set[str] = set()
for entry in overrides:
if not isinstance(entry, Mapping):
continue
sentence = str(entry.get("sentence") or "").strip()
if not sentence:
continue
choice = str(entry.get("choice") or "").strip()
if not choice:
continue
replacement_sentence = ""
options = entry.get("options")
if isinstance(options, list):
for opt in options:
if not isinstance(opt, Mapping):
continue
if str(opt.get("key") or "").strip() == choice:
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
break
if not replacement_sentence:
continue
rule_key = f"{sentence}\n{choice}".casefold()
if rule_key in seen:
continue
seen.add(rule_key)
parts = [p for p in re.split(r"\s+", sentence) if p]
if not parts:
continue
pattern_text = r"\s+".join(re.escape(p) for p in parts)
pattern = re.compile(pattern_text)
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
return compiled
def apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
if not text or not rules:
return text
result = text
for rule in rules:
pattern = rule["pattern"]
replacement = rule["replacement"]
result = pattern.sub(replacement, result)
return result
def apply_pronunciation_rules(
text: str,
rules: List[Dict[str, Any]],
usage_counter: Optional[Dict[str, int]] = None,
) -> str:
if not text or not rules:
return text
result = text
for rule in rules:
pattern = rule["pattern"]
pronunciation_value = rule["replacement"]
usage_key = str(rule.get("normalized") or "").strip()
def _replacement(match: re.Match[str]) -> str:
suffix = match.group("possessive") or ""
if usage_counter is not None and usage_key:
usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1
return pronunciation_value + suffix
result = pattern.sub(_replacement, result)
return result
def merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]:
"""Return pronunciation override entries, ensuring manual overrides are included.
Pending jobs keep both ``manual_overrides`` and ``pronunciation_overrides``, but the
latter can be stale if the UI didn't resync before enqueue. During conversion,
we must merge manual overrides so they always apply (before TTS).
Precedence: manual overrides win over existing entries for the same normalized key.
"""
collected: Dict[str, Dict[str, Any]] = {}
existing = getattr(job, "pronunciation_overrides", None)
if isinstance(existing, list):
for entry in existing:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("token") or "").strip()
pronunciation_value = str(entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(entry.get("voice") or "").strip() or None,
"notes": str(entry.get("notes") or "").strip() or None,
"context": str(entry.get("context") or "").strip() or None,
"source": str(entry.get("source") or "pronunciation"),
"language": getattr(job, "language", None),
}
speakers = getattr(job, "speakers", None)
if isinstance(speakers, dict):
for payload in speakers.values():
if not isinstance(payload, Mapping):
continue
token_value = str(payload.get("token") or "").strip()
pronunciation_value = str(payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(
payload.get("resolved_voice")
or payload.get("voice")
or getattr(job, "voice", "")
).strip()
or None,
"notes": None,
"context": None,
"source": "speaker",
"language": getattr(job, "language", None),
}
manual = getattr(job, "manual_overrides", None)
if isinstance(manual, list):
for entry in manual:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("token") or "").strip()
pronunciation_value = str(entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(entry.get("voice") or "").strip() or None,
"notes": str(entry.get("notes") or "").strip() or None,
"context": str(entry.get("context") or "").strip() or None,
"source": str(entry.get("source") or "manual"),
"language": getattr(job, "language", None),
}
return list(collected.values())
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
"""Unified split pattern logic extracted from 3 copies."""
import re
PUNCTUATION_SENTENCE = r".!?。!?"
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
def get_split_pattern(language: str, subtitle_mode: str) -> str:
"""Get the appropriate split pattern based on language and subtitle mode.
Args:
language: Language code (a, b, e, f, etc.)
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
Returns:
Split pattern string
"""
# For English, always use newline splitting only
if language in ("a", "b"):
return "\n"
# Determine spacing pattern based on language
spacing = r"\s*" if language in ("z", "j") else r"\s+"
# For CJK languages, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"):
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
if subtitle_mode == "Line":
return "\n"
elif subtitle_mode == "Sentence":
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
elif subtitle_mode == "Sentence + Comma":
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
else:
return r"\n+"
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional
from .metadata_helpers import (
ensure_sentence,
extract_series_metadata,
format_author_sentence,
format_series_sentence,
normalize_metadata_map,
)
def build_title_intro_text(
metadata: Optional[Mapping[str, Any]],
fallback_basename: str,
) -> str:
"""Build the title introduction text from metadata."""
normalized = normalize_metadata_map(metadata)
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
title = (
normalized.get("title")
or normalized.get("book_title")
or normalized.get("album")
or fallback_title
)
if not title:
title = fallback_title
subtitle = normalized.get("subtitle") or normalized.get("sub_title")
if subtitle and title and subtitle.casefold() == title.casefold():
subtitle = ""
author_value = ""
for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"):
value = normalized.get(candidate)
if value:
author_value = value
break
series_name, series_number = extract_series_metadata(normalized)
series_sentence = format_series_sentence(series_name, series_number)
sentences: List[str] = []
if series_sentence:
sentences.append(ensure_sentence(series_sentence))
if title:
sentences.append(ensure_sentence(title))
if subtitle:
sentences.append(ensure_sentence(subtitle))
author_sentence = format_author_sentence(author_value)
if author_sentence:
sentences.append(ensure_sentence(author_sentence))
return " ".join(sentences).strip()
def build_outro_text(
metadata: Optional[Mapping[str, Any]],
fallback_basename: str,
) -> str:
"""Build the outro/closing text from metadata."""
normalized = normalize_metadata_map(metadata)
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
title = (
normalized.get("title")
or normalized.get("book_title")
or normalized.get("album")
or fallback_title
)
author_value = ""
for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"):
value = normalized.get(candidate)
if value:
author_value = value
break
author_sentence = format_author_sentence(author_value)
authors_fragment = (
author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
)
if title and authors_fragment:
closing_line = f"The end of {title} from {authors_fragment}"
elif title:
closing_line = f"The end of {title}"
elif authors_fragment:
closing_line = f"The end from {authors_fragment}"
else:
closing_line = "The end"
series_name, series_number = extract_series_metadata(normalized)
series_sentence = format_series_sentence(series_name, series_number)
sentences: List[str] = [ensure_sentence(closing_line)]
if series_sentence:
sentences.append(ensure_sentence(series_sentence))
return " ".join(sentence for sentence in sentences if sentence).strip()
+190
View File
@@ -0,0 +1,190 @@
"""Voice resolution helpers.
Functions for resolving voice specifications, collecting required voice IDs,
and determining the voice to use for chapters and chunks.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Set
from abogen.tts_plugin.utils import get_voices, get_default_voice
from abogen.voice_formulas import extract_voice_ids
from abogen.voice_cache import ensure_voice_assets
def spec_to_voice_ids(spec: Any) -> Set[str]:
text = str(spec or "").strip()
if not text:
return set()
if text == "__custom_mix":
return set()
if "*" in text:
try:
return set(extract_voice_ids(text))
except ValueError:
return set()
if text in get_voices("kokoro"):
return {text}
return set()
def job_voice_fallback(job: Any) -> str:
base = str(getattr(job, "voice", "") or "").strip()
if base and base != "__custom_mix":
return base
speakers = getattr(job, "speakers", None)
if isinstance(speakers, dict):
narrator = speakers.get("narrator")
if isinstance(narrator, dict):
for key in ("resolved_voice", "voice_formula", "voice"):
value = narrator.get(key)
candidate = str(value or "").strip()
if candidate and candidate != "__custom_mix":
return candidate
for payload in speakers.values() or []:
if not isinstance(payload, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
value = payload.get(key)
candidate = str(value or "").strip()
if candidate and candidate != "__custom_mix":
return candidate
for chapter in getattr(job, "chapters", []) or []:
if not isinstance(chapter, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
candidate = str(chapter.get(key) or "").strip()
if candidate and candidate != "__custom_mix":
return candidate
return ""
def collect_required_voice_ids(job: Any) -> Set[str]:
voices: Set[str] = set()
voices.update(spec_to_voice_ids(job.voice))
voices.update(spec_to_voice_ids(job_voice_fallback(job)))
for chapter in getattr(job, "chapters", []) or []:
if not isinstance(chapter, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(spec_to_voice_ids(chapter.get(key)))
for chunk in getattr(job, "chunks", []) or []:
if not isinstance(chunk, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(spec_to_voice_ids(chunk.get(key)))
speakers = getattr(job, "speakers", {})
if isinstance(speakers, dict):
for payload in speakers.values() or []:
if not isinstance(payload, dict):
continue
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(spec_to_voice_ids(payload.get(key)))
voices.update(get_voices("kokoro"))
return voices
def initialize_voice_cache(job: Any) -> None:
try:
targets = collect_required_voice_ids(job)
downloaded, errors = ensure_voice_assets(
targets,
on_progress=lambda message: job.add_log(message, level="debug"),
)
except RuntimeError as exc:
job.add_log(f"Voice cache unavailable: {exc}", level="warning")
return
if downloaded:
job.add_log(
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
level="info",
)
for voice_id, error in errors.items():
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str:
if not override:
return job_voice_fallback(job)
resolved = str(override.get("resolved_voice", "")).strip()
if resolved:
return resolved
formula = str(override.get("voice_formula", "")).strip()
if formula:
return formula
voice = str(override.get("voice", "")).strip()
if voice:
return voice
return job_voice_fallback(job)
def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
for key in ("resolved_voice", "voice_formula", "voice"):
value = chunk.get(key)
if value:
return str(value)
speaker_id = chunk.get("speaker_id")
speakers = getattr(job, "speakers", None)
if isinstance(speakers, dict) and speaker_id in speakers:
speaker_entry = speakers.get(speaker_id) or {}
if isinstance(speaker_entry, dict):
for key in ("resolved_voice", "voice_formula", "voice"):
value = speaker_entry.get(key)
if value:
return str(value)
profile_formula = speaker_entry.get("voice_formula")
if profile_formula:
return str(profile_formula)
profile_name = chunk.get("voice_profile")
if profile_name:
if isinstance(speakers, dict):
speaker_entry = speakers.get(profile_name)
if isinstance(speaker_entry, dict):
for key in ("resolved_voice", "voice_formula", "voice"):
value = speaker_entry.get(key)
if value:
return str(value)
if fallback:
return fallback
return job_voice_fallback(job)
def resolve_fallback_voice_spec(
base_spec: str,
job_voice: str,
voice_cache_keys: list[str],
provider: str = "kokoro",
) -> str:
"""Resolve the voice spec for intro/outro with a priority fallback chain.
Priority: base_spec job_voice first voice_cache key default voice.
``"__custom_mix"`` is treated as empty (it is not a usable voice spec).
"""
spec = base_spec or job_voice
if spec == "__custom_mix":
spec = job_voice or ""
if not spec:
for key in voice_cache_keys:
if key and key != "__custom_mix":
spec = key.split(":", 1)[-1]
break
if not spec:
spec = get_default_voice(provider)
return spec
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from typing import Any, Mapping, Optional, Tuple, Set
from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.tts_plugin.utils import get_voices
def infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
"""Infer TTS provider from voice specification."""
raw = str(value or "").strip()
if not raw:
return fallback
if raw.upper() == raw and raw.replace("_", "").isalnum():
return "supertonic"
if raw == "__custom_mix" or "*" in raw or "+" in raw:
return "kokoro"
if raw in get_voices("kokoro"):
return "kokoro"
return fallback
def supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
"""Normalize a voice specification for Supertonic.
This function only performs Supertonic-specific normalization (uppercase conversion
and fallback handling). Backend resolution is handled by the registry.
"""
raw = str(spec or "").strip()
fallback_raw = str(fallback or "").strip()
# Normalize to uppercase for Supertonic voice IDs
upper = raw.upper() if raw else ""
# If empty or contains formula characters, use fallback
if not upper or "*" in upper or "+" in upper:
upper = fallback_raw.upper() if fallback_raw else ""
# If still empty, use default Supertonic voice
if not upper or "*" in upper or "+" in upper:
upper = "M1"
return upper
def split_speaker_reference(value: Any) -> Tuple[Optional[str], str]:
"""Parse speaker/profile reference from string.
Expected format: "speaker:name" or "profile:name"
Returns (name, original) or (None, original) if not a valid reference.
"""
raw = str(value or "").strip()
if not raw or ":" not in raw:
return None, raw
prefix, remainder = raw.split(":", 1)
prefix = prefix.strip().lower()
if prefix not in {"speaker", "profile"}:
return None, raw
name = remainder.strip()
return (name or None), raw
def formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
"""Build voice formula string from kokoro entry."""
voices = entry.get("voices") or []
if not voices:
return ""
total = 0.0
parts: list[tuple[str, float]] = []
for item in voices:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
name = str(item[0] or "").strip()
try:
weight = float(item[1])
except (TypeError, ValueError):
continue
if name and weight > 0:
parts.append((name, weight))
total += weight
if not parts:
return ""
normalized = [(name, weight / total) for name, weight in parts]
return " + ".join(f"{name}*{weight:.6f}" for name, weight in normalized)
def coerce_truthy(value: Any, default: bool = True) -> bool:
"""Coerce a value to boolean with default."""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() not in {"false", "0", "no", "off", ""}
if value is None:
return default
return bool(value)
+673
View File
@@ -0,0 +1,673 @@
from __future__ import annotations
import json
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Mapping, Sequence
import static_ffmpeg
from abogen.epub3.exporter import build_epub3_package
from abogen.integrations.audiobookshelf import (
AudiobookshelfClient,
AudiobookshelfConfig,
AudiobookshelfUploadError,
)
from abogen.utils import create_process
logger = logging.getLogger(__name__)
@dataclass
class ExportConfig:
"""Configuration for export operations."""
ffmpeg_path: str = "ffmpeg"
verify_ssl: bool = True
class ExportService:
"""Unified service for audiobook exports (M4B, FFMETADATA, EPUB3, Audiobookshelf)."""
def __init__(self, config: Optional[ExportConfig] = None):
self.config = config or ExportConfig()
static_ffmpeg.add_paths()
# ----------------------------------------------------------------------
# FFMETADATA
# ----------------------------------------------------------------------
def render_ffmetadata(
self,
metadata: Dict[str, Any],
chapters: List[Dict[str, Any]],
) -> str:
"""Render FFMETADATA content."""
lines = [";FFMETADATA1"]
for key, value in (metadata or {}).items():
if value is None:
continue
key_str = str(key).strip()
if not key_str:
continue
lines.append(f"{key_str}={self._escape_ffmetadata_value(value)}")
for chapter in chapters or []:
start = chapter.get("start")
end = chapter.get("end")
if start is None or end is None:
continue
try:
start_ms = max(0, int(round(float(start) * 1000)))
end_ms = int(round(float(end) * 1000))
except (TypeError, ValueError):
continue
if end_ms <= start_ms:
end_ms = start_ms + 1
lines.append("[CHAPTER]")
lines.append("TIMEBASE=1/1000")
lines.append(f"START={start_ms}")
lines.append(f"END={end_ms}")
title = chapter.get("title")
if title:
lines.append(f"title={self._escape_ffmetadata_value(title)}")
voice = chapter.get("voice")
if voice:
lines.append(f"voice={self._escape_ffmetadata_value(voice)}")
return "\n".join(lines) + "\n"
@staticmethod
def _escape_ffmetadata_value(value: Any) -> str:
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
return escaped
def write_ffmetadata_file(
self,
audio_path: Path,
metadata: Dict[str, Any],
chapters: List[Dict[str, Any]],
) -> Optional[Path]:
"""Write FFMETADATA file to temp location."""
content = self.render_ffmetadata(metadata, chapters)
if content.strip() == ";FFMETADATA1":
return None
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".ffmeta",
delete=False,
dir=str(directory),
) as handle:
handle.write(content)
return Path(handle.name)
# ----------------------------------------------------------------------
# M4B Export
# ----------------------------------------------------------------------
def embed_m4b_metadata(
self,
audio_path: Path,
metadata: Dict[str, Any],
chapters: List[Dict[str, Any]],
cover_path: Optional[Path] = None,
cover_mime: Optional[str] = None,
log_callback: Optional[callable] = None,
) -> None:
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
metadata_args = self._metadata_to_ffmpeg_args(metadata)
cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
if ffmetadata_path:
cmd.extend(["-f", "ffmetadata", "-i", str(ffmetadata_path)])
if cover_path and cover_path.exists():
cmd.extend(["-i", str(cover_path)])
cmd.extend(["-map", "0:a"])
cmd.extend(["-map", "1:v:0", "-c:v:0", "mjpeg", "-disposition:v:0", "attached_pic"])
if cover_mime:
cmd.extend(["-metadata:s:v:0", f"mimetype={cover_mime}"])
cmd.extend(["-metadata:s:v:0", "title=Cover Art"])
else:
cmd.extend(["-map", "0:a"])
cmd.extend(["-c:a", "copy"])
if ffmetadata_path:
cmd.extend(["-map_metadata", "1", "-map_chapters", "1"])
else:
cmd.extend(["-map_metadata", "0"])
if metadata_args:
cmd.extend(metadata_args)
cmd.extend(["-movflags", "+faststart+use_metadata_tags"])
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}:
cmd.extend(["-f", "mp4"])
cmd.append(str(temp_output))
if log_callback:
log_callback("Embedding metadata into M4B output")
process = create_process(cmd, text=True)
return_code = process.wait()
if ffmetadata_path and ffmetadata_path.exists():
try:
ffmetadata_path.unlink()
except OSError:
pass
if return_code != 0:
if temp_output.exists():
temp_output.unlink(missing_ok=True)
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
temp_output.replace(audio_path)
if log_callback:
log_callback("Embedded metadata and chapters into M4B output", "info")
# Apply chapters via Mutagen for better compatibility
self._apply_m4b_chapters_mutagen(audio_path, chapters, log_callback)
@staticmethod
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
args = []
for key, value in (metadata or {}).items():
if value in (None, ""):
continue
key_str = str(key).strip()
if not key_str:
continue
normalized_key = key_str.lower()
if normalized_key == "year":
ffmpeg_key = "date"
else:
ffmpeg_key = key_str
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
return args
def _apply_m4b_chapters_mutagen(
self,
audio_path: Path,
chapters: List[Dict[str, Any]],
log_callback: Optional[callable] = None,
) -> bool:
"""Apply chapter atoms using Mutagen."""
if not chapters:
return False
try:
from fractions import Fraction
from mutagen.mp4 import MP4, MP4Chapter
except ImportError:
if log_callback:
log_callback("Unable to write MP4 chapter atoms because mutagen is not installed.", "warning")
return False
try:
mp4 = MP4(str(audio_path))
except Exception as exc:
if log_callback:
log_callback(f"Failed to open m4b for chapter embedding: {exc}", "warning")
return False
chapter_objects = []
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
start_raw = entry.get("start")
if start_raw is None:
continue
try:
start_seconds = max(0.0, float(start_raw))
except (TypeError, ValueError):
continue
title_value = entry.get("title")
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
chapter_atom = MP4Chapter(start_fraction, title_text)
end_raw = entry.get("end")
if end_raw is not None:
try:
end_seconds = float(end_raw)
except (TypeError, ValueError):
end_seconds = None
if end_seconds is not None and end_seconds > start_seconds:
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
chapter_objects.append(chapter_atom)
if not chapter_objects:
return False
try:
mp4.chapters = chapter_objects
mp4.save()
except Exception as exc:
if log_callback:
log_callback(f"Failed to persist MP4 chapter atoms: {exc}", "warning")
return False
if log_callback:
log_callback(f"Applied {len(chapter_objects)} chapter markers via mutagen", "info")
return True
# ----------------------------------------------------------------------
# EPUB3 Export
# ----------------------------------------------------------------------
def export_epub3(
self,
output_path: Path,
book_id: str,
extraction: Any, # ExtractionResult
metadata_tags: Dict[str, Any],
chapter_markers: Sequence[Dict[str, Any]],
chunk_markers: Sequence[Dict[str, Any]],
chunks: Iterable[Dict[str, Any]],
audio_path: Path,
speaker_mode: str = "single",
cover_path: Optional[Path] = None,
cover_mime: Optional[str] = None,
) -> Path:
"""Export EPUB3 with media overlays."""
return build_epub3_package(
output_path=output_path,
book_id=book_id,
extraction=extraction,
metadata_tags=metadata_tags,
chapter_markers=chapter_markers,
chunk_markers=chunk_markers,
chunks=chunks,
audio_path=audio_path,
speaker_mode=speaker_mode,
cover_image_path=cover_path,
cover_image_mime=cover_mime,
)
# ----------------------------------------------------------------------
# Audiobookshelf Integration
# ----------------------------------------------------------------------
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
"""Build Audiobookshelf metadata from job."""
tags = self._normalize_metadata_casefold(getattr(job, "metadata_tags", {}))
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
title = self._first_nonempty(
tags.get("title"),
tags.get("book_title"),
tags.get("name"),
tags.get("album"),
filename,
)
authors = self._split_people_field(
tags.get("authors")
or tags.get("author")
or tags.get("album_artist")
or tags.get("artist")
)
narrators = self._split_people_field(tags.get("narrators") or tags.get("narrator"))
description = self._first_nonempty(
tags.get("description"), tags.get("summary"), tags.get("comment")
)
genres = self._split_simple_list(tags.get("genre"))
keywords = self._split_simple_list(tags.get("tags") or tags.get("keywords"))
language = self._first_nonempty(tags.get("language"), tags.get("lang")) or getattr(job, "language", "") or ""
series_name = self._first_nonempty(
tags.get("series"),
tags.get("series_name"),
tags.get("seriesname"),
tags.get("series_title"),
tags.get("seriestitle"),
)
series_sequence = None
for key in ("series_index", "series_position", "series_sequence", "series_number", "seriesnumber", "book_number", "booknumber"):
raw = tags.get(key)
normalized = self._normalize_series_sequence(raw)
if normalized:
series_sequence = normalized
break
if not series_name:
series_sequence = None
data = {
"title": title,
"subtitle": tags.get("subtitle"),
"authors": authors,
"narrators": narrators,
"description": description,
"publisher": tags.get("publisher"),
"genres": genres,
"tags": keywords,
"language": language,
"publishedYear": self._extract_year(
tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")
),
"seriesName": series_name,
"seriesSequence": series_sequence,
"isbn": self._first_nonempty(tags.get("isbn"), tags.get("asin")),
}
published_date = self._first_nonempty(
tags.get("published"), tags.get("publication_date"), tags.get("date")
)
if published_date:
data["publishedDate"] = published_date
rating_text = self._first_nonempty(tags.get("rating"), tags.get("my_rating"))
if rating_text:
try:
data["rating"] = float(str(rating_text).strip())
except ValueError:
pass
rating_max_text = self._first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
if rating_max_text:
try:
data["ratingMax"] = float(str(rating_max_text).strip())
except ValueError:
pass
# Remove empty values
cleaned = {}
for key, value in data.items():
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
if isinstance(value, (list, tuple)) and not value:
continue
cleaned[key] = value
return cleaned
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
"""Load chapters from job artifacts for Audiobookshelf."""
metadata_ref = job.result.artifacts.get("metadata") if getattr(job, "result", None) else None
if not metadata_ref:
return None
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
if not metadata_path.exists():
return None
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
chapters = payload.get("chapters")
if not isinstance(chapters, list):
return None
cleaned = []
for entry in chapters:
if not isinstance(entry, Mapping):
continue
title = self._first_nonempty(entry.get("title"), entry.get("original_title"))
start = entry.get("start")
end = entry.get("end")
if title is None or not isinstance(start, (int, float)):
continue
chapter_payload = {"title": title, "start": float(start)}
if isinstance(end, (int, float)):
chapter_payload["end"] = float(end)
cleaned.append(chapter_payload)
return cleaned or None
def upload_audiobookshelf(
self,
job: Any,
audio_path: Path,
subtitle_paths: List[Path],
chapters: List[Dict[str, Any]],
metadata: Dict[str, Any],
cover_path: Optional[Path] = None,
config: Optional[AudiobookshelfConfig] = None,
log_callback: Optional[callable] = None,
) -> None:
"""Upload to Audiobookshelf."""
if config is None:
# Load from job or global config
cfg = getattr(job, "_abs_config", None)
if cfg is None:
from abogen.utils import load_config
global_cfg = load_config() or {}
abs_cfg = global_cfg.get("audiobookshelf")
if isinstance(abs_cfg, Mapping):
config = AudiobookshelfConfig(
base_url=str(abs_cfg.get("base_url") or "").strip(),
api_token=str(abs_cfg.get("api_token") or "").strip(),
library_id=str(abs_cfg.get("library_id") or "").strip(),
collection_id=(str(abs_cfg.get("collection_id") or "").strip() or None),
folder_id=str(abs_cfg.get("folder_id") or "").strip(),
verify_ssl=self._coerce_bool(abs_cfg.get("verify_ssl"), True),
send_cover=self._coerce_bool(abs_cfg.get("send_cover"), True),
send_chapters=self._coerce_bool(abs_cfg.get("send_chapters"), True),
send_subtitles=self._coerce_bool(abs_cfg.get("send_subtitles"), False),
timeout=float(abs_cfg.get("timeout", 3600.0)),
)
else:
if log_callback:
log_callback("Audiobookshelf upload skipped: not configured", "warning")
return
if not config.base_url or not config.api_token or not config.library_id:
if log_callback:
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
return
if not config.folder_id:
if log_callback:
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
return
if not audio_path.exists():
if log_callback:
log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
return
existing_subtitles = [p for p in subtitle_paths if p.exists()] if config.send_subtitles else None
chapters_to_send = chapters if config.send_chapters else None
client = AudiobookshelfClient(config)
display_title = metadata.get("title") or audio_path.stem
try:
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc:
if log_callback:
log_callback(f"Audiobookshelf lookup failed: {exc}", "error")
return
if existing_items:
if log_callback:
log_callback(f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", "info")
try:
client.delete_items(existing_items)
except Exception as exc:
if log_callback:
log_callback(f"Failed to remove existing item(s): {exc}", "warning")
cover_to_send = cover_path
if config.send_cover and cover_to_send:
if isinstance(cover_to_send, str):
cover_to_send = Path(cover_to_send)
if not cover_to_send.exists():
cover_to_send = None
client.upload_audiobook(
audio_path,
metadata=metadata,
cover_path=cover_to_send,
chapters=chapters_to_send,
subtitles=existing_subtitles,
)
if log_callback:
log_callback("Audiobookshelf upload queued.", "info")
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
@staticmethod
def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
normalized = {}
if not values:
return normalized
for key, value in values.items():
if value is None:
continue
key_text = str(key).strip().lower()
if not key_text:
continue
if isinstance(value, (list, tuple, set)):
normalized[key_text] = value
else:
text = str(value).strip()
if text:
normalized[key_text] = text
return normalized
@staticmethod
def _split_people_field(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results = []
for item in raw:
results.extend(ExportService._split_people_field(item))
return results
text = str(raw or "").strip()
if not text:
return []
import re
tokens = [token.strip() for token in re.split(r"[;,/&]|\band\b", text, flags=re.IGNORECASE) if token.strip()]
seen = set()
ordered = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
@staticmethod
def _split_simple_list(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results = []
for item in raw:
results.extend(ExportService._split_simple_list(item))
return results
text = str(raw or "").strip()
if not text:
return []
import re
tokens = [token.strip() for token in re.split(r"[;,\n]", text) if token.strip()]
seen = set()
ordered = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
@staticmethod
def _first_nonempty(*values: Any) -> Optional[str]:
for value in values:
if value is None:
continue
if isinstance(value, (list, tuple, set)):
items = list(value)
if not items:
continue
value = items[0]
text = str(value).strip()
if text:
return text
return None
@staticmethod
def _extract_year(raw: Optional[str]) -> Optional[int]:
if not raw:
return None
text = str(raw).strip()
if not text:
return None
import re
match = re.search(r"(19|20)\d{2}", text)
if match:
try:
return int(match.group(0))
except ValueError:
return None
try:
parsed = int(text)
except ValueError:
return None
if 0 < parsed < 3000:
return parsed
return None
@staticmethod
def _normalize_series_sequence(raw: Any) -> Optional[str]:
if raw is None:
return None
if isinstance(raw, (int, float)):
if isinstance(raw, float) and (raw != raw or raw == float("inf") or raw == float("-inf")):
return None
text = str(raw)
else:
text = str(raw).strip()
if not text:
return None
candidate = text.replace(",", ".")
import re
match = re.search(r"\d+(?:\.\d+)?", candidate)
if not match:
return None
normalized = match.group(0)
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or "0"
try:
return str(int(normalized))
except ValueError:
cleaned = normalized.lstrip("0")
return cleaned or "0"
@staticmethod
def _coerce_bool(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
if value is None:
return default
return bool(value)
__all__ = [
"ExportConfig",
"ExportService",
]
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import List, Optional, TextIO
from abogen.subtitle_utils import clean_subtitle_text
class SubtitleFormat(Enum):
SRT = "srt"
ASS = "ass"
VTT = "vtt"
class SubtitleMode(Enum):
DISABLED = "Disabled"
LINE = "Line"
SENTENCE = "Sentence"
SENTENCE_COMMA = "Sentence + Comma"
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
class SubtitleAlignment(Enum):
LEFT = "left"
CENTER = "center"
NARROW = "narrow"
CENTER_NARROW = "center_narrow"
@dataclass
class SubtitleConfig:
"""Configuration for subtitle writer."""
format: SubtitleFormat
mode: SubtitleMode
alignment: SubtitleAlignment = SubtitleAlignment.LEFT
max_words: int = 50
highlight_color: str = "&H00FFFF00" # ASS highlight color
class SubtitleWriter(ABC):
"""Abstract base class for subtitle writers."""
def __init__(self, path: Path, config: SubtitleConfig):
self.path = path
self.config = config
self._file: Optional[TextIO] = None
self._index = 0
self._opened = False
def open(self) -> None:
"""Open the subtitle file and write header."""
if self._opened:
return
self._file = open(self.path, "w", encoding="utf-8", errors="replace")
self._write_header()
self._opened = True
@abstractmethod
def _write_header(self) -> None:
pass
def write_entry(
self,
start: float,
end: float,
text: str,
voice: Optional[str] = None,
) -> None:
"""Write a subtitle entry."""
if not self._opened:
self.open()
text = clean_subtitle_text(text)
if not text:
return
self._index += 1
self._write_entry(self._index, start, end, text, voice)
@abstractmethod
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
pass
def close(self) -> None:
"""Close the subtitle file."""
if self._file:
self._file.close()
self._file = None
self._opened = False
def __enter__(self) -> "SubtitleWriter":
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
class SrtWriter(SubtitleWriter):
"""SRT subtitle writer."""
def _write_header(self) -> None:
pass # SRT has no header
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
self._file.write(f"{index}\n")
self._file.write(f"{start_str} --> {end_str}\n")
self._file.write(f"{text}\n\n")
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds - int(seconds)) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
class VttWriter(SubtitleWriter):
"""WebVTT subtitle writer."""
def _write_header(self) -> None:
self._file.write("WEBVTT\n\n")
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
self._file.write(f"{index}\n")
self._file.write(f"{start_str} --> {end_str}\n")
self._file.write(f"{text}\n\n")
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ".")
class AssWriter(SubtitleWriter):
"""ASS subtitle writer with karaoke highlighting support."""
def __init__(self, path: Path, config: SubtitleConfig):
super().__init__(path, config)
self._is_centered = config.alignment in (SubtitleAlignment.CENTER, SubtitleAlignment.CENTER_NARROW)
self._is_narrow = config.alignment in (SubtitleAlignment.NARROW, SubtitleAlignment.CENTER_NARROW)
def _write_header(self) -> None:
margin = "90" if self._is_narrow else "10"
alignment = "5" if self._is_centered else "2"
self._file.write("[Script Info]\n")
self._file.write("Title: Generated by Abogen\n")
self._file.write("ScriptType: v4.00+\n\n")
# Styles
self._file.write("[V4+ Styles]\n")
self._file.write(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Karaoke style with highlighting
self._file.write(
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n"
)
self._file.write(
f"Style: Highlight,Arial,24,&H0000FFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
)
else:
self._file.write(
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
)
self._file.write("[Events]\n")
self._file.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
style = "Default"
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Add karaoke tags for highlighting
text = self._add_karaoke_tags(text)
style = "Highlight"
alignment_tag = r"{\an5}" if self._is_centered else ""
self._file.write(
f"Dialogue: 0,{start_str},{end_str},{style},,0,0,0,,{alignment_tag}{text}\n"
)
def _add_karaoke_tags(self, text: str) -> str:
"""Add karaoke highlighting tags to text."""
# Simple word-level karaoke timing
words = text.split()
if not words:
return text
# This is a simplified version - real karaoke needs per-word timing
# For now, just return the text with the highlight color
return r"{\k100}" + r"{\k100}".join(words) + r"{\k0}"
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def create_subtitle_writer(
path: Path,
format: str,
mode: str,
alignment: str = "left",
max_words: int = 50,
) -> SubtitleWriter:
"""Factory function to create subtitle writer."""
fmt = SubtitleFormat(format.lower())
mode = SubtitleMode(mode)
align = SubtitleAlignment(alignment.lower())
config = SubtitleConfig(
format=fmt,
mode=mode,
alignment=align,
max_words=max_words,
)
if fmt == SubtitleFormat.SRT:
return SrtWriter(path, config)
elif fmt == SubtitleFormat.VTT:
return VttWriter(path, config)
elif fmt == SubtitleFormat.ASS:
return AssWriter(path, config)
else:
raise ValueError(f"Unsupported subtitle format: {format}")
__all__ = [
"SubtitleFormat",
"SubtitleMode",
"SubtitleAlignment",
"SubtitleConfig",
"SubtitleWriter",
"SrtWriter",
"VttWriter",
"AssWriter",
"create_subtitle_writer",
]
+5 -15
View File
@@ -2,13 +2,14 @@
from __future__ import annotations from __future__ import annotations
import atexit
import os import os
import platform import platform
import signal
import sys
from abogen.utils import load_config, prevent_sleep_end # Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
from abogen import shutdown # noqa: F401
shutdown.register_shutdown()
from abogen.utils import load_config
from abogen.webui.app import main as _run_web_ui from abogen.webui.app import main as _run_web_ui
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults). # Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
@@ -27,17 +28,6 @@ os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
if platform.system() == "Darwin" and platform.processor() == "arm": if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
atexit.register(prevent_sleep_end)
def _cleanup_sleep(signum, _frame):
prevent_sleep_end()
sys.exit(0)
signal.signal(signal.SIGINT, _cleanup_sleep)
signal.signal(signal.SIGTERM, _cleanup_sleep)
def main() -> None: def main() -> None:
"""Launch the Flask-based web UI.""" """Launch the Flask-based web UI."""
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL from abogen.constants import COLORS
from abogen.tts_plugin.utils import get_voices
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = VOICES_INTERNAL voice_list = get_voices("kokoro")
for idx, voice in enumerate(voice_list, start=1): for idx, voice in enumerate(voice_list, start=1):
if self._cancelled: if self._cancelled:
self._voices_success = False self._voices_success = False
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
try: try:
from huggingface_hub import try_to_load_from_cache from huggingface_hub import try_to_load_from_cache
for voice in VOICES_INTERNAL: for voice in get_voices("kokoro"):
if not try_to_load_from_cache( if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
): ):
missing.append(voice) missing.append(voice)
except Exception: except Exception:
# If HF missing, report all as missing # If HF missing, report all as missing
return False, list(VOICES_INTERNAL) return False, list(get_voices("kokoro"))
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
+113 -254
View File
@@ -5,6 +5,7 @@ import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import numpy as np
import soundfile as sf import soundfile as sf
from abogen.utils import ( from abogen.utils import (
create_process, create_process,
@@ -13,20 +14,25 @@ from abogen.utils import (
) )
from abogen.constants import ( from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS,
COLORS, COLORS,
CHAPTER_OPTIONS_COUNTDOWN, CHAPTER_OPTIONS_COUNTDOWN,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
SUPPORTED_SUBTITLE_FORMATS, SUPPORTED_SUBTITLE_FORMATS,
) )
from abogen.tts_supertonic import SupertonicPipeline, SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
from abogen.infrastructure.subtitle_writer import _format_timestamp
from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.output_paths import (
resolve_output_directory,
build_output_path,
sanitize_output_stem,
)
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
import subprocess import subprocess
import platform
# Configuration constants # Configuration constants
_USER_RESPONSE_TIMEOUT = ( _USER_RESPONSE_TIMEOUT = (
@@ -43,10 +49,7 @@ from abogen.subtitle_utils import (
get_sample_voice_text, get_sample_voice_text,
sanitize_name_for_os, sanitize_name_for_os,
_CHAPTER_MARKER_SEARCH_PATTERN, _CHAPTER_MARKER_SEARCH_PATTERN,
_VOICE_MARKER_PATTERN, split_text_by_voice_markers
_VOICE_MARKER_SEARCH_PATTERN,
split_text_by_voice_markers,
validate_voice_name,
) )
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
@@ -216,40 +219,6 @@ class ConversionThread(QThread):
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、," PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
PUNCTUATION_COMMAS = ",,、" PUNCTUATION_COMMAS = ",,、"
def _get_split_pattern(self, lang_code, subtitle_mode):
"""
Get the appropriate split pattern based on language and subtitle mode.
Args:
lang_code: Language code (a, b, e, f, etc.)
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
Returns:
Split pattern string
"""
# For English, always use newline splitting only
if lang_code in ["a", "b"]:
return "\n"
# Determine spacing pattern based on language
spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+"
# For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]:
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
if subtitle_mode == "Line":
return "\n"
elif subtitle_mode == "Sentence":
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
elif subtitle_mode == "Sentence + Comma":
return r"(?<=[{}]){}|\n+".format(
self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern
)
else:
return r"\n+" # Default to line breaks
def __init__( def __init__(
self, self,
file_name, file_name,
@@ -260,30 +229,21 @@ class ConversionThread(QThread):
output_folder, output_folder,
subtitle_mode, subtitle_mode,
output_format, output_format,
np_module, backend,
kpipeline_class,
start_time, start_time,
total_char_count, total_char_count,
use_gpu=True, use_gpu=True,
from_queue=False, from_queue=False,
save_base_path=None, save_base_path=None,
tts_provider="kokoro", ): # Add use_gpu parameter
supertonic_language="en",
supertonic_total_steps=8,
):
super().__init__() super().__init__()
self._chapter_options_event = threading.Event() self._chapter_options_event = threading.Event()
self._timestamp_response_event = threading.Event() self._timestamp_response_event = threading.Event()
self.np = np_module self.backend = backend
self.KPipeline = kpipeline_class
self.file_name = file_name self.file_name = file_name
self.lang_code = lang_code self.lang_code = lang_code
self.speed = speed self.speed = speed
self.voice = voice self.voice = voice
self.tts_provider = tts_provider
self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
self.save_option = save_option self.save_option = save_option
self.output_folder = output_folder self.output_folder = output_folder
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
@@ -307,7 +267,7 @@ class ConversionThread(QThread):
self.silence_duration = 2.0 # Default value, will be overridden from GUI self.silence_duration = 2.0 # Default value, will be overridden from GUI
self.use_spacy_segmentation = True # Default, will be overridden from GUI self.use_spacy_segmentation = True # Default, will be overridden from GUI
# Set split pattern based on language and subtitle mode # Set split pattern based on language and subtitle mode
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) self.split_pattern = get_split_pattern(lang_code, subtitle_mode)
self.voice_cache = {} # Cache for loaded voices self.voice_cache = {} # Cache for loaded voices
def load_voice_cached(self, voice_name, tts): def load_voice_cached(self, voice_name, tts):
@@ -435,10 +395,6 @@ class ConversionThread(QThread):
) )
self.log_updated.emit(f"- Voice: {self.voice}") self.log_updated.emit(f"- Voice: {self.voice}")
self.log_updated.emit(f"- Speed: {self.speed}") self.log_updated.emit(f"- Speed: {self.speed}")
tts_provider_label = self.tts_provider.capitalize()
if self.tts_provider == "supertonic":
tts_provider_label += f" (lang={self.supertonic_language}, steps={self.supertonic_total_steps})"
self.log_updated.emit(f"- TTS Engine: {tts_provider_label}")
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}") self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
self.log_updated.emit(f"- Output format: {self.output_format}") self.log_updated.emit(f"- Output format: {self.output_format}")
self.log_updated.emit( self.log_updated.emit(
@@ -502,26 +458,6 @@ class ConversionThread(QThread):
self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
else:
device = "cuda" # Use CUDA for other platforms
else:
device = "cpu"
if self.tts_provider == "supertonic":
tts = SupertonicPipeline(
sample_rate=self.sample_rate,
lang=self.supertonic_language,
total_steps=self.supertonic_total_steps,
)
else:
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Check if the input is a subtitle file or timestamp text file # Check if the input is a subtitle file or timestamp text file
is_subtitle_file = False is_subtitle_file = False
is_timestamp_text = False is_timestamp_text = False
@@ -557,7 +493,7 @@ class ConversionThread(QThread):
# Process subtitle files separately # Process subtitle files separately
if is_subtitle_file or is_timestamp_text: if is_subtitle_file or is_timestamp_text:
self._process_subtitle_file(tts, base_path, is_timestamp_text) self._process_subtitle_file(self.backend, base_path, is_timestamp_text)
return return
if self.is_direct_text: if self.is_direct_text:
@@ -709,15 +645,17 @@ class ConversionThread(QThread):
base_path = self.display_path if self.display_path else self.file_name base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0] base_name = os.path.splitext(os.path.basename(base_path))[0]
# Sanitize base_name for folder/file creation based on OS
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
if self.save_option == "Save to Desktop": parent_dir = resolve_output_directory(
parent_dir = user_desktop_dir() save_mode=self.save_option,
elif self.save_option == "Save next to input file": stored_path=Path(base_path),
parent_dir = os.path.dirname(base_path) output_folder=getattr(self, "output_folder", None),
else: desktop_dir=Path(user_desktop_dir()),
parent_dir = self.output_folder or os.getcwd() user_output_path=None,
user_cache_outputs=Path(os.getcwd()),
)
parent_dir = str(parent_dir)
# Ensure the output folder exists, error if it doesn't # Ensure the output folder exists, error if it doesn't
if not os.path.exists(parent_dir): if not os.path.exists(parent_dir):
self.log_updated.emit( self.log_updated.emit(
@@ -766,7 +704,7 @@ class ConversionThread(QThread):
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
subtitle_entries = [] subtitle_entries = []
current_time = 0.0 current_time = 0.0
rate = self.sample_rate rate = 24000
subtitle_mode = self.subtitle_mode subtitle_mode = self.subtitle_mode
self.etr_start_time = time.time() self.etr_start_time = time.time()
self.processed_char_count = 0 self.processed_char_count = 0
@@ -782,38 +720,45 @@ class ConversionThread(QThread):
merged_out_file = sf.SoundFile( merged_out_file = sf.SoundFile(
merged_out_path, merged_out_path,
"w", "w",
samplerate=self.sample_rate, samplerate=24000,
channels=1, channels=1,
format=self.output_format, format=self.output_format,
) )
ffmpeg_proc = None ffmpeg_proc = None
elif self.output_format == "m4b": elif self.output_format in ("m4b", "opus"):
# Real-time M4B generation using FFmpeg pipe # Real-time generation using FFmpeg pipe
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
merged_out_file = None merged_out_file = None
ffmpeg_proc = None ffmpeg_proc = None
metadata_options, cover_path = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
if self.output_format == "m4b"
else ([], None)
) )
# Prepare ffmpeg command for m4b output cmd = build_ffmpeg_command(
cmd = [ Path(merged_out_path),
"ffmpeg", self.output_format,
"-y", )
"-thread_queue_size", # Insert thread queue size after ffmpeg header
"32768", cmd.insert(2, "-thread_queue_size")
"-f", cmd.insert(3, "32768")
"f32le", if self.output_format == "m4b" and cover_path and os.path.exists(cover_path):
"-ar", # Insert cover image input before the output path
str(self.sample_rate), output_path = cmd.pop()
"-ac", cmd.extend([
"1", "-i", cover_path,
"-i", "-map", "0:a",
"pipe:0", "-map", "1",
] "-c:v", "copy",
cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) "-disposition:v", "attached_pic",
cmd.append(merged_out_path) ])
cmd.extend(metadata_options)
cmd.append(output_path)
elif self.output_format == "m4b":
output_path = cmd.pop()
cmd.extend(metadata_options)
cmd.append(output_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
merged_out_file = None
else: else:
self.log_updated.emit( self.log_updated.emit(
(f"Unsupported output format: {self.output_format}", "red") (f"Unsupported output format: {self.output_format}", "red")
@@ -891,7 +836,7 @@ class ConversionThread(QThread):
merged_out_path = None merged_out_path = None
subtitle_entries = [] subtitle_entries = []
current_time = 0.0 current_time = 0.0
rate = self.sample_rate rate = 24000
subtitle_mode = self.subtitle_mode subtitle_mode = self.subtitle_mode
self.etr_start_time = time.time() self.etr_start_time = time.time()
self.processed_char_count = 0 self.processed_char_count = 0
@@ -944,7 +889,7 @@ class ConversionThread(QThread):
chapter_out_file = sf.SoundFile( chapter_out_file = sf.SoundFile(
chapter_out_path, chapter_out_path,
"w", "w",
samplerate=self.sample_rate, samplerate=24000,
channels=1, channels=1,
format=separate_chapters_format, format=separate_chapters_format,
) )
@@ -959,7 +904,7 @@ class ConversionThread(QThread):
"-f", "-f",
"f32le", "f32le",
"-ar", "-ar",
str(self.sample_rate), "24000",
"-ac", "-ac",
"1", "1",
"-i", "-i",
@@ -1046,7 +991,7 @@ class ConversionThread(QThread):
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments): for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
# Load voice for this segment (with caching) # Load voice for this segment (with caching)
try: try:
loaded_voice = self.load_voice_cached(voice_name, tts) loaded_voice = self.load_voice_cached(voice_name, self.backend)
if segment_idx > 0: if segment_idx > 0:
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..." voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
self.log_updated.emit((f" → Voice: {voice_display}", "grey")) self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
@@ -1055,7 +1000,7 @@ class ConversionThread(QThread):
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange") (f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
) )
if segment_idx == 0: if segment_idx == 0:
loaded_voice = self.load_voice_cached(self.voice, tts) loaded_voice = self.load_voice_cached(self.voice, self.backend)
# Determine if spaCy segmentation should be used for PRE-TTS segmentation # Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Only non-English languages use spaCy for pre-segmentation # Only non-English languages use spaCy for pre-segmentation
@@ -1141,7 +1086,7 @@ class ConversionThread(QThread):
print("Using split pattern: (unprintable)") print("Using split pattern: (unprintable)")
for text_segment in text_segments: for text_segment in text_segments:
for result in tts( for result in self.backend(
text_segment, text_segment,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1247,8 +1192,8 @@ class ConversionThread(QThread):
) )
if "ass" in subtitle_format: if "ass" in subtitle_format:
for start, end, text in new_entries: for start, end, text in new_entries:
start_time = self._ass_time(start) start_time = _format_timestamp(start, ass=True)
end_time = self._ass_time(end) end_time = _format_timestamp(end, ass=True)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = ( effect = (
"karaoke" "karaoke"
@@ -1263,7 +1208,7 @@ class ConversionThread(QThread):
for entry in new_entries: for entry in new_entries:
start, end, text = entry start, end, text = entry
merged_subtitle_file.write( merged_subtitle_file.write(
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" f"{merged_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
) )
merged_srt_index += 1 merged_srt_index += 1
# Per-chapter subtitle processing for both file and ffmpeg_proc # Per-chapter subtitle processing for both file and ffmpeg_proc
@@ -1281,8 +1226,8 @@ class ConversionThread(QThread):
) )
if "ass" in subtitle_format: if "ass" in subtitle_format:
for start, end, text in new_chapter_entries: for start, end, text in new_chapter_entries:
start_time = self._ass_time(start) start_time = _format_timestamp(start, ass=True)
end_time = self._ass_time(end) end_time = _format_timestamp(end, ass=True)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = ( effect = (
"karaoke" "karaoke"
@@ -1297,7 +1242,7 @@ class ConversionThread(QThread):
for entry in new_chapter_entries: for entry in new_chapter_entries:
start, end, text = entry start, end, text = entry
chapter_subtitle_file.write( chapter_subtitle_file.write(
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" f"{chapter_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
) )
chapter_srt_index += 1 chapter_srt_index += 1
if merge_chapters_at_end: if merge_chapters_at_end:
@@ -1341,9 +1286,9 @@ class ConversionThread(QThread):
# Add silence between chapters for merged output (except after the last chapter) # Add silence between chapters for merged output (except after the last chapter)
if merge_chapters_at_end and chapter_idx < total_chapters: if merge_chapters_at_end and chapter_idx < total_chapters:
silence_samples = int( silence_samples = int(
self.silence_duration * self.sample_rate self.silence_duration * 24000
) # Silence duration at 24,000 Hz ) # Silence duration at 24,000 Hz
silence_audio = self.np.zeros(silence_samples, dtype="float32") silence_audio = np.zeros(silence_samples, dtype="float32")
silence_bytes = silence_audio.tobytes() silence_bytes = silence_audio.tobytes()
if merged_out_file: if merged_out_file:
@@ -1572,7 +1517,7 @@ class ConversionThread(QThread):
parent_dir, f"{sanitized_base_name}{suffix}" parent_dir, f"{sanitized_base_name}{suffix}"
) )
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
rate = self.sample_rate rate = 24000
# Setup audio output # Setup audio output
merged_out_file, ffmpeg_proc = None, None merged_out_file, ffmpeg_proc = None, None
@@ -1586,58 +1531,27 @@ class ConversionThread(QThread):
) )
else: else:
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
cmd = [ cmd = build_ffmpeg_command(
"ffmpeg", Path(merged_out_path),
"-y", self.output_format,
"-thread_queue_size", )
"32768", cmd.insert(2, "-thread_queue_size")
"-f", cmd.insert(3, "32768")
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"-i",
"pipe:0",
]
if self.output_format == "m4b": if self.output_format == "m4b":
metadata_options, cover_path = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
) )
if cover_path and os.path.exists(cover_path): if cover_path and os.path.exists(cover_path):
cmd.extend( output_path = cmd.pop()
[ cmd.extend([
"-i", "-i", cover_path,
cover_path, "-map", "0:a",
"-map", "-map", "1",
"0:a", "-c:v", "copy",
"-map", "-disposition:v", "attached_pic",
"1", ])
"-c:v", cmd.append(output_path)
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd.extend(metadata_options) cmd.extend(metadata_options)
elif self.output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
else:
self.log_updated.emit(
(f"Unsupported output format: {self.output_format}", "red")
)
return
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
# Always generate subtitles for subtitle input files # Always generate subtitles for subtitle input files
@@ -1682,7 +1596,7 @@ class ConversionThread(QThread):
max_end_time = max( max_end_time = max(
(end for _, end, _ in subtitles if end is not None), default=0 (end for _, end, _ in subtitles if end is not None), default=0
) )
audio_buffer = self.np.zeros( audio_buffer = np.zeros(
int(max_end_time * rate) + rate, dtype="float32" int(max_end_time * rate) + rate, dtype="float32"
) )
@@ -1746,7 +1660,7 @@ class ConversionThread(QThread):
# Generate TTS audio # Generate TTS audio
tts_results = [ tts_results = [
r r
for r in tts( for r in self.backend(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1764,11 +1678,11 @@ class ConversionThread(QThread):
# Concatenate audio and determine duration # Concatenate audio and determine duration
full_audio = ( full_audio = (
self.np.concatenate( np.concatenate(
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
) )
if audio_chunks if audio_chunks
else self.np.zeros( else np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32" int((subtitle_duration or 0) * rate), dtype="float32"
) )
) )
@@ -1802,8 +1716,8 @@ class ConversionThread(QThread):
num_stages = max( num_stages = max(
1, 1,
int( int(
self.np.ceil( np.ceil(
self.np.log(speed_factor) / self.np.log(2.0) np.log(speed_factor) / np.log(2.0)
) )
), ),
) )
@@ -1836,7 +1750,7 @@ class ConversionThread(QThread):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
full_audio = self.np.frombuffer( full_audio = np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0], speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32", dtype="float32",
) )
@@ -1850,7 +1764,7 @@ class ConversionThread(QThread):
tts_results = [ tts_results = [
r r
for r in tts( for r in self.backend(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=new_speed, speed=new_speed,
@@ -1861,14 +1775,14 @@ class ConversionThread(QThread):
audio_chunks = [r.audio for r in tts_results] audio_chunks = [r.audio for r in tts_results]
full_audio = ( full_audio = (
self.np.concatenate( np.concatenate(
[ [
a.numpy() if hasattr(a, "numpy") else a a.numpy() if hasattr(a, "numpy") else a
for a in audio_chunks for a in audio_chunks
] ]
) )
if audio_chunks if audio_chunks
else self.np.zeros( else np.zeros(
int(subtitle_duration * rate), dtype="float32" int(subtitle_duration * rate), dtype="float32"
) )
) )
@@ -1885,10 +1799,10 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration # Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate) target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples: if len(full_audio) < target_samples:
full_audio = self.np.concatenate( full_audio = np.concatenate(
[ [
full_audio, full_audio,
self.np.zeros( np.zeros(
target_samples - len(full_audio), dtype="float32" target_samples - len(full_audio), dtype="float32"
), ),
] ]
@@ -1901,10 +1815,10 @@ class ConversionThread(QThread):
end_sample = start_sample + len(full_audio) end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer): if end_sample > len(audio_buffer):
# Extend buffer if needed # Extend buffer if needed
audio_buffer = self.np.concatenate( audio_buffer = np.concatenate(
[ [
audio_buffer, audio_buffer,
self.np.zeros( np.zeros(
end_sample - len(audio_buffer), dtype="float32" end_sample - len(audio_buffer), dtype="float32"
), ),
] ]
@@ -1927,11 +1841,11 @@ class ConversionThread(QThread):
else processed_text.replace("\n", "\\N") else processed_text.replace("\n", "\\N")
) )
subtitle_file.write( subtitle_file.write(
f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n" f"Dialogue: 0,{_format_timestamp(start_time, ass=True)},{_format_timestamp(end_time, ass=True)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
) )
else: else:
subtitle_file.write( subtitle_file.write(
f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n" f"{srt_index}\n{_format_timestamp(start_time)} --> {_format_timestamp(end_time)}\n{processed_text}\n\n"
) )
srt_index += 1 srt_index += 1
@@ -1946,7 +1860,7 @@ class ConversionThread(QThread):
self.progress_updated.emit(percent, etr_str) self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps # Normalize audio buffer to prevent clipping from mixed overlaps
max_amplitude = self.np.abs(audio_buffer).max() max_amplitude = np.abs(audio_buffer).max()
if max_amplitude > 1.0: if max_amplitude > 1.0:
self.log_updated.emit( self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
@@ -2093,22 +2007,6 @@ class ConversionThread(QThread):
# Add these to ffmpeg command # Add these to ffmpeg command
return metadata_options, cover_path return metadata_options, cover_path
def _srt_time(self, t):
"""Helper function to format time for SRT files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - int(t)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _ass_time(self, t):
"""Helper function to format time for ASS files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
cs = int((t - int(t)) * 100) # Centiseconds for ASS format
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
def _process_subtitle_tokens( def _process_subtitle_tokens(
self, self,
tokens_with_timestamps, tokens_with_timestamps,
@@ -2386,6 +2284,10 @@ class ConversionThread(QThread):
self.cancel_requested = True self.cancel_requested = True
self.should_cancel = True self.should_cancel = True
self.waiting_for_user_input = False self.waiting_for_user_input = False
# Clear voice cache (instance and module-level)
self.voice_cache.clear()
from abogen.voice_cache import clear_voice_cache
clear_voice_cache()
# Terminate subprocess if running # Terminate subprocess if running
if self.process: if self.process:
try: try:
@@ -2415,28 +2317,19 @@ class VoicePreviewThread(QThread):
def __init__( def __init__(
self, self,
np_module, backend,
kpipeline_class,
lang_code, lang_code,
voice, voice,
speed, speed,
use_gpu=False, use_gpu=False,
parent=None, parent=None,
tts_provider="kokoro",
supertonic_language="en",
supertonic_total_steps=8,
): ):
super().__init__(parent) super().__init__(parent)
self.np_module = np_module self.backend = backend
self.kpipeline_class = kpipeline_class
self.lang_code = lang_code self.lang_code = lang_code
self.voice = voice self.voice = voice
self.speed = speed self.speed = speed
self.use_gpu = use_gpu self.use_gpu = use_gpu
self.tts_provider = tts_provider
self.supertonic_language = supertonic_language
self.supertonic_total_steps = supertonic_total_steps
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
# Cache location for preview audio # Cache location for preview audio
self.cache_dir = get_user_cache_path("preview_cache") self.cache_dir = get_user_cache_path("preview_cache")
@@ -2446,11 +2339,6 @@ class VoicePreviewThread(QThread):
def _get_cache_path(self): def _get_cache_path(self):
"""Generate a unique filename for the voice with its parameters""" """Generate a unique filename for the voice with its parameters"""
if self.tts_provider == "supertonic":
voice_id = self.voice or "M1"
filename = f"st_{voice_id}_{self.supertonic_language}_steps{self.supertonic_total_steps}_{self.speed:.2f}.wav"
return os.path.join(self.cache_dir, filename)
# For a voice formula, use a hash of the formula # For a voice formula, use a hash of the formula
if "*" in self.voice: if "*" in self.voice:
voice_id = ( voice_id = (
@@ -2465,56 +2353,27 @@ class VoicePreviewThread(QThread):
def run(self): def run(self):
print( print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nTTS Provider: {self.tts_provider}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
) )
# Generate the preview and save to cache # Generate the preview and save to cache
try: try:
if self.tts_provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline
tts = SupertonicPipeline( # Enable voice formula support for preview
sample_rate=self.sample_rate,
lang=self.supertonic_language,
total_steps=self.supertonic_total_steps,
)
loaded_voice = self.voice or "M1"
sample_text = "Hello, this is a sample of the selected voice."
audio_segments = []
for result in tts(
sample_text,
voice=loaded_voice,
speed=self.speed,
split_pattern=None,
):
audio_segments.append(result.audio)
else:
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps"
else:
device = "cuda"
else:
device = "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
if "*" in self.voice: if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) loaded_voice = get_new_voice(self.backend, self.voice, self.use_gpu)
else: else:
loaded_voice = self.voice loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code) sample_text = get_sample_voice_text(self.lang_code)
audio_segments = [] audio_segments = []
for result in tts( for result in self.backend(
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
): ):
audio_segments.append(result.audio) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
audio = self.np_module.concatenate(audio_segments) audio = np.concatenate(audio_segments)
sf.write(self.cache_path, audio, self.sample_rate) # Save directly to the cache path
sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path self.temp_wav = self.cache_path
self.finished.emit() self.finished.emit()
except Exception as e: except Exception as e:
+42 -248
View File
@@ -7,9 +7,9 @@ import base64
import re import re
from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem from abogen.pyqt.queued_item import QueuedItem
from abogen.domain.device import select_device as _select_device
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation import hashlib # Added for cache path generation
from abogen.tts_supertonic import SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
QWidget, QWidget,
@@ -83,13 +83,11 @@ from abogen.constants import (
GITHUB_URL, GITHUB_URL,
PROGRAM_DESCRIPTION, PROGRAM_DESCRIPTION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
VOICES_INTERNAL,
KOKORO_LANG_TO_COUNTRY,
SUPERTONIC_LANG_TO_COUNTRY,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
COLORS, COLORS,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
) )
from abogen.tts_plugin.utils import get_voices
import threading import threading
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
@@ -973,9 +971,6 @@ class abogen(QWidget):
self.fix_nonstandard_punctuation = self.config.get( self.fix_nonstandard_punctuation = self.config.get(
"fix_nonstandard_punctuation", False "fix_nonstandard_punctuation", False
) )
self.tts_provider_config = self.config.get("tts_provider", "kokoro")
self.supertonic_language_config = self.config.get("supertonic_language", "en")
self.supertonic_total_steps_config = self.config.get("supertonic_total_steps", 8)
self._pending_close_event = None self._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status self.gpu_ok = False # Initialize GPU availability status
@@ -1024,16 +1019,6 @@ class abogen(QWidget):
else: else:
self.mixed_voice_state = entry self.mixed_voice_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None self.selected_lang = entry[0][0] if entry and entry[0] else None
# Restore TTS provider and supertonic settings from config
provider_text = "Supertonic" if self.tts_provider_config == "supertonic" else "Kokoro"
idx_st = self.provider_combo.findText(provider_text)
if idx_st >= 0:
self.provider_combo.setCurrentIndex(idx_st)
self.st_lang_combo.setCurrentText(self.supertonic_language_config)
idx_steps = self.st_steps_combo.findData(self.supertonic_total_steps_config)
if idx_steps >= 0:
self.st_steps_combo.setCurrentIndex(idx_steps)
if self.save_option == "Choose output folder" and self.selected_output_folder: if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder) self.save_path_label.setText(self.selected_output_folder)
self.save_path_row_widget.show() self.save_path_row_widget.show()
@@ -1123,53 +1108,6 @@ class abogen(QWidget):
speed_layout.addWidget(self.speed_label) speed_layout.addWidget(self.speed_label)
controls_layout.addLayout(speed_layout) controls_layout.addLayout(speed_layout)
self.speed_slider.valueChanged.connect(self.update_speed_label) self.speed_slider.valueChanged.connect(self.update_speed_label)
# TTS Provider selection
provider_layout = QHBoxLayout()
provider_layout.setSpacing(7)
provider_label = QLabel("TTS Engine:", self)
provider_layout.addWidget(provider_label)
self.provider_combo = QComboBox(self)
self.provider_combo.addItem("Kokoro", "kokoro")
self.provider_combo.addItem("Supertonic", "supertonic")
self.provider_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
self.provider_combo.currentIndexChanged.connect(self.on_provider_changed)
provider_layout.addWidget(self.provider_combo)
controls_layout.addLayout(provider_layout)
# Supertonic-specific controls (language + steps), hidden by default
self.supertonic_row = QWidget()
supertonic_row_layout = QHBoxLayout(self.supertonic_row)
supertonic_row_layout.setContentsMargins(0, 0, 0, 0)
supertonic_row_layout.setSpacing(7)
st_lang_label = QLabel("Language:", self)
supertonic_row_layout.addWidget(st_lang_label)
self.st_lang_combo = QComboBox(self)
for code in SUPERTONIC_AVAILABLE_LANGS:
country_code = SUPERTONIC_LANG_TO_COUNTRY.get(code, code)
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
icon_st = QIcon(flag) if flag and os.path.exists(flag) else QIcon()
self.st_lang_combo.addItem(icon_st, code, code)
self.st_lang_combo.setCurrentText("en")
self.st_lang_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
self.st_lang_combo.currentTextChanged.connect(self._on_st_lang_changed)
supertonic_row_layout.addWidget(self.st_lang_combo)
st_steps_label = QLabel("Steps:", self)
supertonic_row_layout.addWidget(st_steps_label)
self.st_steps_combo = QComboBox(self)
for val in range(2, 16):
self.st_steps_combo.addItem(str(val), val)
self.st_steps_combo.setCurrentIndex(self.st_steps_combo.findData(8))
self.st_steps_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
self.st_steps_combo.currentIndexChanged.connect(self._on_st_steps_changed)
supertonic_row_layout.addWidget(self.st_steps_combo)
supertonic_row_layout.addStretch()
self.supertonic_row.hide()
controls_layout.addWidget(self.supertonic_row)
# Voice selection # Voice selection
voice_layout = QHBoxLayout() voice_layout = QHBoxLayout()
voice_layout.setSpacing(7) voice_layout.setSpacing(7)
@@ -1827,12 +1765,6 @@ class abogen(QWidget):
Update the enabled state of subtitle options based on the selected language. Update the enabled state of subtitle options based on the selected language.
For non-English languages, only sentence-based and line-based modes are supported. For non-English languages, only sentence-based and line-based modes are supported.
""" """
provider = self.provider_combo.currentData()
if provider == "supertonic":
self.subtitle_combo.setEnabled(False)
self.subtitle_format_combo.setEnabled(False)
return
# Check if current file is a subtitle file # Check if current file is a subtitle file
is_subtitle_input = False is_subtitle_input = False
if self.selected_file and self.selected_file.lower().endswith( if self.selected_file and self.selected_file.lower().endswith(
@@ -1892,48 +1824,6 @@ class abogen(QWidget):
# Enable/disable subtitle options based on language # Enable/disable subtitle options based on language
self.update_subtitle_options_availability() self.update_subtitle_options_availability()
def on_provider_changed(self, index):
provider = self.provider_combo.itemData(index)
self.config["tts_provider"] = provider
save_config(self.config)
is_supertonic = provider == "supertonic"
# Show/hide Supertonic controls
self.supertonic_row.setVisible(is_supertonic)
# Update subtitles availability
self.update_subtitle_options_availability()
# Repopulate voice list
self.populate_profiles_in_voice_combo()
# Clear/reset mixed voice state when switching provider
if is_supertonic:
self.mixed_voice_state = None
self.btn_voice_formula_mixer.setEnabled(False)
self.voice_combo.setToolTip(
"Supertonic voices:\n"
"M1-M5 = Male voices\n"
"F1-F5 = Female voices"
)
else:
self.btn_voice_formula_mixer.setEnabled(True)
self.voice_combo.setToolTip(
"The first character represents the language:\n"
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
)
def _on_st_lang_changed(self, lang):
self.config["supertonic_language"] = lang
save_config(self.config)
if self.provider_combo.currentData() == "supertonic":
self.selected_lang = lang
self.update_subtitle_options_availability()
def _on_st_steps_changed(self):
self.config["supertonic_total_steps"] = self.st_steps_combo.currentData()
save_config(self.config)
def on_voice_combo_changed(self, index): def on_voice_combo_changed(self, index):
data = self.voice_combo.itemData(index) data = self.voice_combo.itemData(index)
if isinstance(data, str) and data.startswith("profile:"): if isinstance(data, str) and data.startswith("profile:"):
@@ -1942,24 +1832,8 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(pname, {}) entry = load_profiles().get(pname, {})
# set mixed voices and language
if isinstance(entry, dict): if isinstance(entry, dict):
entry_provider = str(entry.get("provider", "")).strip().lower()
if entry_provider == "supertonic":
# Switch provider to Supertonic if not already
if self.provider_combo.currentData() != "supertonic":
self.provider_combo.setCurrentIndex(1)
self.mixed_voice_state = None
self.selected_lang = entry.get("language", self.st_lang_combo.currentText())
# Sync supertonic controls from profile
profile_steps = entry.get("total_steps")
if profile_steps is not None:
idx_steps = self.st_steps_combo.findData(int(profile_steps))
if idx_steps >= 0:
self.st_steps_combo.setCurrentIndex(idx_steps)
profile_lang = entry.get("language")
if profile_lang and profile_lang in SUPERTONIC_AVAILABLE_LANGS:
self.st_lang_combo.setCurrentText(profile_lang)
else:
self.mixed_voice_state = entry.get("voices", []) self.mixed_voice_state = entry.get("voices", [])
self.selected_lang = entry.get("language") self.selected_lang = entry.get("language")
else: else:
@@ -1974,12 +1848,7 @@ class abogen(QWidget):
else: else:
self.mixed_voice_state = None self.mixed_voice_state = None
self.selected_profile_name = None self.selected_profile_name = None
self.selected_voice = data self.selected_voice, self.selected_lang = data, data[0]
provider = self.provider_combo.currentData()
if provider == "supertonic":
self.selected_lang = self.st_lang_combo.currentText()
else:
self.selected_lang = data[0] if data else ""
self.config["selected_voice"] = data self.config["selected_voice"] = data
if "selected_profile_name" in self.config: if "selected_profile_name" in self.config:
del self.config["selected_profile_name"] del self.config["selected_profile_name"]
@@ -1998,37 +1867,16 @@ class abogen(QWidget):
def populate_profiles_in_voice_combo(self): def populate_profiles_in_voice_combo(self):
# preserve current voice or profile # preserve current voice or profile
current = self.voice_combo.currentData() current = self.voice_combo.currentData()
provider = self.provider_combo.currentData()
self.voice_combo.blockSignals(True) self.voice_combo.blockSignals(True)
self.voice_combo.clear() self.voice_combo.clear()
# re-add profiles matching current provider # re-add profiles
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png")) profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
for pname, entry in load_profiles().items(): for pname in load_profiles().keys():
entry_provider = ""
if isinstance(entry, dict):
entry_provider = str(entry.get("provider", "")).strip().lower()
if provider == "supertonic":
if entry_provider == "supertonic":
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
else:
if entry_provider != "supertonic":
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
# re-add voices # re-add voices
if provider == "supertonic": for v in get_voices("kokoro"):
for v in DEFAULT_SUPERTONIC_VOICES:
icon = QIcon() icon = QIcon()
if v.startswith("F"): flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
icon_path = get_resource_path("abogen.assets", "female.png")
else:
icon_path = get_resource_path("abogen.assets", "male.png")
if icon_path and os.path.exists(icon_path):
icon = QIcon(icon_path)
self.voice_combo.addItem(icon, f"{v}", v)
else:
for v in VOICES_INTERNAL:
icon = QIcon()
country_code = KOKORO_LANG_TO_COUNTRY.get(v[0], v[0])
flag_path = get_resource_path("abogen.assets.flags", f"{country_code}.png")
if flag_path and os.path.exists(flag_path): if flag_path and os.path.exists(flag_path):
icon = QIcon(flag_path) icon = QIcon(flag_path)
self.voice_combo.addItem(icon, f"{v}", v) self.voice_combo.addItem(icon, f"{v}", v)
@@ -2222,9 +2070,6 @@ class abogen(QWidget):
save_base_path=save_base_path, save_base_path=save_base_path,
save_chapters_separately=getattr(self, "save_chapters_separately", None), save_chapters_separately=getattr(self, "save_chapters_separately", None),
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None), merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
tts_provider=self.provider_combo.currentData(),
supertonic_language=self.st_lang_combo.currentText(),
supertonic_total_steps=self.st_steps_combo.currentData(),
) )
# Prevent adding duplicate items to the queue # Prevent adding duplicate items to the queue
@@ -2368,15 +2213,6 @@ class abogen(QWidget):
self.config["replace_numerals"] = self.replace_numerals self.config["replace_numerals"] = self.replace_numerals
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
# TTS provider settings
tts_provider = getattr(queued_item, "tts_provider", "kokoro")
self.provider_combo.setCurrentText("Supertonic" if tts_provider == "supertonic" else "Kokoro")
self.st_lang_combo.setCurrentText(getattr(queued_item, "supertonic_language", "en"))
steps_val = getattr(queued_item, "supertonic_total_steps", 8)
idx_steps = self.st_steps_combo.findData(steps_val)
if idx_steps >= 0:
self.st_steps_combo.setCurrentIndex(idx_steps)
# Sync Voice/Profile in config # Sync Voice/Profile in config
self.config["selected_voice"] = self.selected_voice self.config["selected_voice"] = self.selected_voice
if "selected_profile_name" in self.config: if "selected_profile_name" in self.config:
@@ -2399,8 +2235,6 @@ class abogen(QWidget):
self.current_queue_index = 0 # Reset for next time self.current_queue_index = 0 # Reset for next time
def get_voice_formula(self) -> str: def get_voice_formula(self) -> str:
if self.provider_combo.currentData() == "supertonic":
return self._get_supertonic_voice()
if self.mixed_voice_state: if self.mixed_voice_state:
formula_components = [ formula_components = [
f"{name}*{weight}" for name, weight in self.mixed_voice_state f"{name}*{weight}" for name, weight in self.mixed_voice_state
@@ -2410,8 +2244,6 @@ class abogen(QWidget):
return self.selected_voice return self.selected_voice
def get_selected_lang(self, voice_formula) -> str: def get_selected_lang(self, voice_formula) -> str:
if self.provider_combo.currentData() == "supertonic":
return self.st_lang_combo.currentText()
if self.selected_profile_name: if self.selected_profile_name:
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
@@ -2485,9 +2317,9 @@ class abogen(QWidget):
file_size_str = "Unknown" file_size_str = "Unknown"
# pipeline_loaded_callback remains unchanged # pipeline_loaded_callback remains unchanged
def pipeline_loaded_callback(np_module, kpipeline_class, error): def pipeline_loaded_callback(backend, error):
if error: if error:
self.update_log((f"Error loading numpy or KPipeline: {error}", "red")) self.update_log((f"Error loading TTS backend: {error}", "red"))
prevent_sleep_end() prevent_sleep_end()
return return
@@ -2501,10 +2333,6 @@ class abogen(QWidget):
# determine selected language: use profile setting if profile selected, else voice code # determine selected language: use profile setting if profile selected, else voice code
selected_lang = self.get_selected_lang(voice_formula) selected_lang = self.get_selected_lang(voice_formula)
tts_provider = self.provider_combo.currentData()
supertonic_language = self.st_lang_combo.currentText()
supertonic_total_steps = self.st_steps_combo.currentData()
self.conversion_thread = ConversionThread( self.conversion_thread = ConversionThread(
self.selected_file, self.selected_file,
selected_lang, selected_lang,
@@ -2514,17 +2342,13 @@ class abogen(QWidget):
self.selected_output_folder, self.selected_output_folder,
subtitle_mode=actual_subtitle_mode, subtitle_mode=actual_subtitle_mode,
output_format=self.selected_format, output_format=self.selected_format,
np_module=np_module, backend=backend,
kpipeline_class=kpipeline_class,
start_time=self.start_time, start_time=self.start_time,
total_char_count=self.char_count, total_char_count=self.char_count,
use_gpu=self.gpu_ok, use_gpu=self.gpu_ok,
from_queue=from_queue, from_queue=from_queue,
save_base_path=self.displayed_file_path, save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB)
tts_provider=tts_provider, ) # Use gpu_ok status
supertonic_language=supertonic_language,
supertonic_total_steps=supertonic_total_steps,
)
# Pass the displayed file path to the log_updated signal handler in ConversionThread # Pass the displayed file path to the log_updated signal handler in ConversionThread
self.conversion_thread.display_path = display_path self.conversion_thread.display_path = display_path
# Pass the file size string # Pass the file size string
@@ -2601,14 +2425,18 @@ class abogen(QWidget):
# Store gpu_ok status to use when creating the conversion thread # Store gpu_ok status to use when creating the conversion thread
self.gpu_ok = gpu_ok self.gpu_ok = gpu_ok
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
tts_provider = self.provider_combo.currentData()
if tts_provider == "supertonic":
# Supertonic doesn't need KPipeline, call callback directly
import numpy as np
pipeline_loaded_callback(np, None, None)
else:
self.update_log("Loading modules...") self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback)
# Determine device based on GPU availability
if gpu_ok:
device = _select_device()
else:
device = "cpu"
lang_code = self.selected_lang or "a"
load_thread = LoadPipelineThread(
pipeline_loaded_callback, lang_code=lang_code, device=device
)
load_thread.start() load_thread.start()
threading.Thread(target=gpu_and_load, daemon=True).start() threading.Thread(target=gpu_and_load, daemon=True).start()
@@ -2922,32 +2750,9 @@ class abogen(QWidget):
"Open File Error", f"Could not open file:\n{e}" "Open File Error", f"Could not open file:\n{e}"
) )
def _get_supertonic_voice(self) -> str:
"""Resolve the effective Supertonic voice from the current combo selection."""
if self.selected_profile_name:
from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
if isinstance(entry, dict):
return str(entry.get("voice", "M1"))
return "M1"
current_data = self.voice_combo.currentData()
if current_data and isinstance(current_data, str) and not current_data.startswith("profile:"):
return current_data
return "M1"
def _get_preview_cache_path(self): def _get_preview_cache_path(self):
"""Generate the expected cache path for the current voice settings.""" """Generate the expected cache path for the current voice settings."""
speed = self.speed_slider.value() / 100.0 speed = self.speed_slider.value() / 100.0
provider = self.provider_combo.currentData()
if provider == "supertonic":
voice_to_cache = self._get_supertonic_voice()
lang_to_cache = self.st_lang_combo.currentText()
steps = self.st_steps_combo.currentData()
cache_dir = get_user_cache_path("preview_cache")
filename = f"st_{voice_to_cache}_{lang_to_cache}_steps{steps}_{speed:.2f}.wav"
return os.path.join(cache_dir, filename)
voice_to_cache = "" voice_to_cache = ""
lang_to_cache = "" lang_to_cache = ""
@@ -3052,13 +2857,6 @@ class abogen(QWidget):
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
self.btn_start.setEnabled(False) # Disable start button during preview self.btn_start.setEnabled(False) # Disable start button during preview
# For Supertonic, skip KPipeline loading and use SupertonicPipeline directly
if self.provider_combo.currentData() == "supertonic":
import numpy as np
self.loading_movie.start()
self._on_pipeline_loaded_for_preview(np, None, None)
return
# Start loading animation - ensure signal connection is always active # Start loading animation - ensure signal connection is always active
if hasattr(self, "loading_movie"): if hasattr(self, "loading_movie"):
# Disconnect previous connections to avoid multiple connections # Disconnect previous connections to avoid multiple connections
@@ -3075,18 +2873,24 @@ class abogen(QWidget):
) )
self.loading_movie.start() self.loading_movie.start()
def pipeline_loaded_callback(np_module, kpipeline_class, error): # Determine device based on GPU availability
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) if self.gpu_ok:
device = _select_device()
else:
device = "cpu"
load_thread = LoadPipelineThread(pipeline_loaded_callback) lang = self.selected_lang or "a"
load_thread = LoadPipelineThread(
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device
)
load_thread.start() load_thread.start()
def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error): def _on_pipeline_loaded_for_preview(self, backend, error):
# stop loading animation and restore icon on error # stop loading animation and restore icon on error
if error: if error:
self.loading_movie.stop() self.loading_movie.stop()
self._show_error_message_box( self._show_error_message_box(
"Loading Error", f"Error loading numpy or KPipeline: {error}" "Loading Error", f"Error loading TTS backend: {error}"
) )
self.btn_preview.setIcon(self.play_icon) self.btn_preview.setIcon(self.play_icon)
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
@@ -3117,28 +2921,14 @@ class abogen(QWidget):
else None else None
) )
else: else:
if self.provider_combo.currentData() == "supertonic": lang = self.selected_voice[0]
voice = self._get_supertonic_voice() voice = self.selected_voice
else:
voice = self.selected_voice or ""
tts_provider = self.provider_combo.currentData()
supertonic_language = self.st_lang_combo.currentText()
supertonic_total_steps = self.st_steps_combo.currentData()
if tts_provider == "supertonic":
lang = supertonic_language
else:
lang = self.selected_voice[0] if self.selected_voice else ""
# use same gpu/cpu logic as in conversion # use same gpu/cpu logic as in conversion
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
self.preview_thread = VoicePreviewThread( self.preview_thread = VoicePreviewThread(
np_module, kpipeline_class, lang, voice, speed, gpu_ok, backend, lang, voice, speed, gpu_ok
tts_provider=tts_provider,
supertonic_language=supertonic_language,
supertonic_total_steps=supertonic_total_steps,
) )
self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
@@ -3441,12 +3231,16 @@ class abogen(QWidget):
) )
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes: if box.exec() == QMessageBox.StandardButton.Yes:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
else: else:
event.ignore() event.ignore()
else: else:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
+4 -22
View File
@@ -1,10 +1,10 @@
import os import os
import sys import sys
import platform import platform
import atexit
import signal
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
from abogen import shutdown # noqa: F401
shutdown.register_shutdown()
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 # Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -94,6 +94,7 @@ os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
from abogen.utils import load_config
if load_config().get("disable_kokoro_internet", False): if load_config().get("disable_kokoro_internet", False):
print("INFO: Kokoro's internet access is disabled.") print("INFO: Kokoro's internet access is disabled.")
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
@@ -105,25 +106,6 @@ from abogen.constants import PROGRAM_NAME, VERSION
os.environ["MIOPEN_FIND_MODE"] = "FAST" os.environ["MIOPEN_FIND_MODE"] = "FAST"
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
# Reset sleep states
atexit.register(prevent_sleep_end)
# Also handle signals (Ctrl+C, kill, etc.)
def _cleanup_sleep(signum, frame):
prevent_sleep_end()
sys.exit(0)
signal.signal(signal.SIGINT, _cleanup_sleep)
signal.signal(signal.SIGTERM, _cleanup_sleep)
# Ensure sys.stdout and sys.stderr are valid in GUI mode
if sys.stdout is None:
sys.stdout = open(os.devnull, "w")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w")
# Enable MPS GPU acceleration on Mac Apple Silicon # Enable MPS GPU acceleration on Mac Apple Silicon
if platform.system() == "Darwin" and platform.processor() == "arm": if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL from abogen.constants import COLORS
from abogen.tts_plugin.utils import get_voices
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = VOICES_INTERNAL voice_list = get_voices("kokoro")
for idx, voice in enumerate(voice_list, start=1): for idx, voice in enumerate(voice_list, start=1):
if self._cancelled: if self._cancelled:
self._voices_success = False self._voices_success = False
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
try: try:
from huggingface_hub import try_to_load_from_cache from huggingface_hub import try_to_load_from_cache
for voice in VOICES_INTERNAL: for voice in get_voices("kokoro"):
if not try_to_load_from_cache( if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
): ):
missing.append(voice) missing.append(voice)
except Exception: except Exception:
# If HF missing, report all as missing # If HF missing, report all as missing
return False, list(VOICES_INTERNAL) return False, list(get_voices("kokoro"))
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
-4
View File
@@ -26,7 +26,3 @@ class QueuedItem:
replace_all_caps: bool = False replace_all_caps: bool = False
replace_numerals: bool = False replace_numerals: bool = False
fix_nonstandard_punctuation: bool = False fix_nonstandard_punctuation: bool = False
# TTS Provider fields
tts_provider: str = "kokoro"
supertonic_language: str = "en"
supertonic_total_steps: int = 8
+5 -8
View File
@@ -28,12 +28,11 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import ( from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
KOKORO_LANG_TO_COUNTRY,
COLORS, COLORS,
) )
from abogen.tts_plugin.utils import get_voices
import re import re
import platform import platform
from abogen.utils import get_resource_path from abogen.utils import get_resource_path
@@ -180,7 +179,7 @@ class VoiceMixer(QWidget):
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
# Voice name label with gender icon # Voice name label with gender icon
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f"
# Icons layout (flag and gender) # Icons layout (flag and gender)
icons_layout = QHBoxLayout() icons_layout = QHBoxLayout()
@@ -190,9 +189,8 @@ class VoiceMixer(QWidget):
) # Center the icons horizontally ) # Center the icons horizontally
# Flag icon # Flag icon
country_code = KOKORO_LANG_TO_COUNTRY.get(language_code, language_code)
flag_icon_path = get_resource_path( flag_icon_path = get_resource_path(
"abogen.assets.flags", f"{country_code}.png" "abogen.assets.flags", f"{language_code}.png"
) )
gender_icon_path = get_resource_path( gender_icon_path = get_resource_path(
"abogen.assets", "female.png" if is_female else "male.png" "abogen.assets", "female.png" if is_female else "male.png"
@@ -514,8 +512,7 @@ class VoiceFormulaDialog(QDialog):
header_row.addWidget(QLabel("Language:")) header_row.addWidget(QLabel("Language:"))
self.language_combo = QComboBox() self.language_combo = QComboBox()
for code, desc in LANGUAGE_OPTIONS: for code, desc in LANGUAGE_OPTIONS:
country_code = KOKORO_LANG_TO_COUNTRY.get(code, code) flag = get_resource_path("abogen.assets.flags", f"{code}.png")
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
if flag and os.path.exists(flag): if flag and os.path.exists(flag):
self.language_combo.addItem(QIcon(flag), desc, code) self.language_combo.addItem(QIcon(flag), desc, code)
else: else:
@@ -775,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
def add_voices(self, initial_state): def add_voices(self, initial_state):
first_enabled_voice = None first_enabled_voice = None
for voice in VOICES_INTERNAL: for voice in get_voices("kokoro"):
language_code = voice[0] # First character is the language code language_code = voice[0] # First character is the language code
matching_voice = next( matching_voice = next(
(item for item in initial_state if item[0] == voice), None (item for item in initial_state if item[0] == voice), None
+160
View File
@@ -0,0 +1,160 @@
"""Graceful shutdown - single module, no over-engineering."""
from __future__ import annotations
import atexit
import gc
import signal
import sys
from typing import Callable
_CLEANUP_FUNCS: list[Callable[[], None]] = []
_EXECUTED = False
def register_cleanup(fn: Callable[[], None]) -> None:
"""Register a cleanup function to run on shutdown."""
_CLEANUP_FUNCS.append(fn)
def _run_cleanups() -> None:
global _EXECUTED
if _EXECUTED:
return
_EXECUTED = True
for fn in _CLEANUP_FUNCS:
try:
fn()
except Exception:
pass
# ---- Register built-in cleanup functions ----
# 1. Restore sleep prevention
def _restore_sleep() -> None:
try:
from abogen.utils import prevent_sleep_end
prevent_sleep_end()
except Exception:
pass
register_cleanup(_restore_sleep)
# 2. Shutdown web UI ConversionService
def _shutdown_conversion_service() -> None:
try:
from abogen.webui.service import get_service
svc = get_service()
if svc is not None:
svc.shutdown()
except Exception:
pass
register_cleanup(_shutdown_conversion_service)
# 3. Clear TTS pipelines and GPU memory
def _cleanup_tts_pipelines() -> None:
# Clear web UI pipeline cache
try:
from abogen.webui.conversion_runner import _PIPELINES
_PIPELINES.clear()
except Exception:
pass
# Clear PyQt conversion thread voice cache
try:
from abogen.pyqt.conversion import ConversionThread
if hasattr(ConversionThread, "voice_cache"):
ConversionThread.voice_cache.clear()
except Exception:
pass
gc.collect()
# Release CUDA cache
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
except Exception:
pass
register_cleanup(_cleanup_tts_pipelines)
# 4. Clear global voice cache
def _clear_voice_cache() -> None:
try:
from abogen.voice_cache import clear_voice_cache
clear_voice_cache()
except Exception:
pass
register_cleanup(_clear_voice_cache)
# 5. Terminate child processes (ffmpeg, etc.)
def _terminate_subprocesses() -> None:
try:
import psutil
except Exception:
return
try:
current = psutil.Process()
for child in current.children(recursive=True):
try:
child.terminate()
except Exception:
pass
gone, alive = psutil.wait_procs(current.children(recursive=True), timeout=3)
for proc in alive:
try:
proc.kill()
except Exception:
pass
except Exception:
pass
register_cleanup(_terminate_subprocesses)
def register_shutdown() -> None:
"""Install process-wide shutdown hooks (atexit, signals, Qt)."""
if register_shutdown._registered:
return
register_shutdown._registered = True
atexit.register(_run_cleanups)
# POSIX signals
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _on_signal)
except Exception:
pass
# Qt hook
try:
from PyQt6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.aboutToQuit.connect(_run_cleanups)
except Exception:
pass
register_shutdown._registered = False
def _on_signal(signum: int, _frame) -> None:
_run_cleanups()
sys.exit(0)
def request_shutdown() -> None:
"""Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
_run_cleanups()
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"]
+7 -7
View File
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
def validate_voice_name(voice_name): def validate_voice_name(voice_name):
"""Validate voice name against VOICES_INTERNAL list (case-insensitive). """Validate voice name against available voices (case-insensitive).
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'. Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
Args: Args:
@@ -477,10 +477,10 @@ def validate_voice_name(voice_name):
- is_valid: True if all voices in the name/formula are valid - is_valid: True if all voices in the name/formula are valid
- invalid_voice_name: The first invalid voice found, or None if all valid - invalid_voice_name: The first invalid voice found, or None if all valid
""" """
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_voices
# Create case-insensitive lookup set (done once per call) # Create case-insensitive lookup set (done once per call)
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL} voice_lookup_lower = {v.lower() for v in get_voices("kokoro")}
voice_name = voice_name.strip() voice_name = voice_name.strip()
# Check if it's a formula (contains *) # Check if it's a formula (contains *)
@@ -505,7 +505,7 @@ def split_text_by_voice_markers(text, default_voice):
"""Split text by voice markers, returning list of (voice, text) tuples. """Split text by voice markers, returning list of (voice, text) tuples.
IMPORTANT: Returns the last voice used so it can persist across chapters. IMPORTANT: Returns the last voice used so it can persist across chapters.
Voice names are normalized to lowercase to match VOICES_INTERNAL. Voice names are normalized to lowercase to match canonical voice names.
Args: Args:
text: Text potentially containing <<VOICE:name>> markers text: Text potentially containing <<VOICE:name>> markers
@@ -518,7 +518,7 @@ def split_text_by_voice_markers(text, default_voice):
- valid_count: Number of valid voice markers processed - valid_count: Number of valid voice markers processed
- invalid_count: Number of invalid voice markers skipped - invalid_count: Number of invalid voice markers skipped
""" """
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_voices
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text)) voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
@@ -560,7 +560,7 @@ def split_text_by_voice_markers(text, default_voice):
# Find the canonical (lowercase) voice name # Find the canonical (lowercase) voice name
voice_part_lower = voice_part.strip().lower() voice_part_lower = voice_part.strip().lower()
canonical_voice = next( canonical_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower), (v for v in get_voices("kokoro") if v.lower() == voice_part_lower),
voice_part.strip() voice_part.strip()
) )
normalized_parts.append(f"{canonical_voice}*{weight.strip()}") normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
@@ -569,7 +569,7 @@ def split_text_by_voice_markers(text, default_voice):
# Find the canonical (lowercase) voice name # Find the canonical (lowercase) voice name
voice_name_lower = voice_name.lower() voice_name_lower = voice_name.lower()
current_voice = next( current_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower), (v for v in get_voices("kokoro") if v.lower() == voice_name_lower),
voice_name voice_name
) )
valid_markers += 1 valid_markers += 1
+170
View File
@@ -0,0 +1,170 @@
"""TTS Plugin Architecture - Public API.
This package defines the frozen Plugin API for the TTS Plugin Architecture.
All public interfaces are fully defined but contain no business logic.
Public modules:
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
- errors: Error hierarchy (EngineError and subtypes)
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
- engine: Engine and EngineSession protocols
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
- host_context: HostContext dataclass
- plugin: Plugin contract (create_engine function signature)
- loader: Plugin discovery and loading
- plugin_manager: Plugin management and engine creation
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
Usage:
from abogen.tts_plugin import (
# Types
AudioFormat,
Duration,
VoiceSelection,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
EngineConfig,
# Errors
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
# Manifest
PluginManifest,
EngineManifest,
VoiceSourceManifest,
VoiceManifest,
ParameterManifest,
AudioFormatManifest,
EnumOption,
RequirementManifest,
GpuRequirement,
ModelManifest,
# Engine
Engine,
EngineSession,
# Capabilities
VoiceLister,
PreviewGenerator,
StreamingSynthesizer,
CancelableSession,
# Host Context
HostContext,
HttpClient,
# Plugin Manager
get_plugin_manager,
reset_plugin_manager,
# Utils
get_voices,
get_default_voice,
is_plugin_registered,
resolve_voice_to_plugin,
create_pipeline,
)
"""
from abogen.tts_plugin.capabilities import (
CancelableSession,
PreviewGenerator,
StreamingSynthesizer,
VoiceLister,
)
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import (
CancelledError,
ConfigurationError,
EngineError,
InternalError,
InvalidInputError,
ModelLoadError,
ModelNotFoundError,
NetworkError,
)
from abogen.tts_plugin.host_context import HttpClient, HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
EnumOption,
GpuRequirement,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
# Plugin Manager and Utils
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import (
create_pipeline,
get_default_voice,
get_voices,
is_plugin_registered,
resolve_voice_to_plugin,
)
__all__ = [
# Types
"AudioFormat",
"Duration",
"VoiceSelection",
"ParameterValues",
"SynthesisRequest",
"SynthesizedAudio",
"EngineConfig",
# Errors
"EngineError",
"ModelNotFoundError",
"ModelLoadError",
"NetworkError",
"InvalidInputError",
"ConfigurationError",
"CancelledError",
"InternalError",
# Manifest
"PluginManifest",
"EngineManifest",
"VoiceSourceManifest",
"VoiceManifest",
"ParameterManifest",
"AudioFormatManifest",
"EnumOption",
"RequirementManifest",
"GpuRequirement",
"ModelManifest",
# Engine
"Engine",
"EngineSession",
# Capabilities
"VoiceLister",
"PreviewGenerator",
"StreamingSynthesizer",
"CancelableSession",
# Host Context
"HostContext",
"HttpClient",
# Plugin Manager
"get_plugin_manager",
"reset_plugin_manager",
# Utils
"get_voices",
"get_default_voice",
"is_plugin_registered",
"resolve_voice_to_plugin",
"create_pipeline",
]
+103
View File
@@ -0,0 +1,103 @@
"""Capability interfaces for the TTS Plugin Architecture.
This module defines optional capability interfaces that engines can implement.
Capabilities are additive; implementing new capabilities doesn't break old plugins.
"""
from __future__ import annotations
from typing import Iterator, Protocol, runtime_checkable
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
@runtime_checkable
class VoiceLister(Protocol):
"""Protocol for listing available voices.
Engines that support voice listing should implement this interface.
"""
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available voices for a given source.
Args:
sourceId: The voice source identifier.
Returns:
List of VoiceManifest describing available voices.
Raises:
EngineError: On failure.
"""
...
@runtime_checkable
class PreviewGenerator(Protocol):
"""Protocol for generating voice previews.
Engines that support voice preview should implement this interface.
"""
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
"""Generate a preview audio for a voice.
Args:
voice: Voice selection for the preview.
text: Text to use for the preview.
Returns:
SynthesizedAudio with the preview audio data.
Raises:
EngineError: On failure.
"""
...
@runtime_checkable
class StreamingSynthesizer(Protocol):
"""Protocol for streaming synthesis.
Optional capability of EngineSession, not Engine.
Engines that support streaming synthesis should implement this interface.
"""
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
"""Synthesize audio in streaming mode.
Args:
request: The synthesis request.
Yields:
Audio chunks as they become available.
Raises:
CancelledError: If cancel() is called during iteration.
EngineError: On synthesis failure.
"""
...
# This is a generator function; implementation will use yield
yield b"" # pragma: no cover
@runtime_checkable
class CancelableSession(Protocol):
"""Protocol for cancellation support.
Optional capability for engines that support cancellation.
cancel() causes synthesize() to raise CancelledError.
"""
def cancel(self) -> None:
"""Cancel in-progress synthesis.
After cancellation, synthesize() raises CancelledError.
The session remains usable after cancellation.
Raises:
EngineError: If called after dispose().
"""
...
+95
View File
@@ -0,0 +1,95 @@
"""Engine interfaces for the TTS Plugin Architecture.
This module defines the core Engine and EngineSession protocols.
These are the primary interfaces that plugin implementations must satisfy.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
@runtime_checkable
class EngineSession(Protocol):
"""Protocol for a session that owns mutable execution state.
An EngineSession is created by Engine.createSession() and owns
mutable execution state isolated from other concurrent work.
It is NOT thread-safe.
Lifecycle:
1. Created by Engine.createSession()
2. Used for synthesis via synthesize()
3. Disposed via dispose()
After dispose(), all methods except dispose() raise EngineError.
"""
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text.
Args:
request: The synthesis request containing text, voice, parameters, and format.
Returns:
SynthesizedAudio with the synthesized audio data.
Raises:
EngineError: On synthesis failure. Session remains usable after error.
EngineError: If called after dispose().
"""
...
def dispose(self) -> None:
"""Release session resources.
This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError.
"""
...
@runtime_checkable
class Engine(Protocol):
"""Protocol for a TTS engine that creates sessions.
An Engine is a factory for EngineSession instances. It is stateless
and thread-safe for createSession().
Lifecycle:
1. Created via create_engine() (plugin contract)
2. Sessions created via createSession()
3. Disposed via dispose()
Thread Safety:
- createSession() is thread-safe and can be called from any thread.
- dispose() must be called after all sessions are disposed.
- Disposing engine while sessions are alive violates API contract.
"""
def createSession(self) -> EngineSession:
"""Create a new session for synthesis.
Returns:
A new EngineSession instance. Ownership transfers to caller.
Raises:
EngineError: On failure. No partially initialized session is returned.
"""
...
def dispose(self) -> None:
"""Release engine resources.
Caller must ensure all sessions created by this engine are disposed
before calling dispose(). Disposing an engine while any session is
still alive violates the API contract; behavior is undefined.
This method is idempotent and safe to call multiple times.
It never raises exceptions (catches and logs internally).
After dispose(), all methods except dispose() raise EngineError.
"""
...
+62
View File
@@ -0,0 +1,62 @@
"""Error hierarchy for the TTS Plugin Architecture.
This module defines typed exceptions that engines raise.
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
"""
from __future__ import annotations
class EngineError(Exception):
"""Base exception for all engine errors.
All engine operations that can fail should raise EngineError or one of its subtypes.
After dispose(), all methods except dispose() raise EngineError.
"""
pass
class ModelNotFoundError(EngineError):
"""Raised when a required model is not found."""
pass
class ModelLoadError(EngineError):
"""Raised when a model fails to load."""
pass
class NetworkError(EngineError):
"""Raised when a network operation fails."""
pass
class InvalidInputError(EngineError):
"""Raised when invalid input is provided to the engine."""
pass
class ConfigurationError(EngineError):
"""Raised when there is a configuration error."""
pass
class CancelledError(EngineError):
"""Raised when an operation is cancelled.
This is raised by synthesize() when cancel() is called during synthesis.
"""
pass
class InternalError(EngineError):
"""Raised when an internal engine error occurs."""
pass
+46
View File
@@ -0,0 +1,46 @@
"""Host context for the TTS Plugin Architecture.
This module defines the HostContext dataclass that provides minimal
host services to plugins. It is the only interface through which
plugins can access host functionality.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, runtime_checkable
@runtime_checkable
class HttpClient(Protocol):
"""Protocol for HTTP client provided by host.
Plugins can use this for network requests (e.g., API-based engines).
"""
def get(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP GET request."""
...
def post(self, url: str, **kwargs: object) -> object:
"""Perform an HTTP POST request."""
...
@dataclass(frozen=True)
class HostContext:
"""Minimal host context provided to plugins.
Contains only essential host services. No business logic.
Attributes:
config_dir: Directory for API keys, preferences, and configuration.
logger: Logger for plugin logging.
http_client: HTTP client for network requests.
"""
config_dir: Path
logger: logging.Logger
http_client: HttpClient
+365
View File
@@ -0,0 +1,365 @@
"""Plugin loader infrastructure for the TTS Plugin Architecture.
This module provides functionality to discover, import, validate, and load
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
messages for errors.
The loader does NOT:
- Create Engine instances (that's the plugin's create_engine() responsibility)
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
- Implement any TTS engine functionality
"""
from __future__ import annotations
import importlib
import re
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
# Host API version for compatibility checking
HOST_API_VERSION = "1.0"
@dataclass(frozen=True)
class PluginLoadError:
"""Diagnostic information for a failed plugin load.
Attributes:
plugin_id: Plugin identifier if available, otherwise directory name.
path: Path to the plugin directory.
errors: List of error messages describing what went wrong.
"""
plugin_id: str
path: Path
errors: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class PluginLoadResult:
"""Result of loading a plugin.
Attributes:
success: Whether the plugin loaded successfully.
manifest: The plugin manifest if successful.
model_requirements: Model requirements if successful.
create_engine: The create_engine function if successful.
module: The plugin module if successful.
error: Error information if failed.
"""
success: bool
manifest: PluginManifest | None = None
model_requirements: tuple[ModelManifest, ...] | None = None
create_engine: Callable[..., Any] | None = None
module: types.ModuleType | None = None
error: PluginLoadError | None = None
def _parse_api_version(version: str) -> tuple[int, int] | None:
"""Parse an api_version string into (major, minor) tuple.
Args:
version: Version string in format "MAJOR.MINOR".
Returns:
Tuple of (major, minor) or None if invalid format.
"""
match = re.match(r"^(\d+)\.(\d+)$", version)
if match:
return int(match.group(1)), int(match.group(2))
return None
def _check_api_version_compatibility(plugin_version: str) -> str | None:
"""Check if plugin api_version is compatible with host.
Architecture spec:
- Format: semver (MAJOR.MINOR)
- Compatibility: Host rejects plugin if major version differs
- Minor version: backward compatible, Host accepts higher minor
Args:
plugin_version: Plugin's api_version string.
Returns:
Error message if incompatible, None if compatible.
"""
plugin_ver = _parse_api_version(plugin_version)
if plugin_ver is None:
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
host_ver = _parse_api_version(HOST_API_VERSION)
if host_ver is None:
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
if plugin_ver[0] != host_ver[0]:
return (
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
f"Major version must match for compatibility."
)
return None
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
"""Validate that a plugin module has required exports.
Args:
module: The imported plugin module.
plugin_dir: Path to the plugin directory.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
# Check PLUGIN_MANIFEST
manifest = getattr(module, "PLUGIN_MANIFEST", None)
if manifest is None:
errors.append("Missing PLUGIN_MANIFEST export")
elif not isinstance(manifest, PluginManifest):
errors.append(
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
f"got {type(manifest).__name__}"
)
# Check MODEL_REQUIREMENTS
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
if model_reqs is None:
errors.append("Missing MODEL_REQUIREMENTS export")
elif not isinstance(model_reqs, list):
errors.append(
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
)
else:
for i, req in enumerate(model_reqs):
if not isinstance(req, ModelManifest):
errors.append(
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
f"got {type(req).__name__}"
)
# Check create_engine
create_engine = getattr(module, "create_engine", None)
if create_engine is None:
errors.append("Missing create_engine export")
elif not callable(create_engine):
errors.append(
f"create_engine must be callable, got {type(create_engine).__name__}"
)
return errors
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
"""Validate plugin capabilities.
Args:
manifest: The plugin manifest to validate.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
# Known capabilities (can be extended)
known_capabilities = frozenset({
"voice_list",
"preview",
"voice_clone",
"voice_blend",
"streaming",
"cancel",
})
for cap in manifest.capabilities:
if cap not in known_capabilities:
errors.append(f"Unknown capability: '{cap}'")
return errors
def _validate_api_version(manifest: PluginManifest) -> list[str]:
"""Validate api_version compatibility.
Args:
manifest: The plugin manifest to validate.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
error = _check_api_version_compatibility(manifest.api_version)
if error:
errors.append(error)
return errors
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
"""Load and validate a plugin from a directory.
The plugin directory must contain an __init__.py that exports:
- PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable
Args:
plugin_dir: Path to the plugin directory.
Returns:
PluginLoadResult with success status and either plugin data or error info.
"""
plugin_id = plugin_dir.name
errors: list[str] = []
# Check if directory exists
if not plugin_dir.exists():
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Plugin directory does not exist: {plugin_dir}",),
),
)
# Check for __init__.py
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=("Missing __init__.py in plugin directory",),
),
)
# Import the module
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
try:
# Remove from cache if already imported (for testing)
if module_name in sys.modules:
del sys.modules[module_name]
spec = importlib.util.spec_from_file_location(
module_name, init_file, submodule_search_locations=[]
)
if spec is None or spec.loader is None:
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Failed to create module spec for {init_file}",),
),
)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
except Exception as e:
# Clean up module from sys.modules on import failure
if module_name in sys.modules:
del sys.modules[module_name]
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Failed to import plugin module: {e}",),
),
)
# Validate manifest
manifest_errors = _validate_manifest(module, plugin_dir)
errors.extend(manifest_errors)
# If manifest is valid, perform additional validation
manifest = getattr(module, "PLUGIN_MANIFEST", None)
if isinstance(manifest, PluginManifest):
# Validate api_version
api_errors = _validate_api_version(manifest)
errors.extend(api_errors)
# Validate capabilities
cap_errors = _validate_capabilities(manifest)
errors.extend(cap_errors)
# Use manifest id if available
plugin_id = manifest.id
# Check if any errors occurred
if errors:
# Clean up module from sys.modules
if module_name in sys.modules:
del sys.modules[module_name]
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=tuple(errors),
),
)
# Get MODEL_REQUIREMENTS
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
create_engine = getattr(module, "create_engine", None)
return PluginLoadResult(
success=True,
manifest=manifest,
model_requirements=model_requirements,
create_engine=create_engine,
module=module,
)
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
"""Discover and load plugins from multiple directories.
Args:
plugin_dirs: List of directories to scan for plugins.
Returns:
List of PluginLoadResult, one per plugin directory found.
"""
results: list[PluginLoadResult] = []
for plugin_dir in plugin_dirs:
if not plugin_dir.exists():
continue
# Scan for subdirectories (each is a potential plugin)
for item in sorted(plugin_dir.iterdir()):
if item.is_dir() and not item.name.startswith("."):
result = load_plugin_from_dir(item)
results.append(result)
return results
def load_plugin(
plugin_dir: Path,
) -> PluginLoadResult:
"""Load a single plugin from a directory.
This is the main entry point for loading a plugin.
Args:
plugin_dir: Path to the plugin directory.
Returns:
PluginLoadResult with success status and either plugin data or error info.
"""
return load_plugin_from_dir(plugin_dir)
+189
View File
@@ -0,0 +1,189 @@
"""Plugin manifest types for the TTS Plugin Architecture.
This module contains static metadata types that describe plugins.
These types have no dependencies and are immutable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class AudioFormatManifest:
"""Manifest describing an audio format.
Attributes:
mime: MIME type of the audio.
extension: File extension.
"""
mime: str
extension: str
@dataclass(frozen=True)
class EnumOption:
"""Manifest describing an enum option for a parameter.
Attributes:
value: The enum value.
label: Human-readable label.
"""
value: str
label: str
@dataclass(frozen=True)
class ParameterManifest:
"""Manifest describing a synthesis parameter.
Attributes:
id: Parameter identifier.
name: Human-readable name.
description: Parameter description.
type: Parameter type ("float", "int", "string", "boolean", "enum").
default: Default value.
min: Minimum value (optional, for numeric types).
max: Maximum value (optional, for numeric types).
step: Step size (optional, for numeric types).
options: Available options (optional, for enum type).
unit: Unit of measurement (optional).
group: Parameter group (optional).
"""
id: str
name: str
description: str
type: str
default: Any
min: float | None = None
max: float | None = None
step: float | None = None
options: tuple[EnumOption, ...] = field(default_factory=tuple)
unit: str | None = None
group: str | None = None
@dataclass(frozen=True)
class VoiceManifest:
"""Manifest describing a voice.
Attributes:
id: Voice identifier.
name: Human-readable name.
tags: Voice tags (e.g., language, style).
"""
id: str
name: str
tags: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class VoiceSourceManifest:
"""Manifest describing a voice source.
Attributes:
id: Voice source identifier.
name: Human-readable name.
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
config: Source-specific configuration.
"""
id: str
name: str
type: str
config: Any = None
@dataclass(frozen=True)
class EngineManifest:
"""Manifest describing engine capabilities.
Attributes:
voiceSources: Available voice sources.
parameters: Available synthesis parameters.
audioFormats: Supported audio formats.
"""
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class GpuRequirement:
"""Manifest describing GPU requirements.
Attributes:
required: Whether GPU is required.
type: GPU type (e.g., "cuda", "rocm").
memory: Required GPU memory in GB.
"""
required: bool = False
type: str | None = None
memory: float | None = None
@dataclass(frozen=True)
class RequirementManifest:
"""Manifest describing plugin requirements.
Attributes:
gpu: GPU requirements (optional).
memory: Required RAM in GB (optional).
internet: Whether internet is required (optional).
"""
gpu: GpuRequirement | None = None
memory: float | None = None
internet: bool | None = None
@dataclass(frozen=True)
class ModelManifest:
"""Manifest describing a model requirement.
Attributes:
id: Model identifier.
name: Human-readable name.
size: Model size as string (e.g., "100MB", "2GB").
"""
id: str
name: str
size: str
@dataclass(frozen=True)
class PluginManifest:
"""Main manifest for a TTS plugin.
Attributes:
id: Plugin identifier (unique).
name: Human-readable name.
version: Plugin version.
api_version: API version (semver format: MAJOR.MINOR).
description: Plugin description.
author: Plugin author.
capabilities: List of capability identifiers.
requires: Plugin requirements.
engine: Engine manifest.
voices: Optional static voice catalog. None = not declared (use VoiceLister),
empty tuple = explicitly no static voices, non-empty = static catalog.
"""
id: str
name: str
version: str
api_version: str
description: str
author: str
capabilities: tuple[str, ...] = field(default_factory=tuple)
requires: RequirementManifest = field(default_factory=RequirementManifest)
engine: EngineManifest = field(default_factory=EngineManifest)
voices: tuple[VoiceManifest, ...] | None = None
+55
View File
@@ -0,0 +1,55 @@
"""Plugin contract for the TTS Plugin Architecture.
This module defines the plugin contract that all TTS plugins must implement.
Each plugin must export:
- PLUGIN_MANIFEST: PluginManifest instance
- MODEL_REQUIREMENTS: list of ModelManifest instances
- create_engine(): Factory function that creates an Engine
The create_engine() function is the entry point for plugin activation.
It must be atomic: succeed fully or raise and clean up.
"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol, runtime_checkable
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
@runtime_checkable
class Plugin(Protocol):
"""Protocol defining the plugin contract.
Every TTS plugin must implement this protocol by exporting:
- PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
"""
def create_engine(
self,
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create an engine instance.
This is the factory function that creates an Engine from a plugin.
It must be atomic: succeed fully or raise EngineError and clean up.
Args:
context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for cloud/no-model engines.
config: Engine initialization settings.
Returns:
A fully initialized Engine instance.
Raises:
EngineError: On failure. Cleans up partially created resources.
"""
...
+153
View File
@@ -0,0 +1,153 @@
"""Plugin Manager
Provides a simple interface for consumers to access TTS engines via the
new Plugin Architecture. Discovers, loads, and manages plugins from the
plugins directory.
Usage:
from abogen.tts_plugin.plugin_manager import get_plugin_manager
manager = get_plugin_manager()
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
session = engine.create_session()
try:
result = session.synthesize("Hello world")
finally:
session.dispose()
"""
from typing import Any, Dict, List, Optional, Type
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import AudioFormat
class PluginManager:
"""Manages TTS plugins and provides a simple interface for consumers."""
def __init__(self) -> None:
self._plugins: Dict[str, dict] = {}
self._engines: Dict[str, Engine] = {}
self._loaded = False
def discover(self, plugins_dir: str = "plugins") -> None:
"""Discover and load all plugins from the given directory."""
import os
from pathlib import Path
from abogen.tts_plugin.loader import load_plugin_from_dir
self._plugins.clear()
self._engines.clear()
plugins_path = Path(plugins_dir)
if not plugins_path.exists():
self._loaded = True
return
for entry in plugins_path.iterdir():
if entry.is_dir() and (entry / "__init__.py").exists():
try:
result = load_plugin_from_dir(entry)
if result.success and result.manifest is not None:
self._plugins[result.manifest.id] = {
"manifest": result.manifest,
"create_engine": result.create_engine,
"module": result.module,
}
except Exception as e:
# Log error but continue with other plugins
print(f"Warning: Failed to load plugin from {entry}: {e}")
self._loaded = True
def _ensure_loaded(self) -> None:
"""Ensure plugins have been discovered."""
if not self._loaded:
self.discover()
def list_plugins(self) -> List[PluginManifest]:
"""Return manifests for all loaded plugins."""
self._ensure_loaded()
return [info["manifest"] for info in self._plugins.values()]
def get_plugin(self, plugin_id: str) -> Optional[dict]:
"""Get plugin info by ID."""
self._ensure_loaded()
return self._plugins.get(plugin_id)
def has_plugin(self, plugin_id: str) -> bool:
"""Check if a plugin is loaded."""
self._ensure_loaded()
return plugin_id in self._plugins
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Create an engine instance for the given plugin.
Args:
plugin_id: The plugin identifier (e.g., "kokoro")
**kwargs: Arguments passed to the engine constructor
Returns:
An Engine instance
Raises:
KeyError: If plugin_id is not found
Exception: If engine creation fails
"""
self._ensure_loaded()
if plugin_id not in self._plugins:
raise KeyError(f"Plugin not found: {plugin_id}")
plugin_info = self._plugins[plugin_id]
create_engine_func = plugin_info["create_engine"]
# Create engine using the plugin's factory
engine = create_engine_func(**kwargs)
return engine
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
"""Get an existing engine or create a new one.
Engines are cached by plugin_id. If you need multiple instances
with different parameters, use create_engine() directly.
"""
self._ensure_loaded()
cache_key = plugin_id
if cache_key in self._engines:
return self._engines[cache_key]
engine = self.create_engine(plugin_id, **kwargs)
self._engines[cache_key] = engine
return engine
def dispose_all(self) -> None:
"""Dispose all cached engines."""
for engine in self._engines.values():
try:
engine.dispose()
except Exception:
pass # dispose() should never raise
self._engines.clear()
# Global singleton
_manager: Optional[PluginManager] = None
def get_plugin_manager() -> PluginManager:
"""Get the global PluginManager instance."""
global _manager
if _manager is None:
_manager = PluginManager()
return _manager
def reset_plugin_manager() -> None:
"""Reset the global PluginManager (for testing)."""
global _manager
if _manager is not None:
_manager.dispose_all()
_manager = None
+111
View File
@@ -0,0 +1,111 @@
"""Core domain types for the TTS Plugin Architecture.
This module contains immutable value objects that form the core domain.
These types have zero dependencies and are used across the plugin system.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping
@dataclass(frozen=True)
class AudioFormat:
"""Immutable value object representing an audio format.
Attributes:
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
extension: File extension (e.g., "wav", "mp3").
"""
mime: str
extension: str
@dataclass(frozen=True)
class Duration:
"""Immutable value object representing a time duration.
Attributes:
seconds: Duration in seconds.
"""
seconds: float
@dataclass(frozen=True)
class VoiceSelection:
"""Immutable value object for voice selection. Opaque to engine.
Attributes:
source: Voice source identifier (e.g., "builtin", "clone").
key: Voice key within the source.
payload: Optional payload for clone/blend sources.
"""
source: str
key: str
payload: Any = None
@dataclass(frozen=True)
class ParameterValues:
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
Attributes:
values: Mapping of parameter names to their values.
"""
values: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SynthesisRequest:
"""Immutable value object for a synthesis request.
Attributes:
text: Text to synthesize.
voice: Voice selection.
parameters: Synthesis parameters.
format: Desired audio output format.
"""
text: str
voice: VoiceSelection
parameters: ParameterValues
format: AudioFormat
@dataclass(frozen=True)
class SynthesizedAudio:
"""Immutable value object for synthesized audio result.
Attributes:
data: Raw audio bytes.
format: Audio format of the result.
duration: Duration of the audio.
"""
data: bytes
format: AudioFormat
duration: Duration
@dataclass(frozen=True)
class EngineConfig:
"""Immutable configuration of an Engine instance.
Contains parameters that define how a particular Engine instance is
created and that remain constant throughout the lifetime of that Engine.
Plugin implementations may ignore fields that are not applicable to them.
Attributes:
device: Device to use (e.g., "cpu", "cuda:0").
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
Plugins that do not require a language code ignore this field.
"""
device: str = "cpu"
lang_code: str = "a"
+235
View File
@@ -0,0 +1,235 @@
"""TTS Plugin Architecture — direct utility functions.
Provides helpers that replace the former compatibility adapter by
calling the Plugin Manager directly.
"""
from __future__ import annotations
from typing import Any, Iterator
import numpy as np
from abogen.tts_plugin.plugin_manager import get_plugin_manager
def get_voices(plugin_id: str) -> tuple[str, ...]:
"""Return the voice-id tuple for *plugin_id*.
Uses the official Plugin Architecture: PluginManager Engine VoiceLister.
First checks plugin manifest for static voice catalog.
"""
import logging
import tempfile
from pathlib import Path
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
manager = get_plugin_manager()
if not manager.has_plugin(plugin_id):
return ()
# Check manifest for static voice catalog
plugin_info = manager.get_plugin(plugin_id)
if plugin_info is not None:
manifest = plugin_info.get("manifest")
if manifest is not None and manifest.voices is not None:
return tuple(v.id for v in manifest.voices)
ctx = HostContext(
config_dir=Path(tempfile.gettempdir()),
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
http_client=type("_StubHttpClient", (), {
"get": staticmethod(lambda url, **kw: None),
"post": staticmethod(lambda url, **kw: None),
})(),
)
try:
engine = manager.create_engine(
plugin_id,
context=ctx,
model_path=None,
config=EngineConfig(device="cpu"),
)
except Exception:
return ()
try:
from abogen.tts_plugin.capabilities import VoiceLister
if isinstance(engine, VoiceLister):
manifests = engine.listVoices("builtin")
return tuple(v.id for v in manifests)
return ()
except Exception:
return ()
finally:
engine.dispose()
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
"""Return the first voice of *plugin_id*, or *fallback*."""
voices = get_voices(plugin_id)
return voices[0] if voices else fallback
def is_plugin_registered(plugin_id: str) -> bool:
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
return get_plugin_manager().has_plugin(plugin_id)
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
"""Determine which plugin owns the given voice specification.
Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice-id match against loaded plugins -> plugin id
4. Unknown voice -> fallback
"""
raw = str(spec or "").strip()
if not raw:
return fallback
if "*" in raw or "+" in raw:
return "kokoro"
upper = raw.upper()
manager = get_plugin_manager()
for manifest in manager.list_plugins():
for voice_source in manifest.engine.voiceSources:
if voice_source.type == "list" and isinstance(voice_source.config, dict):
try:
engine = manager.create_engine(manifest.id)
try:
if hasattr(engine, "listVoices"):
voice_manifests = engine.listVoices(voice_source.id)
voice_ids = [v.id.upper() for v in voice_manifests]
if upper in voice_ids:
return manifest.id
finally:
engine.dispose()
except Exception:
continue
return fallback
class Pipeline:
"""Callable wrapper around Engine / EngineSession.
Presents the same interface that old callers expect::
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
for segment in pipeline(text, voice="af_nova", speed=1.0):
audio = segment.audio
"""
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
self._engine = engine
self._engine_kwargs = engine_kwargs
self._session: Any = None
def _ensure_session(self) -> Any:
if self._session is None:
self._session = self._engine.createSession()
return self._session
def __call__(
self,
text: str,
voice: str = "default",
speed: float = 1.0,
split_pattern: str | None = None,
**kwargs: Any,
) -> Iterator[Any]:
from abogen.tts_plugin.types import (
AudioFormat,
ParameterValues,
SynthesisRequest,
VoiceSelection,
)
session = self._ensure_session()
params: dict[str, Any] = {"speed": speed}
if split_pattern is not None:
params["split_pattern"] = split_pattern
params.update(kwargs)
request = SynthesisRequest(
text=text,
voice=VoiceSelection(source="builtin", key=voice),
parameters=ParameterValues(values=params),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
audio_array = np.frombuffer(result.data, dtype=np.float32)
from dataclasses import dataclass
@dataclass
class Segment:
graphemes: str
audio: np.ndarray
yield Segment(graphemes=text, audio=audio_array)
def dispose(self) -> None:
if self._session is not None:
try:
self._session.dispose()
except Exception:
pass
self._session = None
def __del__(self) -> None:
self.dispose()
def create_pipeline(
plugin_id: str,
*,
lang_code: str = "a",
device: str = "cpu",
) -> Pipeline:
"""Create a callable TTS pipeline via the Plugin Architecture.
Builds a proper HostContext and EngineConfig, then delegates to the
PluginManager to create the engine. Returns a :class:`Pipeline` whose
``__call__`` interface matches the callable protocol used by consumers.
Args:
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
lang_code: Language code for the engine.
device: Device to use (e.g., "cpu", "cuda:0").
Returns:
A callable Pipeline instance.
"""
import logging
import tempfile
from pathlib import Path
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
manager = get_plugin_manager()
ctx = HostContext(
config_dir=Path(tempfile.gettempdir()),
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
http_client=type("_StubHttpClient", (), {
"get": staticmethod(lambda url, **kw: None),
"post": staticmethod(lambda url, **kw: None),
})(),
)
config = EngineConfig(device=device, lang_code=lang_code)
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
return Pipeline(engine)
+10 -11
View File
@@ -529,21 +529,20 @@ def prevent_sleep_end():
_sleep_procs[system] = None _sleep_procs[system] = None
def load_numpy_kpipeline():
import numpy as np
from kokoro import KPipeline # type: ignore[import-not-found]
return np, KPipeline
class LoadPipelineThread(Thread): class LoadPipelineThread(Thread):
def __init__(self, callback): def __init__(self, callback, lang_code="a", device="cpu"):
super().__init__() super().__init__()
self.callback = callback self.callback = callback
self.lang_code = lang_code
self.device = device
def run(self): def run(self):
try: try:
np_module, kpipeline_class = load_numpy_kpipeline() from abogen.tts_plugin.utils import create_pipeline
self.callback(np_module, kpipeline_class, None)
backend = create_pipeline(
"kokoro", lang_code=self.lang_code, device=self.device
)
self.callback(backend, None)
except Exception as e: except Exception as e:
self.callback(None, None, str(e)) self.callback(None, str(e))
+12 -3
View File
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
pass pass
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_voices
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
@@ -26,8 +26,9 @@ _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro")
if not voices: if not voices:
return set(VOICES_INTERNAL) return set(kokoro_voices)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
@@ -35,7 +36,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in VOICES_INTERNAL: if voice_id in kokoro_voices:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
@@ -143,3 +144,11 @@ def _ensure_single_voice_asset(
hf_hub_download(resume_download=True, **common_kwargs) hf_hub_download(resume_download=True, **common_kwargs)
return True return True
def clear_voice_cache() -> None:
"""Clear the inprocess voice cache (used during shutdown)."""
with _CACHE_LOCK:
_CACHED_VOICES.clear()
global _BOOTSTRAPPED
_BOOTSTRAPPED = False
+3 -2
View File
@@ -1,7 +1,7 @@
import re import re
from typing import List, Tuple from typing import List, Tuple
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_voices
# Calls parsing and loads the voice to gpu or cpu # Calls parsing and loads the voice to gpu or cpu
@@ -22,6 +22,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Empty voice formula") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro")
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: if not part:
@@ -30,7 +31,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Each component must be in the form voice*weight") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() voice_name = voice_name.strip()
if voice_name not in VOICES_INTERNAL: if voice_name not in kokoro_voices:
raise ValueError(f"Unknown voice: {voice_name}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class VoiceMetadata:
"""
Immutable metadata describing a voice from a TTS backend.
This model describes a voice independently of any backend implementation.
Backends populate these objects; the application consumes them.
The ``backend_id`` field is set by the backend itself (via
``self.metadata.id``) the application never hardcodes it.
This ensures renaming a backend does not require touching voice definitions.
"""
id: str
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
display_name: str
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
language: str
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
gender: str
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
backend_id: str
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
Set automatically by the backend never hardcoded in voice definitions.
"""
+6 -5
View File
@@ -2,8 +2,7 @@ import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_voices, is_plugin_registered
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
@@ -70,7 +69,8 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
def _normalize_supertonic_voice(value: Any) -> str: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1" supertonic_voices = get_voices("supertonic")
return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
return {} return {}
provider = str(entry.get("provider") or "kokoro").strip().lower() provider = str(entry.get("provider") or "kokoro").strip().lower()
if provider not in {"kokoro", "supertonic"}: if not is_plugin_registered(provider):
provider = "kokoro" provider = "kokoro"
language = str(entry.get("language") or "a").strip().lower() or "a" language = str(entry.get("language") or "a").strip().lower() or "a"
@@ -135,6 +135,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro")
for item in entries or []: for item in entries or []:
if isinstance(item, dict): if isinstance(item, dict):
voice = item.get("id") or item.get("voice") voice = item.get("id") or item.get("voice")
@@ -143,7 +144,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
voice, weight = item[0], item[1] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in VOICES_INTERNAL: if voice not in kokoro_voices:
continue continue
if weight is None: if weight is None:
continue continue
+8 -9
View File
@@ -2,7 +2,6 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \ VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH PATH=/opt/venv/bin:$PATH
@@ -27,22 +26,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ COPY pyproject.toml README.md ./
COPY abogen ./abogen RUN pip install uv \
RUN pip install --upgrade pip \
&& if [ -n "$TORCH_VERSION" ]; then \ && if [ -n "$TORCH_VERSION" ]; then \
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \ uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
else \ else \
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \ uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
fi \ fi \
&& pip install --no-cache-dir . \ && uv pip install --system . \
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
&& pip install --no-cache-dir "mutagen>=1.47.0" && uv pip install --system "mutagen>=1.47.0"
COPY abogen ./abogen
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime) # Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
# Set USE_GPU=false to skip this for CPU-only deployments # Set USE_GPU=false to skip this for CPU-only deployments
RUN if [ "$USE_GPU" = "true" ]; then \ RUN if [ "$USE_GPU" = "true" ]; then \
pip install --no-cache-dir onnxruntime-gpu; \ uv pip install --system onnxruntime-gpu; \
fi fi
ENV ABOGEN_HOST=0.0.0.0 \ ENV ABOGEN_HOST=0.0.0.0 \
+8 -3
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import atexit
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -8,6 +7,8 @@ from typing import Any, Optional
from flask import Flask from flask import Flask
from abogen import shutdown # noqa: F401
shutdown.register_shutdown()
from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir
from .conversion_runner import run_conversion_job from .conversion_runner import run_conversion_job
@@ -83,6 +84,12 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
"UPLOAD_FOLDER": str(uploads_dir), "UPLOAD_FOLDER": str(uploads_dir),
"OUTPUT_FOLDER": str(outputs_dir), "OUTPUT_FOLDER": str(outputs_dir),
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads "MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
# Large books can submit four form fields per chapter. Werkzeug's
# defaults reject those requests before the wizard route can process
# them, even though the encoded payload is much smaller than the upload
# limit above.
"MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024,
"MAX_FORM_PARTS": 10_000,
} }
if config: if config:
base_config.update(config) base_config.update(config)
@@ -113,8 +120,6 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
app.register_blueprint(books_bp, url_prefix="/find-books") app.register_blueprint(books_bp, url_prefix="/find-books")
app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(api_bp, url_prefix="/api")
atexit.register(service.shutdown)
global _access_log_filter_attached global _access_log_filter_attached
if not _access_log_filter_attached: if not _access_log_filter_attached:
logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter()) logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter())
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.utils import load_numpy_kpipeline from abogen.tts_plugin.utils import create_pipeline
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@@ -45,8 +45,7 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
device = _select_device() device = _select_device()
_np, KPipeline = load_numpy_kpipeline() return create_pipeline("kokoro", lang_code=language, device=device)
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
@@ -248,4 +247,8 @@ def run_debug_tts_wavs(
"sample_rate": SAMPLE_RATE, "sample_rate": SAMPLE_RATE,
} }
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
try:
pipeline.dispose()
except Exception:
pass
return manifest return manifest
+6 -5
View File
@@ -34,6 +34,7 @@ from abogen.normalization_settings import (
) )
from abogen.llm_client import list_models, LLMClientError from abogen.llm_client import list_models, LLMClientError
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.tts_plugin.utils import is_plugin_registered
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
from abogen.integrations.calibre_opds import ( from abogen.integrations.calibre_opds import (
CalibreOPDSClient, CalibreOPDSClient,
@@ -63,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
if profile is None: if profile is None:
# Speaker Studio payload format # Speaker Studio payload format
provider = str(payload.get("provider") or "kokoro").strip().lower() provider = str(payload.get("provider") or "kokoro").strip().lower()
if provider not in {"kokoro", "supertonic"}: if not is_plugin_registered(provider):
provider = "kokoro" provider = "kokoro"
if provider == "supertonic": if provider == "supertonic":
profile = { profile = {
@@ -162,7 +163,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
formula = str(payload.get("formula") or "").strip() formula = str(payload.get("formula") or "").strip()
profile_name = str(payload.get("profile") or "").strip() profile_name = str(payload.get("profile") or "").strip()
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 8) supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
voice_spec = "" voice_spec = ""
resolved_provider = provider or "kokoro" resolved_provider = provider or "kokoro"
@@ -224,13 +225,13 @@ def api_speaker_preview() -> ResponseReturnValue:
speed_value = payload.get("speed") speed_value = payload.get("speed")
speed = coerce_float(speed_value, 1.0) speed = coerce_float(speed_value, 1.0)
tts_provider = str(payload.get("tts_provider") or "").strip().lower() tts_provider = str(payload.get("tts_provider") or "").strip().lower()
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 8) supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
settings = load_settings() settings = load_settings()
use_gpu = settings.get("use_gpu", False) use_gpu = settings.get("use_gpu", False)
base_spec, speaker_name = split_profile_spec(voice) base_spec, speaker_name = split_profile_spec(voice)
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else "" resolved_provider = tts_provider if is_plugin_registered(tts_provider) else ""
if speaker_name: if speaker_name:
entry = normalize_profile_entry(load_profiles().get(speaker_name)) entry = normalize_profile_entry(load_profiles().get(speaker_name))
@@ -269,7 +270,7 @@ def api_speaker_preview() -> ResponseReturnValue:
use_gpu=use_gpu use_gpu=use_gpu
, ,
tts_provider=resolved_provider, tts_provider=resolved_provider,
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 8), supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
pronunciation_overrides=pronunciation_overrides, pronunciation_overrides=pronunciation_overrides,
manual_overrides=manual_overrides, manual_overrides=manual_overrides,
speakers=speakers, speakers=speakers,
+1 -5
View File
@@ -43,12 +43,8 @@ def update_settings() -> ResponseReturnValue:
current["language"] = (form.get("language") or "en").strip() current["language"] = (form.get("language") or "en").strip()
current["default_speaker"] = (form.get("default_speaker") or "").strip() current["default_speaker"] = (form.get("default_speaker") or "").strip()
current["default_voice"] = (form.get("default_voice") or "").strip() current["default_voice"] = (form.get("default_voice") or "").strip()
provider = str(form.get("tts_provider") or "kokoro").strip().lower()
if provider in {"kokoro", "supertonic"}:
current["tts_provider"] = provider
try: try:
total_steps = int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 8))) current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
current["supertonic_total_steps"] = max(2, min(15, total_steps))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
try: try:
+8 -18
View File
@@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue
from abogen.webui.service import PendingJob, JobStatus from abogen.webui.service import PendingJob, JobStatus
from abogen.webui.routes.utils.service import get_service from abogen.webui.routes.utils.service import get_service
from abogen.tts_plugin.utils import is_plugin_registered
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
load_settings, load_settings,
coerce_bool, coerce_bool,
@@ -32,7 +33,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
from abogen.utils import calculate_text_length from abogen.utils import calculate_text_length
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.constants import VOICES_INTERNAL from abogen.tts_plugin.utils import get_default_voice
from abogen.speaker_configs import get_config from abogen.speaker_configs import get_config
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
from dataclasses import dataclass from dataclasses import dataclass
@@ -577,9 +578,9 @@ def apply_book_step_form(
# NOTE: Do not auto-set a global TTS provider at the book level based on the # NOTE: Do not auto-set a global TTS provider at the book level based on the
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice # narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula). # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
# This enables mixed-provider conversions (e.g. narrator=Supertonic, characters=Kokoro). # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
provider_value = str(form.get("tts_provider") or "").strip().lower() provider_value = str(form.get("tts_provider") or "").strip().lower()
if provider_value in {"kokoro", "supertonic"}: if is_plugin_registered(provider_value):
pending.tts_provider = provider_value pending.tts_provider = provider_value
# Determine the base speaker selection (saved speaker ref or raw voice). # Determine the base speaker selection (saved speaker ref or raw voice).
@@ -616,8 +617,8 @@ def apply_book_step_form(
custom_formula = "" custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL: if not base_voice_spec:
base_voice_spec = VOICES_INTERNAL[0] base_voice_spec = get_default_voice("kokoro")
voice_choice, resolved_language, selected_profile = resolve_voice_choice( voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language, pending.language,
@@ -796,8 +797,8 @@ def build_pending_job_from_extraction(
profile_selection = inferred_profile profile_selection = inferred_profile
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
if not base_voice and VOICES_INTERNAL: if not base_voice:
base_voice = VOICES_INTERNAL[0] base_voice = get_default_voice("kokoro")
selected_speaker_config = (form.get("speaker_config") or "").strip() selected_speaker_config = (form.get("speaker_config") or "").strip()
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
@@ -913,15 +914,6 @@ def build_pending_job_from_extraction(
else: else:
normalization_overrides[key] = default_val normalization_overrides[key] = default_val
provider_value = str(form.get("tts_provider") or "").strip().lower()
if provider_value not in {"kokoro", "supertonic"}:
provider_value = settings.get("tts_provider", "kokoro")
try:
total_steps = int(form.get("supertonic_total_steps", settings.get("supertonic_total_steps", 8)))
supertonic_steps = max(2, min(15, total_steps))
except (TypeError, ValueError):
supertonic_steps = int(settings.get("supertonic_total_steps", 8))
pending = PendingJob( pending = PendingJob(
id=uuid.uuid4().hex, id=uuid.uuid4().hex,
original_filename=original_name, original_filename=original_name,
@@ -937,8 +929,6 @@ def build_pending_job_from_extraction(
replace_single_newlines=replace_single_newlines, replace_single_newlines=replace_single_newlines,
subtitle_format=subtitle_format, subtitle_format=subtitle_format,
total_characters=total_chars, total_characters=total_chars,
tts_provider=provider_value,
supertonic_total_steps=supertonic_steps,
save_chapters_separately=save_chapters_separately, save_chapters_separately=save_chapters_separately,
merge_chapters_at_end=merge_chapters_at_end, merge_chapters_at_end=merge_chapters_at_end,
separate_chapters_format=separate_chapters_format, separate_chapters_format=separate_chapters_format,

Some files were not shown because too many files have changed in this diff Show More