Compare commits

..
Author SHA1 Message Date
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
54 changed files with 2359 additions and 7616 deletions
+31 -11
View File
@@ -1,7 +1,9 @@
name: pip install
run-name: pip install
name: CI
run-name: CI
on:
push:
branches: [main]
paths:
- '**.py'
- 'pyproject.toml'
@@ -11,23 +13,41 @@ on:
- 'pyproject.toml'
- '.github/workflows/**'
workflow_dispatch:
jobs:
install-and-run:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-14, windows-latest]
python-version: ['3.12']
fail-fast: false
continue-on-error: true
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install from repository
run: python -m pip install .
#- name: Run abogen
# run: abogen
- name: Install uv
uses: astral-sh/setup-uv@v8.3.1
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Login to Github Container Registry
# Only if we need to push an image
+420 -522
View File
File diff suppressed because it is too large Load Diff
-58
View File
@@ -63,64 +63,6 @@ SUPPORTED_INPUT_FORMATS = [
# 384 if self.lang_code in 'ab':
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
SAMPLE_VOICE_TEXTS = {
"a": "This is a sample of the selected voice.",
-8
View File
@@ -1,8 +0,0 @@
"""
Abogen Flet Frontend Package.
This package provides a unified, dual-target (desktop + web) user interface
for the Abogen audiobook generation application, built with the Flet framework.
"""
__all__ = ["main"]
-32
View File
@@ -1,32 +0,0 @@
"""Components sub-package."""
from .widgets import (
resolve_icon,
build_drop_zone,
build_log_terminal,
log_entry,
build_progress_row,
build_primary_button,
build_secondary_button,
build_card,
build_section_header,
build_status_badge,
labelled_row,
show_snack,
build_divider,
)
__all__ = [
"build_drop_zone",
"resolve_icon",
"build_log_terminal",
"log_entry",
"build_progress_row",
"build_primary_button",
"build_secondary_button",
"build_card",
"build_section_header",
"build_status_badge",
"labelled_row",
"show_snack",
"build_divider",
]
-630
View File
@@ -1,630 +0,0 @@
"""
Reusable UI components for the Abogen Flet frontend.
Each function in this module returns a standalone Flet control or small
widget tree. Components read the current palette from the page's theme
mode and should not hold any mutable state themselves state lives in the
session's ``AppState`` object.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
import flet as ft
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def resolve_icon(icon: Any) -> Any:
"""Convert a snake_case icon name to Flet IconData when possible."""
if isinstance(icon, str):
return getattr(ft.Icons, icon.upper(), icon)
return icon
# ---------------------------------------------------------------------------
# Drop-zone (file input area)
# ---------------------------------------------------------------------------
def build_drop_zone(
*,
on_pick: Callable[[], None],
label: str = "Drag & drop your file here or click to browse",
sub_label: str = "Supports: .txt · .epub · .pdf · .md · .srt · .ass · .vtt",
accent: bool = False,
error: bool = False,
filename: Optional[str] = None,
file_size: Optional[str] = None,
char_count: Optional[str] = None,
page: Optional[ft.Page] = None,
) -> ft.GestureDetector:
"""
Build an interactive file drop-zone widget.
The zone shows a dashed border and centred instructions by default,
switching to an 'active' green style when a file is loaded and a red
style when an error has occurred.
Args:
on_pick: Callback invoked when the user clicks or activates the zone.
label: Primary instruction text.
sub_label: Secondary hint text shown beneath the label.
accent: When True, renders the 'active/success' green style.
error: When True, renders the 'error/red' style.
filename: When provided, replaces the instruction text with file info.
file_size: Human-readable file size to display alongside the filename.
char_count: Character count to display alongside file info.
page: The current Flet ``Page``; used to derive the active palette.
Returns:
A ``ft.GestureDetector`` wrapping the visual drop-zone container.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
p = get_palette(page) if page else None
# Colour scheme
if error:
border_color = "#e84e3c" if dark else "#c0392b"
bg_color = "#1a0a08" if dark else "#fff5f5"
text_color = "#e84e3c" if dark else "#c0392b"
icon_name = "error_outline"
elif accent:
border_color = "#42ad4a" if dark else "#2e9437"
bg_color = "#091810" if dark else "#f0fff1"
text_color = "#42ad4a" if dark else "#2e9437"
icon_name = "check_circle_outline"
else:
border_color = "#3a4466" if dark else "#a8b4d0"
bg_color = "#151928" if dark else "#f7f8fd"
text_color = "#9ba3b8" if dark else "#5a6172"
icon_name = "upload_file"
if filename:
# Compact file-info display
info_rows: List[ft.Control] = [
ft.Row(
[
ft.Icon(resolve_icon("insert_drive_file"), color=text_color, size=28),
ft.Column(
[
ft.Text(
filename,
weight=ft.FontWeight.W_600,
size=13,
color=text_color,
no_wrap=False,
max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS,
),
],
tight=True,
expand=True,
),
],
alignment=ft.MainAxisAlignment.CENTER,
spacing=SPACE_SM,
)
]
if file_size or char_count:
chips: List[ft.Control] = []
if file_size:
chips.append(
ft.Text(f"📄 {file_size}", size=11, color=text_color, italic=True)
)
if char_count:
chips.append(
ft.Text(f"🔤 {char_count} chars", size=11, color=text_color, italic=True)
)
info_rows.append(
ft.Row(chips, alignment=ft.MainAxisAlignment.CENTER, spacing=SPACE_MD)
)
content = ft.Column(
info_rows,
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=SPACE_SM,
)
else:
content = ft.Column(
[
ft.Icon(resolve_icon(icon_name), size=48, color=border_color, opacity=0.8),
ft.Text(
label,
size=14,
weight=ft.FontWeight.W_500,
color=text_color,
text_align=ft.TextAlign.CENTER,
),
ft.Text(
sub_label,
size=11,
color=text_color,
opacity=0.6,
text_align=ft.TextAlign.CENTER,
),
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=SPACE_SM,
)
inner = ft.Container(
content=content,
border=ft.Border.all(2, border_color),
border_radius=RADIUS_MD,
bgcolor=bg_color,
padding=ft.Padding.all(SPACE_LG),
height=160,
alignment=ft.Alignment.CENTER,
expand=True,
)
return ft.GestureDetector(
content=ft.Row([inner], spacing=0),
on_tap=lambda _: on_pick(),
mouse_cursor=ft.MouseCursor.CLICK,
)
# ---------------------------------------------------------------------------
# Log terminal
# ---------------------------------------------------------------------------
def build_log_terminal(
*,
ref: Optional[ft.Ref] = None,
max_height: int = 260,
page: Optional[ft.Page] = None,
) -> ft.Container:
"""
Build a scrollable, read-only log terminal widget.
Args:
ref: Optional ``ft.Ref[ft.ListView]`` to bind the inner list-view so
callers can append entries programmatically.
max_height: Maximum pixel height before vertical scrolling activates.
page: Current Flet ``Page`` for palette derivation.
Returns:
A styled ``ft.Container`` wrapping a ``ft.ListView``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
bg = "#0d1117" if dark else "#f8f9fc"
text_color = "#b0b8cc" if dark else "#3d4358"
border_color = "#252a38" if dark else "#dce0ea"
list_view = ft.ListView(
expand=True,
auto_scroll=True,
spacing=1,
padding=ft.Padding.all(SPACE_SM),
)
if ref is not None:
ref.current = list_view
return ft.Container(
content=list_view,
bgcolor=bg,
border=ft.Border.all(1, border_color),
border_radius=RADIUS_SM,
height=max_height,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
def log_entry(message: str, level: str = "info", page: Optional[ft.Page] = None) -> ft.Text:
"""
Create a single log-line ``ft.Text`` widget with appropriate colour coding.
Args:
message: The log message string.
level: Severity string: ``'info'``, ``'success'``, ``'error'``,
``'warning'``, ``'debug'``, ``'critical'``.
page: Current Flet ``Page`` for dark/light mode detection.
Returns:
A styled ``ft.Text`` control.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
palette: dict[str, str] = {
"info": "#9ba3b8" if dark else "#5a6172",
"success": "#42ad4a" if dark else "#2e9437",
"error": "#e84e3c" if dark else "#c0392b",
"warning": "#f5a623" if dark else "#d4870a",
"debug": "#5a6172" if dark else "#9ba3b8",
"critical": "#ff5722",
"trace": "#4e5568" if dark else "#b0b8cc",
}
color = palette.get(level.lower(), palette["info"])
return ft.Text(message, size=12, color=color, selectable=True, no_wrap=False)
# ---------------------------------------------------------------------------
# Progress row
# ---------------------------------------------------------------------------
def build_progress_row(
*,
progress_value: float = 0.0,
etr_text: str = "",
page: Optional[ft.Page] = None,
) -> ft.Column:
"""
Build a progress-bar + ETR-label column.
Args:
progress_value: Float in [0.0, 1.0].
etr_text: Pre-formatted estimated-time-remaining string.
page: Current ``Page`` for palette derivation.
Returns:
A ``ft.Column`` containing the progress bar and label.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
fill = "#5b8af5" if dark else "#3a5fc4"
bg = "#1e2230" if dark else "#e4e8f0"
bar = ft.ProgressBar(
value=progress_value,
color=fill,
bgcolor=bg,
height=8,
border_radius=ft.BorderRadius.all(4),
expand=True,
)
label = ft.Text(
etr_text,
size=11,
color="#9ba3b8" if dark else "#5a6172",
text_align=ft.TextAlign.CENTER,
)
return ft.Column(
[bar, label],
spacing=SPACE_SM,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
)
# ---------------------------------------------------------------------------
# Primary action button
# ---------------------------------------------------------------------------
def build_primary_button(
text: str,
*,
icon: Optional[str] = None,
on_click: Optional[Callable] = None,
disabled: bool = False,
width: Optional[int] = None,
page: Optional[ft.Page] = None,
) -> ft.ElevatedButton:
"""
Build a prominent, styled primary action button.
Args:
text: Button label.
icon: Optional Flet icon name (e.g. ``'play_arrow'``).
on_click: Click callback.
disabled: Whether the button is non-interactive.
width: Optional fixed pixel width.
page: Current ``Page`` for accent colour derivation.
Returns:
A styled ``ft.ElevatedButton``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
bg = "#5b8af5" if dark else "#3a5fc4"
on_bg = "#ffffff"
style = ft.ButtonStyle(
bgcolor={
ft.ControlState.DEFAULT: bg,
ft.ControlState.HOVERED: "#3a5fc4" if dark else "#2a4fae",
ft.ControlState.DISABLED: "#2a2f3f" if dark else "#c0c8d8",
},
color={
ft.ControlState.DEFAULT: on_bg,
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
},
elevation={"default": 2, "hovered": 4},
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
animation_duration=150,
)
return ft.ElevatedButton(
content=text,
icon=resolve_icon(icon),
on_click=on_click,
disabled=disabled,
width=width,
style=style,
height=48,
)
# ---------------------------------------------------------------------------
# Secondary / ghost button
# ---------------------------------------------------------------------------
def build_secondary_button(
text: str,
*,
icon: Optional[str] = None,
on_click: Optional[Callable] = None,
disabled: bool = False,
page: Optional[ft.Page] = None,
) -> ft.OutlinedButton:
"""
Build a secondary outlined button.
Args:
text: Button label.
icon: Optional Flet icon name.
on_click: Click callback.
disabled: Whether the button is non-interactive.
page: Current ``Page`` for border colour derivation.
Returns:
A styled ``ft.OutlinedButton``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
border_clr = "#3a4466" if dark else "#a8b4d0"
text_clr = "#e8eaf0" if dark else "#1a1d27"
style = ft.ButtonStyle(
side={
ft.ControlState.DEFAULT: ft.BorderSide(1.5, border_clr),
ft.ControlState.HOVERED: ft.BorderSide(1.5, "#5b8af5" if dark else "#3a5fc4"),
},
color={
ft.ControlState.DEFAULT: text_clr,
ft.ControlState.HOVERED: "#5b8af5" if dark else "#3a5fc4",
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
},
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
animation_duration=150,
)
return ft.OutlinedButton(
content=text,
icon=resolve_icon(icon),
on_click=on_click,
disabled=disabled,
style=style,
height=44,
)
# ---------------------------------------------------------------------------
# Section card
# ---------------------------------------------------------------------------
def build_card(
content: ft.Control,
*,
padding: int = SPACE_LG,
page: Optional[ft.Page] = None,
) -> ft.Container:
"""
Wrap a control in a styled card container.
Args:
content: The child control to embed.
padding: Internal padding in pixels.
page: Current ``Page`` for palette derivation.
Returns:
A styled ``ft.Container``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
bg = "#181b23" if dark else "#ffffff"
border_clr = "#2c3147" if dark else "#dce0ea"
return ft.Container(
content=content,
bgcolor=bg,
border=ft.Border.all(1, border_clr),
border_radius=RADIUS_MD,
padding=ft.Padding.all(padding),
shadow=ft.BoxShadow(
spread_radius=0,
blur_radius=12,
color=ft.Colors.with_opacity(0.12 if dark else 0.06, ft.Colors.BLACK),
offset=ft.Offset(0, 2),
),
)
# ---------------------------------------------------------------------------
# Section header
# ---------------------------------------------------------------------------
def build_section_header(
title: str,
*,
subtitle: Optional[str] = None,
icon: Optional[str] = None,
page: Optional[ft.Page] = None,
) -> ft.Row:
"""
Build a consistent section header row with an optional icon.
Args:
title: Section heading text.
subtitle: Optional explanatory sub-text.
icon: Optional Flet icon name.
page: Current ``Page`` for palette derivation.
Returns:
A ``ft.Row`` containing the icon and text column.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
title_color = "#e8eaf0" if dark else "#1a1d27"
sub_color = "#9ba3b8" if dark else "#5a6172"
accent = "#5b8af5" if dark else "#3a5fc4"
children: List[ft.Control] = []
if icon:
children.append(ft.Icon(resolve_icon(icon), size=20, color=accent))
text_parts: List[ft.Control] = [
ft.Text(title, size=15, weight=ft.FontWeight.W_600, color=title_color)
]
if subtitle:
text_parts.append(ft.Text(subtitle, size=11, color=sub_color))
children.append(
ft.Column(text_parts, spacing=1, tight=True, expand=True)
)
return ft.Row(children, spacing=SPACE_SM, vertical_alignment=ft.CrossAxisAlignment.START)
# ---------------------------------------------------------------------------
# Status badge
# ---------------------------------------------------------------------------
def build_status_badge(
label: str,
*,
variant: str = "info",
page: Optional[ft.Page] = None,
) -> ft.Container:
"""
Build a small status badge chip.
Args:
label: Badge text.
variant: Colour variant: ``'info'``, ``'success'``, ``'error'``,
``'warning'``, ``'neutral'``.
page: Current ``Page`` for theme derivation.
Returns:
A pill-shaped ``ft.Container``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
palette = {
"info": ("#1a2a5e" if dark else "#dde8ff", "#5b8af5" if dark else "#3a5fc4"),
"success": ("#0d2010" if dark else "#d4f4d7", "#42ad4a" if dark else "#2e9437"),
"error": ("#2a0a08" if dark else "#ffe0dc", "#e84e3c" if dark else "#c0392b"),
"warning": ("#2a1a00" if dark else "#fff4d8", "#f5a623" if dark else "#d4870a"),
"neutral": ("#1e2230" if dark else "#edf0f5", "#9ba3b8" if dark else "#5a6172"),
}
bg, fg = palette.get(variant, palette["info"])
return ft.Container(
content=ft.Text(label, size=10, weight=ft.FontWeight.W_600, color=fg),
bgcolor=bg,
border_radius=999,
padding=ft.Padding.symmetric(horizontal=8, vertical=3),
)
# ---------------------------------------------------------------------------
# Labelled control row
# ---------------------------------------------------------------------------
def labelled_row(
label: str,
control: ft.Control,
*,
label_width: int = 200,
tooltip: Optional[str] = None,
page: Optional[ft.Page] = None,
) -> ft.Row:
"""
Lay a label and a control side-by-side in a consistent row.
Args:
label: Human-readable label text.
control: The UI control placed to the right of the label.
label_width: Fixed pixel width of the label column.
tooltip: Optional tooltip text on the label.
page: Current ``Page`` for palette derivation.
Returns:
A ``ft.Row`` with the label pinned to a fixed width.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
lbl_color = "#9ba3b8" if dark else "#5a6172"
lbl = ft.Text(label, size=13, color=lbl_color, weight=ft.FontWeight.W_500, width=label_width)
if tooltip:
lbl.tooltip = tooltip
return ft.Row(
[lbl, ft.Container(content=control, expand=True)],
alignment=ft.MainAxisAlignment.START,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
spacing=SPACE_MD,
)
# ---------------------------------------------------------------------------
# Snack-bar helper
# ---------------------------------------------------------------------------
def show_snack(
page: ft.Page,
message: str,
*,
error: bool = False,
duration: int = 3000,
) -> None:
"""
Display a brief snack-bar notification.
Args:
page: The Flet ``Page`` instance.
message: Text to display.
error: When True, colours the bar red instead of the default accent.
duration: Visible duration in milliseconds.
"""
dark = page.theme_mode == ft.ThemeMode.DARK
bg = "#e84e3c" if error else ("#5b8af5" if dark else "#3a5fc4")
page.snack_bar = ft.SnackBar(
content=ft.Text(message, color="#ffffff", size=13),
bgcolor=bg,
duration=duration,
show_close_icon=True,
close_icon_color="#ffffff",
)
page.snack_bar.open = True
page.update()
# ---------------------------------------------------------------------------
# Divider helper
# ---------------------------------------------------------------------------
def build_divider(page: Optional[ft.Page] = None) -> ft.Divider:
"""
Build a styled horizontal rule divider.
Args:
page: Current ``Page`` for palette derivation.
Returns:
A ``ft.Divider``.
"""
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
return ft.Divider(color="#252a38" if dark else "#e8ebf2", height=1, thickness=1)
-365
View File
@@ -1,365 +0,0 @@
"""
Abogen Flet Frontend main entry point.
Run as desktop app:
python -m abogen.frontend.main
Run as web app (binds to port 8080 by default):
python -m abogen.frontend.main --web --port 8080
Architecture
------------
One ``ft.app()`` call launches the server. For every new browser tab (or the
desktop window) Flet invokes ``_app_entry(page)`` in its own coroutine, which
creates a fresh ``AppState`` and wires together the navigation rail and views.
This guarantees complete per-session isolation in multi-user web deployments.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Optional
import flet as ft
from .state import AppState
from .components import resolve_icon
from .views.dashboard import DashboardView
from .views.settings import SettingsView
from .views.queue_view import QueueView
from .utils.theme import make_theme, DARK, LIGHT, SPACE_SM, SPACE_MD, SPACE_LG, RADIUS_MD
from abogen.constants import PROGRAM_NAME as APP_NAME
# ---------------------------------------------------------------------------
# Navigation destinations
# ---------------------------------------------------------------------------
_NAV_ITEMS = [
("Convert", "swap_horiz", "swap_horiz"),
("Queue", "list_alt", "list_alt"),
("Settings", "settings", "settings"),
]
_ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
def _build_sidebar_item(
*,
label: str,
icon: str,
selected: bool,
palette,
on_click,
) -> ft.Container:
accent = palette.accent if selected else palette.text_secondary
bg = palette.sidebar_selected_bg if selected else palette.sidebar_bg
return ft.Container(
content=ft.Row(
[
ft.Icon(resolve_icon(icon), size=20, color=accent),
ft.Text(
label,
size=13,
weight=ft.FontWeight.W_600 if selected else ft.FontWeight.W_500,
color=accent,
),
],
spacing=SPACE_MD,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
bgcolor=bg,
border_radius=RADIUS_MD,
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=10),
ink=True,
on_click=on_click,
)
# ---------------------------------------------------------------------------
# Per-session entry point
# ---------------------------------------------------------------------------
def _app_entry(page: ft.Page) -> None:
try:
# ── State ────────────────────────────────────────────────────────────
state = AppState()
state.load_from_config()
# ── Page basics ──────────────────────────────────────────────────────
page.title = APP_NAME
page.padding = 0
page.spacing = 0
page.bgcolor = DARK.bg_base
page.theme_mode = ft.ThemeMode.DARK
page.theme = make_theme(dark=True)
page.dark_theme = make_theme(dark=True)
page.fonts = {}
page.window.min_width = 520
page.window.min_height = 600
page.update()
# ── Content area ref ─────────────────────────────────────────────────
content_area = ft.Column(expand=True, spacing=0)
sidebar_body = ft.Column(spacing=SPACE_SM)
theme_button_host = ft.Container()
brand_title = ft.Text(
APP_NAME,
size=18,
weight=ft.FontWeight.W_700,
color=DARK.text_primary,
)
brand_fallback_icon = ft.Icon(resolve_icon("speaker_notes"), size=32, color=DARK.accent)
divider = ft.VerticalDivider(width=1, color=DARK.border)
# ── Views ────────────────────────────────────────────────────────────
dashboard_view = DashboardView(page, state)
settings_view = SettingsView(page, state)
queue_view = QueueView(page, state)
views = [
dashboard_view.build,
queue_view.build,
settings_view.build,
]
_selected_index = [0]
def _refresh_sidebar() -> None:
dark = page.theme_mode == ft.ThemeMode.DARK
pal = DARK if dark else LIGHT
sidebar_body.controls = [
_build_sidebar_item(
label=label,
icon=icon,
selected=index == _selected_index[0],
palette=pal,
on_click=lambda _, i=index: _navigate(i),
)
for index, (label, icon, _) in enumerate(_NAV_ITEMS)
]
sidebar.bgcolor = pal.sidebar_bg
divider.color = pal.border
brand_title.color = pal.text_primary
brand_fallback_icon.color = pal.accent
theme_button_host.content = ft.Container(
content=ft.Icon(
resolve_icon("dark_mode" if dark else "light_mode"),
size=20,
color=pal.text_secondary,
),
tooltip="Toggle theme",
border_radius=RADIUS_MD,
padding=8,
ink=True,
on_click=lambda _: _toggle_theme(page, _refresh_sidebar),
)
def _navigate(index: int) -> None:
_selected_index[0] = index
content_area.controls.clear()
built = views[index]()
content_area.controls.append(
ft.Container(
content=built,
expand=True,
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_LG),
)
)
_refresh_sidebar()
page.update()
# ── Sidebar ──────────────────────────────────────────────────────────
pal = DARK
sidebar = ft.Container(
width=220,
bgcolor=pal.sidebar_bg,
padding=ft.Padding.all(SPACE_MD),
content=ft.Column(
[
ft.Container(
content=ft.Row(
[
ft.Image(
src="icon.png",
width=36,
height=36,
fit=ft.BoxFit.CONTAIN,
error_content=brand_fallback_icon,
),
brand_title,
],
spacing=SPACE_MD,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
padding=ft.Padding.only(top=SPACE_SM, bottom=SPACE_LG),
),
sidebar_body,
ft.Container(expand=True),
ft.Row([theme_button_host], alignment=ft.MainAxisAlignment.END),
],
expand=True,
spacing=SPACE_SM,
),
)
_refresh_sidebar()
# ── Page handle for pubsub (queue → dashboard) ───────────────────────
def _handle_pubsub(topic: str) -> None:
if topic == "start_queue":
_navigate(0)
page.pubsub.subscribe(_handle_pubsub)
# ── Layout ────────────────────────────────────────────────────────────
page.add(
ft.Row(
[
sidebar,
divider,
ft.Container(content=content_area, expand=True),
],
expand=True,
spacing=0,
vertical_alignment=ft.CrossAxisAlignment.START,
)
)
# Show dashboard by default
_navigate(0)
page.update()
except Exception as e:
import traceback
traceback.print_exc()
print(f"ERROR IN _app_entry: {e}")
raise
def _toggle_theme(page: ft.Page, refresh_sidebar) -> None:
"""Switch between dark and light theme modes."""
if page.theme_mode == ft.ThemeMode.DARK:
page.theme_mode = ft.ThemeMode.LIGHT
page.bgcolor = LIGHT.bg_base
else:
page.theme_mode = ft.ThemeMode.DARK
page.bgcolor = DARK.bg_base
page.theme = make_theme(page.theme_mode == ft.ThemeMode.DARK)
refresh_sidebar()
page.update()
# ---------------------------------------------------------------------------
# CLI helpers & entry point
# ---------------------------------------------------------------------------
def _is_port_free(host: str, port: int) -> bool:
import socket
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return True
except OSError:
return False
def _find_free_port(host: str, start_port: int) -> int:
import socket
port = start_port
while port < 65535:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return port
except OSError:
port += 1
return start_port
def main() -> None:
"""
Start the Abogen Flet frontend.
Parses ``--web`` and ``--port`` CLI arguments to choose desktop vs. web
mode, then hands control to ``ft.app()``.
"""
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("flet").setLevel(logging.INFO)
parser = argparse.ArgumentParser(description=f"{APP_NAME} Flet frontend")
parser.add_argument(
"--web", action="store_true",
help="Run as a web server instead of a desktop window.",
)
parser.add_argument(
"--port", type=int, default=8080,
help="Port for the web server (default: 8080). Ignored in desktop mode.",
)
parser.add_argument(
"--host", default="127.0.0.1",
help="Host for the web server (default: 127.0.0.1). Use 0.0.0.0 to expose publicly.",
)
args = parser.parse_args()
if args.web:
port_specified = "--port" in sys.argv
target_port = args.port
if not port_specified:
target_port = _find_free_port(args.host, 8080)
if target_port != 8080:
print(f"Port 8080 is in use. Automatically routed to free port: {target_port}")
else:
if not _is_port_free(args.host, target_port):
print(f"Error: Port {target_port} is already in use on {args.host}.", file=sys.stderr)
print("Please select a different port or omit the --port flag to find one automatically.", file=sys.stderr)
sys.exit(1)
print(f"Starting Abogen WebUI on http://{args.host}:{target_port} ...")
ft.app(
target=_app_entry,
view=ft.AppView.WEB_BROWSER,
port=target_port,
host=args.host,
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
no_cdn=True,
web_renderer="canvaskit",
)
else:
try:
ft.app(
target=_app_entry,
view=ft.AppView.FLET_APP,
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
)
except Exception as e:
print(f"Warning: Failed to launch native desktop window: {e}", file=sys.stderr)
print("Falling back to running as a web application in your default browser...", file=sys.stderr)
target_port = _find_free_port("127.0.0.1", 8080)
print(f"Starting Abogen WebUI on http://127.0.0.1:{target_port} ...")
ft.app(
target=_app_entry,
view=ft.AppView.WEB_BROWSER,
port=target_port,
host="127.0.0.1",
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
no_cdn=True,
web_renderer="canvaskit",
)
def main_web() -> None:
"""
Start the Abogen Flet frontend as a web server.
"""
import sys
if "--web" not in sys.argv:
sys.argv.insert(1, "--web")
main()
if __name__ == "__main__":
main()
-4
View File
@@ -1,4 +0,0 @@
"""State sub-package exports AppState and ConversionJob."""
from .app_state import AppState, ConversionJob
__all__ = ["AppState", "ConversionJob"]
-451
View File
@@ -1,451 +0,0 @@
"""
Centralized, per-session application state for the Abogen Flet frontend.
Each Flet page (session) gets its own instance of AppState, which guarantees
complete isolation between simultaneous web-browser clients and the desktop
window. The class carries every configuration variable, file buffer reference,
and generation progress field that the rest of the UI reads or writes.
This module intentionally has no Flet imports so it can be unit-tested without
a running Flet server.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from abogen.utils import load_config, save_config
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_config() -> Dict[str, Any]:
"""Load the persisted user config dict, returning an empty dict on failure."""
try:
return load_config() or {}
except Exception:
return {}
# ---------------------------------------------------------------------------
# Per-session state
# ---------------------------------------------------------------------------
@dataclass
class ConversionJob:
"""Lightweight descriptor of a single queued conversion job."""
file_path: str
"""Absolute path to the text/epub/pdf/txt input file."""
display_name: str
"""User-visible filename (may be the original epub/pdf path)."""
voice: str
"""Voice formula string (e.g. 'af_heart' or 'af_heart*0.5+am_adam*0.5')."""
lang_code: str
"""Single-char language prefix used by Kokoro (e.g. 'a', 'b', 'e')."""
speed: float = 1.0
"""Playback speed multiplier, range 0.1 2.0."""
output_format: str = "mp3"
"""Output audio container format."""
subtitle_mode: str = "Disabled"
"""Subtitle generation mode."""
save_option: str = "Save next to input file"
"""Save location strategy."""
output_folder: Optional[str] = None
"""Absolute path when save_option is 'Choose output folder'."""
char_count: int = 0
"""Pre-computed character count for ETR estimation."""
replace_single_newlines: bool = True
save_chapters_separately: Optional[bool] = None
merge_chapters_at_end: Optional[bool] = None
@dataclass
class AppState:
"""
Single source of truth for one Flet session.
Instantiated once per ``ft.app()`` call on desktop, and once per browser
tab on web. All UI components receive a reference to this object and
read/write it to keep themselves in sync.
Thread-safety: mutation from background threads should be done via the
provided ``_lock``. The UI update callbacks (``on_log``,
``on_progress``, etc.) are always invoked on the Flet event loop via
``page.run_task()`` and must be set by the view layer.
"""
# -----------------------------------------------------------------------
# Runtime identity
# -----------------------------------------------------------------------
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
# -----------------------------------------------------------------------
# Persisted user config (loaded once, written on every change)
# -----------------------------------------------------------------------
config: Dict[str, Any] = field(default_factory=_default_config)
# -----------------------------------------------------------------------
# File / input state
# -----------------------------------------------------------------------
selected_file: Optional[str] = None
"""Path to the processed text file (may be a temp cache copy for epub/pdf)."""
selected_file_type: Optional[str] = None
"""'txt' | 'epub' | 'pdf' | 'markdown' | None"""
selected_book_path: Optional[str] = None
"""Original epub/pdf path before being converted to txt."""
displayed_file_path: Optional[str] = None
"""Path shown in the UI drop-zone (original book or txt file)."""
selected_chapters: List[str] = field(default_factory=list)
"""Ordered list of selected chapter href tokens (or page numbers for PDFs)."""
save_chapters_separately: Optional[bool] = None
merge_chapters_at_end: Optional[bool] = None
save_as_project: bool = False
char_count: int = 0
# -----------------------------------------------------------------------
# Voice / language
# -----------------------------------------------------------------------
selected_voice: str = "af_heart"
selected_lang: str = "a"
selected_profile_name: Optional[str] = None
mixed_voice_state: Optional[List[Any]] = None
"""List of [voice_id, weight] pairs when the formula mixer is in use."""
# -----------------------------------------------------------------------
# Conversion parameters
# -----------------------------------------------------------------------
speed: float = 1.0
use_gpu: bool = True
selected_format: str = "wav"
subtitle_mode: str = "Sentence"
subtitle_format: str = "ass_centered_narrow"
replace_single_newlines: bool = True
save_option: str = "Save next to input file"
selected_output_folder: Optional[str] = None
silence_duration: float = 2.0
max_subtitle_words: int = 50
separate_chapters_format: str = "wav"
use_silent_gaps: bool = True
subtitle_speed_method: str = "tts"
use_spacy_segmentation: bool = True
chunk_level: str = "paragraph"
generate_epub3: bool = False
# TTS provider
tts_provider: str = "kokoro"
supertonic_total_steps: int = 5
# Chapter options
chapter_intro_delay: float = 0.5
read_title_intro: bool = False
read_closing_outro: bool = True
auto_prefix_chapter_titles: bool = True
normalize_chapter_opening_caps: bool = True
# Speaker analysis
speaker_analysis_threshold: int = 3
# Word substitutions
word_substitutions_enabled: bool = False
word_substitutions_list: str = ""
case_sensitive_substitutions: bool = False
replace_all_caps: bool = False
replace_numerals: bool = False
fix_nonstandard_punctuation: bool = False
# -----------------------------------------------------------------------
# Conversion runtime state
# -----------------------------------------------------------------------
is_converting: bool = False
is_cancelled: bool = False
progress: float = 0.0
"""Fractional progress 0.0 1.0."""
etr_seconds: Optional[float] = None
"""Estimated seconds remaining, or None if unknown."""
last_output_path: Optional[str] = None
log_lines: List[str] = field(default_factory=list)
"""Buffered log messages, capped at LOG_MAX_LINES."""
LOG_MAX_LINES: int = 2000
# -----------------------------------------------------------------------
# Queue
# -----------------------------------------------------------------------
queued_items: List[ConversionJob] = field(default_factory=list)
current_queue_index: int = 0
# -----------------------------------------------------------------------
# Callbacks (set by the view layer, not serialised)
# -----------------------------------------------------------------------
on_log: Optional[Callable[[str, str], None]] = field(default=None, repr=False, compare=False)
"""Called from any thread: ``on_log(message, level)``."""
on_progress: Optional[Callable[[float, Optional[float]], None]] = field(
default=None, repr=False, compare=False
)
"""Called from any thread: ``on_progress(fraction, etr_seconds)``."""
on_conversion_finished: Optional[Callable[[str, Optional[str]], None]] = field(
default=None, repr=False, compare=False
)
"""Called from any thread: ``on_conversion_finished(message, output_path)``."""
# -----------------------------------------------------------------------
# Integrations
# -----------------------------------------------------------------------
audiobookshelf_enabled: bool = False
audiobookshelf_base_url: str = ""
audiobookshelf_api_token: str = ""
audiobookshelf_library_id: str = ""
audiobookshelf_folder_id: str = ""
audiobookshelf_verify_ssl: bool = True
audiobookshelf_auto_send: bool = False
audiobookshelf_send_cover: bool = True
audiobookshelf_send_chapters: bool = True
audiobookshelf_send_subtitles: bool = False
audiobookshelf_timeout: float = 30.0
calibre_opds_enabled: bool = False
calibre_opds_base_url: str = ""
calibre_opds_username: str = ""
calibre_opds_password: str = ""
calibre_opds_verify_ssl: bool = True
# -----------------------------------------------------------------------
# Public helpers
# -----------------------------------------------------------------------
def load_from_config(self) -> None:
"""
Populate all fields from the persisted JSON config file.
Called once at startup and whenever the settings page is saved.
Thread-safe.
"""
with self._lock:
cfg = _default_config()
self.config = cfg
self.selected_voice = cfg.get("selected_voice", "af_heart")
self.selected_lang = self.selected_voice[0] if self.selected_voice else "a"
self.selected_profile_name = cfg.get("selected_profile_name")
self.speed = cfg.get("speed", 1.0)
self.use_gpu = cfg.get("use_gpu", True)
self.selected_format = cfg.get("selected_format", "wav")
self.subtitle_mode = cfg.get("subtitle_mode", "Sentence")
self.subtitle_format = cfg.get("subtitle_format", "ass_centered_narrow")
self.replace_single_newlines = cfg.get("replace_single_newlines", True)
self.save_option = cfg.get("save_option", "Save next to input file")
self.selected_output_folder = cfg.get("selected_output_folder")
self.silence_duration = cfg.get("silence_duration", 2.0)
self.max_subtitle_words = cfg.get("max_subtitle_words", 50)
self.separate_chapters_format = cfg.get("separate_chapters_format", "wav")
self.use_silent_gaps = cfg.get("use_silent_gaps", True)
self.subtitle_speed_method = cfg.get("subtitle_speed_method", "tts")
self.use_spacy_segmentation = cfg.get("use_spacy_segmentation", True)
self.chunk_level = cfg.get("chunk_level", "paragraph")
self.generate_epub3 = cfg.get("generate_epub3", False)
self.tts_provider = cfg.get("tts_provider", "kokoro")
self.supertonic_total_steps = cfg.get("supertonic_total_steps", 5)
self.chapter_intro_delay = cfg.get("chapter_intro_delay", 0.5)
self.read_title_intro = cfg.get("read_title_intro", False)
self.read_closing_outro = cfg.get("read_closing_outro", True)
self.auto_prefix_chapter_titles = cfg.get("auto_prefix_chapter_titles", True)
self.normalize_chapter_opening_caps = cfg.get("normalize_chapter_opening_caps", True)
self.speaker_analysis_threshold = cfg.get("speaker_analysis_threshold", 3)
self.word_substitutions_enabled = cfg.get("word_substitutions_enabled", False)
self.word_substitutions_list = cfg.get("word_substitutions_list", "")
self.case_sensitive_substitutions = cfg.get("case_sensitive_substitutions", False)
self.replace_all_caps = cfg.get("replace_all_caps", False)
self.replace_numerals = cfg.get("replace_numerals", False)
self.fix_nonstandard_punctuation = cfg.get("fix_nonstandard_punctuation", False)
# Integrations
integrations: Dict[str, Any] = cfg.get("integrations", {})
abs_cfg = integrations.get("audiobookshelf", {})
self.audiobookshelf_enabled = bool(abs_cfg.get("enabled", False))
self.audiobookshelf_base_url = str(abs_cfg.get("base_url", ""))
self.audiobookshelf_api_token = str(abs_cfg.get("api_token", ""))
self.audiobookshelf_library_id = str(abs_cfg.get("library_id", ""))
self.audiobookshelf_folder_id = str(abs_cfg.get("folder_id", ""))
self.audiobookshelf_verify_ssl = bool(abs_cfg.get("verify_ssl", True))
self.audiobookshelf_auto_send = bool(abs_cfg.get("auto_send", False))
self.audiobookshelf_send_cover = bool(abs_cfg.get("send_cover", True))
self.audiobookshelf_send_chapters = bool(abs_cfg.get("send_chapters", True))
self.audiobookshelf_send_subtitles = bool(abs_cfg.get("send_subtitles", False))
self.audiobookshelf_timeout = float(abs_cfg.get("timeout", 30.0))
cal_cfg = integrations.get("calibre_opds", {})
self.calibre_opds_enabled = bool(cal_cfg.get("enabled", False))
self.calibre_opds_base_url = str(cal_cfg.get("base_url", ""))
self.calibre_opds_username = str(cal_cfg.get("username", ""))
self.calibre_opds_password = str(cal_cfg.get("password", ""))
self.calibre_opds_verify_ssl = bool(cal_cfg.get("verify_ssl", True))
def persist_config(self) -> None:
"""
Write the current config snapshot back to disk.
Only the fields that map to the JSON config are written; runtime state
(progress, log_lines, callbacks) is not persisted.
Thread-safe.
"""
with self._lock:
cfg = self.config.copy()
cfg["selected_voice"] = self.selected_voice
cfg["selected_profile_name"] = self.selected_profile_name
cfg["speed"] = self.speed
cfg["use_gpu"] = self.use_gpu
cfg["selected_format"] = self.selected_format
cfg["subtitle_mode"] = self.subtitle_mode
cfg["subtitle_format"] = self.subtitle_format
cfg["replace_single_newlines"] = self.replace_single_newlines
cfg["save_option"] = self.save_option
cfg["selected_output_folder"] = self.selected_output_folder
cfg["silence_duration"] = self.silence_duration
cfg["max_subtitle_words"] = self.max_subtitle_words
cfg["separate_chapters_format"] = self.separate_chapters_format
cfg["use_silent_gaps"] = self.use_silent_gaps
cfg["subtitle_speed_method"] = self.subtitle_speed_method
cfg["use_spacy_segmentation"] = self.use_spacy_segmentation
cfg["chunk_level"] = self.chunk_level
cfg["generate_epub3"] = self.generate_epub3
cfg["tts_provider"] = self.tts_provider
cfg["supertonic_total_steps"] = self.supertonic_total_steps
cfg["chapter_intro_delay"] = self.chapter_intro_delay
cfg["read_title_intro"] = self.read_title_intro
cfg["read_closing_outro"] = self.read_closing_outro
cfg["auto_prefix_chapter_titles"] = self.auto_prefix_chapter_titles
cfg["normalize_chapter_opening_caps"] = self.normalize_chapter_opening_caps
cfg["speaker_analysis_threshold"] = self.speaker_analysis_threshold
cfg["word_substitutions_enabled"] = self.word_substitutions_enabled
cfg["word_substitutions_list"] = self.word_substitutions_list
cfg["case_sensitive_substitutions"] = self.case_sensitive_substitutions
cfg["replace_all_caps"] = self.replace_all_caps
cfg["replace_numerals"] = self.replace_numerals
cfg["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
# Integrations
cfg.setdefault("integrations", {})
cfg["integrations"]["audiobookshelf"] = {
"enabled": self.audiobookshelf_enabled,
"base_url": self.audiobookshelf_base_url,
"api_token": self.audiobookshelf_api_token,
"library_id": self.audiobookshelf_library_id,
"folder_id": self.audiobookshelf_folder_id,
"verify_ssl": self.audiobookshelf_verify_ssl,
"auto_send": self.audiobookshelf_auto_send,
"send_cover": self.audiobookshelf_send_cover,
"send_chapters": self.audiobookshelf_send_chapters,
"send_subtitles": self.audiobookshelf_send_subtitles,
"timeout": self.audiobookshelf_timeout,
}
cfg["integrations"]["calibre_opds"] = {
"enabled": self.calibre_opds_enabled,
"base_url": self.calibre_opds_base_url,
"username": self.calibre_opds_username,
"password": self.calibre_opds_password,
"verify_ssl": self.calibre_opds_verify_ssl,
}
self.config = cfg
try:
save_config(cfg)
except Exception:
pass
def append_log(self, message: str, level: str = "info") -> None:
"""
Thread-safely append a log line and trigger the UI callback.
Caps the internal buffer at ``LOG_MAX_LINES`` to prevent unbounded
memory growth during very long conversion tasks.
"""
with self._lock:
self.log_lines.append(f"[{level.upper()}] {message}")
if len(self.log_lines) > self.LOG_MAX_LINES:
# Trim oldest 10 % to amortise the cost of trimming
trim = self.LOG_MAX_LINES // 10
self.log_lines = self.log_lines[trim:]
cb = self.on_log
if cb is not None:
try:
cb(message, level)
except Exception:
pass
def update_progress(self, fraction: float, etr: Optional[float] = None) -> None:
"""
Update fractional progress and ETR, then notify the UI callback.
Args:
fraction: Value in [0.0, 1.0].
etr: Estimated seconds remaining, or None.
"""
with self._lock:
self.progress = max(0.0, min(1.0, fraction))
self.etr_seconds = etr
cb = self.on_progress
if cb is not None:
try:
cb(fraction, etr)
except Exception:
pass
def get_voice_formula(self) -> str:
"""
Return the effective voice formula string.
Uses the mixed_voice_state if the formula mixer is active, otherwise
returns the raw selected_voice.
"""
if self.mixed_voice_state:
parts = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
return " + ".join(filter(None, parts))
return self.selected_voice or "af_heart"
def reset_file_state(self) -> None:
"""Clear all file-related fields without touching voice/settings."""
with self._lock:
self.selected_file = None
self.selected_file_type = None
self.selected_book_path = None
self.displayed_file_path = None
self.selected_chapters = []
self.save_chapters_separately = None
self.merge_chapters_at_end = None
self.save_as_project = False
self.char_count = 0
def reset_conversion_state(self) -> None:
"""Clear all runtime conversion fields to start fresh."""
with self._lock:
self.is_converting = False
self.is_cancelled = False
self.progress = 0.0
self.etr_seconds = None
self.last_output_path = None
self.log_lines = []
-38
View File
@@ -1,38 +0,0 @@
"""Utils sub-package."""
from .helpers import (
human_readable_size,
format_duration,
format_etr,
detect_file_type,
is_supported_file,
is_book_type,
voice_lang_code,
language_label,
grouped_voices,
voice_display_name,
parse_voice_formula,
format_number,
safe_basename,
output_format_label,
subtitle_format_label,
SUPPORTED_EXTENSIONS,
)
__all__ = [
"human_readable_size",
"format_duration",
"format_etr",
"detect_file_type",
"is_supported_file",
"is_book_type",
"voice_lang_code",
"language_label",
"grouped_voices",
"voice_display_name",
"parse_voice_formula",
"format_number",
"safe_basename",
"output_format_label",
"subtitle_format_label",
"SUPPORTED_EXTENSIONS",
]
-462
View File
@@ -1,462 +0,0 @@
"""
Background conversion bridge for the Abogen Flet frontend.
This module wraps the existing ``abogen.webui.conversion_runner`` (and its
``ConversionService`` / ``Job`` machinery) in an async-friendly interface that
can push real-time progress and log updates back to the Flet event loop without
blocking the UI thread.
Key design decisions
--------------------
* All heavy work is offloaded to daemon threads. The Flet page event loop
is never blocked.
* Progress and log callbacks are scheduled back onto the Flet page via
``page.run_task()`` so Flet's session isolation remains intact.
* Cancellation is cooperative: the underlying job's ``cancel_requested``
flag is set, and the runner checks it at chunk boundaries.
* The module is a pure adapter it does NOT duplicate any processing logic
from the core pipeline.
"""
from __future__ import annotations
import asyncio
import os
import tempfile
import threading
import time
import traceback
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import flet as ft
from abogen.utils import (
get_gpu_acceleration,
get_user_cache_path,
get_user_output_path,
load_numpy_kpipeline,
prevent_sleep_end,
prevent_sleep_start,
)
from abogen.webui.service import (
ConversionService,
Job,
JobStatus,
PendingJob,
build_service,
)
from abogen.webui.conversion_runner import run_conversion_job
from ..state import AppState
# ---------------------------------------------------------------------------
# Module-level singleton ConversionService (shared across sessions, as in the
# web UI but each job carries its own output folder keyed by session).
# ---------------------------------------------------------------------------
_SERVICE_LOCK = threading.Lock()
_SERVICE: Optional[ConversionService] = None
def _get_service() -> ConversionService:
"""
Return (creating if necessary) the module-level ConversionService.
The service manages the background worker thread and persistent job state.
Thread-safe via a module-level lock.
"""
global _SERVICE
with _SERVICE_LOCK:
if _SERVICE is None:
output_root = Path(get_user_output_path("frontend"))
uploads_root = Path(get_user_cache_path("frontend/uploads"))
_SERVICE = build_service(
runner=run_conversion_job,
output_root=output_root,
uploads_root=uploads_root,
)
return _SERVICE
# ---------------------------------------------------------------------------
# Public conversion bridge
# ---------------------------------------------------------------------------
class ConversionBridge:
"""
Thin adapter between the Flet UI session and the core conversion pipeline.
One ``ConversionBridge`` instance is created per Flet page (session) and
is responsible for:
1. Accepting a conversion request from the UI.
2. Writing the input text to a temp file if needed.
3. Submitting the job to ``ConversionService``.
4. Polling the job from a daemon thread and forwarding progress/logs to
the Flet page via ``page.run_task()``.
5. Providing a ``cancel()`` method that sets the cooperative flag.
"""
def __init__(self, page: ft.Page, state: AppState) -> None:
"""
Initialise the bridge.
Args:
page: The Flet ``Page`` for this session. Used to schedule
UI callbacks on the correct event loop.
state: The session's ``AppState`` instance.
"""
self._page = page
self._state = state
self._current_job: Optional[Job] = None
self._poll_thread: Optional[threading.Thread] = None
self._stop_poll = threading.Event()
self._seen_log_count = 0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(
self,
*,
input_file: str,
voice: str,
lang_code: str,
speed: float,
output_format: str,
subtitle_mode: str,
subtitle_format: str,
use_gpu: bool,
save_option: str,
output_folder: Optional[str],
replace_single_newlines: bool,
char_count: int,
chapters: Optional[List[Dict[str, Any]]] = None,
save_chapters_separately: bool = False,
merge_chapters_at_end: bool = True,
separate_chapters_format: str = "wav",
silence_between_chapters: float = 2.0,
max_subtitle_words: int = 50,
chapter_intro_delay: float = 0.5,
read_title_intro: bool = False,
read_closing_outro: bool = True,
auto_prefix_chapter_titles: bool = True,
normalize_chapter_opening_caps: bool = True,
tts_provider: str = "kokoro",
supertonic_total_steps: int = 5,
chunk_level: str = "paragraph",
generate_epub3: bool = False,
word_substitutions_enabled: bool = False,
word_substitutions_list: str = "",
case_sensitive_substitutions: bool = False,
replace_all_caps: bool = False,
replace_numerals: bool = False,
fix_nonstandard_punctuation: bool = False,
) -> None:
"""
Submit a conversion job and begin the progress-polling loop.
This method returns immediately; all heavy work runs on daemon threads.
UI callbacks (``state.on_log``, ``state.on_progress``,
``state.on_conversion_finished``) are scheduled on the Flet event loop.
Args:
input_file: Absolute path to the text/epub/pdf input file.
voice: Kokoro voice formula string.
lang_code: Single-char language code.
speed: Playback speed multiplier (0.1 2.0).
output_format: Audio container key (``'wav'``, ``'mp3'``, …).
subtitle_mode: Subtitle generation mode string.
subtitle_format: Subtitle container key (``'srt'``, ``'ass_wide'``, …).
use_gpu: Whether to request GPU acceleration.
save_option: Save-location strategy string.
output_folder: Explicit output folder or None.
replace_single_newlines: Pre-processing flag.
char_count: Pre-computed character count for ETR estimation.
chapters: Optional list of chapter dicts for epub/pdf.
save_chapters_separately: Split chapters into separate files.
merge_chapters_at_end: Merge chapter files into one after generation.
separate_chapters_format: Format for individual chapter files.
silence_between_chapters: Silence gap (seconds) between chapters.
max_subtitle_words: Maximum words per subtitle block.
chapter_intro_delay: Silence before chapter title announcement (s).
read_title_intro: Announce book title at the start.
read_closing_outro: Announce book title at the end.
auto_prefix_chapter_titles: Prepend "Chapter N." to titles.
normalize_chapter_opening_caps: Fix ALL-CAPS opening lines.
tts_provider: ``'kokoro'`` or ``'supertonic'``.
supertonic_total_steps: Quality steps for the Supertonic pipeline.
chunk_level: ``'paragraph'`` or ``'sentence'`` chunking granularity.
generate_epub3: Also produce an EPUB3 audiobook package.
word_substitutions_enabled: Toggle word-substitution pre-processing.
word_substitutions_list: Newline-delimited ``word|replacement`` rules.
case_sensitive_substitutions: Case-sensitive matching for substitutions.
replace_all_caps: Lowercase ALL-CAPS words.
replace_numerals: Convert digits to spoken words.
fix_nonstandard_punctuation: Normalise curly quotes etc.
"""
if self._state.is_converting:
return
# Resolve the effective output folder
resolved_output: Optional[Path] = self._resolve_output_folder(
save_option=save_option,
output_folder=output_folder,
input_file=input_file,
)
# Store the input file as a Path
stored_path = Path(input_file)
original_filename = stored_path.name
# Block signals until the job is submitted
prevent_sleep_start()
self._state.is_converting = True
self._state.is_cancelled = False
self._state.progress = 0.0
self._state.etr_seconds = None
self._state.log_lines = []
self._seen_log_count = 0
# Enqueue the job on the service
service = _get_service()
job = service.enqueue(
original_filename=original_filename,
stored_path=stored_path,
language=lang_code,
voice=voice,
speed=speed,
tts_provider=tts_provider,
supertonic_total_steps=supertonic_total_steps,
use_gpu=use_gpu,
subtitle_mode=subtitle_mode,
output_format=output_format,
save_mode=self._save_mode_key(save_option),
output_folder=resolved_output,
replace_single_newlines=replace_single_newlines,
subtitle_format=subtitle_format,
total_characters=char_count,
chapters=chapters or [],
save_chapters_separately=save_chapters_separately,
merge_chapters_at_end=merge_chapters_at_end,
separate_chapters_format=separate_chapters_format,
silence_between_chapters=silence_between_chapters,
max_subtitle_words=max_subtitle_words,
chapter_intro_delay=chapter_intro_delay,
read_title_intro=read_title_intro,
read_closing_outro=read_closing_outro,
auto_prefix_chapter_titles=auto_prefix_chapter_titles,
normalize_chapter_opening_caps=normalize_chapter_opening_caps,
chunk_level=chunk_level,
generate_epub3=generate_epub3,
)
self._current_job = job
# Persist word-substitution settings to config so the runner picks them up
self._state.word_substitutions_enabled = word_substitutions_enabled
self._state.word_substitutions_list = word_substitutions_list
self._state.case_sensitive_substitutions = case_sensitive_substitutions
self._state.replace_all_caps = replace_all_caps
self._state.replace_numerals = replace_numerals
self._state.fix_nonstandard_punctuation = fix_nonstandard_punctuation
self._state.persist_config()
# Start the poll thread
self._stop_poll.clear()
self._poll_thread = threading.Thread(
target=self._poll_job_loop, daemon=True, name="abogen-poll"
)
self._poll_thread.start()
def cancel(self) -> None:
"""
Request cancellation of the currently running job.
Sets the cooperative flag on the underlying ``Job`` object; the runner
will stop after completing the current text chunk.
"""
if self._current_job is not None:
self._state.is_cancelled = True
try:
_get_service().cancel(self._current_job.id)
except Exception:
pass
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _save_mode_key(option: str) -> str:
"""
Convert the human-readable save option to the service's internal key.
Args:
option: UI-facing string (``'Save next to input file'``, …).
Returns:
Service key string.
"""
mapping = {
"Save next to input file": "save_next_to_input",
"Save to Desktop": "save_to_desktop",
"Choose output folder": "custom",
}
return mapping.get(option, "save_next_to_input")
@staticmethod
def _resolve_output_folder(
save_option: str,
output_folder: Optional[str],
input_file: str,
) -> Optional[Path]:
"""
Return the output ``Path`` based on the save option, or None for
the "next to input" strategy (the runner handles that internally).
Args:
save_option: UI-facing save strategy string.
output_folder: Explicit path when ``save_option`` is ``'Choose output folder'``.
input_file: Path to the source file for the ``'Save to Desktop'`` strategy.
Returns:
Resolved ``Path`` or ``None``.
"""
if save_option == "Choose output folder" and output_folder:
p = Path(output_folder)
p.mkdir(parents=True, exist_ok=True)
return p
if save_option == "Save to Desktop":
desktop = Path.home() / "Desktop"
desktop.mkdir(exist_ok=True)
return desktop
# "Save next to input file" let the runner decide
return None
def _poll_job_loop(self) -> None:
"""
Background daemon loop that polls the current Job for updates.
Runs until the job enters a terminal state or until ``_stop_poll``
is set. Uses ``page.run_task()`` to schedule UI updates on the Flet
event loop without triggering thread-safety violations.
"""
job = self._current_job
if job is None:
return
service = _get_service()
POLL_INTERVAL = 0.25 # seconds
while not self._stop_poll.is_set():
# Re-fetch the current job state (it's mutated in-place by the runner)
current = service.get_job(job.id)
if current is None:
break
# Forward new log lines
new_logs = current.logs[self._seen_log_count:]
self._seen_log_count += len(new_logs)
for log_entry in new_logs:
level = getattr(log_entry, "level", "info")
message = getattr(log_entry, "message", str(log_entry))
self._schedule_log(message, level)
# Forward progress
if current.progress is not None:
etr = getattr(current, "estimated_time_remaining", None)
self._schedule_progress(float(current.progress), etr)
# Check for terminal states
status = current.status
if status in (
JobStatus.COMPLETED,
JobStatus.FAILED,
JobStatus.CANCELLED,
):
output_path: Optional[str] = None
if current.result and current.result.audio_path:
output_path = str(current.result.audio_path)
if status == JobStatus.COMPLETED:
finish_msg = "Conversion completed successfully."
elif status == JobStatus.CANCELLED:
finish_msg = "Cancelled"
else:
finish_msg = f"Conversion failed: {current.error or 'Unknown error'}"
self._schedule_finished(finish_msg, output_path)
break
time.sleep(POLL_INTERVAL)
prevent_sleep_end()
self._state.is_converting = False
def _schedule_log(self, message: str, level: str) -> None:
"""Schedule a log update on the Flet event loop."""
state = self._state
page = self._page
state.append_log(message, level)
async def _update() -> None:
cb = state.on_log
if cb:
cb(message, level)
try:
page.update()
except Exception:
pass
try:
page.run_task(_update)
except Exception:
pass
def _schedule_progress(self, fraction: float, etr: Optional[float]) -> None:
"""Schedule a progress update on the Flet event loop."""
state = self._state
page = self._page
state.progress = max(0.0, min(1.0, fraction))
state.etr_seconds = etr
async def _update() -> None:
cb = state.on_progress
if cb:
cb(fraction, etr)
try:
page.update()
except Exception:
pass
try:
page.run_task(_update)
except Exception:
pass
def _schedule_finished(
self, message: str, output_path: Optional[str]
) -> None:
"""Schedule a completion notification on the Flet event loop."""
state = self._state
page = self._page
state.last_output_path = output_path
self._stop_poll.set()
async def _update() -> None:
state.is_converting = False
state.progress = 1.0
state.last_output_path = output_path
cb = state.on_conversion_finished
if cb:
cb(message, output_path)
try:
page.update()
except Exception:
pass
try:
page.run_task(_update)
except Exception:
pass
-313
View File
@@ -1,313 +0,0 @@
"""
Frontend-specific utilities for the Abogen Flet application.
Contains helpers for:
- Human-readable size / duration formatting
- Voice formula parsing and display
- File-type detection
- ETR (Estimated Time Remaining) formatting
- Path resolution that adapts to desktop vs. web context
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import List, Optional, Tuple
from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SUPPORTED_INPUT_FORMATS,
SUPPORTED_SOUND_FORMATS,
SUBTITLE_FORMATS,
VOICES_INTERNAL,
)
# ---------------------------------------------------------------------------
# Size / duration helpers
# ---------------------------------------------------------------------------
def human_readable_size(size_bytes: int, decimal_places: int = 2) -> str:
"""
Convert a byte count into a human-readable string.
Args:
size_bytes: Number of bytes.
decimal_places: Significant decimal digits in the output.
Returns:
A string like ``"3.14 MB"`` or ``"1.00 KB"``.
"""
for unit in ("B", "KB", "MB", "GB", "TB"):
if size_bytes < 1024.0:
return f"{size_bytes:.{decimal_places}f} {unit}"
size_bytes /= 1024.0 # type: ignore[assignment]
return f"{size_bytes:.{decimal_places}f} PB"
def format_duration(seconds: float) -> str:
"""
Format a duration in seconds as ``HH:MM:SS``.
Args:
seconds: Non-negative floating-point duration.
Returns:
A colon-delimited time string, e.g. ``"00:03:42"``.
"""
total = max(0, int(seconds))
h, remainder = divmod(total, 3600)
m, s = divmod(remainder, 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def format_etr(etr_seconds: Optional[float]) -> str:
"""
Format an estimated time remaining value for the UI.
Args:
etr_seconds: Seconds remaining, or None when unknown.
Returns:
Human-readable string such as ``"~3 min 42 sec"`` or ``"Calculating…"``.
"""
if etr_seconds is None:
return "Calculating…"
total = max(0, int(etr_seconds))
if total < 60:
return f"~{total} sec"
m, s = divmod(total, 60)
if m < 60:
return f"~{m} min {s} sec"
h, m = divmod(m, 60)
return f"~{h} h {m} min"
# ---------------------------------------------------------------------------
# File helpers
# ---------------------------------------------------------------------------
SUPPORTED_EXTENSIONS: Tuple[str, ...] = (
".txt",
".epub",
".pdf",
".md",
".markdown",
".srt",
".ass",
".vtt",
)
"""All file extensions that the drop-zone accepts."""
def detect_file_type(file_path: str) -> str:
"""
Return a normalised file-type token for the given path.
Args:
file_path: Absolute or relative path to the input file.
Returns:
One of ``'txt'``, ``'epub'``, ``'pdf'``, ``'markdown'``,
``'subtitle'``, or ``'unknown'``.
"""
ext = Path(file_path).suffix.lower()
if ext == ".epub":
return "epub"
if ext == ".pdf":
return "pdf"
if ext in (".md", ".markdown"):
return "markdown"
if ext in (".srt", ".ass", ".vtt"):
return "subtitle"
if ext == ".txt":
return "txt"
return "unknown"
def is_supported_file(file_path: str) -> bool:
"""
Return True when the file extension is in the supported set.
Args:
file_path: Path whose extension is inspected.
"""
return Path(file_path).suffix.lower() in SUPPORTED_EXTENSIONS
def is_book_type(file_type: str) -> bool:
"""
Return True for file types that contain chapters / pages.
Args:
file_type: Token from ``detect_file_type()``.
"""
return file_type in ("epub", "pdf", "markdown")
# ---------------------------------------------------------------------------
# Voice helpers
# ---------------------------------------------------------------------------
def voice_lang_code(voice: str) -> str:
"""
Extract the language code character from a Kokoro voice name.
The first character of every internal voice name encodes the language
(e.g. ``'a'`` for American English, ``'b'`` for British English).
Args:
voice: Raw voice string like ``'af_heart'`` or a formula.
Returns:
Single lowercase character, defaulting to ``'a'`` on failure.
"""
if not voice:
return "a"
# For plain voice IDs the first char is the language
if voice[0].isalpha() and "_" in voice[:4]:
return voice[0].lower()
# Formula: extract first alpha char
match = re.search(r"\b([a-z])", voice)
return match.group(1) if match else "a"
def language_label(lang_code: str) -> str:
"""
Return the human-readable label for a language code.
Args:
lang_code: Single-character code (``'a'``, ``'b'``, …).
Returns:
Display string, e.g. ``"American English"``.
"""
return LANGUAGE_DESCRIPTIONS.get(lang_code, lang_code.upper())
def grouped_voices() -> List[Tuple[str, List[str]]]:
"""
Return the internal voice list grouped by language for display.
Returns:
List of ``(language_label, [voice_id, …])`` tuples.
"""
groups: dict[str, List[str]] = {}
for v in VOICES_INTERNAL:
lang = language_label(v[0])
groups.setdefault(lang, []).append(v)
return sorted(groups.items())
def voice_display_name(voice_id: str) -> str:
"""
Convert a raw voice ID like ``'af_heart'`` to a prettier display name.
Args:
voice_id: Raw internal voice identifier.
Returns:
Formatted string, e.g. ``"af_heart"`` (unchanged; may be enhanced later).
"""
return voice_id
def parse_voice_formula(formula: str) -> List[Tuple[str, float]]:
"""
Parse a Kokoro voice mix formula into a list of ``(voice_id, weight)`` tuples.
Example:
``"af_heart*0.7+am_adam*0.3"`` → ``[('af_heart', 0.7), ('am_adam', 0.3)]``
Args:
formula: Space- or ``+``-joined mix formula string.
Returns:
Parsed list; empty if parsing fails.
"""
parts: List[Tuple[str, float]] = []
for token in re.split(r"[+\s]+", formula.strip()):
token = token.strip()
if not token:
continue
if "*" in token:
name, _, weight_str = token.partition("*")
try:
parts.append((name.strip(), float(weight_str.strip())))
except ValueError:
pass
else:
# Bare voice id — assume full weight
if token in VOICES_INTERNAL:
parts.append((token, 1.0))
return parts
# ---------------------------------------------------------------------------
# Number formatting
# ---------------------------------------------------------------------------
def format_number(n: int) -> str:
"""
Format an integer with thousands separators.
Args:
n: Integer value.
Returns:
Formatted string, e.g. ``"1,234,567"``.
"""
return f"{n:,}"
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def safe_basename(path: Optional[str]) -> str:
"""
Return the basename of a path, or an empty string when path is None/empty.
Args:
path: Optional file-system path.
"""
if not path:
return ""
return os.path.basename(path)
def output_format_label(fmt: str) -> str:
"""
Return a display label for an audio output format key.
Args:
fmt: Lowercase format key (``'wav'``, ``'mp3'``, …).
"""
labels = {
"wav": "WAV (lossless)",
"flac": "FLAC (lossless compressed)",
"mp3": "MP3",
"opus": "Opus (best compression)",
"m4b": "M4B (with chapters)",
}
return labels.get(fmt, fmt.upper())
def subtitle_format_label(key: str) -> str:
"""
Return the display label for a subtitle format key.
Args:
key: Internal subtitle format key (e.g. ``'ass_centered_narrow'``).
"""
for k, label in SUBTITLE_FORMATS:
if k == key:
return label
return key
-264
View File
@@ -1,264 +0,0 @@
"""
Design tokens and theme configuration for the Abogen Flet frontend.
This module defines the application's complete colour palette, typography
scale, spacing constants, and border radii in one canonical place.
All component modules import from here; changing a value here propagates
instantly across the entire UI.
Flet's ``ft.Theme`` uses ``ColorScheme``, but for custom widgets we paint
directly with hex colours drawn from ``LIGHT`` and ``DARK`` palettes.
"""
from __future__ import annotations
import flet as ft
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Colour palettes
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _Palette:
"""A complete colour palette for one theme mode."""
# Backgrounds
bg_base: str # Deepest background (window / page)
bg_surface: str # Cards, panels, dialogs
bg_elevated: str # Slightly raised elements (toolbar, sidebar)
bg_input: str # Text-field / dropdown backgrounds
# Brand accent
accent: str # Primary interactive colour (buttons, links)
accent_muted: str # Hover tint over accents
accent_on: str # Text drawn on top of accent fills
# Semantic
success: str
error: str
warning: str
info: str
# Text hierarchy
text_primary: str
text_secondary: str
text_disabled: str
text_on_accent: str
# Borders / dividers
border: str
border_focused: str
divider: str
# Specific UI atoms
drop_zone_border: str
drop_zone_bg: str
drop_zone_active_border: str
drop_zone_active_bg: str
log_bg: str
log_text: str
progress_bar_bg: str
progress_bar_fill: str
sidebar_bg: str
sidebar_selected_bg: str
sidebar_selected_text: str
nav_indicator: str
DARK = _Palette(
bg_base="#0f1117",
bg_surface="#181b23",
bg_elevated="#1e2230",
bg_input="#252a38",
accent="#5b8af5",
accent_muted="#3a5fc4",
accent_on="#ffffff",
success="#42ad4a",
error="#e84e3c",
warning="#f5a623",
info="#5b8af5",
text_primary="#e8eaf0",
text_secondary="#9ba3b8",
text_disabled="#4e5568",
text_on_accent="#ffffff",
border="#2c3147",
border_focused="#5b8af5",
divider="#252a38",
drop_zone_border="#3a4466",
drop_zone_bg="#151928",
drop_zone_active_border="#42ad4a",
drop_zone_active_bg="#0d1f10",
log_bg="#0d1117",
log_text="#b0b8cc",
progress_bar_bg="#1e2230",
progress_bar_fill="#5b8af5",
sidebar_bg="#13161f",
sidebar_selected_bg="#252a38",
sidebar_selected_text="#5b8af5",
nav_indicator="#5b8af5",
)
LIGHT = _Palette(
bg_base="#f4f5f8",
bg_surface="#ffffff",
bg_elevated="#edf0f5",
bg_input="#f0f2f7",
accent="#3a5fc4",
accent_muted="#2a4fae",
accent_on="#ffffff",
success="#2e9437",
error="#c0392b",
warning="#d4870a",
info="#3a5fc4",
text_primary="#1a1d27",
text_secondary="#5a6172",
text_disabled="#9ba3b8",
text_on_accent="#ffffff",
border="#dce0ea",
border_focused="#3a5fc4",
divider="#e8ebf2",
drop_zone_border="#a8b4d0",
drop_zone_bg="#f7f8fd",
drop_zone_active_border="#2e9437",
drop_zone_active_bg="#f0fff1",
log_bg="#f8f9fc",
log_text="#3d4358",
progress_bar_bg="#e4e8f0",
progress_bar_fill="#3a5fc4",
sidebar_bg="#eff1f5",
sidebar_selected_bg="#dde3f2",
sidebar_selected_text="#3a5fc4",
nav_indicator="#3a5fc4",
)
# ---------------------------------------------------------------------------
# Typography
# ---------------------------------------------------------------------------
FONT_FAMILY = "Inter, Segoe UI, Roboto, system-ui, sans-serif"
FONT_SIZE_XS = 11
FONT_SIZE_SM = 12
FONT_SIZE_BASE = 14
FONT_SIZE_MD = 16
FONT_SIZE_LG = 20
FONT_SIZE_XL = 26
FONT_SIZE_DISPLAY = 34
# ---------------------------------------------------------------------------
# Spacing scale (pixels)
# ---------------------------------------------------------------------------
SPACE_XS = 4
SPACE_SM = 8
SPACE_MD = 12
SPACE_LG = 16
SPACE_XL = 24
SPACE_2XL = 32
SPACE_3XL = 48
# ---------------------------------------------------------------------------
# Border radii
# ---------------------------------------------------------------------------
RADIUS_SM = 6
RADIUS_MD = 10
RADIUS_LG = 16
RADIUS_FULL = 999 # Pill-shaped
# ---------------------------------------------------------------------------
# Flet ColorScheme builders
# ---------------------------------------------------------------------------
def build_color_scheme(palette: _Palette) -> ft.ColorScheme:
"""
Construct a ``ft.ColorScheme`` from a ``_Palette`` object.
Args:
palette: The ``DARK`` or ``LIGHT`` palette.
Returns:
A fully-populated Flet ``ColorScheme``.
"""
return ft.ColorScheme(
primary=palette.accent,
on_primary=palette.accent_on,
primary_container=palette.accent_muted,
secondary=palette.accent,
on_secondary=palette.text_on_accent,
surface=palette.bg_surface,
on_surface=palette.text_primary,
on_surface_variant=palette.text_secondary,
error=palette.error,
on_error=palette.text_on_accent,
outline=palette.border,
)
def build_text_theme() -> ft.TextTheme:
"""
Construct a ``ft.TextTheme`` using the application's type scale.
Returns:
A Flet ``TextTheme`` with consistent font-size assignments.
"""
return ft.TextTheme(
display_large=ft.TextStyle(size=FONT_SIZE_DISPLAY, weight=ft.FontWeight.W_700),
headline_large=ft.TextStyle(size=FONT_SIZE_XL, weight=ft.FontWeight.W_700),
headline_medium=ft.TextStyle(size=FONT_SIZE_LG, weight=ft.FontWeight.W_600),
title_large=ft.TextStyle(size=FONT_SIZE_MD, weight=ft.FontWeight.W_600),
title_medium=ft.TextStyle(size=FONT_SIZE_BASE, weight=ft.FontWeight.W_500),
body_large=ft.TextStyle(size=FONT_SIZE_BASE),
body_medium=ft.TextStyle(size=FONT_SIZE_SM),
label_large=ft.TextStyle(size=FONT_SIZE_SM, weight=ft.FontWeight.W_500),
label_medium=ft.TextStyle(size=FONT_SIZE_XS),
)
def make_theme(dark: bool) -> ft.Theme:
"""
Build a complete Flet ``Theme`` for the requested mode.
Args:
dark: True for dark-mode theme, False for light-mode theme.
Returns:
A configured ``ft.Theme`` instance.
"""
palette = DARK if dark else LIGHT
return ft.Theme(
color_scheme=build_color_scheme(palette),
text_theme=build_text_theme(),
color_scheme_seed=palette.accent,
use_material3=True,
)
def get_palette(page: ft.Page) -> _Palette:
"""
Return the active colour palette for the given page.
Args:
page: The Flet ``Page`` instance.
Returns:
``DARK`` or ``LIGHT`` depending on the page's theme mode.
"""
return DARK if page.theme_mode == ft.ThemeMode.DARK else LIGHT
-6
View File
@@ -1,6 +0,0 @@
"""Views sub-package for the Abogen Flet frontend."""
from .dashboard import DashboardView
from .settings import SettingsView
from .queue_view import QueueView
__all__ = ["DashboardView", "SettingsView", "QueueView"]
-587
View File
@@ -1,587 +0,0 @@
"""
Dashboard view the primary conversion screen.
Hosts the file drop-zone, voice/speed/format controls, real-time log
terminal, progress bar, and the Start/Cancel/Finish action row.
All heavy work is delegated to ConversionBridge which runs on daemon
threads and schedules UI updates back onto the Flet event loop.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Optional
import flet as ft
from ..state import AppState
from ..utils.helpers import (
detect_file_type, human_readable_size, format_number,
format_etr, grouped_voices, output_format_label,
subtitle_format_label, is_book_type, voice_lang_code, SUPPORTED_EXTENSIONS
)
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG, SPACE_XL
from ..utils.conversion_bridge import ConversionBridge
from ..components import (
build_drop_zone, build_log_terminal, log_entry,
build_primary_button, build_secondary_button,
build_card, build_section_header, labelled_row, show_snack,
)
from abogen.constants import (
SUBTITLE_FORMATS, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL,
)
from abogen.utils import get_gpu_acceleration, get_user_cache_path, calculate_text_length, clean_text
class DashboardView:
"""
The main conversion dashboard.
Instantiated once per Flet session and mounted as a ``ft.Column``
inside the page's content area.
"""
def __init__(self, page: ft.Page, state: AppState) -> None:
self._page = page
self._state = state
self._bridge = ConversionBridge(page, state)
# Internal refs
self._log_list: Optional[ft.ListView] = None
self._progress_bar: Optional[ft.ProgressBar] = None
self._etr_label: Optional[ft.Text] = None
self._drop_zone_ref: Optional[ft.GestureDetector] = None
self._drop_zone_container: Optional[ft.Container] = None
self._file_picker: Optional[ft.FilePicker] = None
# Wire state callbacks
state.on_log = self._on_log
state.on_progress = self._on_progress
state.on_conversion_finished = self._on_finished
# Build UI refs
self._voice_dd: Optional[ft.Dropdown] = None
self._speed_slider: Optional[ft.Slider] = None
self._speed_label: Optional[ft.Text] = None
self._format_dd: Optional[ft.Dropdown] = None
self._subtitle_dd: Optional[ft.Dropdown] = None
self._subtitle_fmt_dd: Optional[ft.Dropdown] = None
self._gpu_switch: Optional[ft.Switch] = None
self._start_btn: Optional[ft.ElevatedButton] = None
self._cancel_btn: Optional[ft.OutlinedButton] = None
self._finish_col: Optional[ft.Column] = None
self._controls_col: Optional[ft.Column] = None
self._log_section: Optional[ft.Container] = None
self._progress_col: Optional[ft.Column] = None
# ------------------------------------------------------------------
# Build
# ------------------------------------------------------------------
def build(self) -> ft.Column:
"""Return the complete dashboard column."""
p = self._page
dark = p.theme_mode == ft.ThemeMode.DARK
pal = get_palette(p)
if self._file_picker is None:
self._file_picker = ft.FilePicker()
# --- Drop zone ---
self._drop_zone_container = ft.Container()
self._refresh_drop_zone()
# --- Voice selector ---
voice_items = []
for lang_label, voices in grouped_voices():
voice_items.append(ft.dropdown.Option(key=f"__hdr_{lang_label}", text=f"── {lang_label} ──", disabled=True))
for v in voices:
voice_items.append(ft.dropdown.Option(key=v, text=v))
self._voice_dd = ft.Dropdown(
options=voice_items,
value=self._state.selected_voice,
on_select=self._on_voice_changed,
dense=True,
expand=True,
border_radius=RADIUS_SM,
)
# --- Speed slider ---
self._speed_label = ft.Text(f"{self._state.speed:.2f}", size=13, width=40)
self._speed_slider = ft.Slider(
min=0.1, max=2.0, value=self._state.speed,
divisions=190, label="{value}",
on_change=self._on_speed_changed,
expand=True,
)
# --- Format ---
self._format_dd = ft.Dropdown(
options=[ft.dropdown.Option(key=k, text=output_format_label(k))
for k in ("wav", "flac", "mp3", "opus", "m4b")],
value=self._state.selected_format,
on_select=lambda e: self._set_field("selected_format", e.control.value),
dense=True, expand=True, border_radius=RADIUS_SM,
)
# --- Subtitle mode ---
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
self._subtitle_dd = ft.Dropdown(
options=[ft.dropdown.Option(m) for m in sub_modes],
value=self._state.subtitle_mode,
on_select=lambda e: self._set_field("subtitle_mode", e.control.value),
dense=True, expand=True, border_radius=RADIUS_SM,
)
# --- Subtitle format ---
self._subtitle_fmt_dd = ft.Dropdown(
options=[ft.dropdown.Option(key=k, text=lbl) for k, lbl in SUBTITLE_FORMATS],
value=self._state.subtitle_format,
on_select=lambda e: self._set_field("subtitle_format", e.control.value),
dense=True, expand=True, border_radius=RADIUS_SM,
)
# --- GPU ---
self._gpu_switch = ft.Switch(
value=self._state.use_gpu, label="",
on_change=lambda e: self._set_field("use_gpu", e.control.value),
active_color="#5b8af5" if dark else "#3a5fc4",
)
# --- Log ---
log_lv = ft.ListView(expand=True, auto_scroll=True, spacing=1, padding=ft.Padding.all(8))
self._log_list = log_lv
bg_log = "#0d1117" if dark else "#f8f9fc"
bd_log = "#252a38" if dark else "#dce0ea"
self._log_section = ft.Container(
content=log_lv, bgcolor=bg_log,
border=ft.Border.all(1, bd_log),
border_radius=RADIUS_SM, height=220,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
visible=False,
)
# --- Progress ---
fill = "#5b8af5" if dark else "#3a5fc4"
bg_p = "#1e2230" if dark else "#e4e8f0"
self._progress_bar = ft.ProgressBar(
value=0, color=fill, bgcolor=bg_p, height=8,
border_radius=ft.BorderRadius.all(4), expand=True,
)
self._etr_label = ft.Text("", size=11, color=pal.text_secondary, text_align=ft.TextAlign.CENTER)
self._progress_col = ft.Column([
ft.Row([self._progress_bar], spacing=0),
self._etr_label,
], spacing=SPACE_SM, horizontal_alignment=ft.CrossAxisAlignment.CENTER, visible=False)
# --- Buttons ---
self._start_btn = build_primary_button(
"Start Conversion",
icon="play_arrow",
on_click=self._on_start,
page=p,
)
self._cancel_btn = build_secondary_button(
"Cancel", icon="stop",
on_click=self._on_cancel, page=p,
)
self._cancel_btn.visible = False
# --- Finish row ---
self._finish_col = ft.Column([
ft.Row([
build_secondary_button("Open File", icon="open_in_new",
on_click=self._on_open_file, page=p),
build_secondary_button("Go to Folder", icon="folder_open",
on_click=self._on_go_folder, page=p),
build_secondary_button("New Conversion", icon="refresh",
on_click=self._on_reset, page=p),
], wrap=True, spacing=SPACE_SM, run_spacing=SPACE_SM),
], visible=False)
# --- Controls column ---
self._controls_col = ft.Column([
build_section_header("Voice & Speed", icon="record_voice_over", page=p),
labelled_row("Voice", self._voice_dd, page=p),
labelled_row("Speed", ft.Row([self._speed_slider, self._speed_label], expand=True, spacing=SPACE_SM), page=p),
ft.Divider(height=1, color=pal.divider),
build_section_header("Output", icon="audio_file", page=p),
labelled_row("Format", self._format_dd, page=p),
labelled_row("Subtitles", self._subtitle_dd, page=p),
labelled_row("Subtitle Format", self._subtitle_fmt_dd, page=p),
ft.Divider(height=1, color=pal.divider),
build_section_header("Processing", icon="memory", page=p),
labelled_row("GPU Acceleration", self._gpu_switch, page=p),
], spacing=SPACE_MD)
outer = ft.Column([
self._drop_zone_container,
ft.Container(height=SPACE_MD),
build_card(self._controls_col, page=p),
ft.Container(height=SPACE_SM),
self._log_section,
self._progress_col,
ft.Row([self._start_btn, self._cancel_btn], spacing=SPACE_SM, wrap=True),
self._finish_col,
], spacing=SPACE_MD, expand=True, scroll=ft.ScrollMode.AUTO)
return outer
# ------------------------------------------------------------------
# Drop-zone management
# ------------------------------------------------------------------
def _refresh_drop_zone(self, *, accent: bool = False, error: bool = False, err_msg: str = "") -> None:
"""Rebuild the drop-zone widget and update its container."""
p = self._page
s = self._state
fname = None; fsize = None; fchars = None
if s.selected_file and os.path.exists(s.selected_file):
disp = s.displayed_file_path or s.selected_file
fname = os.path.basename(disp)
try:
fsize = human_readable_size(os.path.getsize(s.selected_file))
except Exception:
fsize = ""
if s.char_count:
fchars = format_number(s.char_count)
label = err_msg if error else "Drag & drop your file here or click to browse"
sub = "Supports .txt · .epub · .pdf · .md · .srt · .ass · .vtt"
dz = build_drop_zone(
on_pick=self._open_file_picker,
label=label, sub_label=sub,
accent=accent, error=error,
filename=fname, file_size=fsize, char_count=fchars,
page=p,
)
if self._drop_zone_container is not None:
self._drop_zone_container.content = dz
self._drop_zone_ref = dz
# ------------------------------------------------------------------
# File picking
# ------------------------------------------------------------------
def _open_file_picker(self) -> None:
"""Open the native file picker dialog."""
self._page.run_task(self._pick_files_async)
async def _pick_files_async(self) -> None:
"""Run the file picker using Flet's async service API."""
picker = self._file_picker
if picker is None:
picker = ft.FilePicker()
self._file_picker = picker
try:
files = await picker.pick_files(
dialog_title="Select Input File",
file_type=ft.FilePickerFileType.CUSTOM,
allowed_extensions=["txt", "epub", "pdf", "md", "markdown", "srt", "ass", "vtt"],
allow_multiple=False,
)
except Exception as ex:
self._refresh_drop_zone(error=True, err_msg="Could not open file picker.")
show_snack(self._page, f"File picker error: {ex}", error=True)
self._page.update()
return
if not files:
return
file_path = files[0].path
if not file_path or not os.path.exists(file_path):
return
self._load_file(file_path)
def _load_file(self, file_path: str) -> None:
"""Validate and load a file into the session state."""
from pathlib import Path as _Path
ext = _Path(file_path).suffix.lower()
if ext not in SUPPORTED_EXTENSIONS:
self._state.reset_file_state()
self._refresh_drop_zone(error=True, err_msg=f"Unsupported file type: {ext}")
self._page.update()
return
ftype = detect_file_type(file_path)
s = self._state
if ftype in ("epub", "pdf", "markdown"):
# For book types: extract text to temp cache
self._handle_book_file(file_path, ftype)
else:
# Plain text / subtitle files
s.selected_file = file_path
s.selected_file_type = ftype
s.displayed_file_path = file_path
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
s.char_count = calculate_text_length(clean_text(text))
except Exception:
s.char_count = 0
self._refresh_drop_zone(accent=True)
self._update_subtitle_availability()
self._page.update()
def _handle_book_file(self, book_path: str, ftype: str) -> None:
"""Extract text from epub/pdf/markdown and store as temp txt."""
import threading as _t
s = self._state
def _extract():
try:
from abogen.text_extractor import extract_from_path
chapters = extract_from_path(book_path, file_type=ftype)
combined = "\n\n".join(ch.text for ch in chapters if ch.text.strip())
cache_dir = get_user_cache_path()
base = os.path.splitext(os.path.basename(book_path))[0]
fd, tmp = tempfile.mkstemp(prefix=f"{base}_", suffix=".txt", dir=cache_dir)
os.close(fd)
with open(tmp, "w", encoding="utf-8") as f:
f.write(combined)
s.selected_file = tmp
s.selected_file_type = ftype
s.selected_book_path = book_path
s.displayed_file_path = book_path
s.char_count = calculate_text_length(clean_text(combined))
s.selected_chapters = [f"ch_{i}" for i in range(len(chapters))]
self._refresh_drop_zone(accent=True)
self._update_subtitle_availability()
self._page.update()
except Exception as ex:
s.reset_file_state()
self._refresh_drop_zone(error=True, err_msg=f"Could not parse file: {ex}")
self._page.update()
_t.Thread(target=_extract, daemon=True).start()
# ------------------------------------------------------------------
# Control event handlers
# ------------------------------------------------------------------
def _set_field(self, attr: str, value) -> None:
setattr(self._state, attr, value)
self._state.persist_config()
def _on_voice_changed(self, e: ft.ControlEvent) -> None:
v = e.control.value or "af_heart"
self._state.selected_voice = v
self._state.selected_lang = voice_lang_code(v)
self._state.persist_config()
self._update_subtitle_availability()
self._page.update()
def _on_speed_changed(self, e: ft.ControlEvent) -> None:
val = round(float(e.control.value), 2)
self._state.speed = val
if self._speed_label:
self._speed_label.value = f"{val:.2f}"
self._state.persist_config()
self._page.update()
def _update_subtitle_availability(self) -> None:
"""Enable or disable subtitle controls based on selected language."""
lang = self._state.selected_lang
enabled = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
if self._subtitle_dd:
self._subtitle_dd.disabled = not enabled
if self._subtitle_fmt_dd:
self._subtitle_fmt_dd.disabled = not enabled
# ------------------------------------------------------------------
# Conversion control
# ------------------------------------------------------------------
def _on_start(self, _: ft.ControlEvent) -> None:
"""Validate inputs and kick off conversion."""
s = self._state
if not s.selected_file or not os.path.exists(s.selected_file):
self._refresh_drop_zone(error=True, err_msg="Please select an input file first.")
self._page.update()
return
# Transition UI to converting state
self._set_converting_ui(True)
self._bridge.start(
input_file=s.selected_file,
voice=s.get_voice_formula(),
lang_code=s.selected_lang,
speed=s.speed,
output_format=s.selected_format,
subtitle_mode=s.subtitle_mode,
subtitle_format=s.subtitle_format,
use_gpu=s.use_gpu,
save_option=s.save_option,
output_folder=s.selected_output_folder,
replace_single_newlines=s.replace_single_newlines,
char_count=s.char_count,
save_chapters_separately=s.save_chapters_separately or False,
merge_chapters_at_end=True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
separate_chapters_format=s.separate_chapters_format,
silence_between_chapters=s.silence_duration,
max_subtitle_words=s.max_subtitle_words,
chapter_intro_delay=s.chapter_intro_delay,
read_title_intro=s.read_title_intro,
read_closing_outro=s.read_closing_outro,
auto_prefix_chapter_titles=s.auto_prefix_chapter_titles,
normalize_chapter_opening_caps=s.normalize_chapter_opening_caps,
tts_provider=s.tts_provider,
supertonic_total_steps=s.supertonic_total_steps,
chunk_level=s.chunk_level,
generate_epub3=s.generate_epub3,
word_substitutions_enabled=s.word_substitutions_enabled,
word_substitutions_list=s.word_substitutions_list,
case_sensitive_substitutions=s.case_sensitive_substitutions,
replace_all_caps=s.replace_all_caps,
replace_numerals=s.replace_numerals,
fix_nonstandard_punctuation=s.fix_nonstandard_punctuation,
)
def _on_cancel(self, _: ft.ControlEvent) -> None:
self._bridge.cancel()
def _set_converting_ui(self, converting: bool) -> None:
"""Toggle UI between idle and converting states."""
if self._start_btn:
self._start_btn.visible = not converting
if self._cancel_btn:
self._cancel_btn.visible = converting
if self._controls_col:
self._controls_col.visible = not converting
if self._log_section:
self._log_section.visible = converting
if self._log_list:
self._log_list.controls.clear()
if self._progress_col:
self._progress_col.visible = converting
if self._progress_bar:
self._progress_bar.value = 0
if self._etr_label:
self._etr_label.value = "Estimating…"
if self._finish_col:
self._finish_col.visible = False
self._page.update()
# ------------------------------------------------------------------
# State callbacks (called from background thread via page.run_task)
# ------------------------------------------------------------------
def _on_log(self, message: str, level: str) -> None:
if self._log_list is None:
return
entry = log_entry(message, level, self._page)
self._log_list.controls.append(entry)
# Cap log lines
if len(self._log_list.controls) > 2000:
self._log_list.controls = self._log_list.controls[-1800:]
try:
self._page.update()
except Exception:
pass
def _on_progress(self, fraction: float, etr: Optional[float]) -> None:
if self._progress_bar:
self._progress_bar.value = min(fraction, 0.99)
if self._etr_label:
self._etr_label.value = format_etr(etr)
try:
self._page.update()
except Exception:
pass
def _on_finished(self, message: str, output_path: Optional[str]) -> None:
if self._progress_bar:
self._progress_bar.value = 1.0
if self._cancel_btn:
self._cancel_btn.visible = False
if message == "Cancelled":
# Restore idle state
self._set_converting_ui(False)
show_snack(self._page, "Conversion cancelled.", error=True)
return
if "failed" in message.lower() or "error" in message.lower():
self._log_on_log(message, "error")
self._set_converting_ui(False)
show_snack(self._page, f"Error: {message}", error=True)
return
# Success
if self._log_section:
self._log_section.visible = True
if self._progress_col:
self._progress_col.visible = False
if self._controls_col:
self._controls_col.visible = False
if self._finish_col:
self._finish_col.visible = True
if self._start_btn:
self._start_btn.visible = False
show_snack(self._page, "Conversion completed!")
try:
self._page.update()
except Exception:
pass
def _log_on_log(self, message: str, level: str) -> None:
self._on_log(message, level)
# ------------------------------------------------------------------
# Finish actions
# ------------------------------------------------------------------
def _on_open_file(self, _: ft.ControlEvent) -> None:
path = self._state.last_output_path
if path and os.path.exists(path):
import subprocess, platform
try:
if platform.system() == "Darwin":
subprocess.Popen(["open", path])
elif platform.system() == "Windows":
os.startfile(path)
else:
subprocess.Popen(["xdg-open", path])
except Exception as ex:
show_snack(self._page, f"Cannot open file: {ex}", error=True)
else:
show_snack(self._page, "Output file not found.", error=True)
def _on_go_folder(self, _: ft.ControlEvent) -> None:
path = self._state.last_output_path
folder = os.path.dirname(path) if path and os.path.isfile(path) else path
if folder and os.path.isdir(folder):
import subprocess, platform
try:
if platform.system() == "Darwin":
subprocess.Popen(["open", folder])
elif platform.system() == "Windows":
subprocess.Popen(["explorer", folder])
else:
subprocess.Popen(["xdg-open", folder])
except Exception as ex:
show_snack(self._page, f"Cannot open folder: {ex}", error=True)
else:
show_snack(self._page, "Output folder not found.", error=True)
def _on_reset(self, _: ft.ControlEvent) -> None:
self._state.reset_file_state()
self._state.reset_conversion_state()
self._refresh_drop_zone()
self._set_converting_ui(False)
if self._finish_col:
self._finish_col.visible = False
if self._controls_col:
self._controls_col.visible = True
if self._start_btn:
self._start_btn.visible = True
self._page.update()
-154
View File
@@ -1,154 +0,0 @@
"""
Queue management view.
Displays the current conversion queue, allowing the user to reorder,
remove, and inspect queued items before starting batch processing.
"""
from __future__ import annotations
from typing import Optional
import flet as ft
from ..state import AppState, ConversionJob
from ..utils.theme import get_palette, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
from ..utils.helpers import safe_basename, output_format_label, format_number
from ..components import (
build_card, build_section_header, build_primary_button,
build_secondary_button, show_snack, build_divider,
resolve_icon,
)
class QueueView:
"""Queue manager view."""
def __init__(self, page: ft.Page, state: AppState) -> None:
self._page = page
self._state = state
self._list_col: Optional[ft.Column] = None
def build(self) -> ft.Column:
p = self._page
s = self._state
pal = get_palette(p)
dark = p.theme_mode == ft.ThemeMode.DARK
self._list_col = ft.Column(spacing=SPACE_SM)
self._refresh_list()
header = build_section_header("Conversion Queue",
icon="list_alt", page=p)
action_row = ft.Row([
build_primary_button(
"Start Queue",
icon="play_arrow",
on_click=self._on_start_queue,
page=p,
disabled=not s.queued_items,
),
build_secondary_button(
"Clear All",
icon="delete_sweep",
on_click=self._on_clear_queue,
page=p,
),
], spacing=SPACE_SM, wrap=True)
queue_card = build_card(ft.Column([
header,
ft.Divider(height=1, color=pal.divider),
self._list_col,
ft.Container(height=SPACE_SM),
action_row,
], spacing=SPACE_MD), page=p)
return ft.Column([queue_card], scroll=ft.ScrollMode.AUTO, expand=True)
# ------------------------------------------------------------------
def _refresh_list(self) -> None:
if self._list_col is None:
return
self._list_col.controls.clear()
s = self._state
pal = get_palette(self._page)
dark = self._page.theme_mode == ft.ThemeMode.DARK
if not s.queued_items:
self._list_col.controls.append(
ft.Text("No items in the queue.", size=13,
color=pal.text_secondary,
text_align=ft.TextAlign.CENTER)
)
return
for idx, job in enumerate(s.queued_items):
tile = self._build_job_tile(idx, job, dark, pal)
self._list_col.controls.append(tile)
try:
self._page.update()
except Exception:
pass
def _build_job_tile(self, idx: int, job: ConversionJob, dark: bool, pal) -> ft.Container:
"""Build a single queue-item tile."""
bg = pal.bg_elevated
border_clr = pal.border
accent = "#5b8af5" if dark else "#3a5fc4"
text_primary = pal.text_primary
text_secondary = pal.text_secondary
def _remove(_):
self._state.queued_items.pop(idx)
self._refresh_list()
name = safe_basename(job.display_name or job.file_path)
details = (
f"Voice: {job.voice} · Format: {output_format_label(job.output_format)}"
f" · Speed: {job.speed:.2f}x · Chars: {format_number(job.char_count)}"
)
return ft.Container(
content=ft.Row([
ft.Container(
content=ft.Text(str(idx + 1), size=12, weight=ft.FontWeight.W_700,
color=accent),
width=32,
),
ft.Column([
ft.Text(name, size=13, weight=ft.FontWeight.W_600, color=text_primary,
no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
ft.Text(details, size=11, color=text_secondary),
], expand=True, tight=True, spacing=2),
ft.IconButton(
icon=resolve_icon("delete_outline"),
icon_color=pal.error if hasattr(pal, "error") else "#e84e3c",
icon_size=18,
tooltip="Remove",
on_click=_remove,
),
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=SPACE_SM),
bgcolor=bg,
border=ft.Border.all(1, border_clr),
border_radius=RADIUS_SM,
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=SPACE_SM),
)
# ------------------------------------------------------------------
def _on_start_queue(self, _: ft.ControlEvent) -> None:
if not self._state.queued_items:
show_snack(self._page, "Queue is empty.", error=True)
return
# Navigate to dashboard and trigger queue start
# This is wired in main.py via the nav controller
self._page.pubsub.send_all("start_queue")
def _on_clear_queue(self, _: ft.ControlEvent) -> None:
if not self._state.queued_items:
return
self._state.queued_items.clear()
self._refresh_list()
show_snack(self._page, "Queue cleared.")
-305
View File
@@ -1,305 +0,0 @@
"""
Settings view a categorised, scrollable settings page.
Groups settings into collapsible cards:
- Output (format, save location, chapters)
- Text processing (newlines, caps, substitutions, numerals)
- Subtitle options
- TTS pipeline (provider, GPU, chunking)
- Integrations (Audiobookshelf, Calibre OPDS)
"""
from __future__ import annotations
from typing import Optional
import flet as ft
from ..state import AppState
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
from ..utils.helpers import output_format_label, subtitle_format_label, SUPPORTED_EXTENSIONS
from ..components import (
build_card, build_section_header, labelled_row, show_snack, build_divider,
build_primary_button,
)
from abogen.constants import SUBTITLE_FORMATS
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dd(options, value, on_change, **kw):
"""Compact dropdown factory."""
return ft.Dropdown(
options=[ft.dropdown.Option(key=k, text=v) for k, v in options],
value=value, on_select=on_change, dense=True,
border_radius=RADIUS_SM, expand=True, **kw
)
def _sw(value, on_change, label=""):
return ft.Switch(value=value, on_change=on_change, label=label)
class SettingsView:
"""The full settings panel."""
def __init__(self, page: ft.Page, state: AppState) -> None:
self._page = page
self._state = state
def build(self) -> ft.Column:
p = self._page
s = self._state
pal = get_palette(p)
# ── Output card ──────────────────────────────────────────────
format_dd = _dd(
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus", "m4b")],
s.selected_format,
lambda e: self._save("selected_format", e.control.value),
)
save_dd = _dd(
[
("Save next to input file", "Save next to input file"),
("Save to Desktop", "Save to Desktop"),
("Choose output folder", "Choose output folder"),
],
s.save_option,
lambda e: self._save("save_option", e.control.value),
)
chapters_sw = _sw(s.save_chapters_separately or False,
lambda e: self._save("save_chapters_separately", e.control.value))
merge_sw = _sw(True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
lambda e: self._save("merge_chapters_at_end", e.control.value))
sep_fmt_dd = _dd(
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus")],
s.separate_chapters_format,
lambda e: self._save("separate_chapters_format", e.control.value),
)
epub3_sw = _sw(s.generate_epub3, lambda e: self._save("generate_epub3", e.control.value))
output_card = build_card(ft.Column([
build_section_header("Output", icon="audio_file", page=p),
labelled_row("Audio Format", format_dd, page=p),
labelled_row("Save Location", save_dd, page=p),
build_divider(p),
labelled_row("Save Chapters Separately", chapters_sw, page=p),
labelled_row("Merge at End", merge_sw, page=p),
labelled_row("Chapter Format", sep_fmt_dd, page=p),
labelled_row("Generate EPUB3", epub3_sw, page=p),
], spacing=SPACE_MD), page=p)
# ── Text processing card ─────────────────────────────────────
newlines_sw = _sw(s.replace_single_newlines,
lambda e: self._save("replace_single_newlines", e.control.value))
caps_sw = _sw(s.replace_all_caps, lambda e: self._save("replace_all_caps", e.control.value))
norm_sw = _sw(s.normalize_chapter_opening_caps,
lambda e: self._save("normalize_chapter_opening_caps", e.control.value))
numerals_sw = _sw(s.replace_numerals, lambda e: self._save("replace_numerals", e.control.value))
punct_sw = _sw(s.fix_nonstandard_punctuation,
lambda e: self._save("fix_nonstandard_punctuation", e.control.value))
wordsub_sw = _sw(s.word_substitutions_enabled,
lambda e: self._save("word_substitutions_enabled", e.control.value))
wordsub_tf = ft.TextField(
value=s.word_substitutions_list,
multiline=True, min_lines=3, max_lines=6,
hint_text="word|replacement (one per line)",
on_change=lambda e: self._save("word_substitutions_list", e.control.value),
expand=True, border_radius=RADIUS_SM, text_size=12,
)
case_sw = _sw(s.case_sensitive_substitutions,
lambda e: self._save("case_sensitive_substitutions", e.control.value))
spacy_sw = _sw(s.use_spacy_segmentation,
lambda e: self._save("use_spacy_segmentation", e.control.value))
chunk_dd = _dd(
[("paragraph", "Paragraph"), ("sentence", "Sentence")],
s.chunk_level,
lambda e: self._save("chunk_level", e.control.value),
)
title_intro_sw = _sw(s.read_title_intro, lambda e: self._save("read_title_intro", e.control.value))
outro_sw = _sw(s.read_closing_outro, lambda e: self._save("read_closing_outro", e.control.value))
prefix_sw = _sw(s.auto_prefix_chapter_titles,
lambda e: self._save("auto_prefix_chapter_titles", e.control.value))
text_card = build_card(ft.Column([
build_section_header("Text Processing", icon="text_fields", page=p),
labelled_row("Replace Single Newlines", newlines_sw,
tooltip="Replace single newlines with spaces before processing.", page=p),
labelled_row("Replace ALL CAPS Words", caps_sw, page=p),
labelled_row("Normalize Opening CAPS", norm_sw, page=p),
labelled_row("Replace Numerals (spoken)", numerals_sw, page=p),
labelled_row("Fix Non-standard Punctuation", punct_sw, page=p),
build_divider(p),
labelled_row("Word Substitutions", wordsub_sw, page=p),
labelled_row("Case Sensitive", case_sw, page=p),
ft.Text("Substitution rules (word|replacement, one per line):",
size=12, color=pal.text_secondary),
wordsub_tf,
build_divider(p),
build_section_header("Chapter Options", icon="library_books", page=p),
labelled_row("Announce Book Title (intro)", title_intro_sw, page=p),
labelled_row("Announce Book Title (outro)", outro_sw, page=p),
labelled_row("Auto-prefix Chapter Titles", prefix_sw, page=p),
labelled_row("Chunk Level", chunk_dd, page=p),
labelled_row("Use spaCy Segmentation", spacy_sw, page=p),
], spacing=SPACE_MD), page=p)
# ── Subtitle card ─────────────────────────────────────────────
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
sub_mode_dd = _dd(
[(m, m) for m in sub_modes],
s.subtitle_mode,
lambda e: self._save("subtitle_mode", e.control.value),
)
sub_fmt_dd = _dd(
[(k, lbl) for k, lbl in SUBTITLE_FORMATS],
s.subtitle_format,
lambda e: self._save("subtitle_format", e.control.value),
)
def _mk_mw_slider():
lbl = ft.Text(str(s.max_subtitle_words), size=12, width=36)
sl = ft.Slider(
min=1, max=200, value=s.max_subtitle_words, divisions=199, label="{value}",
expand=True,
on_change=lambda e: (self._save("max_subtitle_words", int(e.control.value)),
setattr(lbl, "value", str(int(e.control.value))),
self._page.update()),
)
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
sub_speed_dd = _dd(
[("tts", "TTS duration"), ("silence", "Silence detection")],
s.subtitle_speed_method,
lambda e: self._save("subtitle_speed_method", e.control.value),
)
silent_gaps_sw = _sw(s.use_silent_gaps,
lambda e: self._save("use_silent_gaps", e.control.value))
subtitle_card = build_card(ft.Column([
build_section_header("Subtitles", icon="subtitles", page=p),
labelled_row("Mode", sub_mode_dd, page=p),
labelled_row("Format", sub_fmt_dd, page=p),
labelled_row("Max Words / Block", _mk_mw_slider(), page=p),
labelled_row("Speed Method", sub_speed_dd, page=p),
labelled_row("Silent Gaps", silent_gaps_sw, page=p),
], spacing=SPACE_MD), page=p)
# ── Pipeline card ─────────────────────────────────────────────
provider_dd = _dd(
[("kokoro", "Kokoro (default)"), ("supertonic", "Supertonic")],
s.tts_provider,
lambda e: self._save("tts_provider", e.control.value),
)
gpu_sw = _sw(s.use_gpu, lambda e: self._save("use_gpu", e.control.value),
label="GPU acceleration (if available)")
def _mk_steps_slider():
lbl = ft.Text(str(s.supertonic_total_steps), size=12, width=28)
sl = ft.Slider(
min=2, max=15, value=s.supertonic_total_steps, divisions=13,
label="{value}", expand=True,
on_change=lambda e: (self._save("supertonic_total_steps", int(e.control.value)),
setattr(lbl, "value", str(int(e.control.value))),
self._page.update()),
)
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
thresh_tf = ft.TextField(
value=str(s.speaker_analysis_threshold), width=80,
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
on_change=lambda e: self._save_int("speaker_analysis_threshold", e.control.value, 1, 25),
)
silence_tf = ft.TextField(
value=str(s.silence_duration), width=80,
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
on_change=lambda e: self._save_float("silence_duration", e.control.value, 0.0),
)
intro_tf = ft.TextField(
value=str(s.chapter_intro_delay), width=80,
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
on_change=lambda e: self._save_float("chapter_intro_delay", e.control.value, 0.0),
)
pipeline_card = build_card(ft.Column([
build_section_header("TTS Pipeline", icon="settings", page=p),
labelled_row("Provider", provider_dd, page=p),
labelled_row("GPU Acceleration", gpu_sw, page=p),
labelled_row("Supertonic Steps", _mk_steps_slider(), page=p),
build_divider(p),
labelled_row("Speaker Analysis Threshold", thresh_tf, page=p),
labelled_row("Silence Between Chapters (s)", silence_tf, page=p),
labelled_row("Chapter Intro Delay (s)", intro_tf, page=p),
], spacing=SPACE_MD), page=p)
# ── Integration card (Audiobookshelf) ─────────────────────────
abs_enabled_sw = _sw(s.audiobookshelf_enabled,
lambda e: self._save("audiobookshelf_enabled", e.control.value))
abs_url_tf = ft.TextField(value=s.audiobookshelf_base_url, hint_text="http://abs-server:13378",
expand=True, border_radius=RADIUS_SM, text_size=12,
on_change=lambda e: self._save("audiobookshelf_base_url", e.control.value))
abs_token_tf = ft.TextField(value=s.audiobookshelf_api_token, password=True,
can_reveal_password=True, expand=True,
border_radius=RADIUS_SM, text_size=12,
on_change=lambda e: self._save("audiobookshelf_api_token", e.control.value))
abs_lib_tf = ft.TextField(value=s.audiobookshelf_library_id, hint_text="Library ID",
expand=True, border_radius=RADIUS_SM, text_size=12,
on_change=lambda e: self._save("audiobookshelf_library_id", e.control.value))
abs_auto_sw = _sw(s.audiobookshelf_auto_send,
lambda e: self._save("audiobookshelf_auto_send", e.control.value))
integ_card = build_card(ft.Column([
build_section_header("Audiobookshelf Integration",
icon="cloud_upload", page=p),
labelled_row("Enabled", abs_enabled_sw, page=p),
labelled_row("Server URL", abs_url_tf, page=p),
labelled_row("API Token", abs_token_tf, page=p),
labelled_row("Library ID", abs_lib_tf, page=p),
labelled_row("Auto-upload on finish", abs_auto_sw, page=p),
], spacing=SPACE_MD), page=p)
save_btn = build_primary_button(
"Save Settings", icon="save",
on_click=self._on_save, page=p,
)
return ft.Column([
output_card,
ft.Container(height=SPACE_MD),
text_card,
ft.Container(height=SPACE_MD),
subtitle_card,
ft.Container(height=SPACE_MD),
pipeline_card,
ft.Container(height=SPACE_MD),
integ_card,
ft.Container(height=SPACE_LG),
save_btn,
ft.Container(height=SPACE_LG),
], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _save(self, attr: str, value) -> None:
setattr(self._state, attr, value)
def _save_int(self, attr: str, raw: str, lo: int, hi: int) -> None:
try:
v = max(lo, min(hi, int(raw)))
setattr(self._state, attr, v)
except ValueError:
pass
def _save_float(self, attr: str, raw: str, lo: float) -> None:
try:
v = max(lo, float(raw))
setattr(self._state, attr, v)
except ValueError:
pass
def _on_save(self, _: ft.ControlEvent) -> None:
self._state.persist_config()
show_snack(self._page, "Settings saved.")
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL
from abogen.constants import COLORS
from abogen.tts_backend_registry import get_metadata
from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False
return
voice_list = VOICES_INTERNAL
voice_list = get_metadata("kokoro").voices
for idx, voice in enumerate(voice_list, start=1):
if self._cancelled:
self._voices_success = False
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
try:
from huggingface_hub import try_to_load_from_cache
for voice in VOICES_INTERNAL:
for voice in get_metadata("kokoro").voices:
if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
):
missing.append(voice)
except Exception:
# If HF missing, report all as missing
return False, list(VOICES_INTERNAL)
return False, list(get_metadata("kokoro").voices)
return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool:
+28 -56
View File
@@ -5,6 +5,7 @@ import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import numpy as np
import soundfile as sf
from abogen.utils import (
create_process,
@@ -259,8 +260,7 @@ class ConversionThread(QThread):
output_folder,
subtitle_mode,
output_format,
np_module,
kpipeline_class,
backend,
start_time,
total_char_count,
use_gpu=True,
@@ -270,8 +270,7 @@ class ConversionThread(QThread):
super().__init__()
self._chapter_options_event = threading.Event()
self._timestamp_response_event = threading.Event()
self.np = np_module
self.KPipeline = kpipeline_class
self.backend = backend
self.file_name = file_name
self.lang_code = lang_code
self.speed = speed
@@ -490,19 +489,6 @@ class ConversionThread(QThread):
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"
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
is_subtitle_file = False
is_timestamp_text = False
@@ -538,7 +524,7 @@ class ConversionThread(QThread):
# Process subtitle files separately
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
if self.is_direct_text:
@@ -1071,7 +1057,7 @@ class ConversionThread(QThread):
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
# Load voice for this segment (with caching)
try:
loaded_voice = self.load_voice_cached(voice_name, tts)
loaded_voice = self.load_voice_cached(voice_name, self.backend)
if segment_idx > 0:
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
@@ -1080,7 +1066,7 @@ class ConversionThread(QThread):
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
)
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
# Only non-English languages use spaCy for pre-segmentation
@@ -1166,7 +1152,7 @@ class ConversionThread(QThread):
print("Using split pattern: (unprintable)")
for text_segment in text_segments:
for result in tts(
for result in self.backend(
text_segment,
voice=loaded_voice,
speed=self.speed,
@@ -1368,7 +1354,7 @@ class ConversionThread(QThread):
silence_samples = int(
self.silence_duration * 24000
) # 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()
if merged_out_file:
@@ -1707,7 +1693,7 @@ class ConversionThread(QThread):
max_end_time = max(
(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"
)
@@ -1771,7 +1757,7 @@ class ConversionThread(QThread):
# Generate TTS audio
tts_results = [
r
for r in tts(
for r in self.backend(
processed_text,
voice=loaded_voice,
speed=self.speed,
@@ -1789,11 +1775,11 @@ class ConversionThread(QThread):
# Concatenate audio and determine duration
full_audio = (
self.np.concatenate(
np.concatenate(
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
)
if audio_chunks
else self.np.zeros(
else np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32"
)
)
@@ -1827,8 +1813,8 @@ class ConversionThread(QThread):
num_stages = max(
1,
int(
self.np.ceil(
self.np.log(speed_factor) / self.np.log(2.0)
np.ceil(
np.log(speed_factor) / np.log(2.0)
)
),
)
@@ -1861,7 +1847,7 @@ class ConversionThread(QThread):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
full_audio = self.np.frombuffer(
full_audio = np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32",
)
@@ -1875,7 +1861,7 @@ class ConversionThread(QThread):
tts_results = [
r
for r in tts(
for r in self.backend(
processed_text,
voice=loaded_voice,
speed=new_speed,
@@ -1886,14 +1872,14 @@ class ConversionThread(QThread):
audio_chunks = [r.audio for r in tts_results]
full_audio = (
self.np.concatenate(
np.concatenate(
[
a.numpy() if hasattr(a, "numpy") else a
for a in audio_chunks
]
)
if audio_chunks
else self.np.zeros(
else np.zeros(
int(subtitle_duration * rate), dtype="float32"
)
)
@@ -1910,10 +1896,10 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples:
full_audio = self.np.concatenate(
full_audio = np.concatenate(
[
full_audio,
self.np.zeros(
np.zeros(
target_samples - len(full_audio), dtype="float32"
),
]
@@ -1926,10 +1912,10 @@ class ConversionThread(QThread):
end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer):
# Extend buffer if needed
audio_buffer = self.np.concatenate(
audio_buffer = np.concatenate(
[
audio_buffer,
self.np.zeros(
np.zeros(
end_sample - len(audio_buffer), dtype="float32"
),
]
@@ -1971,7 +1957,7 @@ class ConversionThread(QThread):
self.progress_updated.emit(percent, etr_str)
# 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:
self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
@@ -2440,8 +2426,7 @@ class VoicePreviewThread(QThread):
def __init__(
self,
np_module,
kpipeline_class,
backend,
lang_code,
voice,
speed,
@@ -2449,8 +2434,7 @@ class VoicePreviewThread(QThread):
parent=None,
):
super().__init__(parent)
self.np_module = np_module
self.kpipeline_class = kpipeline_class
self.backend = backend
self.lang_code = lang_code
self.voice = voice
self.speed = speed
@@ -2484,31 +2468,19 @@ class VoicePreviewThread(QThread):
# Generate the preview and save to cache
try:
# 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"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview
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:
loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code)
audio_segments = []
for result in tts(
for result in self.backend(
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
):
audio_segments.append(result.audio)
if audio_segments:
audio = self.np_module.concatenate(audio_segments)
audio = np.concatenate(audio_segments)
# Save directly to the cache path
sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path
+34 -13
View File
@@ -82,11 +82,11 @@ from abogen.constants import (
GITHUB_URL,
PROGRAM_DESCRIPTION,
LANGUAGE_DESCRIPTIONS,
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
COLORS,
SUBTITLE_FORMATS,
)
from abogen.tts_backend_registry import get_metadata
import threading
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
from abogen.voice_profiles import load_profiles
@@ -1873,7 +1873,7 @@ class abogen(QWidget):
for pname in load_profiles().keys():
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
# re-add voices
for v in VOICES_INTERNAL:
for v in get_metadata("kokoro").voices:
icon = QIcon()
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
if flag_path and os.path.exists(flag_path):
@@ -2316,9 +2316,9 @@ class abogen(QWidget):
file_size_str = "Unknown"
# pipeline_loaded_callback remains unchanged
def pipeline_loaded_callback(np_module, kpipeline_class, error):
def pipeline_loaded_callback(backend, 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()
return
@@ -2341,8 +2341,7 @@ class abogen(QWidget):
self.selected_output_folder,
subtitle_mode=actual_subtitle_mode,
output_format=self.selected_format,
np_module=np_module,
kpipeline_class=kpipeline_class,
backend=backend,
start_time=self.start_time,
total_char_count=self.char_count,
use_gpu=self.gpu_ok,
@@ -2426,7 +2425,20 @@ class abogen(QWidget):
self.gpu_ok = gpu_ok
self.update_log((gpu_msg, gpu_ok))
self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback)
# Determine device based on GPU availability
if gpu_ok:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps"
else:
device = "cuda"
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()
threading.Thread(target=gpu_and_load, daemon=True).start()
@@ -2863,18 +2875,27 @@ class abogen(QWidget):
)
self.loading_movie.start()
def pipeline_loaded_callback(np_module, kpipeline_class, error):
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
# Determine device based on GPU availability
if self.gpu_ok:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps"
else:
device = "cuda"
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()
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
if error:
self.loading_movie.stop()
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.setEnabled(True)
@@ -2912,7 +2933,7 @@ class abogen(QWidget):
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
self.preview_thread = VoicePreviewThread(
np_module, kpipeline_class, lang, voice, speed, gpu_ok
backend, lang, voice, speed, gpu_ok
)
self.preview_thread.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error)
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL
from abogen.constants import COLORS
from abogen.tts_backend_registry import get_metadata
from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False
return
voice_list = VOICES_INTERNAL
voice_list = get_metadata("kokoro").voices
for idx, voice in enumerate(voice_list, start=1):
if self._cancelled:
self._voices_success = False
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
try:
from huggingface_hub import try_to_load_from_cache
for voice in VOICES_INTERNAL:
for voice in get_metadata("kokoro").voices:
if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
):
missing.append(voice)
except Exception:
# If HF missing, report all as missing
return False, list(VOICES_INTERNAL)
return False, list(get_metadata("kokoro").voices)
return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool:
+3 -3
View File
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS,
COLORS,
)
from abogen.tts_backend_registry import get_metadata
import re
import platform
from abogen.utils import get_resource_path
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
# 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_metadata("kokoro").voices and self.voice_name[1] == "f"
# Icons layout (flag and gender)
icons_layout = QHBoxLayout()
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
def add_voices(self, initial_state):
first_enabled_voice = None
for voice in VOICES_INTERNAL:
for voice in get_metadata("kokoro").voices:
language_code = voice[0] # First character is the language code
matching_voice = next(
(item for item in initial_state if item[0] == voice), None
+7 -7
View File
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
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'.
Args:
@@ -477,10 +477,10 @@ def validate_voice_name(voice_name):
- 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
"""
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
# 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_metadata("kokoro").voices}
voice_name = voice_name.strip()
# 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.
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:
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
- invalid_count: Number of invalid voice markers skipped
"""
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
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
voice_part_lower = voice_part.strip().lower()
canonical_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower),
(v for v in get_metadata("kokoro").voices if v.lower() == voice_part_lower),
voice_part.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
voice_name_lower = voice_name.lower()
current_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower),
(v for v in get_metadata("kokoro").voices if v.lower() == voice_name_lower),
voice_name
)
valid_markers += 1
+89
View File
@@ -0,0 +1,89 @@
"""
TTS Backend Interface
This module defines the protocol for TTS backends and the
metadata model that describes a backend implementation.
"""
from dataclasses import dataclass
from typing import Protocol, List, Dict, Any
@dataclass(frozen=True)
class TTSBackendMetadata:
"""
Immutable metadata describing a TTS backend implementation.
Attributes:
id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``).
name: Human-readable display name.
description: Short description of the backend.
voices: Tuple of supported voice identifiers.
"""
id: str
name: str
description: str
voices: tuple[str, ...] = ()
class TTSBackend(Protocol):
"""
Protocol for TTS backends.
All TTS backends must implement this interface to be compatible
with the application.
"""
@property
def metadata(self) -> TTSBackendMetadata:
...
def __init__(self, **kwargs) -> None:
"""
Initialize the TTS backend.
Args:
**kwargs: Backend-specific configuration parameters
"""
...
def synthesize(self, text: str, **kwargs) -> bytes:
"""
Synthesize speech from text.
Args:
text: Text to synthesize
**kwargs: Additional parameters for synthesis
Returns:
Audio data as bytes
"""
...
def get_available_voices(self) -> List[str]:
"""
Get list of available voices.
Returns:
List of voice identifiers
"""
...
def get_supported_formats(self) -> List[str]:
"""
Get list of supported audio formats.
Returns:
List of supported audio formats
"""
...
def get_info(self) -> Dict[str, Any]:
"""
Get backend information.
Returns:
Dictionary with backend information
"""
...
+146
View File
@@ -0,0 +1,146 @@
"""
TTS Backend Registry
Provides a global registry for TTS backend factories.
Backends register themselves with metadata and a factory callable.
The registry is universal and does not know about backend constructors.
"""
from typing import Callable, Any
from abogen.tts_backend import TTSBackend, TTSBackendMetadata
class TTSBackendRegistry:
"""Registry of TTS backend factories.
Stores metadata and factory callables for registered backends.
"""
def __init__(self) -> None:
self._backends: dict[str, TTSBackendMetadata] = {}
self._factories: dict[str, Callable[..., TTSBackend]] = {}
def register(
self,
metadata: TTSBackendMetadata,
factory: Callable[..., TTSBackend],
) -> None:
"""Register a backend with its metadata and factory callable."""
self._backends[metadata.id] = metadata
self._factories[metadata.id] = factory
def is_registered(self, backend_id: str) -> bool:
"""Return True if a backend with the given id is registered."""
return backend_id in self._backends
def list_backends(self) -> list[TTSBackendMetadata]:
"""Return metadata for all registered backends."""
return list(self._backends.values())
def get_metadata(self, backend_id: str) -> TTSBackendMetadata:
"""Get metadata for a specific backend.
Raises:
KeyError: If backend with given id is not registered.
"""
if backend_id not in self._backends:
raise KeyError(f"Unknown backend: {backend_id}")
return self._backends[backend_id]
def create_backend(self, backend_id: str, **kwargs: Any) -> TTSBackend:
"""Create a backend instance by id.
Raises:
KeyError: If backend with given id is not registered.
"""
if backend_id not in self._factories:
raise KeyError(f"Unknown backend: {backend_id}")
return self._factories[backend_id](**kwargs)
def resolve_backend_for_voice(
self,
spec: str,
fallback: str = "kokoro",
) -> str:
"""Determine which backend owns the given voice specification.
Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice ID match against registered backends -> backend 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()
for metadata in self._backends.values():
if upper in metadata.voices:
return metadata.id
return fallback
_registry = TTSBackendRegistry()
def register_backend(
metadata: TTSBackendMetadata,
factory: Callable[..., TTSBackend],
) -> None:
"""Register a TTS backend in the global registry."""
_registry.register(metadata, factory)
def get_metadata(backend_id: str) -> TTSBackendMetadata:
"""Get metadata for a specific backend by id.
Ensures all backends are registered by importing the tts_backends
package on first access.
Raises:
KeyError: If backend with given id is not registered.
"""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.get_metadata(backend_id)
def get_default_voice(backend_id: str, fallback: str = "") -> str:
"""Return the first voice of a backend, or *fallback* if none."""
voices = get_metadata(backend_id).voices
return voices[0] if voices else fallback
def create_backend(backend_id: str, **kwargs: Any) -> TTSBackend:
"""Create a TTS backend instance by provider id."""
return _registry.create_backend(backend_id, **kwargs)
def is_registered_backend(backend_id: str) -> bool:
"""Return True if *backend_id* is a registered TTS backend."""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.is_registered(backend_id)
def resolve_backend_for_voice(
spec: str,
fallback: str = "kokoro",
) -> str:
"""Determine which backend owns the given voice specification.
Ensures all backends are registered by importing the tts_backends
package on first access.
Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice ID match against registered backends -> backend id
4. Unknown voice -> fallback
"""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.resolve_backend_for_voice(spec, fallback=fallback)
+20
View File
@@ -0,0 +1,20 @@
"""TTS backends package.
Backend modules are auto-discovered and imported here.
Each backend module registers itself with the global registry
when imported.
"""
import importlib
import pkgutil
def _discover_backends():
"""Import all modules in this package to trigger their registration."""
package = __name__
for _importer, modname, _ispkg in pkgutil.iter_modules(path=__path__):
importlib.import_module(f"{package}.{modname}")
_discover_backends()
+179
View File
@@ -0,0 +1,179 @@
"""
Kokoro TTS Backend
Encapsulates the Kokoro KPipeline as a TTSBackend implementation.
"""
from __future__ import annotations
from typing import Any, Dict, Iterator, List, Optional
import numpy as np
from abogen.tts_backend import TTSBackendMetadata
# Internal voice list — source of truth for Kokoro voices.
# The rest of the project accesses voices via get_metadata("kokoro").voices.
_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",
]
_KOKORO_METADATA = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS engine",
voices=tuple(_VOICES_INTERNAL),
)
def _load_kpipeline():
"""Lazy-load Kokoro dependencies."""
from kokoro import KPipeline # type: ignore[import-not-found]
return KPipeline
class KokoroBackend:
"""TTSBackend implementation wrapping the Kokoro KPipeline.
All interaction with KPipeline is encapsulated here.
The rest of the project depends only on this class.
"""
def __init__(self, **kwargs: Any) -> None:
lang_code = kwargs["lang_code"]
repo_id = kwargs.get("repo_id", "hexgrad/Kokoro-82M")
device = kwargs.get("device", "cpu")
KPipeline = _load_kpipeline()
self._pipeline = KPipeline(
lang_code=lang_code,
repo_id=repo_id,
device=device,
)
self._lang_code = lang_code
@property
def metadata(self) -> TTSBackendMetadata:
return _KOKORO_METADATA
def __call__(
self,
text: str,
*,
voice: Any,
speed: float = 1.0,
split_pattern: Optional[str] = None,
) -> Iterator[Any]:
"""Delegate to KPipeline's __call__."""
return self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
)
def load_single_voice(self, voice_name: str) -> Any:
"""Load a single voice tensor. Used by voice formula system."""
return self._pipeline.load_single_voice(voice_name)
def synthesize(self, text: str, **kwargs: Any) -> bytes:
"""Synthesize speech from text. Returns raw audio bytes."""
voice = kwargs.get("voice", "")
speed = kwargs.get("speed", 1.0)
split_pattern = kwargs.get("split_pattern", None)
audio_parts: list[np.ndarray] = []
for segment in self(text, voice=voice, speed=speed, split_pattern=split_pattern):
audio = segment.audio
if hasattr(audio, "numpy"):
audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32"))
if not audio_parts:
return b""
combined = np.concatenate(audio_parts).astype("float32", copy=False)
return combined.tobytes()
def get_available_voices(self) -> List[str]:
"""Return known Kokoro voice identifiers."""
return list(self.metadata.voices)
def get_supported_formats(self) -> List[str]:
"""Kokoro outputs raw PCM float32 audio."""
return ["pcm_float32"]
def get_info(self) -> Dict[str, Any]:
return {
"id": "kokoro",
"name": "Kokoro",
"lang_code": self._lang_code,
}
def create_kokoro_backend(**kwargs: Any) -> KokoroBackend:
"""Factory callable registered with TTSBackendRegistry."""
return KokoroBackend(**kwargs)
# --- Registration ---
from abogen.tts_backend_registry import register_backend # noqa: E402
register_backend(
metadata=_KOKORO_METADATA,
factory=create_kokoro_backend,
)
@@ -5,7 +5,7 @@ from dataclasses import dataclass
import logging
import math
import re
from typing import Any, Iterable, Iterator, Optional
from typing import Any, Dict, Iterable, Iterator, List, Optional
import numpy as np
@@ -15,6 +15,15 @@ logger = logging.getLogger(__name__)
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
from abogen.tts_backend import TTSBackendMetadata
_SUPERTONIC_METADATA = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS engine",
voices=DEFAULT_SUPERTONIC_VOICES,
)
@dataclass
class SupertonicSegment:
@@ -273,3 +282,111 @@ class SupertonicPipeline:
audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
class SupertonicBackend:
"""Supertonic TTS backend implementing the TTSBackend protocol.
Encapsulates ``SupertonicPipeline`` as an internal implementation detail.
"""
@property
def metadata(self) -> TTSBackendMetadata:
return _SUPERTONIC_METADATA
def __init__(self, **kwargs: Any) -> None:
self._pipeline = SupertonicPipeline(
sample_rate=kwargs.get("sample_rate", 24000),
auto_download=kwargs.get("auto_download", True),
total_steps=kwargs.get("total_steps", 5),
)
def synthesize(self, text: str, **kwargs: Any) -> bytes:
"""Synthesize speech and return raw audio bytes (WAV).
Delegates to the internal :class:`SupertonicPipeline` and concatenates
all produced segments into a single byte buffer.
"""
import io
import soundfile as sf
voice = kwargs.get("voice", "M1")
speed = float(kwargs.get("speed", 1.0))
split_pattern = kwargs.get("split_pattern")
total_steps = kwargs.get("total_steps")
segments = self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
total_steps=total_steps,
)
audio_parts: list[np.ndarray] = []
for seg in segments:
audio_parts.append(seg.audio)
if not audio_parts:
return b""
combined = np.concatenate(audio_parts)
buf = io.BytesIO()
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
return buf.getvalue()
def get_available_voices(self) -> List[str]:
"""Return the list of built-in SuperTonic voice identifiers."""
return list(self.metadata.voices)
def get_supported_formats(self) -> List[str]:
return ["wav"]
def get_info(self) -> Dict[str, Any]:
return {
"sample_rate": self._pipeline.sample_rate,
"total_steps": self._pipeline.total_steps,
"max_chunk_length": self._pipeline.max_chunk_length,
"voices": list(DEFAULT_SUPERTONIC_VOICES),
}
def __call__(
self,
text: str,
*,
voice: str,
speed: float,
split_pattern: Optional[str] = None,
total_steps: Optional[int] = None,
) -> Iterator[SupertonicSegment]:
"""Backward-compatible call interface, delegates to the pipeline."""
return self._pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
total_steps=total_steps,
)
def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend:
"""Create a SuperTonic TTS backend instance.
Args:
sample_rate: Audio sample rate. Defaults to 24000.
auto_download: Auto-download models. Defaults to True.
total_steps: Inference steps. Defaults to 5.
Returns:
SupertonicBackend instance.
"""
return SupertonicBackend(**kwargs)
from abogen.tts_backend_registry import register_backend # noqa: E402
register_backend(
metadata=_SUPERTONIC_METADATA,
factory=create_supertonic_backend,
)
+10 -11
View File
@@ -529,21 +529,20 @@ def prevent_sleep_end():
_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):
def __init__(self, callback):
def __init__(self, callback, lang_code="a", device="cpu"):
super().__init__()
self.callback = callback
self.lang_code = lang_code
self.device = device
def run(self):
try:
np_module, kpipeline_class = load_numpy_kpipeline()
self.callback(np_module, kpipeline_class, None)
from abogen.tts_backend_registry import create_backend
backend = create_backend(
"kokoro", lang_code=self.lang_code, device=self.device
)
self.callback(backend, None)
except Exception as e:
self.callback(None, None, str(e))
self.callback(None, str(e))
+4 -3
View File
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
pass
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
_CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set()
@@ -26,8 +26,9 @@ _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_metadata("kokoro").voices
if not voices:
return set(VOICES_INTERNAL)
return set(kokoro_voices)
normalized: Set[str] = set()
for voice in voices:
if not voice:
@@ -35,7 +36,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
voice_id = str(voice).strip()
if not voice_id:
continue
if voice_id in VOICES_INTERNAL:
if voice_id in kokoro_voices:
normalized.add(voice_id)
return normalized
+3 -2
View File
@@ -1,7 +1,7 @@
import re
from typing import List, Tuple
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
# 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")
terms: List[Tuple[str, float]] = []
kokoro_voices = get_metadata("kokoro").voices
for segment in formula.split("+"):
part = segment.strip()
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")
voice_name, raw_weight = part.split("*", 1)
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}")
try:
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
from typing import Any, Dict, Iterable, List, Tuple
from abogen.constants import VOICES_INTERNAL
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
from abogen.tts_backend_registry import get_metadata, is_registered_backend
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:
raw = str(value or "").strip().upper()
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
supertonic_voices = get_metadata("supertonic").voices
return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int:
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
return {}
provider = str(entry.get("provider") or "kokoro").strip().lower()
if provider not in {"kokoro", "supertonic"}:
if not is_registered_backend(provider):
provider = "kokoro"
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]]:
normalized: List[Tuple[str, float]] = []
kokoro_voices = get_metadata("kokoro").voices
for item in entries or []:
if isinstance(item, dict):
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]
else:
continue
if voice not in VOICES_INTERNAL:
if voice not in kokoro_voices:
continue
if weight is None:
continue
+8 -9
View File
@@ -2,7 +2,6 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH
@@ -27,22 +26,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app
COPY pyproject.toml README.md ./
COPY abogen ./abogen
RUN pip install --upgrade pip \
RUN pip install uv \
&& 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 \
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
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 \
&& 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)
# Set USE_GPU=false to skip this for CPU-only deployments
RUN if [ "$USE_GPU" = "true" ]; then \
pip install --no-cache-dir onnxruntime-gpu; \
uv pip install --system onnxruntime-gpu; \
fi
ENV ABOGEN_HOST=0.0.0.0 \
+48 -57
View File
@@ -20,7 +20,7 @@ import numpy as np
import soundfile as sf
import static_ffmpeg
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata, is_registered_backend, resolve_backend_for_voice
from abogen.epub3.exporter import build_epub3_package
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
from abogen.normalization_settings import (
@@ -39,14 +39,15 @@ from abogen.utils import (
get_user_cache_path,
get_user_output_path,
load_config,
load_numpy_kpipeline,
)
from abogen.tts_backend_registry import create_backend
from abogen.tts_backend import TTSBackend
from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
from .service import Job, JobStatus
@@ -56,26 +57,27 @@ SAMPLE_RATE = 24000
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()
# SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
if not raw or "*" in raw or "+" in raw:
raw = fallback_raw
if not raw or "*" in raw or "+" in raw:
raw = "M1"
# 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"
upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES:
return upper
fallback_upper = fallback_raw.upper() if fallback_raw else ""
if fallback_upper in DEFAULT_SUPERTONIC_VOICES:
return fallback_upper
return "M1"
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
raw = str(value or "").strip()
@@ -118,15 +120,7 @@ def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
raw = str(value or "").strip()
if not raw:
return fallback
upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES:
return "supertonic"
if "*" in raw or "+" in raw:
return "kokoro"
return fallback
return resolve_backend_for_voice(str(value or ""), fallback=fallback)
class _JobCancelled(Exception):
@@ -575,7 +569,7 @@ def _spec_to_voice_ids(spec: Any) -> Set[str]:
return set(extract_voice_ids(text))
except ValueError:
return set()
if text in VOICES_INTERNAL:
if text in get_metadata("kokoro").voices:
return {text}
return set()
@@ -639,7 +633,7 @@ def _collect_required_voice_ids(job: Job) -> Set[str]:
for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(payload.get(key)))
voices.update(VOICES_INTERNAL)
voices.update(get_metadata("kokoro").voices)
return voices
@@ -1573,7 +1567,7 @@ def run_conversion_job(job: Job) -> None:
def get_pipeline(provider: str) -> Any:
nonlocal kokoro_cache_ready
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
if provider_norm not in {"kokoro", "supertonic"}:
if not is_registered_backend(provider_norm):
provider_norm = "kokoro"
existing = pipelines.get(provider_norm)
@@ -1581,7 +1575,8 @@ def run_conversion_job(job: Job) -> None:
return existing
if provider_norm == "supertonic":
pipelines[provider_norm] = SupertonicPipeline(
pipelines[provider_norm] = create_backend(
"supertonic",
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
@@ -1594,16 +1589,12 @@ def run_conversion_job(job: Job) -> None:
device = "cpu"
if not disable_gpu:
device = _select_device()
_np, KPipeline = load_numpy_kpipeline()
# Try to initialize with the selected device; fall back to CPU if CUDA fails
try:
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
except RuntimeError as e:
if "CUDA" in str(e) and device != "cpu":
job.add_log(f"CUDA initialization failed, falling back to CPU: {e}", level="warning")
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device="cpu")
else:
raise
# Create KPipeline instance directly (conforms to TTSBackend protocol)
pipelines[provider_norm] = create_backend(
"kokoro",
lang_code=job.language,
device=device
)
if not kokoro_cache_ready:
_initialize_voice_cache(job)
kokoro_cache_ready = True
@@ -1644,8 +1635,8 @@ def run_conversion_job(job: Job) -> None:
return provider, resolved, cached, speed, steps
if provider == "kokoro":
kokoro_pipeline = get_pipeline("kokoro")
choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu)
kokoro_backend = get_pipeline("kokoro")
choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu)
else:
choice = resolved
@@ -1774,8 +1765,8 @@ def run_conversion_job(job: Job) -> None:
voice_cache: Dict[str, Any] = {}
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
kokoro_pipeline = get_pipeline("kokoro")
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
kokoro_backend = get_pipeline("kokoro")
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu)
processed_chars = 0
subtitle_index = 1
current_time = 0.0
@@ -1805,8 +1796,8 @@ def run_conversion_job(job: Job) -> None:
fallback_key = next(iter(voice_cache.keys()), "")
if fallback_key and fallback_key != "__custom_mix":
intro_voice_spec = fallback_key.split(":", 1)[-1]
if not intro_voice_spec and VOICES_INTERNAL:
intro_voice_spec = VOICES_INTERNAL[0]
if not intro_voice_spec:
intro_voice_spec = get_default_voice("kokoro")
if intro_voice_spec:
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
@@ -1860,8 +1851,8 @@ def run_conversion_job(job: Job) -> None:
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
)
else:
kokoro_pipeline = get_pipeline("kokoro")
segment_iter = kokoro_pipeline(
kokoro_backend = get_pipeline("kokoro")
segment_iter = kokoro_backend(
normalized,
voice=voice_choice,
speed=float(speed_override if speed_override is not None else job.speed),
@@ -1950,8 +1941,8 @@ def run_conversion_job(job: Job) -> None:
if chapter_provider == "kokoro":
voice_choice = voice_cache.get(chapter_cache_key)
if voice_choice is None:
kokoro_pipeline = get_pipeline("kokoro")
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
kokoro_backend = get_pipeline("kokoro")
voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu)
voice_cache[chapter_cache_key] = voice_choice
else:
voice_choice = chapter_voice_resolved
@@ -2095,9 +2086,9 @@ def run_conversion_job(job: Job) -> None:
if chunk_provider == "kokoro":
chunk_voice_choice = voice_cache.get(chunk_cache_key)
if chunk_voice_choice is None:
kokoro_pipeline = get_pipeline("kokoro")
kokoro_backend = get_pipeline("kokoro")
chunk_voice_choice = _resolve_voice(
kokoro_pipeline,
kokoro_backend,
chunk_voice_resolved,
job.use_gpu,
)
@@ -2239,8 +2230,8 @@ def run_conversion_job(job: Job) -> None:
if fallback_key and fallback_key != "__custom_mix":
# `voice_cache` keys are internal and include provider prefixes.
outro_voice_spec = fallback_key.split(":", 1)[-1]
if not outro_voice_spec and VOICES_INTERNAL:
outro_voice_spec = VOICES_INTERNAL[0]
if not outro_voice_spec:
outro_voice_spec = get_default_voice("kokoro")
if outro_text and outro_voice_spec:
outro_start_time = current_time
@@ -2445,7 +2436,8 @@ def _load_pipeline(job: Job):
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower()
if provider == "supertonic":
return SupertonicPipeline(
return create_backend(
"supertonic",
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
@@ -2454,8 +2446,7 @@ def _load_pipeline(job: Job):
device = "cpu"
if not disable_gpu:
device = _select_device()
_np, KPipeline = load_numpy_kpipeline()
return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
return create_backend("kokoro", lang_code=job.language, device=device)
def _select_device() -> str:
+2 -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.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.utils import load_numpy_kpipeline
from abogen.tts_backend_registry import create_backend
_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"
if use_gpu:
device = _select_device()
_np, KPipeline = load_numpy_kpipeline()
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
return create_backend("kokoro", lang_code=language, device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
+3 -2
View File
@@ -34,6 +34,7 @@ from abogen.normalization_settings import (
)
from abogen.llm_client import list_models, LLMClientError
from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.tts_backend_registry import is_registered_backend
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
from abogen.integrations.calibre_opds import (
CalibreOPDSClient,
@@ -63,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
if profile is None:
# Speaker Studio payload format
provider = str(payload.get("provider") or "kokoro").strip().lower()
if provider not in {"kokoro", "supertonic"}:
if not is_registered_backend(provider):
provider = "kokoro"
if provider == "supertonic":
profile = {
@@ -230,7 +231,7 @@ def api_speaker_preview() -> ResponseReturnValue:
use_gpu = settings.get("use_gpu", False)
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_registered_backend(tts_provider) else ""
if speaker_name:
entry = normalize_profile_entry(load_profiles().get(speaker_name))
+7 -6
View File
@@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue
from abogen.webui.service import PendingJob, JobStatus
from abogen.webui.routes.utils.service import get_service
from abogen.tts_backend_registry import is_registered_backend
from abogen.webui.routes.utils.settings import (
load_settings,
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.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_default_voice
from abogen.speaker_configs import get_config
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
from dataclasses import dataclass
@@ -579,7 +580,7 @@ def apply_book_step_form(
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
provider_value = str(form.get("tts_provider") or "").strip().lower()
if provider_value in {"kokoro", "supertonic"}:
if is_registered_backend(provider_value):
pending.tts_provider = provider_value
# Determine the base speaker selection (saved speaker ref or raw voice).
@@ -616,8 +617,8 @@ def apply_book_step_form(
custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL:
base_voice_spec = VOICES_INTERNAL[0]
if not base_voice_spec:
base_voice_spec = get_default_voice("kokoro")
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language,
@@ -796,8 +797,8 @@ def build_pending_job_from_extraction(
profile_selection = inferred_profile
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
if not base_voice and VOICES_INTERNAL:
base_voice = VOICES_INTERNAL[0]
if not base_voice:
base_voice = get_default_voice("kokoro")
selected_speaker_config = (form.get("speaker_config") or "").strip()
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
+4 -5
View File
@@ -78,10 +78,9 @@ def get_preview_pipeline(language: str, device: str) -> Any:
pipeline = _preview_pipelines.get(key)
if pipeline is not None:
return pipeline
from abogen.utils import load_numpy_kpipeline
from abogen.tts_backend_registry import create_backend
_, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
pipeline = create_backend("kokoro", lang_code=language, device=device)
_preview_pipelines[key] = pipeline
return pipeline
@@ -137,9 +136,9 @@ def generate_preview_audio(
normalized_text = source_text
if provider == "supertonic":
from abogen.tts_supertonic import SupertonicPipeline
from abogen.tts_backend_registry import create_backend
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
pipeline = create_backend("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
segments = pipeline(
normalized_text,
voice=voice_spec,
+2 -2
View File
@@ -6,8 +6,8 @@ from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
)
from abogen.tts_backend_registry import get_default_voice
from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT,
environment_llm_defaults,
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
"subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_speaker": "",
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
"default_voice": get_default_voice("kokoro"),
"supertonic_total_steps": 5,
"supertonic_speed": 1.0,
"replace_single_newlines": False,
+5 -6
View File
@@ -17,10 +17,10 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
SAMPLE_VOICE_TEXTS,
VOICES_INTERNAL,
)
from abogen.tts_backend_registry import get_metadata
from abogen.speaker_configs import list_configs
from abogen.utils import load_numpy_kpipeline
from abogen.tts_backend_registry import create_backend
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
_preview_pipeline_lock = threading.RLock()
@@ -285,7 +285,7 @@ def filter_voice_catalog(
def build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"}
for voice_id in VOICES_INTERNAL:
for voice_id in get_metadata("kokoro").voices:
prefix, _, rest = voice_id.partition("_")
language_code = prefix[0] if prefix else "a"
gender_code = prefix[1] if len(prefix) > 1 else ""
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
voice_catalog = build_voice_catalog()
return {
"languages": LANGUAGE_DESCRIPTIONS,
"voices": VOICES_INTERNAL,
"voices": get_metadata("kokoro").voices,
"subtitle_formats": SUBTITLE_FORMATS,
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
"output_formats": SUPPORTED_SOUND_FORMATS,
@@ -741,8 +741,7 @@ def get_preview_pipeline(language: str, device: str):
pipeline = _preview_pipelines.get(key)
if pipeline is not None:
return pipeline
_, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
pipeline = create_backend("kokoro", lang_code=language, device=device)
_preview_pipelines[key] = pipeline
return pipeline
+1 -1
View File
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
save_configs,
delete_config,
)
from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__)
+2 -5
View File
@@ -50,8 +50,6 @@ dependencies = [
"num2words>=0.5.13",
"httpx>=0.27.0",
"PyQt6>=6.5.0",
"flet>=0.85.1",
"msgpack>=1.0.0",
]
classifiers = [
@@ -79,12 +77,11 @@ allow-direct-references = true
[project.gui-scripts]
abogen = "abogen.frontend.main:main"
abogen = "abogen.pyqt.main:main"
[project.scripts]
abogen-ui = "abogen.frontend.main:main"
abogen-web = "abogen.frontend.main:main_web"
abogen-cli = "abogen.webui.app:main"
abogen-web = "abogen.webui.app:main"
abogen-pyqt = "abogen.pyqt.main:main"
[tool.hatch.build.targets.sdist]
+2 -2
View File
@@ -1,7 +1,7 @@
from types import SimpleNamespace
from typing import cast
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
from abogen.webui.conversion_runner import (
_chapter_voice_spec,
_chunk_voice_spec,
@@ -49,4 +49,4 @@ def test_voice_collection_includes_formula_components():
voices = _collect_required_voice_ids(job)
assert {"af_nova", "am_liam"}.issubset(voices)
assert voices.issuperset(VOICES_INTERNAL)
assert voices.issuperset(get_metadata("kokoro").voices)
+1 -1
View File
@@ -197,7 +197,7 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
)
assert match is not None
original_text = html.unescape(match.group(1))
assert "Second line\n\nThird paragraph." in original_text
assert "Second line\n\nThird paragraph." in original_text.replace("\r\n", "\n")
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
+216
View File
@@ -0,0 +1,216 @@
"""Tests for KokoroBackend class."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterator, List
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from abogen.tts_backend import TTSBackendMetadata
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@dataclass
class _FakeSegment:
graphemes: str
audio: Any # np.ndarray or torch-like tensor
class _FakePipeline:
"""Minimal mock for kokoro.KPipeline."""
def __init__(self, *, lang_code: str, repo_id: str, device: str):
self.lang_code = lang_code
self.repo_id = repo_id
self.device = device
self._voices: dict[str, np.ndarray] = {}
def __call__(
self,
text: str,
*,
voice: Any = "",
speed: float = 1.0,
split_pattern: str | None = None,
) -> Iterator[_FakeSegment]:
yield _FakeSegment(graphemes=text, audio=np.zeros(100, dtype="float32"))
def load_single_voice(self, name: str) -> np.ndarray:
if name not in self._voices:
self._voices[name] = np.ones((1, 256), dtype="float32") * 0.5
return self._voices[name]
def _make_backend(**kwargs: Any):
"""Create KokoroBackend with mocked KPipeline."""
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
from abogen.tts_backends.kokoro import KokoroBackend
return KokoroBackend(**kwargs)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestKokoroBackendMetadata:
def test_metadata_returns_tts_backend_metadata(self):
backend = _make_backend(lang_code="a")
meta = backend.metadata
assert isinstance(meta, TTSBackendMetadata)
def test_metadata_fields(self):
backend = _make_backend(lang_code="a")
meta = backend.metadata
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
assert "Kokoro" in meta.description
class TestKokoroBackendInit:
def test_stores_lang_code(self):
backend = _make_backend(lang_code="b")
assert backend._lang_code == "b"
def test_default_repo_id(self):
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
from abogen.tts_backends.kokoro import KokoroBackend
b = KokoroBackend(lang_code="a")
assert b._pipeline.repo_id == "hexgrad/Kokoro-82M"
def test_custom_repo_id(self):
backend = _make_backend(lang_code="a", repo_id="custom/repo")
assert backend._pipeline.repo_id == "custom/repo"
def test_default_device(self):
backend = _make_backend(lang_code="a")
assert backend._pipeline.device == "cpu"
def test_custom_device(self):
backend = _make_backend(lang_code="a", device="cuda")
assert backend._pipeline.device == "cuda"
class TestKokoroBackendCall:
def test_call_delegates_to_pipeline(self):
backend = _make_backend(lang_code="a")
results = list(backend("hello", voice="af_heart", speed=1.2, split_pattern=r"\n"))
assert len(results) == 1
assert results[0].graphemes == "hello"
def test_call_returns_iterator(self):
backend = _make_backend(lang_code="a")
result = backend("test", voice="af_heart")
assert hasattr(result, "__iter__")
def test_call_with_voice_tensor(self):
backend = _make_backend(lang_code="a")
voice_tensor = np.ones((1, 256), dtype="float32")
results = list(backend("test", voice=voice_tensor))
assert len(results) == 1
def test_call_default_speed(self):
backend = _make_backend(lang_code="a")
# Should not raise with default speed
list(backend("text", voice="af_heart"))
def test_call_default_split_pattern_is_none(self):
backend = _make_backend(lang_code="a")
# split_pattern defaults to None
list(backend("text", voice="af_heart"))
class TestLoadSingleVoice:
def test_load_single_voice_delegates(self):
backend = _make_backend(lang_code="a")
tensor = backend.load_single_voice("af_heart")
assert isinstance(tensor, np.ndarray)
assert tensor.shape == (1, 256)
def test_load_single_voice_caches(self):
backend = _make_backend(lang_code="a")
t1 = backend.load_single_voice("af_heart")
t2 = backend.load_single_voice("af_heart")
assert t1 is t2 # same object
class TestSynthesize:
def test_synthesize_returns_bytes(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart")
assert isinstance(result, bytes)
def test_synthesize_nonempty(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart")
assert len(result) > 0
def test_synthesize_with_speed(self):
backend = _make_backend(lang_code="a")
result = backend.synthesize("hello", voice="af_heart", speed=1.5)
assert isinstance(result, bytes)
def test_synthesize_empty_text(self):
backend = _make_backend(lang_code="a")
# Empty text produces no segments
result = backend.synthesize("", voice="af_heart")
assert isinstance(result, bytes)
class TestProtocolMethods:
def test_get_available_voices(self):
backend = _make_backend(lang_code="a")
voices = backend.get_available_voices()
assert isinstance(voices, list)
assert len(voices) > 0
assert all(isinstance(v, str) for v in voices)
def test_get_supported_formats(self):
backend = _make_backend(lang_code="a")
formats = backend.get_supported_formats()
assert "pcm_float32" in formats
def test_get_info(self):
backend = _make_backend(lang_code="a")
info = backend.get_info()
assert info["id"] == "kokoro"
assert info["name"] == "Kokoro"
assert info["lang_code"] == "a"
class TestRegistration:
def test_factory_creates_kokoro_backend(self):
from abogen.tts_backends.kokoro import create_kokoro_backend, KokoroBackend
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
backend = create_kokoro_backend(lang_code="a")
assert isinstance(backend, KokoroBackend)
def test_registry_has_kokoro(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
def test_registry_factory_returns_kokoro_backend(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
from abogen.tts_backends.kokoro import KokoroBackend
factory = _registry._factories["kokoro"]
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
load.return_value = _FakePipeline
backend = factory(lang_code="a")
assert isinstance(backend, KokoroBackend)
+11 -6
View File
@@ -19,7 +19,7 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
# We'll instead validate by calling the override logic through generate_preview_audio
# with provider=supertonic and stub SupertonicPipeline to capture input.
# with provider=supertonic and stub create_backend to capture input.
captured = {}
class DummyPipeline:
@@ -30,11 +30,16 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
captured["text"] = text
return iter(())
monkeypatch.setitem(
__import__("sys").modules,
"abogen.tts_supertonic",
type("M", (), {"SupertonicPipeline": DummyPipeline}),
)
from abogen import tts_backend_registry
original_create_backend = tts_backend_registry.create_backend
def _mock_create_backend(backend_id, **kwargs):
if backend_id == "supertonic":
return DummyPipeline(**kwargs)
return original_create_backend(backend_id, **kwargs)
monkeypatch.setattr(tts_backend_registry, "create_backend", _mock_create_backend)
try:
preview.generate_preview_audio(
+314
View File
@@ -0,0 +1,314 @@
from dataclasses import dataclass
from abogen.tts_backend import TTSBackendMetadata
from abogen.tts_backend_registry import TTSBackendRegistry
class TestTTSBackendMetadata:
def test_is_frozen_dataclass(self):
assert dataclass(TTSBackendMetadata)
def test_fields_are_present(self):
meta = TTSBackendMetadata(
id="test",
name="Test Backend",
description="A test backend",
)
assert meta.id == "test"
assert meta.name == "Test Backend"
assert meta.description == "A test backend"
def test_voices_field_default_empty(self):
meta = TTSBackendMetadata(
id="test",
name="Test",
description="Test backend",
)
assert meta.voices == ()
def test_voices_field_stored(self):
meta = TTSBackendMetadata(
id="test",
name="Test",
description="Test backend",
voices=("v1", "v2"),
)
assert meta.voices == ("v1", "v2")
def test_is_immutable(self):
import pytest
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Test",
)
with pytest.raises(Exception):
meta.id = "changed"
class TestTTSBackendRegistry:
def test_register_and_list(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="a", name="A", description="Backend A")
registry.register(metadata=meta, factory=lambda: None)
backends = registry.list_backends()
assert len(backends) == 1
assert backends[0].id == "a"
def test_list_multiple(self):
registry = TTSBackendRegistry()
meta_a = TTSBackendMetadata(id="a", name="A", description="A")
meta_b = TTSBackendMetadata(id="b", name="B", description="B")
registry.register(metadata=meta_a, factory=lambda: None)
registry.register(metadata=meta_b, factory=lambda: None)
backends = registry.list_backends()
ids = [b.id for b in backends]
assert "a" in ids
assert "b" in ids
def test_get_metadata(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="x", name="X", description="X backend")
registry.register(metadata=meta, factory=lambda: None)
result = registry.get_metadata("x")
assert result.id == "x"
assert result.name == "X"
def test_get_metadata_unknown_raises(self):
import pytest
registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: nope"):
registry.get_metadata("nope")
def test_create_backend(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="test", name="Test", description="Test backend")
def factory(**kwargs):
return {"created": True, "kwargs": kwargs}
registry.register(metadata=meta, factory=factory)
result = registry.create_backend("test", foo="bar")
assert result == {"created": True, "kwargs": {"foo": "bar"}}
def test_create_backend_unknown_raises(self):
import pytest
registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: missing"):
registry.create_backend("missing")
def test_register_overwrites(self):
registry = TTSBackendRegistry()
meta1 = TTSBackendMetadata(id="x", name="V1", description="First")
meta2 = TTSBackendMetadata(id="x", name="V2", description="Second")
registry.register(metadata=meta1, factory=lambda: "v1")
registry.register(metadata=meta2, factory=lambda: "v2")
result = registry.get_metadata("x")
assert result.name == "V2"
assert registry.create_backend("x") == "v2"
class TestBackendRegistration:
"""Tests that existing backends are auto-registered."""
def test_import_triggers_registration(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
backends = _registry.list_backends()
ids = [b.id for b in backends]
assert "kokoro" in ids
assert "supertonic" in ids
def test_kokoro_metadata(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert meta.id == "kokoro"
assert meta.name == "Kokoro"
assert "Kokoro" in meta.description
def test_supertonic_metadata(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
assert meta.id == "supertonic"
assert meta.name == "SuperTonic"
assert "SuperTonic" in meta.description
def test_kokoro_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) > 0
assert all(isinstance(v, str) for v in meta.voices)
def test_supertonic_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) == 10
assert meta.voices == ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
def test_kokoro_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["kokoro"]
assert callable(factory)
def test_supertonic_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["supertonic"]
assert callable(factory)
def test_kokoro_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.kokoro import _KOKORO_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("kokoro")
assert _KOKORO_METADATA is registry_meta
assert _KOKORO_METADATA.voices == registry_meta.voices
def test_supertonic_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.supertonic import _SUPERTONIC_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("supertonic")
assert _SUPERTONIC_METADATA is registry_meta
assert _SUPERTONIC_METADATA.voices == registry_meta.voices
class TestResolveBackendForVoice:
"""Tests for the resolve_backend_for_voice method."""
def test_empty_spec_returns_fallback(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("", fallback="kokoro") == "kokoro"
assert registry.resolve_backend_for_voice("", fallback="supertonic") == "supertonic"
def test_none_spec_returns_fallback(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice(None, fallback="kokoro") == "kokoro"
def test_kokoro_formula_with_star_returns_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("af_nova*0.7") == "kokoro"
def test_kokoro_formula_with_plus_returns_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("af_nova*0.7+am_liam*0.3") == "kokoro"
def test_kokoro_voice_id_resolves_to_kokoro(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova", "am_liam"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
assert registry.resolve_backend_for_voice("am_liam") == "kokoro"
def test_supertonic_voice_id_resolves_to_supertonic(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1", "M2", "F1", "F2"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("M1") == "supertonic"
assert registry.resolve_backend_for_voice("F2") == "supertonic"
def test_unknown_voice_returns_fallback(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova",),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("unknown_voice") == "kokoro"
assert registry.resolve_backend_for_voice("unknown_voice", fallback="supertonic") == "supertonic"
def test_case_insensitive_matching(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1", "F1"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("m1") == "supertonic"
assert registry.resolve_backend_for_voice("f1") == "supertonic"
def test_default_fallback_is_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("unknown") == "kokoro"
def test_multiple_backends_resolution(self):
registry = TTSBackendRegistry()
kokoro_meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova",),
)
supertonic_meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1",),
)
registry.register(metadata=kokoro_meta, factory=lambda: None)
registry.register(metadata=supertonic_meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
assert registry.resolve_backend_for_voice("M1") == "supertonic"
def test_global_wrapper_resolve_backend_for_voice(self):
from abogen.tts_backend_registry import resolve_backend_for_voice
# Test with empty spec
assert resolve_backend_for_voice("") == "kokoro"
# Test with formula
assert resolve_backend_for_voice("af_nova*0.7") == "kokoro"
# Test with a registered voice
assert resolve_backend_for_voice("af_nova") == "kokoro"
assert resolve_backend_for_voice("M1") == "supertonic"
+63 -8
View File
@@ -1,6 +1,6 @@
import numpy as np
from abogen.tts_supertonic import SupertonicPipeline
from abogen.tts_backends.supertonic import SupertonicBackend, SupertonicPipeline
class _DummyTTS:
@@ -26,13 +26,23 @@ class _DummyTTS:
return audio, 0.05
def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
# Avoid importing/initializing real supertonic by manually constructing the pipeline.
def _make_pipeline() -> SupertonicPipeline:
pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
pipeline.sample_rate = 24000
pipeline.total_steps = 5
pipeline.max_chunk_length = 1000
pipeline._tts = _DummyTTS()
return pipeline
def _make_backend() -> SupertonicBackend:
backend = SupertonicBackend.__new__(SupertonicBackend)
backend._pipeline = _make_pipeline()
return backend
def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
pipeline = _make_pipeline()
segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
assert len(segs) == 1
@@ -43,11 +53,56 @@ def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
pipeline.sample_rate = 24000
pipeline.total_steps = 5
pipeline.max_chunk_length = 1000
pipeline._tts = _DummyTTS()
pipeline = _make_pipeline()
segs = list(pipeline("", voice="M1", speed=1.0))
assert segs == []
# --- SupertonicBackend tests ---
def test_backend_metadata():
backend = _make_backend()
meta = backend.metadata
assert meta.id == "supertonic"
assert meta.name == "SuperTonic"
assert "SuperTonic" in meta.description
def test_backend_get_available_voices():
backend = _make_backend()
voices = backend.get_available_voices()
assert isinstance(voices, list)
assert "M1" in voices
assert "F1" in voices
def test_backend_get_supported_formats():
backend = _make_backend()
formats = backend.get_supported_formats()
assert "wav" in formats
def test_backend_get_info():
backend = _make_backend()
info = backend.get_info()
assert info["sample_rate"] == 24000
assert info["total_steps"] == 5
assert isinstance(info["voices"], list)
def test_backend_call_delegates_to_pipeline():
backend = _make_backend()
segs = list(backend("Hello • world", voice="M1", speed=1.0))
assert len(segs) == 1
assert segs[0].audio.size > 0
def test_backend_synthesize_returns_wav_bytes():
backend = _make_backend()
wav_bytes = backend.synthesize("Hello world", voice="M1", speed=1.0)
assert isinstance(wav_bytes, bytes)
assert len(wav_bytes) > 0
# WAV magic number
assert wav_bytes[:4] == b"RIFF"
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import cast
import pytest
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend_registry import get_metadata
from abogen.voice_cache import (
LocalEntryNotFoundError,
_CACHED_VOICES,
@@ -66,4 +66,4 @@ def test_collect_required_voice_ids_includes_all():
voices = _collect_required_voice_ids(cast(Job, job))
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
assert voices.issuperset(VOICES_INTERNAL)
assert voices.issuperset(get_metadata("kokoro").voices)
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
+233
View File
@@ -0,0 +1,233 @@
import pytest
from abogen.voice_metadata import VoiceMetadata
class TestVoiceMetadataCreation:
def test_create_with_all_fields(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert voice.id == "af_alloy"
assert voice.display_name == "Alloy"
assert voice.language == "a"
assert voice.gender == "female"
assert voice.backend_id == "kokoro"
def test_create_supertonic_voice(self):
voice = VoiceMetadata(
id="M1",
display_name="Male 1",
language="en",
gender="male",
backend_id="supertonic",
)
assert voice.id == "M1"
assert voice.backend_id == "supertonic"
def test_create_with_unknown_gender(self):
voice = VoiceMetadata(
id="custom_voice",
display_name="Custom",
language="en",
gender="unknown",
backend_id="custom_backend",
)
assert voice.gender == "unknown"
class TestVoiceMetadataImmutability:
def test_frozen_dataclass(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.id = "new_id"
def test_cannot_modify_display_name(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.display_name = "New Name"
def test_cannot_modify_backend_id(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
with pytest.raises(AttributeError):
voice.backend_id = "new_backend"
class TestVoiceMetadataEquality:
def test_equal_voices_are_equal(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert voice1 == voice2
def test_different_voices_are_not_equal(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="am_adam",
display_name="Adam",
language="a",
gender="male",
backend_id="kokoro",
)
assert voice1 != voice2
def test_different_backend_id_not_equal(self):
voice1 = VoiceMetadata(
id="custom",
display_name="Custom",
language="en",
gender="unknown",
backend_id="backend_a",
)
voice2 = VoiceMetadata(
id="custom",
display_name="Custom",
language="en",
gender="unknown",
backend_id="backend_b",
)
assert voice1 != voice2
class TestVoiceMetadataHashing:
def test_hashable(self):
voice = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert hash(voice) is not None
def test_equal_voices_same_hash(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
assert hash(voice1) == hash(voice2)
def test_usable_in_set(self):
voice1 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice2 = VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id="kokoro",
)
voice3 = VoiceMetadata(
id="am_adam",
display_name="Adam",
language="a",
gender="male",
backend_id="kokoro",
)
voice_set = {voice1, voice2, voice3}
assert len(voice_set) == 2
class TestVoiceMetadataUseCases:
def test_backend_populates_backend_id(self):
"""Simulate how a backend would populate backend_id automatically."""
class MockBackend:
def __init__(self):
self._backend_id = "kokoro"
def get_voices(self):
return [
VoiceMetadata(
id="af_alloy",
display_name="Alloy",
language="a",
gender="female",
backend_id=self._backend_id,
),
]
backend = MockBackend()
voices = backend.get_voices()
assert voices[0].backend_id == "kokoro"
def test_filter_by_language(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="jf_alpha", display_name="Alpha", language="j", gender="female", backend_id="kokoro"),
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
]
english_voices = [v for v in voices if v.language == "a"]
assert len(english_voices) == 2
def test_filter_by_gender(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
VoiceMetadata(id="am_puck", display_name="Puck", language="a", gender="male", backend_id="kokoro"),
]
male_voices = [v for v in voices if v.gender == "male"]
assert len(male_voices) == 2
def test_filter_by_backend(self):
voices = [
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
VoiceMetadata(id="M1", display_name="Male 1", language="en", gender="male", backend_id="supertonic"),
]
kokoro_voices = [v for v in voices if v.backend_id == "kokoro"]
assert len(kokoro_voices) == 1
assert kokoro_voices[0].id == "af_alloy"
Generated
-2888
View File
File diff suppressed because it is too large Load Diff