Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd2c109d8 |
@@ -323,13 +323,6 @@ if /I "%IS_NVIDIA%"=="true" (
|
|||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
|
|
||||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Failed to install onnxruntime-gpu.
|
|
||||||
pause
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
) else (
|
) else (
|
||||||
echo CUDA is available on NVIDIA GPU.
|
echo CUDA is available on NVIDIA GPU.
|
||||||
)
|
)
|
||||||
@@ -355,13 +348,6 @@ if /I "%IS_NVIDIA%"=="true" (
|
|||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
echo Installing onnxruntime-gpu for Supertonic GPU acceleration...
|
|
||||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system onnxruntime-gpu
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Failed to install onnxruntime-gpu.
|
|
||||||
pause
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 992 B |
|
After Width: | Height: | Size: 786 B |
|
Before Width: | Height: | Size: 877 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 868 B |
|
Before Width: | Height: | Size: 808 B |
|
After Width: | Height: | Size: 372 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 883 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 885 B |
|
After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 855 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 917 B |
|
After Width: | Height: | Size: 441 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 837 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 875 B |
|
After Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 891 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 851 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 431 B |
@@ -29,57 +29,6 @@ LANGUAGE_DESCRIPTIONS = {
|
|||||||
"z": "Mandarin Chinese",
|
"z": "Mandarin Chinese",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mapping from Kokoro single-letter language codes to ISO 3166-1 alpha-2 country codes
|
|
||||||
# Used for loading flag icons
|
|
||||||
KOKORO_LANG_TO_COUNTRY = {
|
|
||||||
"a": "us", # American English -> United States
|
|
||||||
"b": "gb", # British English -> United Kingdom
|
|
||||||
"e": "es", # Spanish -> Spain
|
|
||||||
"f": "fr", # French -> France
|
|
||||||
"h": "in", # Hindi -> India
|
|
||||||
"i": "it", # Italian -> Italy
|
|
||||||
"j": "jp", # Japanese -> Japan
|
|
||||||
"p": "br", # Brazilian Portuguese -> Brazil
|
|
||||||
"z": "cn", # Mandarin Chinese -> China
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mapping from Supertonic ISO 639-1 language codes to ISO 3166-1 alpha-2 country codes
|
|
||||||
# Used for loading flag icons in the Supertonic language picker
|
|
||||||
SUPERTONIC_LANG_TO_COUNTRY = {
|
|
||||||
"en": "gb",
|
|
||||||
"ko": "kr",
|
|
||||||
"ja": "jp",
|
|
||||||
"ar": "ae",
|
|
||||||
"bg": "bg",
|
|
||||||
"cs": "cz",
|
|
||||||
"da": "dk",
|
|
||||||
"de": "de",
|
|
||||||
"el": "gr",
|
|
||||||
"es": "es",
|
|
||||||
"et": "ee",
|
|
||||||
"fi": "fi",
|
|
||||||
"fr": "fr",
|
|
||||||
"hi": "in",
|
|
||||||
"hr": "hr",
|
|
||||||
"hu": "hu",
|
|
||||||
"id": "id",
|
|
||||||
"it": "it",
|
|
||||||
"lt": "lt",
|
|
||||||
"lv": "lv",
|
|
||||||
"nl": "nl",
|
|
||||||
"pl": "pl",
|
|
||||||
"pt": "pt",
|
|
||||||
"ro": "ro",
|
|
||||||
"ru": "ru",
|
|
||||||
"sk": "sk",
|
|
||||||
"sl": "si",
|
|
||||||
"sv": "se",
|
|
||||||
"tr": "tr",
|
|
||||||
"uk": "ua",
|
|
||||||
"vi": "vn",
|
|
||||||
"na": "na",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Supported sound formats
|
# Supported sound formats
|
||||||
SUPPORTED_SOUND_FORMATS = [
|
SUPPORTED_SOUND_FORMATS = [
|
||||||
"wav",
|
"wav",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""State sub-package – exports AppState and ConversionJob."""
|
||||||
|
from .app_state import AppState, ConversionJob
|
||||||
|
|
||||||
|
__all__ = ["AppState", "ConversionJob"]
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""
|
||||||
|
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 = []
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""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"]
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
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.")
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
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.")
|
||||||
@@ -20,7 +20,6 @@ from abogen.constants import (
|
|||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
SUPPORTED_SUBTITLE_FORMATS,
|
SUPPORTED_SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
from abogen.tts_supertonic import SupertonicPipeline, SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
|
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
import abogen.hf_tracker as hf_tracker
|
import abogen.hf_tracker as hf_tracker
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
@@ -267,10 +266,7 @@ class ConversionThread(QThread):
|
|||||||
use_gpu=True,
|
use_gpu=True,
|
||||||
from_queue=False,
|
from_queue=False,
|
||||||
save_base_path=None,
|
save_base_path=None,
|
||||||
tts_provider="kokoro",
|
): # Add use_gpu parameter
|
||||||
supertonic_language="en",
|
|
||||||
supertonic_total_steps=8,
|
|
||||||
):
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._chapter_options_event = threading.Event()
|
self._chapter_options_event = threading.Event()
|
||||||
self._timestamp_response_event = threading.Event()
|
self._timestamp_response_event = threading.Event()
|
||||||
@@ -280,10 +276,6 @@ class ConversionThread(QThread):
|
|||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
self.tts_provider = tts_provider
|
|
||||||
self.supertonic_language = supertonic_language
|
|
||||||
self.supertonic_total_steps = supertonic_total_steps
|
|
||||||
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
|
|
||||||
self.save_option = save_option
|
self.save_option = save_option
|
||||||
self.output_folder = output_folder
|
self.output_folder = output_folder
|
||||||
self.subtitle_mode = subtitle_mode
|
self.subtitle_mode = subtitle_mode
|
||||||
@@ -435,10 +427,6 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
self.log_updated.emit(f"- Voice: {self.voice}")
|
self.log_updated.emit(f"- Voice: {self.voice}")
|
||||||
self.log_updated.emit(f"- Speed: {self.speed}")
|
self.log_updated.emit(f"- Speed: {self.speed}")
|
||||||
tts_provider_label = self.tts_provider.capitalize()
|
|
||||||
if self.tts_provider == "supertonic":
|
|
||||||
tts_provider_label += f" (lang={self.supertonic_language}, steps={self.supertonic_total_steps})"
|
|
||||||
self.log_updated.emit(f"- TTS Engine: {tts_provider_label}")
|
|
||||||
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
||||||
self.log_updated.emit(f"- Output format: {self.output_format}")
|
self.log_updated.emit(f"- Output format: {self.output_format}")
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
@@ -511,13 +499,6 @@ class ConversionThread(QThread):
|
|||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
if self.tts_provider == "supertonic":
|
|
||||||
tts = SupertonicPipeline(
|
|
||||||
sample_rate=self.sample_rate,
|
|
||||||
lang=self.supertonic_language,
|
|
||||||
total_steps=self.supertonic_total_steps,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
tts = self.KPipeline(
|
tts = self.KPipeline(
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
)
|
)
|
||||||
@@ -766,7 +747,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
||||||
subtitle_entries = []
|
subtitle_entries = []
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
rate = self.sample_rate
|
rate = 24000
|
||||||
subtitle_mode = self.subtitle_mode
|
subtitle_mode = self.subtitle_mode
|
||||||
self.etr_start_time = time.time()
|
self.etr_start_time = time.time()
|
||||||
self.processed_char_count = 0
|
self.processed_char_count = 0
|
||||||
@@ -782,7 +763,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_file = sf.SoundFile(
|
merged_out_file = sf.SoundFile(
|
||||||
merged_out_path,
|
merged_out_path,
|
||||||
"w",
|
"w",
|
||||||
samplerate=self.sample_rate,
|
samplerate=24000,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=self.output_format,
|
format=self.output_format,
|
||||||
)
|
)
|
||||||
@@ -804,7 +785,51 @@ class ConversionThread(QThread):
|
|||||||
"-f",
|
"-f",
|
||||||
"f32le",
|
"f32le",
|
||||||
"-ar",
|
"-ar",
|
||||||
str(self.sample_rate),
|
"24000",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
]
|
||||||
|
if cover_path and os.path.exists(cover_path):
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"-i",
|
||||||
|
cover_path,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map",
|
||||||
|
"1",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-disposition:v",
|
||||||
|
"attached_pic",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-q:a",
|
||||||
|
"2",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart+use_metadata_tags",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cmd += metadata_options
|
||||||
|
cmd.append(merged_out_path)
|
||||||
|
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||||
|
elif self.output_format == "opus":
|
||||||
|
static_ffmpeg.add_paths()
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-thread_queue_size",
|
||||||
|
"32768",
|
||||||
|
"-f",
|
||||||
|
"f32le",
|
||||||
|
"-ar",
|
||||||
|
"24000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"1",
|
"1",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -891,7 +916,7 @@ class ConversionThread(QThread):
|
|||||||
merged_out_path = None
|
merged_out_path = None
|
||||||
subtitle_entries = []
|
subtitle_entries = []
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
rate = self.sample_rate
|
rate = 24000
|
||||||
subtitle_mode = self.subtitle_mode
|
subtitle_mode = self.subtitle_mode
|
||||||
self.etr_start_time = time.time()
|
self.etr_start_time = time.time()
|
||||||
self.processed_char_count = 0
|
self.processed_char_count = 0
|
||||||
@@ -944,7 +969,7 @@ class ConversionThread(QThread):
|
|||||||
chapter_out_file = sf.SoundFile(
|
chapter_out_file = sf.SoundFile(
|
||||||
chapter_out_path,
|
chapter_out_path,
|
||||||
"w",
|
"w",
|
||||||
samplerate=self.sample_rate,
|
samplerate=24000,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=separate_chapters_format,
|
format=separate_chapters_format,
|
||||||
)
|
)
|
||||||
@@ -959,7 +984,7 @@ class ConversionThread(QThread):
|
|||||||
"-f",
|
"-f",
|
||||||
"f32le",
|
"f32le",
|
||||||
"-ar",
|
"-ar",
|
||||||
str(self.sample_rate),
|
"24000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"1",
|
"1",
|
||||||
"-i",
|
"-i",
|
||||||
@@ -1341,7 +1366,7 @@ class ConversionThread(QThread):
|
|||||||
# Add silence between chapters for merged output (except after the last chapter)
|
# Add silence between chapters for merged output (except after the last chapter)
|
||||||
if merge_chapters_at_end and chapter_idx < total_chapters:
|
if merge_chapters_at_end and chapter_idx < total_chapters:
|
||||||
silence_samples = int(
|
silence_samples = int(
|
||||||
self.silence_duration * self.sample_rate
|
self.silence_duration * 24000
|
||||||
) # Silence duration at 24,000 Hz
|
) # Silence duration at 24,000 Hz
|
||||||
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
||||||
silence_bytes = silence_audio.tobytes()
|
silence_bytes = silence_audio.tobytes()
|
||||||
@@ -1572,7 +1597,7 @@ class ConversionThread(QThread):
|
|||||||
parent_dir, f"{sanitized_base_name}{suffix}"
|
parent_dir, f"{sanitized_base_name}{suffix}"
|
||||||
)
|
)
|
||||||
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
|
||||||
rate = self.sample_rate
|
rate = 24000
|
||||||
|
|
||||||
# Setup audio output
|
# Setup audio output
|
||||||
merged_out_file, ffmpeg_proc = None, None
|
merged_out_file, ffmpeg_proc = None, None
|
||||||
@@ -2422,9 +2447,6 @@ class VoicePreviewThread(QThread):
|
|||||||
speed,
|
speed,
|
||||||
use_gpu=False,
|
use_gpu=False,
|
||||||
parent=None,
|
parent=None,
|
||||||
tts_provider="kokoro",
|
|
||||||
supertonic_language="en",
|
|
||||||
supertonic_total_steps=8,
|
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.np_module = np_module
|
self.np_module = np_module
|
||||||
@@ -2433,10 +2455,6 @@ class VoicePreviewThread(QThread):
|
|||||||
self.voice = voice
|
self.voice = voice
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.use_gpu = use_gpu
|
self.use_gpu = use_gpu
|
||||||
self.tts_provider = tts_provider
|
|
||||||
self.supertonic_language = supertonic_language
|
|
||||||
self.supertonic_total_steps = supertonic_total_steps
|
|
||||||
self.sample_rate = 44100 if tts_provider == "supertonic" else 24000
|
|
||||||
|
|
||||||
# Cache location for preview audio
|
# Cache location for preview audio
|
||||||
self.cache_dir = get_user_cache_path("preview_cache")
|
self.cache_dir = get_user_cache_path("preview_cache")
|
||||||
@@ -2446,11 +2464,6 @@ class VoicePreviewThread(QThread):
|
|||||||
|
|
||||||
def _get_cache_path(self):
|
def _get_cache_path(self):
|
||||||
"""Generate a unique filename for the voice with its parameters"""
|
"""Generate a unique filename for the voice with its parameters"""
|
||||||
if self.tts_provider == "supertonic":
|
|
||||||
voice_id = self.voice or "M1"
|
|
||||||
filename = f"st_{voice_id}_{self.supertonic_language}_steps{self.supertonic_total_steps}_{self.speed:.2f}.wav"
|
|
||||||
return os.path.join(self.cache_dir, filename)
|
|
||||||
|
|
||||||
# For a voice formula, use a hash of the formula
|
# For a voice formula, use a hash of the formula
|
||||||
if "*" in self.voice:
|
if "*" in self.voice:
|
||||||
voice_id = (
|
voice_id = (
|
||||||
@@ -2465,42 +2478,25 @@ class VoicePreviewThread(QThread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
print(
|
print(
|
||||||
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nTTS Provider: {self.tts_provider}\n"
|
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate the preview and save to cache
|
# Generate the preview and save to cache
|
||||||
try:
|
try:
|
||||||
if self.tts_provider == "supertonic":
|
|
||||||
from abogen.tts_supertonic import SupertonicPipeline
|
|
||||||
|
|
||||||
tts = SupertonicPipeline(
|
|
||||||
sample_rate=self.sample_rate,
|
|
||||||
lang=self.supertonic_language,
|
|
||||||
total_steps=self.supertonic_total_steps,
|
|
||||||
)
|
|
||||||
loaded_voice = self.voice or "M1"
|
|
||||||
sample_text = "Hello, this is a sample of the selected voice."
|
|
||||||
audio_segments = []
|
|
||||||
for result in tts(
|
|
||||||
sample_text,
|
|
||||||
voice=loaded_voice,
|
|
||||||
speed=self.speed,
|
|
||||||
split_pattern=None,
|
|
||||||
):
|
|
||||||
audio_segments.append(result.audio)
|
|
||||||
else:
|
|
||||||
# Set device based on use_gpu setting and platform
|
# Set device based on use_gpu setting and platform
|
||||||
if self.use_gpu:
|
if self.use_gpu:
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||||
device = "mps"
|
device = "mps" # Use MPS for Apple Silicon
|
||||||
else:
|
else:
|
||||||
device = "cuda"
|
device = "cuda" # Use CUDA for other platforms
|
||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
tts = self.kpipeline_class(
|
tts = self.kpipeline_class(
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
)
|
)
|
||||||
|
# Enable voice formula support for preview
|
||||||
if "*" in self.voice:
|
if "*" in self.voice:
|
||||||
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||||
else:
|
else:
|
||||||
@@ -2511,10 +2507,10 @@ class VoicePreviewThread(QThread):
|
|||||||
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
|
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
|
||||||
):
|
):
|
||||||
audio_segments.append(result.audio)
|
audio_segments.append(result.audio)
|
||||||
|
|
||||||
if audio_segments:
|
if audio_segments:
|
||||||
audio = self.np_module.concatenate(audio_segments)
|
audio = self.np_module.concatenate(audio_segments)
|
||||||
sf.write(self.cache_path, audio, self.sample_rate)
|
# Save directly to the cache path
|
||||||
|
sf.write(self.cache_path, audio, 24000)
|
||||||
self.temp_wav = self.cache_path
|
self.temp_wav = self.cache_path
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from abogen.pyqt.queue_manager_gui import QueueManager
|
|||||||
from abogen.pyqt.queued_item import QueuedItem
|
from abogen.pyqt.queued_item import QueuedItem
|
||||||
import abogen.hf_tracker as hf_tracker
|
import abogen.hf_tracker as hf_tracker
|
||||||
import hashlib # Added for cache path generation
|
import hashlib # Added for cache path generation
|
||||||
from abogen.tts_supertonic import SUPERTONIC_AVAILABLE_LANGS, DEFAULT_SUPERTONIC_VOICES
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -84,8 +83,6 @@ from abogen.constants import (
|
|||||||
PROGRAM_DESCRIPTION,
|
PROGRAM_DESCRIPTION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
KOKORO_LANG_TO_COUNTRY,
|
|
||||||
SUPERTONIC_LANG_TO_COUNTRY,
|
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
COLORS,
|
COLORS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
@@ -973,9 +970,6 @@ class abogen(QWidget):
|
|||||||
self.fix_nonstandard_punctuation = self.config.get(
|
self.fix_nonstandard_punctuation = self.config.get(
|
||||||
"fix_nonstandard_punctuation", False
|
"fix_nonstandard_punctuation", False
|
||||||
)
|
)
|
||||||
self.tts_provider_config = self.config.get("tts_provider", "kokoro")
|
|
||||||
self.supertonic_language_config = self.config.get("supertonic_language", "en")
|
|
||||||
self.supertonic_total_steps_config = self.config.get("supertonic_total_steps", 8)
|
|
||||||
self._pending_close_event = None
|
self._pending_close_event = None
|
||||||
self.gpu_ok = False # Initialize GPU availability status
|
self.gpu_ok = False # Initialize GPU availability status
|
||||||
|
|
||||||
@@ -1024,16 +1018,6 @@ class abogen(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.mixed_voice_state = entry
|
self.mixed_voice_state = entry
|
||||||
self.selected_lang = entry[0][0] if entry and entry[0] else None
|
self.selected_lang = entry[0][0] if entry and entry[0] else None
|
||||||
# Restore TTS provider and supertonic settings from config
|
|
||||||
provider_text = "Supertonic" if self.tts_provider_config == "supertonic" else "Kokoro"
|
|
||||||
idx_st = self.provider_combo.findText(provider_text)
|
|
||||||
if idx_st >= 0:
|
|
||||||
self.provider_combo.setCurrentIndex(idx_st)
|
|
||||||
self.st_lang_combo.setCurrentText(self.supertonic_language_config)
|
|
||||||
idx_steps = self.st_steps_combo.findData(self.supertonic_total_steps_config)
|
|
||||||
if idx_steps >= 0:
|
|
||||||
self.st_steps_combo.setCurrentIndex(idx_steps)
|
|
||||||
|
|
||||||
if self.save_option == "Choose output folder" and self.selected_output_folder:
|
if self.save_option == "Choose output folder" and self.selected_output_folder:
|
||||||
self.save_path_label.setText(self.selected_output_folder)
|
self.save_path_label.setText(self.selected_output_folder)
|
||||||
self.save_path_row_widget.show()
|
self.save_path_row_widget.show()
|
||||||
@@ -1123,53 +1107,6 @@ class abogen(QWidget):
|
|||||||
speed_layout.addWidget(self.speed_label)
|
speed_layout.addWidget(self.speed_label)
|
||||||
controls_layout.addLayout(speed_layout)
|
controls_layout.addLayout(speed_layout)
|
||||||
self.speed_slider.valueChanged.connect(self.update_speed_label)
|
self.speed_slider.valueChanged.connect(self.update_speed_label)
|
||||||
|
|
||||||
# TTS Provider selection
|
|
||||||
provider_layout = QHBoxLayout()
|
|
||||||
provider_layout.setSpacing(7)
|
|
||||||
provider_label = QLabel("TTS Engine:", self)
|
|
||||||
provider_layout.addWidget(provider_label)
|
|
||||||
self.provider_combo = QComboBox(self)
|
|
||||||
self.provider_combo.addItem("Kokoro", "kokoro")
|
|
||||||
self.provider_combo.addItem("Supertonic", "supertonic")
|
|
||||||
self.provider_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
|
||||||
self.provider_combo.currentIndexChanged.connect(self.on_provider_changed)
|
|
||||||
provider_layout.addWidget(self.provider_combo)
|
|
||||||
controls_layout.addLayout(provider_layout)
|
|
||||||
|
|
||||||
# Supertonic-specific controls (language + steps), hidden by default
|
|
||||||
self.supertonic_row = QWidget()
|
|
||||||
supertonic_row_layout = QHBoxLayout(self.supertonic_row)
|
|
||||||
supertonic_row_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
supertonic_row_layout.setSpacing(7)
|
|
||||||
|
|
||||||
st_lang_label = QLabel("Language:", self)
|
|
||||||
supertonic_row_layout.addWidget(st_lang_label)
|
|
||||||
self.st_lang_combo = QComboBox(self)
|
|
||||||
for code in SUPERTONIC_AVAILABLE_LANGS:
|
|
||||||
country_code = SUPERTONIC_LANG_TO_COUNTRY.get(code, code)
|
|
||||||
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
|
||||||
icon_st = QIcon(flag) if flag and os.path.exists(flag) else QIcon()
|
|
||||||
self.st_lang_combo.addItem(icon_st, code, code)
|
|
||||||
self.st_lang_combo.setCurrentText("en")
|
|
||||||
self.st_lang_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
|
||||||
self.st_lang_combo.currentTextChanged.connect(self._on_st_lang_changed)
|
|
||||||
supertonic_row_layout.addWidget(self.st_lang_combo)
|
|
||||||
|
|
||||||
st_steps_label = QLabel("Steps:", self)
|
|
||||||
supertonic_row_layout.addWidget(st_steps_label)
|
|
||||||
self.st_steps_combo = QComboBox(self)
|
|
||||||
for val in range(2, 16):
|
|
||||||
self.st_steps_combo.addItem(str(val), val)
|
|
||||||
self.st_steps_combo.setCurrentIndex(self.st_steps_combo.findData(8))
|
|
||||||
self.st_steps_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
|
||||||
self.st_steps_combo.currentIndexChanged.connect(self._on_st_steps_changed)
|
|
||||||
supertonic_row_layout.addWidget(self.st_steps_combo)
|
|
||||||
|
|
||||||
supertonic_row_layout.addStretch()
|
|
||||||
self.supertonic_row.hide()
|
|
||||||
controls_layout.addWidget(self.supertonic_row)
|
|
||||||
|
|
||||||
# Voice selection
|
# Voice selection
|
||||||
voice_layout = QHBoxLayout()
|
voice_layout = QHBoxLayout()
|
||||||
voice_layout.setSpacing(7)
|
voice_layout.setSpacing(7)
|
||||||
@@ -1827,12 +1764,6 @@ class abogen(QWidget):
|
|||||||
Update the enabled state of subtitle options based on the selected language.
|
Update the enabled state of subtitle options based on the selected language.
|
||||||
For non-English languages, only sentence-based and line-based modes are supported.
|
For non-English languages, only sentence-based and line-based modes are supported.
|
||||||
"""
|
"""
|
||||||
provider = self.provider_combo.currentData()
|
|
||||||
if provider == "supertonic":
|
|
||||||
self.subtitle_combo.setEnabled(False)
|
|
||||||
self.subtitle_format_combo.setEnabled(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if current file is a subtitle file
|
# Check if current file is a subtitle file
|
||||||
is_subtitle_input = False
|
is_subtitle_input = False
|
||||||
if self.selected_file and self.selected_file.lower().endswith(
|
if self.selected_file and self.selected_file.lower().endswith(
|
||||||
@@ -1892,48 +1823,6 @@ class abogen(QWidget):
|
|||||||
# Enable/disable subtitle options based on language
|
# Enable/disable subtitle options based on language
|
||||||
self.update_subtitle_options_availability()
|
self.update_subtitle_options_availability()
|
||||||
|
|
||||||
def on_provider_changed(self, index):
|
|
||||||
provider = self.provider_combo.itemData(index)
|
|
||||||
self.config["tts_provider"] = provider
|
|
||||||
save_config(self.config)
|
|
||||||
is_supertonic = provider == "supertonic"
|
|
||||||
|
|
||||||
# Show/hide Supertonic controls
|
|
||||||
self.supertonic_row.setVisible(is_supertonic)
|
|
||||||
|
|
||||||
# Update subtitles availability
|
|
||||||
self.update_subtitle_options_availability()
|
|
||||||
|
|
||||||
# Repopulate voice list
|
|
||||||
self.populate_profiles_in_voice_combo()
|
|
||||||
|
|
||||||
# Clear/reset mixed voice state when switching provider
|
|
||||||
if is_supertonic:
|
|
||||||
self.mixed_voice_state = None
|
|
||||||
self.btn_voice_formula_mixer.setEnabled(False)
|
|
||||||
self.voice_combo.setToolTip(
|
|
||||||
"Supertonic voices:\n"
|
|
||||||
"M1-M5 = Male voices\n"
|
|
||||||
"F1-F5 = Female voices"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.btn_voice_formula_mixer.setEnabled(True)
|
|
||||||
self.voice_combo.setToolTip(
|
|
||||||
"The first character represents the language:\n"
|
|
||||||
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_st_lang_changed(self, lang):
|
|
||||||
self.config["supertonic_language"] = lang
|
|
||||||
save_config(self.config)
|
|
||||||
if self.provider_combo.currentData() == "supertonic":
|
|
||||||
self.selected_lang = lang
|
|
||||||
self.update_subtitle_options_availability()
|
|
||||||
|
|
||||||
def _on_st_steps_changed(self):
|
|
||||||
self.config["supertonic_total_steps"] = self.st_steps_combo.currentData()
|
|
||||||
save_config(self.config)
|
|
||||||
|
|
||||||
def on_voice_combo_changed(self, index):
|
def on_voice_combo_changed(self, index):
|
||||||
data = self.voice_combo.itemData(index)
|
data = self.voice_combo.itemData(index)
|
||||||
if isinstance(data, str) and data.startswith("profile:"):
|
if isinstance(data, str) and data.startswith("profile:"):
|
||||||
@@ -1942,24 +1831,8 @@ class abogen(QWidget):
|
|||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
|
|
||||||
entry = load_profiles().get(pname, {})
|
entry = load_profiles().get(pname, {})
|
||||||
|
# set mixed voices and language
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
entry_provider = str(entry.get("provider", "")).strip().lower()
|
|
||||||
if entry_provider == "supertonic":
|
|
||||||
# Switch provider to Supertonic if not already
|
|
||||||
if self.provider_combo.currentData() != "supertonic":
|
|
||||||
self.provider_combo.setCurrentIndex(1)
|
|
||||||
self.mixed_voice_state = None
|
|
||||||
self.selected_lang = entry.get("language", self.st_lang_combo.currentText())
|
|
||||||
# Sync supertonic controls from profile
|
|
||||||
profile_steps = entry.get("total_steps")
|
|
||||||
if profile_steps is not None:
|
|
||||||
idx_steps = self.st_steps_combo.findData(int(profile_steps))
|
|
||||||
if idx_steps >= 0:
|
|
||||||
self.st_steps_combo.setCurrentIndex(idx_steps)
|
|
||||||
profile_lang = entry.get("language")
|
|
||||||
if profile_lang and profile_lang in SUPERTONIC_AVAILABLE_LANGS:
|
|
||||||
self.st_lang_combo.setCurrentText(profile_lang)
|
|
||||||
else:
|
|
||||||
self.mixed_voice_state = entry.get("voices", [])
|
self.mixed_voice_state = entry.get("voices", [])
|
||||||
self.selected_lang = entry.get("language")
|
self.selected_lang = entry.get("language")
|
||||||
else:
|
else:
|
||||||
@@ -1974,12 +1847,7 @@ class abogen(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.mixed_voice_state = None
|
self.mixed_voice_state = None
|
||||||
self.selected_profile_name = None
|
self.selected_profile_name = None
|
||||||
self.selected_voice = data
|
self.selected_voice, self.selected_lang = data, data[0]
|
||||||
provider = self.provider_combo.currentData()
|
|
||||||
if provider == "supertonic":
|
|
||||||
self.selected_lang = self.st_lang_combo.currentText()
|
|
||||||
else:
|
|
||||||
self.selected_lang = data[0] if data else ""
|
|
||||||
self.config["selected_voice"] = data
|
self.config["selected_voice"] = data
|
||||||
if "selected_profile_name" in self.config:
|
if "selected_profile_name" in self.config:
|
||||||
del self.config["selected_profile_name"]
|
del self.config["selected_profile_name"]
|
||||||
@@ -1998,37 +1866,16 @@ class abogen(QWidget):
|
|||||||
def populate_profiles_in_voice_combo(self):
|
def populate_profiles_in_voice_combo(self):
|
||||||
# preserve current voice or profile
|
# preserve current voice or profile
|
||||||
current = self.voice_combo.currentData()
|
current = self.voice_combo.currentData()
|
||||||
provider = self.provider_combo.currentData()
|
|
||||||
self.voice_combo.blockSignals(True)
|
self.voice_combo.blockSignals(True)
|
||||||
self.voice_combo.clear()
|
self.voice_combo.clear()
|
||||||
# re-add profiles matching current provider
|
# re-add profiles
|
||||||
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
||||||
for pname, entry in load_profiles().items():
|
for pname in load_profiles().keys():
|
||||||
entry_provider = ""
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
entry_provider = str(entry.get("provider", "")).strip().lower()
|
|
||||||
if provider == "supertonic":
|
|
||||||
if entry_provider == "supertonic":
|
|
||||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
|
||||||
else:
|
|
||||||
if entry_provider != "supertonic":
|
|
||||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||||
# re-add voices
|
# re-add voices
|
||||||
if provider == "supertonic":
|
|
||||||
for v in DEFAULT_SUPERTONIC_VOICES:
|
|
||||||
icon = QIcon()
|
|
||||||
if v.startswith("F"):
|
|
||||||
icon_path = get_resource_path("abogen.assets", "female.png")
|
|
||||||
else:
|
|
||||||
icon_path = get_resource_path("abogen.assets", "male.png")
|
|
||||||
if icon_path and os.path.exists(icon_path):
|
|
||||||
icon = QIcon(icon_path)
|
|
||||||
self.voice_combo.addItem(icon, f"{v}", v)
|
|
||||||
else:
|
|
||||||
for v in VOICES_INTERNAL:
|
for v in VOICES_INTERNAL:
|
||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
country_code = KOKORO_LANG_TO_COUNTRY.get(v[0], v[0])
|
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
||||||
flag_path = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
|
||||||
if flag_path and os.path.exists(flag_path):
|
if flag_path and os.path.exists(flag_path):
|
||||||
icon = QIcon(flag_path)
|
icon = QIcon(flag_path)
|
||||||
self.voice_combo.addItem(icon, f"{v}", v)
|
self.voice_combo.addItem(icon, f"{v}", v)
|
||||||
@@ -2222,9 +2069,6 @@ class abogen(QWidget):
|
|||||||
save_base_path=save_base_path,
|
save_base_path=save_base_path,
|
||||||
save_chapters_separately=getattr(self, "save_chapters_separately", None),
|
save_chapters_separately=getattr(self, "save_chapters_separately", None),
|
||||||
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
|
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
|
||||||
tts_provider=self.provider_combo.currentData(),
|
|
||||||
supertonic_language=self.st_lang_combo.currentText(),
|
|
||||||
supertonic_total_steps=self.st_steps_combo.currentData(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prevent adding duplicate items to the queue
|
# Prevent adding duplicate items to the queue
|
||||||
@@ -2368,15 +2212,6 @@ class abogen(QWidget):
|
|||||||
self.config["replace_numerals"] = self.replace_numerals
|
self.config["replace_numerals"] = self.replace_numerals
|
||||||
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||||
|
|
||||||
# TTS provider settings
|
|
||||||
tts_provider = getattr(queued_item, "tts_provider", "kokoro")
|
|
||||||
self.provider_combo.setCurrentText("Supertonic" if tts_provider == "supertonic" else "Kokoro")
|
|
||||||
self.st_lang_combo.setCurrentText(getattr(queued_item, "supertonic_language", "en"))
|
|
||||||
steps_val = getattr(queued_item, "supertonic_total_steps", 8)
|
|
||||||
idx_steps = self.st_steps_combo.findData(steps_val)
|
|
||||||
if idx_steps >= 0:
|
|
||||||
self.st_steps_combo.setCurrentIndex(idx_steps)
|
|
||||||
|
|
||||||
# Sync Voice/Profile in config
|
# Sync Voice/Profile in config
|
||||||
self.config["selected_voice"] = self.selected_voice
|
self.config["selected_voice"] = self.selected_voice
|
||||||
if "selected_profile_name" in self.config:
|
if "selected_profile_name" in self.config:
|
||||||
@@ -2399,8 +2234,6 @@ class abogen(QWidget):
|
|||||||
self.current_queue_index = 0 # Reset for next time
|
self.current_queue_index = 0 # Reset for next time
|
||||||
|
|
||||||
def get_voice_formula(self) -> str:
|
def get_voice_formula(self) -> str:
|
||||||
if self.provider_combo.currentData() == "supertonic":
|
|
||||||
return self._get_supertonic_voice()
|
|
||||||
if self.mixed_voice_state:
|
if self.mixed_voice_state:
|
||||||
formula_components = [
|
formula_components = [
|
||||||
f"{name}*{weight}" for name, weight in self.mixed_voice_state
|
f"{name}*{weight}" for name, weight in self.mixed_voice_state
|
||||||
@@ -2410,8 +2243,6 @@ class abogen(QWidget):
|
|||||||
return self.selected_voice
|
return self.selected_voice
|
||||||
|
|
||||||
def get_selected_lang(self, voice_formula) -> str:
|
def get_selected_lang(self, voice_formula) -> str:
|
||||||
if self.provider_combo.currentData() == "supertonic":
|
|
||||||
return self.st_lang_combo.currentText()
|
|
||||||
if self.selected_profile_name:
|
if self.selected_profile_name:
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
|
|
||||||
@@ -2501,10 +2332,6 @@ class abogen(QWidget):
|
|||||||
# determine selected language: use profile setting if profile selected, else voice code
|
# determine selected language: use profile setting if profile selected, else voice code
|
||||||
selected_lang = self.get_selected_lang(voice_formula)
|
selected_lang = self.get_selected_lang(voice_formula)
|
||||||
|
|
||||||
tts_provider = self.provider_combo.currentData()
|
|
||||||
supertonic_language = self.st_lang_combo.currentText()
|
|
||||||
supertonic_total_steps = self.st_steps_combo.currentData()
|
|
||||||
|
|
||||||
self.conversion_thread = ConversionThread(
|
self.conversion_thread = ConversionThread(
|
||||||
self.selected_file,
|
self.selected_file,
|
||||||
selected_lang,
|
selected_lang,
|
||||||
@@ -2520,11 +2347,8 @@ class abogen(QWidget):
|
|||||||
total_char_count=self.char_count,
|
total_char_count=self.char_count,
|
||||||
use_gpu=self.gpu_ok,
|
use_gpu=self.gpu_ok,
|
||||||
from_queue=from_queue,
|
from_queue=from_queue,
|
||||||
save_base_path=self.displayed_file_path,
|
save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB)
|
||||||
tts_provider=tts_provider,
|
) # Use gpu_ok status
|
||||||
supertonic_language=supertonic_language,
|
|
||||||
supertonic_total_steps=supertonic_total_steps,
|
|
||||||
)
|
|
||||||
# Pass the displayed file path to the log_updated signal handler in ConversionThread
|
# Pass the displayed file path to the log_updated signal handler in ConversionThread
|
||||||
self.conversion_thread.display_path = display_path
|
self.conversion_thread.display_path = display_path
|
||||||
# Pass the file size string
|
# Pass the file size string
|
||||||
@@ -2601,12 +2425,6 @@ class abogen(QWidget):
|
|||||||
# Store gpu_ok status to use when creating the conversion thread
|
# Store gpu_ok status to use when creating the conversion thread
|
||||||
self.gpu_ok = gpu_ok
|
self.gpu_ok = gpu_ok
|
||||||
self.update_log((gpu_msg, gpu_ok))
|
self.update_log((gpu_msg, gpu_ok))
|
||||||
tts_provider = self.provider_combo.currentData()
|
|
||||||
if tts_provider == "supertonic":
|
|
||||||
# Supertonic doesn't need KPipeline, call callback directly
|
|
||||||
import numpy as np
|
|
||||||
pipeline_loaded_callback(np, None, None)
|
|
||||||
else:
|
|
||||||
self.update_log("Loading modules...")
|
self.update_log("Loading modules...")
|
||||||
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||||
load_thread.start()
|
load_thread.start()
|
||||||
@@ -2922,32 +2740,9 @@ class abogen(QWidget):
|
|||||||
"Open File Error", f"Could not open file:\n{e}"
|
"Open File Error", f"Could not open file:\n{e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_supertonic_voice(self) -> str:
|
|
||||||
"""Resolve the effective Supertonic voice from the current combo selection."""
|
|
||||||
if self.selected_profile_name:
|
|
||||||
from abogen.voice_profiles import load_profiles
|
|
||||||
entry = load_profiles().get(self.selected_profile_name, {})
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
return str(entry.get("voice", "M1"))
|
|
||||||
return "M1"
|
|
||||||
current_data = self.voice_combo.currentData()
|
|
||||||
if current_data and isinstance(current_data, str) and not current_data.startswith("profile:"):
|
|
||||||
return current_data
|
|
||||||
return "M1"
|
|
||||||
|
|
||||||
def _get_preview_cache_path(self):
|
def _get_preview_cache_path(self):
|
||||||
"""Generate the expected cache path for the current voice settings."""
|
"""Generate the expected cache path for the current voice settings."""
|
||||||
speed = self.speed_slider.value() / 100.0
|
speed = self.speed_slider.value() / 100.0
|
||||||
provider = self.provider_combo.currentData()
|
|
||||||
|
|
||||||
if provider == "supertonic":
|
|
||||||
voice_to_cache = self._get_supertonic_voice()
|
|
||||||
lang_to_cache = self.st_lang_combo.currentText()
|
|
||||||
steps = self.st_steps_combo.currentData()
|
|
||||||
cache_dir = get_user_cache_path("preview_cache")
|
|
||||||
filename = f"st_{voice_to_cache}_{lang_to_cache}_steps{steps}_{speed:.2f}.wav"
|
|
||||||
return os.path.join(cache_dir, filename)
|
|
||||||
|
|
||||||
voice_to_cache = ""
|
voice_to_cache = ""
|
||||||
lang_to_cache = ""
|
lang_to_cache = ""
|
||||||
|
|
||||||
@@ -3052,13 +2847,6 @@ class abogen(QWidget):
|
|||||||
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
|
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
|
||||||
self.btn_start.setEnabled(False) # Disable start button during preview
|
self.btn_start.setEnabled(False) # Disable start button during preview
|
||||||
|
|
||||||
# For Supertonic, skip KPipeline loading and use SupertonicPipeline directly
|
|
||||||
if self.provider_combo.currentData() == "supertonic":
|
|
||||||
import numpy as np
|
|
||||||
self.loading_movie.start()
|
|
||||||
self._on_pipeline_loaded_for_preview(np, None, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Start loading animation - ensure signal connection is always active
|
# Start loading animation - ensure signal connection is always active
|
||||||
if hasattr(self, "loading_movie"):
|
if hasattr(self, "loading_movie"):
|
||||||
# Disconnect previous connections to avoid multiple connections
|
# Disconnect previous connections to avoid multiple connections
|
||||||
@@ -3117,28 +2905,14 @@ class abogen(QWidget):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if self.provider_combo.currentData() == "supertonic":
|
lang = self.selected_voice[0]
|
||||||
voice = self._get_supertonic_voice()
|
voice = self.selected_voice
|
||||||
else:
|
|
||||||
voice = self.selected_voice or ""
|
|
||||||
|
|
||||||
tts_provider = self.provider_combo.currentData()
|
|
||||||
supertonic_language = self.st_lang_combo.currentText()
|
|
||||||
supertonic_total_steps = self.st_steps_combo.currentData()
|
|
||||||
|
|
||||||
if tts_provider == "supertonic":
|
|
||||||
lang = supertonic_language
|
|
||||||
else:
|
|
||||||
lang = self.selected_voice[0] if self.selected_voice else ""
|
|
||||||
|
|
||||||
# use same gpu/cpu logic as in conversion
|
# use same gpu/cpu logic as in conversion
|
||||||
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
||||||
|
|
||||||
self.preview_thread = VoicePreviewThread(
|
self.preview_thread = VoicePreviewThread(
|
||||||
np_module, kpipeline_class, lang, voice, speed, gpu_ok,
|
np_module, kpipeline_class, lang, voice, speed, gpu_ok
|
||||||
tts_provider=tts_provider,
|
|
||||||
supertonic_language=supertonic_language,
|
|
||||||
supertonic_total_steps=supertonic_total_steps,
|
|
||||||
)
|
)
|
||||||
self.preview_thread.finished.connect(self._play_preview_audio)
|
self.preview_thread.finished.connect(self._play_preview_audio)
|
||||||
self.preview_thread.error.connect(self._preview_error)
|
self.preview_thread.error.connect(self._preview_error)
|
||||||
|
|||||||
@@ -26,7 +26,3 @@ class QueuedItem:
|
|||||||
replace_all_caps: bool = False
|
replace_all_caps: bool = False
|
||||||
replace_numerals: bool = False
|
replace_numerals: bool = False
|
||||||
fix_nonstandard_punctuation: bool = False
|
fix_nonstandard_punctuation: bool = False
|
||||||
# TTS Provider fields
|
|
||||||
tts_provider: str = "kokoro"
|
|
||||||
supertonic_language: str = "en"
|
|
||||||
supertonic_total_steps: int = 8
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from abogen.constants import (
|
|||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
KOKORO_LANG_TO_COUNTRY,
|
|
||||||
COLORS,
|
COLORS,
|
||||||
)
|
)
|
||||||
import re
|
import re
|
||||||
@@ -190,9 +189,8 @@ class VoiceMixer(QWidget):
|
|||||||
) # Center the icons horizontally
|
) # Center the icons horizontally
|
||||||
|
|
||||||
# Flag icon
|
# Flag icon
|
||||||
country_code = KOKORO_LANG_TO_COUNTRY.get(language_code, language_code)
|
|
||||||
flag_icon_path = get_resource_path(
|
flag_icon_path = get_resource_path(
|
||||||
"abogen.assets.flags", f"{country_code}.png"
|
"abogen.assets.flags", f"{language_code}.png"
|
||||||
)
|
)
|
||||||
gender_icon_path = get_resource_path(
|
gender_icon_path = get_resource_path(
|
||||||
"abogen.assets", "female.png" if is_female else "male.png"
|
"abogen.assets", "female.png" if is_female else "male.png"
|
||||||
@@ -514,8 +512,7 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
header_row.addWidget(QLabel("Language:"))
|
header_row.addWidget(QLabel("Language:"))
|
||||||
self.language_combo = QComboBox()
|
self.language_combo = QComboBox()
|
||||||
for code, desc in LANGUAGE_OPTIONS:
|
for code, desc in LANGUAGE_OPTIONS:
|
||||||
country_code = KOKORO_LANG_TO_COUNTRY.get(code, code)
|
flag = get_resource_path("abogen.assets.flags", f"{code}.png")
|
||||||
flag = get_resource_path("abogen.assets.flags", f"{country_code}.png")
|
|
||||||
if flag and os.path.exists(flag):
|
if flag and os.path.exists(flag):
|
||||||
self.language_combo.addItem(QIcon(flag), desc, code)
|
self.language_combo.addItem(QIcon(flag), desc, code)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import ast
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable, Iterator, Optional
|
from typing import Any, Iterable, Iterator, Optional
|
||||||
|
|
||||||
@@ -16,13 +15,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
||||||
|
|
||||||
SUPERTONIC_AVAILABLE_LANGS = [
|
|
||||||
"en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el",
|
|
||||||
"es", "et", "fi", "fr", "hi", "hr", "hu", "id", "it",
|
|
||||||
"lt", "lv", "nl", "pl", "pt", "ro", "ru", "sk", "sl",
|
|
||||||
"sv", "tr", "uk", "vi", "na",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SupertonicSegment:
|
class SupertonicSegment:
|
||||||
@@ -97,7 +89,7 @@ _UNSUPPORTED_CHARS_RE = re.compile(
|
|||||||
|
|
||||||
|
|
||||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||||
"""Best-effort extraction of unsupported characters from Supertonic errors."""
|
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||||
|
|
||||||
message = " ".join(
|
message = " ".join(
|
||||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||||
@@ -163,7 +155,6 @@ def _configure_supertonic_gpu() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||||
|
|
||||||
SUPERTONIC_MAX_CHUNK_LENGTH = 500
|
|
||||||
|
|
||||||
class SupertonicPipeline:
|
class SupertonicPipeline:
|
||||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||||
@@ -174,14 +165,11 @@ class SupertonicPipeline:
|
|||||||
sample_rate: int,
|
sample_rate: int,
|
||||||
auto_download: bool = True,
|
auto_download: bool = True,
|
||||||
total_steps: int = 5,
|
total_steps: int = 5,
|
||||||
max_chunk_length: int = SUPERTONIC_MAX_CHUNK_LENGTH,
|
max_chunk_length: int = 300,
|
||||||
lang: str = "en",
|
|
||||||
intra_op_num_threads: Optional[int] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sample_rate = int(sample_rate)
|
self.sample_rate = int(sample_rate)
|
||||||
self.total_steps = int(total_steps)
|
self.total_steps = int(total_steps)
|
||||||
self.max_chunk_length = int(max_chunk_length)
|
self.max_chunk_length = int(max_chunk_length)
|
||||||
self.lang = str(lang or "en")
|
|
||||||
|
|
||||||
# Configure GPU providers before importing TTS
|
# Configure GPU providers before importing TTS
|
||||||
_configure_supertonic_gpu()
|
_configure_supertonic_gpu()
|
||||||
@@ -193,8 +181,7 @@ class SupertonicPipeline:
|
|||||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
"Supertonic is not installed. Install it with `pip install supertonic`."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
threads = intra_op_num_threads if intra_op_num_threads is not None else os.cpu_count()
|
self._tts = TTS(auto_download=auto_download)
|
||||||
self._tts = TTS(auto_download=auto_download, intra_op_num_threads=threads)
|
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -204,14 +191,12 @@ class SupertonicPipeline:
|
|||||||
speed: float,
|
speed: float,
|
||||||
split_pattern: Optional[str] = None,
|
split_pattern: Optional[str] = None,
|
||||||
total_steps: Optional[int] = None,
|
total_steps: Optional[int] = None,
|
||||||
lang: Optional[str] = None,
|
|
||||||
) -> Iterator[SupertonicSegment]:
|
) -> Iterator[SupertonicSegment]:
|
||||||
voice_name = (voice or "").strip() or "M1"
|
voice_name = (voice or "").strip() or "M1"
|
||||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
steps = int(total_steps) if total_steps is not None else self.total_steps
|
||||||
steps = max(2, min(15, steps))
|
steps = max(2, min(15, steps))
|
||||||
speed_value = float(speed) if speed is not None else 1.0
|
speed_value = float(speed) if speed is not None else 1.0
|
||||||
speed_value = max(0.7, min(2.0, speed_value))
|
speed_value = max(0.7, min(2.0, speed_value))
|
||||||
language = str(lang or self.lang or "en")
|
|
||||||
|
|
||||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
style = self._tts.get_voice_style(voice_name=voice_name)
|
||||||
chunks = _split_text(
|
chunks = _split_text(
|
||||||
@@ -222,13 +207,12 @@ class SupertonicPipeline:
|
|||||||
removed: set[str] = set()
|
removed: set[str] = set()
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
# Supertonic can raise ValueError for unsupported characters; strip and retry.
|
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
wav, duration = self._tts.synthesize(
|
wav, duration = self._tts.synthesize(
|
||||||
text=chunk_to_speak,
|
text=chunk_to_speak,
|
||||||
voice_style=style,
|
voice_style=style,
|
||||||
lang=language,
|
|
||||||
total_steps=steps,
|
total_steps=steps,
|
||||||
speed=speed_value,
|
speed=speed_value,
|
||||||
max_chunk_length=self.max_chunk_length,
|
max_chunk_length=self.max_chunk_length,
|
||||||
@@ -254,14 +238,14 @@ class SupertonicPipeline:
|
|||||||
chunk_to_speak = sanitized
|
chunk_to_speak = sanitized
|
||||||
if not chunk_to_speak:
|
if not chunk_to_speak:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Supertonic: dropped a chunk after removing unsupported characters: %s",
|
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
||||||
sorted(removed),
|
sorted(removed),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Supertonic: removed unsupported characters %s and retried.",
|
"SuperTonic: removed unsupported characters %s and retried.",
|
||||||
sorted(removed),
|
sorted(removed),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
|||||||
raw = str(spec or "").strip()
|
raw = str(spec or "").strip()
|
||||||
fallback_raw = str(fallback or "").strip()
|
fallback_raw = str(fallback or "").strip()
|
||||||
|
|
||||||
# Supertonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix
|
# 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.
|
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
|
||||||
if not raw or "*" in raw or "+" in raw:
|
if not raw or "*" in raw or "+" in raw:
|
||||||
raw = fallback_raw
|
raw = fallback_raw
|
||||||
@@ -1584,7 +1584,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
pipelines[provider_norm] = SupertonicPipeline(
|
pipelines[provider_norm] = SupertonicPipeline(
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=SAMPLE_RATE,
|
||||||
auto_download=True,
|
auto_download=True,
|
||||||
total_steps=int(getattr(job, "supertonic_total_steps", 8) or 8),
|
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||||
)
|
)
|
||||||
return pipelines[provider_norm]
|
return pipelines[provider_norm]
|
||||||
|
|
||||||
@@ -1618,7 +1618,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1"
|
||||||
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 8) or 8)
|
steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5)
|
||||||
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0)
|
||||||
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps
|
||||||
formula = _formula_from_kokoro_entry(entry)
|
formula = _formula_from_kokoro_entry(entry)
|
||||||
@@ -1634,7 +1634,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
"""Resolve a raw voice spec into (provider, resolved_spec, choice, speed, steps).
|
"""Resolve a raw voice spec into (provider, resolved_spec, choice, speed, steps).
|
||||||
|
|
||||||
For Kokoro formulas, `choice` will be a resolved voice tensor (via `voice_formulas`).
|
For Kokoro formulas, `choice` will be a resolved voice tensor (via `voice_formulas`).
|
||||||
For Supertonic, `choice` will be a valid Supertonic voice id.
|
For SuperTonic, `choice` will be a valid SuperTonic voice id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider, resolved, speed, steps = resolve_voice_target(raw_spec)
|
provider, resolved, speed, steps = resolve_voice_target(raw_spec)
|
||||||
@@ -1857,7 +1857,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice=voice_name,
|
voice=voice_name,
|
||||||
speed=float(speed_override if speed_override is not None else job.speed),
|
speed=float(speed_override if speed_override is not None else job.speed),
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 8)),
|
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
kokoro_pipeline = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
@@ -2448,7 +2448,7 @@ def _load_pipeline(job: Job):
|
|||||||
return SupertonicPipeline(
|
return SupertonicPipeline(
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=SAMPLE_RATE,
|
||||||
auto_download=True,
|
auto_download=True,
|
||||||
total_steps=int(getattr(job, "supertonic_total_steps", 8) or 8),
|
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
@@ -2610,7 +2610,7 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str
|
|||||||
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
|
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
|
||||||
if "*" in voice_spec:
|
if "*" in voice_spec:
|
||||||
# Voice formulas are a Kokoro-only feature (they require a pipeline that can
|
# Voice formulas are a Kokoro-only feature (they require a pipeline that can
|
||||||
# load individual Kokoro voices). When running with Supertonic (or when the
|
# load individual Kokoro voices). When running with SuperTonic (or when the
|
||||||
# pipeline is otherwise unavailable), treat the spec as a plain string and
|
# pipeline is otherwise unavailable), treat the spec as a plain string and
|
||||||
# allow downstream provider-specific resolution to choose a safe fallback.
|
# allow downstream provider-specific resolution to choose a safe fallback.
|
||||||
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
|
|||||||
formula = str(payload.get("formula") or "").strip()
|
formula = str(payload.get("formula") or "").strip()
|
||||||
profile_name = str(payload.get("profile") or "").strip()
|
profile_name = str(payload.get("profile") or "").strip()
|
||||||
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
|
||||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 8)
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
|
||||||
|
|
||||||
voice_spec = ""
|
voice_spec = ""
|
||||||
resolved_provider = provider or "kokoro"
|
resolved_provider = provider or "kokoro"
|
||||||
@@ -224,7 +224,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
speed_value = payload.get("speed")
|
speed_value = payload.get("speed")
|
||||||
speed = coerce_float(speed_value, 1.0)
|
speed = coerce_float(speed_value, 1.0)
|
||||||
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
||||||
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 8)
|
supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
use_gpu = settings.get("use_gpu", False)
|
use_gpu = settings.get("use_gpu", False)
|
||||||
@@ -269,7 +269,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
use_gpu=use_gpu
|
use_gpu=use_gpu
|
||||||
,
|
,
|
||||||
tts_provider=resolved_provider,
|
tts_provider=resolved_provider,
|
||||||
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 8),
|
supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
|
||||||
pronunciation_overrides=pronunciation_overrides,
|
pronunciation_overrides=pronunciation_overrides,
|
||||||
manual_overrides=manual_overrides,
|
manual_overrides=manual_overrides,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
|
|||||||
@@ -43,12 +43,8 @@ def update_settings() -> ResponseReturnValue:
|
|||||||
current["language"] = (form.get("language") or "en").strip()
|
current["language"] = (form.get("language") or "en").strip()
|
||||||
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
current["default_speaker"] = (form.get("default_speaker") or "").strip()
|
||||||
current["default_voice"] = (form.get("default_voice") or "").strip()
|
current["default_voice"] = (form.get("default_voice") or "").strip()
|
||||||
provider = str(form.get("tts_provider") or "kokoro").strip().lower()
|
|
||||||
if provider in {"kokoro", "supertonic"}:
|
|
||||||
current["tts_provider"] = provider
|
|
||||||
try:
|
try:
|
||||||
total_steps = int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 8)))
|
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
|
||||||
current["supertonic_total_steps"] = max(2, min(15, total_steps))
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -577,7 +577,7 @@ def apply_book_step_form(
|
|||||||
# NOTE: Do not auto-set a global TTS provider at the book level based on the
|
# NOTE: Do not auto-set a global TTS provider at the book level based on the
|
||||||
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
|
# narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
|
||||||
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
||||||
# This enables mixed-provider conversions (e.g. narrator=Supertonic, characters=Kokoro).
|
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
||||||
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
||||||
if provider_value in {"kokoro", "supertonic"}:
|
if provider_value in {"kokoro", "supertonic"}:
|
||||||
pending.tts_provider = provider_value
|
pending.tts_provider = provider_value
|
||||||
@@ -913,15 +913,6 @@ def build_pending_job_from_extraction(
|
|||||||
else:
|
else:
|
||||||
normalization_overrides[key] = default_val
|
normalization_overrides[key] = default_val
|
||||||
|
|
||||||
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
|
||||||
if provider_value not in {"kokoro", "supertonic"}:
|
|
||||||
provider_value = settings.get("tts_provider", "kokoro")
|
|
||||||
try:
|
|
||||||
total_steps = int(form.get("supertonic_total_steps", settings.get("supertonic_total_steps", 8)))
|
|
||||||
supertonic_steps = max(2, min(15, total_steps))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
supertonic_steps = int(settings.get("supertonic_total_steps", 8))
|
|
||||||
|
|
||||||
pending = PendingJob(
|
pending = PendingJob(
|
||||||
id=uuid.uuid4().hex,
|
id=uuid.uuid4().hex,
|
||||||
original_filename=original_name,
|
original_filename=original_name,
|
||||||
@@ -937,8 +928,6 @@ def build_pending_job_from_extraction(
|
|||||||
replace_single_newlines=replace_single_newlines,
|
replace_single_newlines=replace_single_newlines,
|
||||||
subtitle_format=subtitle_format,
|
subtitle_format=subtitle_format,
|
||||||
total_characters=total_chars,
|
total_characters=total_chars,
|
||||||
tts_provider=provider_value,
|
|
||||||
supertonic_total_steps=supertonic_steps,
|
|
||||||
save_chapters_separately=save_chapters_separately,
|
save_chapters_separately=save_chapters_separately,
|
||||||
merge_chapters_at_end=merge_chapters_at_end,
|
merge_chapters_at_end=merge_chapters_at_end,
|
||||||
separate_chapters_format=separate_chapters_format,
|
separate_chapters_format=separate_chapters_format,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def generate_preview_audio(
|
|||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 8,
|
supertonic_total_steps: int = 5,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
@@ -201,7 +201,7 @@ def synthesize_preview(
|
|||||||
speed: float,
|
speed: float,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 8,
|
supertonic_total_steps: int = 5,
|
||||||
max_seconds: float = 8.0,
|
max_seconds: float = 8.0,
|
||||||
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def submit_job(pending: PendingJob) -> str:
|
|||||||
tts_provider=getattr(pending, "tts_provider", "kokoro"),
|
tts_provider=getattr(pending, "tts_provider", "kokoro"),
|
||||||
voice=pending.voice,
|
voice=pending.voice,
|
||||||
speed=pending.speed,
|
speed=pending.speed,
|
||||||
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 8),
|
supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5),
|
||||||
use_gpu=pending.use_gpu,
|
use_gpu=pending.use_gpu,
|
||||||
subtitle_mode=pending.subtitle_mode,
|
subtitle_mode=pending.subtitle_mode,
|
||||||
output_format=pending.output_format,
|
output_format=pending.output_format,
|
||||||
|
|||||||
@@ -175,8 +175,7 @@ def settings_defaults() -> Dict[str, Any]:
|
|||||||
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||||
"default_speaker": "",
|
"default_speaker": "",
|
||||||
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||||
"tts_provider": "kokoro",
|
"supertonic_total_steps": 5,
|
||||||
"supertonic_total_steps": 8,
|
|
||||||
"supertonic_speed": 1.0,
|
"supertonic_speed": 1.0,
|
||||||
"replace_single_newlines": False,
|
"replace_single_newlines": False,
|
||||||
"use_gpu": True,
|
"use_gpu": True,
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ def resolve_voice_choice(
|
|||||||
|
|
||||||
# Provider-aware behavior:
|
# Provider-aware behavior:
|
||||||
# - Kokoro profiles typically represent mixes (formula strings).
|
# - Kokoro profiles typically represent mixes (formula strings).
|
||||||
# - Supertonic profiles represent a discrete voice id + settings.
|
# - SuperTonic profiles represent a discrete voice id + settings.
|
||||||
# In that case, we return a speaker reference so downstream can
|
# In that case, we return a speaker reference so downstream can
|
||||||
# resolve provider per-speaker and allow mixed-provider casting.
|
# resolve provider per-speaker and allow mixed-provider casting.
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class Job:
|
|||||||
subtitle_format: str
|
subtitle_format: str
|
||||||
created_at: float
|
created_at: float
|
||||||
tts_provider: str = "kokoro"
|
tts_provider: str = "kokoro"
|
||||||
supertonic_total_steps: int = 8
|
supertonic_total_steps: int = 5
|
||||||
save_chapters_separately: bool = False
|
save_chapters_separately: bool = False
|
||||||
merge_chapters_at_end: bool = True
|
merge_chapters_at_end: bool = True
|
||||||
separate_chapters_format: str = "wav"
|
separate_chapters_format: str = "wav"
|
||||||
@@ -204,7 +204,7 @@ class Job:
|
|||||||
"queue_position": self.queue_position,
|
"queue_position": self.queue_position,
|
||||||
"options": {
|
"options": {
|
||||||
"tts_provider": getattr(self, "tts_provider", "kokoro"),
|
"tts_provider": getattr(self, "tts_provider", "kokoro"),
|
||||||
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 8),
|
"supertonic_total_steps": getattr(self, "supertonic_total_steps", 5),
|
||||||
"save_chapters_separately": self.save_chapters_separately,
|
"save_chapters_separately": self.save_chapters_separately,
|
||||||
"merge_chapters_at_end": self.merge_chapters_at_end,
|
"merge_chapters_at_end": self.merge_chapters_at_end,
|
||||||
"separate_chapters_format": self.separate_chapters_format,
|
"separate_chapters_format": self.separate_chapters_format,
|
||||||
@@ -552,7 +552,7 @@ class PendingJob:
|
|||||||
normalization_overrides: Dict[str, Any]
|
normalization_overrides: Dict[str, Any]
|
||||||
created_at: float
|
created_at: float
|
||||||
tts_provider: str = "kokoro"
|
tts_provider: str = "kokoro"
|
||||||
supertonic_total_steps: int = 8
|
supertonic_total_steps: int = 5
|
||||||
cover_image_path: Optional[Path] = None
|
cover_image_path: Optional[Path] = None
|
||||||
cover_image_mime: Optional[str] = None
|
cover_image_mime: Optional[str] = None
|
||||||
chapter_intro_delay: float = 0.5
|
chapter_intro_delay: float = 0.5
|
||||||
@@ -621,7 +621,7 @@ class ConversionService:
|
|||||||
voice: str,
|
voice: str,
|
||||||
speed: float,
|
speed: float,
|
||||||
tts_provider: str = "kokoro",
|
tts_provider: str = "kokoro",
|
||||||
supertonic_total_steps: int = 8,
|
supertonic_total_steps: int = 5,
|
||||||
use_gpu: bool,
|
use_gpu: bool,
|
||||||
subtitle_mode: str,
|
subtitle_mode: str,
|
||||||
output_format: str,
|
output_format: str,
|
||||||
@@ -674,7 +674,7 @@ class ConversionService:
|
|||||||
voice=voice,
|
voice=voice,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
tts_provider=tts_provider,
|
tts_provider=tts_provider,
|
||||||
supertonic_total_steps=int(supertonic_total_steps or 8),
|
supertonic_total_steps=int(supertonic_total_steps or 5),
|
||||||
use_gpu=use_gpu,
|
use_gpu=use_gpu,
|
||||||
subtitle_mode=subtitle_mode,
|
subtitle_mode=subtitle_mode,
|
||||||
output_format=output_format,
|
output_format=output_format,
|
||||||
@@ -1147,7 +1147,7 @@ class ConversionService:
|
|||||||
"tts_provider": getattr(job, "tts_provider", "kokoro"),
|
"tts_provider": getattr(job, "tts_provider", "kokoro"),
|
||||||
"voice": job.voice,
|
"voice": job.voice,
|
||||||
"speed": job.speed,
|
"speed": job.speed,
|
||||||
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 8),
|
"supertonic_total_steps": getattr(job, "supertonic_total_steps", 5),
|
||||||
"use_gpu": job.use_gpu,
|
"use_gpu": job.use_gpu,
|
||||||
"subtitle_mode": job.subtitle_mode,
|
"subtitle_mode": job.subtitle_mode,
|
||||||
"output_format": job.output_format,
|
"output_format": job.output_format,
|
||||||
@@ -1275,7 +1275,7 @@ class ConversionService:
|
|||||||
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
|
replace_single_newlines=bool(payload.get("replace_single_newlines", False)),
|
||||||
subtitle_format=payload.get("subtitle_format", "srt"),
|
subtitle_format=payload.get("subtitle_format", "srt"),
|
||||||
created_at=float(payload.get("created_at", time.time())),
|
created_at=float(payload.get("created_at", time.time())),
|
||||||
supertonic_total_steps=int(payload.get("supertonic_total_steps", 8)),
|
supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)),
|
||||||
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
|
save_chapters_separately=bool(payload.get("save_chapters_separately", False)),
|
||||||
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
|
merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)),
|
||||||
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
|
separate_chapters_format=payload.get("separate_chapters_format", "wav"),
|
||||||
|
|||||||
@@ -26,26 +26,6 @@
|
|||||||
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set tts_provider_value = form_values.get('tts_provider') if form_values else None %}
|
|
||||||
{% if not tts_provider_value %}
|
|
||||||
{% if pending and pending.tts_provider %}
|
|
||||||
{% set tts_provider_value = pending.tts_provider %}
|
|
||||||
{% else %}
|
|
||||||
{% set tts_provider_value = settings_dict.get('tts_provider', 'kokoro') %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% set supertonic_steps_value = form_values.get('supertonic_total_steps') if form_values else None %}
|
|
||||||
{% if supertonic_steps_value is none %}
|
|
||||||
{% if pending and pending.supertonic_total_steps is not none %}
|
|
||||||
{% set supertonic_steps_value = pending.supertonic_total_steps %}
|
|
||||||
{% else %}
|
|
||||||
{% set supertonic_steps_value = settings_dict.get('supertonic_total_steps', 8) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if supertonic_steps_value is not none and supertonic_steps_value is string %}
|
|
||||||
{% set supertonic_steps_value = supertonic_steps_value|int %}
|
|
||||||
{% endif %}
|
|
||||||
{% set is_supertonic = tts_provider_value == 'supertonic' %}
|
|
||||||
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
||||||
{% if generate_flag is not none %}
|
{% if generate_flag is not none %}
|
||||||
{% set generate_epub3 = True %}
|
{% set generate_epub3 = True %}
|
||||||
@@ -293,13 +273,6 @@
|
|||||||
<div class="form-section__layout form-section__layout--split">
|
<div class="form-section__layout form-section__layout--split">
|
||||||
<div class="form-section__group">
|
<div class="form-section__group">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tts_provider">TTS Engine</label>
|
|
||||||
<select id="tts_provider" name="tts_provider" data-role="tts-provider" {{ 'disabled' if readonly else '' }}>
|
|
||||||
<option value="kokoro" {% if tts_provider_value == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
|
||||||
<option value="supertonic" {% if tts_provider_value == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" data-role="voice-profile-field">
|
|
||||||
<label for="voice_profile">Voice profile</label>
|
<label for="voice_profile">Voice profile</label>
|
||||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
||||||
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
||||||
@@ -307,13 +280,13 @@
|
|||||||
{% if options.voice_profile_options %}
|
{% if options.voice_profile_options %}
|
||||||
<optgroup label="Saved mixes">
|
<optgroup label="Saved mixes">
|
||||||
{% for profile in options.voice_profile_options %}
|
{% for profile in options.voice_profile_options %}
|
||||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" data-provider="{{ profile.provider|default('kokoro')|lower }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}{% if profile.provider and profile.provider|lower != 'kokoro' %} · {{ profile.provider|capitalize }}{% endif %}</option>
|
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-role="voice-field" data-provider="kokoro" {% if profile_value != '__standard' or is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
<div class="field" data-role="voice-field" {% if profile_value != '__standard' %}hidden aria-hidden="true"{% endif %}>
|
||||||
<label for="voice">Voice</label>
|
<label for="voice">Voice</label>
|
||||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||||
{% for voice in options.voices %}
|
{% for voice in options.voices %}
|
||||||
@@ -321,23 +294,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-role="voice-field" data-provider="supertonic" {% if profile_value != '__standard' or not is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
<div class="field" data-conditional="formula" data-role="formula-field" {% if profile_value != '__formula' %}hidden aria-hidden="true"{% endif %}>
|
||||||
<label for="voice_st">Supertonic voice</label>
|
|
||||||
<select id="voice_st" name="voice" data-role="voice-select" data-default="{{ narrator_voice or 'M1' }}" {{ 'disabled' if readonly else '' }}>
|
|
||||||
{% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %}
|
|
||||||
<option value="{{ voice }}" {% if narrator_voice == voice and profile_value == '__standard' %}selected{% endif %}>{{ voice }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" data-conditional="formula" data-role="formula-field" data-provider="kokoro" {% if profile_value != '__formula' or is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
|
||||||
<label for="voice_formula">Custom voice formula</label>
|
<label for="voice_formula">Custom voice formula</label>
|
||||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-role="supertonic-steps-field" {% if not is_supertonic %}hidden aria-hidden="true"{% endif %}>
|
|
||||||
<label for="supertonic_total_steps">Supertonic quality (total steps)</label>
|
|
||||||
<input type="number" id="supertonic_total_steps" name="supertonic_total_steps" min="2" max="15" value="{{ supertonic_steps_value }}" {{ 'disabled' if readonly else '' }}>
|
|
||||||
<p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-section__group">
|
<div class="form-section__group">
|
||||||
<div class="field field--slider">
|
<div class="field field--slider">
|
||||||
@@ -463,54 +423,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script nonce="{{ csp_nonce() if csp_nonce else '' }}">
|
|
||||||
(function() {
|
|
||||||
const form = document.querySelector('[data-wizard-form="true"][data-step="book"]');
|
|
||||||
if (!form) return;
|
|
||||||
const providerSelect = form.querySelector('[data-role="tts-provider"]');
|
|
||||||
if (!providerSelect) return;
|
|
||||||
|
|
||||||
function filterProfilesByProvider(provider) {
|
|
||||||
const profileSelect = form.querySelector('[data-role="voice-profile"]');
|
|
||||||
if (!profileSelect) return;
|
|
||||||
const options = profileSelect.querySelectorAll('option[data-provider]');
|
|
||||||
options.forEach(function(opt) {
|
|
||||||
const matches = !opt.dataset.provider || opt.dataset.provider === provider;
|
|
||||||
opt.hidden = !matches;
|
|
||||||
if (opt.selected && opt.hidden) {
|
|
||||||
opt.selected = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!profileSelect.value || profileSelect.selectedOptions[0]?.hidden) {
|
|
||||||
const firstVisible = profileSelect.querySelector('option:not([hidden])');
|
|
||||||
if (firstVisible) profileSelect.value = firstVisible.value;
|
|
||||||
}
|
|
||||||
profileSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncProviderUI(provider) {
|
|
||||||
var isSupertonic = provider === 'supertonic';
|
|
||||||
form.querySelectorAll('[data-role="voice-field"]').forEach(function(el) {
|
|
||||||
el.hidden = el.dataset.provider !== provider;
|
|
||||||
el.setAttribute('aria-hidden', el.hidden ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
var formulaField = form.querySelector('[data-role="formula-field"]');
|
|
||||||
if (formulaField) {
|
|
||||||
formulaField.hidden = isSupertonic;
|
|
||||||
formulaField.setAttribute('aria-hidden', isSupertonic ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
var stepsField = form.querySelector('[data-role="supertonic-steps-field"]');
|
|
||||||
if (stepsField) {
|
|
||||||
stepsField.hidden = !isSupertonic;
|
|
||||||
stepsField.setAttribute('aria-hidden', isSupertonic ? 'false' : 'true');
|
|
||||||
}
|
|
||||||
filterProfilesByProvider(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
providerSelect.addEventListener('change', function() {
|
|
||||||
syncProviderUI(providerSelect.value);
|
|
||||||
});
|
|
||||||
syncProviderUI(providerSelect.value);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -61,15 +61,6 @@
|
|||||||
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
<p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="tts_provider">Default TTS Engine</label>
|
|
||||||
<select id="tts_provider" name="tts_provider">
|
|
||||||
<option value="kokoro" {% if settings.tts_provider == 'kokoro' %}selected{% endif %}>Kokoro</option>
|
|
||||||
<option value="supertonic" {% if settings.tts_provider == 'supertonic' %}selected{% endif %}>Supertonic</option>
|
|
||||||
</select>
|
|
||||||
<p class="hint">Select the default TTS engine for new jobs.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field field--wide">
|
<div class="field field--wide">
|
||||||
<p class="tag">Kokoro settings</p>
|
<p class="tag">Kokoro settings</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ dependencies = [
|
|||||||
"pip",
|
"pip",
|
||||||
"kokoro>=0.9.4",
|
"kokoro>=0.9.4",
|
||||||
"misaki[zh]>=0.9.4",
|
"misaki[zh]>=0.9.4",
|
||||||
"supertonic>=1.3.1",
|
"supertonic>=0.1.0",
|
||||||
"ebooklib>=0.19",
|
"ebooklib>=0.19",
|
||||||
"beautifulsoup4>=4.13.4",
|
"beautifulsoup4>=4.13.4",
|
||||||
"spacy>=3.8.7,<4.0",
|
"spacy>=3.8.7,<4.0",
|
||||||
@@ -50,6 +50,8 @@ dependencies = [
|
|||||||
"num2words>=0.5.13",
|
"num2words>=0.5.13",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"PyQt6>=6.5.0",
|
"PyQt6>=6.5.0",
|
||||||
|
"flet>=0.85.1",
|
||||||
|
"msgpack>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -77,11 +79,12 @@ allow-direct-references = true
|
|||||||
|
|
||||||
|
|
||||||
[project.gui-scripts]
|
[project.gui-scripts]
|
||||||
abogen = "abogen.pyqt.main:main"
|
abogen = "abogen.frontend.main:main"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
abogen-ui = "abogen.frontend.main:main"
|
||||||
|
abogen-web = "abogen.frontend.main:main_web"
|
||||||
abogen-cli = "abogen.webui.app:main"
|
abogen-cli = "abogen.webui.app:main"
|
||||||
abogen-web = "abogen.webui.app:main"
|
|
||||||
abogen-pyqt = "abogen.pyqt.main:main"
|
abogen-pyqt = "abogen.pyqt.main:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
@@ -111,11 +114,11 @@ filterwarnings = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# NVIDIA GPU (Windows) (CUDA 12.6) # uv tool install abogen[cuda126]
|
# NVIDIA GPU (Windows) (CUDA 12.6) # uv tool install abogen[cuda126]
|
||||||
cuda126 = ["torch", "onnxruntime-gpu>=1.26.0"]
|
cuda126 = ["torch"]
|
||||||
# NVIDIA GPU (Windows) (CUDA 12.8) # uv tool install abogen[cuda]
|
# NVIDIA GPU (Windows) (CUDA 12.8) # uv tool install abogen[cuda]
|
||||||
cuda = ["torch", "onnxruntime-gpu>=1.26.0"]
|
cuda = ["torch"]
|
||||||
# NVIDIA GPU (Windows) (CUDA 13.0) # uv tool install abogen[cuda130]
|
# NVIDIA GPU (Windows) (CUDA 13.0) # uv tool install abogen[cuda130]
|
||||||
cuda130 = ["torch", "onnxruntime-gpu>=1.26.0"]
|
cuda130 = ["torch"]
|
||||||
# AMD GPU (Linux) (ROCm 6.4) # uv tool install abogen[rocm]
|
# AMD GPU (Linux) (ROCm 6.4) # uv tool install abogen[rocm]
|
||||||
rocm = ["torch", "pytorch-triton-rocm"]
|
rocm = ["torch", "pytorch-triton-rocm"]
|
||||||
# Development dependencies # uv tool install abogen[dev]
|
# Development dependencies # uv tool install abogen[dev]
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
|||||||
|
|
||||||
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
|
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
|
||||||
# This can happen when a previously-saved Kokoro mix formula is present
|
# This can happen when a previously-saved Kokoro mix formula is present
|
||||||
# but the active provider is Supertonic (no Kokoro pipeline object).
|
# but the active provider is SuperTonic (no Kokoro pipeline object).
|
||||||
formula = "af_heart*0.5+af_sky*0.5"
|
formula = "af_heart*0.5+af_sky*0.5"
|
||||||
resolved = _resolve_voice(None, formula, use_gpu=False)
|
resolved = _resolve_voice(None, formula, use_gpu=False)
|
||||||
assert resolved == formula
|
assert resolved == formula
|
||||||
|
|
||||||
|
|
||||||
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
||||||
# When a stale Kokoro mix formula is present, Supertonic should not receive it.
|
# When a stale Kokoro mix formula is present, SuperTonic should not receive it.
|
||||||
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
||||||
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
||||||
|
|||||||