Compare commits

Author SHA1 Message Date
Deniz Şafak 6bd2c109d8 flet ui (experimental) 2026-05-24 14:58:41 +03:00
Deniz ŞafakandGitHub 9fa81fbe1e Merge pull request #160 from JoaGamo/main
Fix #152 : preview buttons on webUI
2026-04-30 13:05:44 +03:00
JoaGamo 9fd9fad238 Fall-back to CPU if no compatible device is available 2026-04-21 22:50:59 -03:00
Deniz ŞafakandGitHub ca5c5ee62d Merge pull request #146 from olandir/131wVoiceTags
Voice Tags and Word Substitution Added to Main Script
2026-03-07 01:48:59 +03:00
olandir e51be95bc1 Update .gitignore 2026-02-28 21:26:57 -05:00
olandirandClaude Sonnet 4.6 2223f46c9e Port voice marker and word substitution features to upstream refactored structure
The upstream project moved PyQt code to abogen/pyqt/ subdirectory, making the
original feature commits non-mergeable. This commit re-applies both features
to the new file locations.

Voice Marker feature (<<VOICE:voice_name>> syntax):
- subtitle_utils.py: Added _VOICE_MARKER_PATTERN, _VOICE_MARKER_SEARCH_PATTERN,
  validate_voice_name(), split_text_by_voice_markers() (with valid/invalid counts)
- pyqt/conversion.py: Added load_voice_cached(), voice marker pre-processing before
  chapter loop, inner voice segment loop wrapping spaCy+TTS block, updated imports
- pyqt/gui.py: Added Insert Voice Marker button and insert_voice_marker() to TextboxDialog

Word Substitution feature (text preprocessing before TTS):
- word_substitution.py: New module (word replacements, ALL CAPS, numerals, punctuation)
- pyqt/conversion.py: apply_word_substitutions() call after clean_text()
- pyqt/gui.py: WordSubstitutionsDialog, word_sub_combo, Settings button,
  on_word_sub_changed(), show_word_sub_dialog(), config persistence, queue restore
- pyqt/queued_item.py: 6 new word substitution fields
- pyqt/queue_manager_gui.py: 6 fields added to OVERRIDE_FIELDS and get_current_attributes()

Note: num2words>=0.5.13 was already added to pyproject.toml by upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:19:39 -05:00
Deniz ŞafakandGitHub 8322f7f416 Merge pull request #128 from vladimir-sol/fix-add-missing-pauses
Ensure appropriate speech pauses by adding newlines at epub processing
2026-02-19 14:57:06 +03:00
Vladimir Sol 2c15d2f78a Ensure appropriate speech pauses by adding newlines at epub processing 2026-02-17 19:59:03 -08:00
Deniz ŞafakandGitHub cc9c2a22ba Update GitHub Sponsors usernames in FUNDING.yml 2026-02-10 16:14:05 +03:00
Deniz ŞafakandGitHub d1366b445d Merge pull request #139 from abenea/crash
Fix importing chapters in the PyQt UI.
2026-02-08 17:51:56 +03:00
Andrei Benea c224cdbb56 Fix importing chapters in the PyQt UI.
The app was crashing after importing a .txt and clicking convert because of a missing import. Fixed the imports and removed the legacy abogen.conversion module which doesn't seem necessary anymore.
2026-02-08 11:01:31 +01:00
Deniz Şafak d30415ffe7 Update project version from 1.3.0 to 1.3.1. 2026-02-07 00:23:08 +03:00
30 changed files with 8120 additions and 741 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [jborza, jeremiahsb, mohangk]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1
View File
@@ -38,3 +38,4 @@ dist/
.old/ .old/
test_assets/ test_assets/
dev_notes/ dev_notes/
.claude/
+539 -437
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.3.1
+5 -1
View File
@@ -915,7 +915,11 @@ class EpubParser(BaseBookParser):
if slice_html.strip(): if slice_html.strip():
slice_soup = BeautifulSoup(slice_html, "html.parser") slice_soup = BeautifulSoup(slice_html, "html.parser")
for tag in slice_soup.find_all(["p", "div"]):
# Add line breaks after block-level elements to ensure pauses in speech
for tag in slice_soup.find_all(
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
):
tag.append("\n\n") tag.append("\n\n")
for ol in slice_soup.find_all("ol"): for ol in slice_soup.find_all("ol"):
-16
View File
@@ -1,16 +0,0 @@
"""Backwards-compatible re-export of conversion module.
The PyQt-based implementation lives in abogen.pyqt.conversion.
The web-based implementation is in abogen.webui.conversion_runner.
"""
from __future__ import annotations
# Re-export PyQt conversion classes for backwards compatibility
from abogen.pyqt.conversion import ( # noqa: F401
ConversionThread,
VoicePreviewThread,
PlayAudioThread,
)
__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"]
+8
View File
@@ -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"]
+32
View File
@@ -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",
]
+630
View File
@@ -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)
+365
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
"""State sub-package exports AppState and ConversionJob."""
from .app_state import AppState, ConversionJob
__all__ = ["AppState", "ConversionJob"]
+451
View File
@@ -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 = []
+38
View File
@@ -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",
]
+462
View File
@@ -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
+313
View File
@@ -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
+264
View File
@@ -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
+6
View File
@@ -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"]
+587
View File
@@ -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()
+154
View File
@@ -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.")
+305
View File
@@ -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.")
+356 -260
View File
@@ -42,6 +42,10 @@ from abogen.subtitle_utils import (
get_sample_voice_text, get_sample_voice_text,
sanitize_name_for_os, sanitize_name_for_os,
_CHAPTER_MARKER_SEARCH_PATTERN, _CHAPTER_MARKER_SEARCH_PATTERN,
_VOICE_MARKER_PATTERN,
_VOICE_MARKER_SEARCH_PATTERN,
split_text_by_voice_markers,
validate_voice_name,
) )
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
@@ -296,6 +300,31 @@ class ConversionThread(QThread):
self.use_spacy_segmentation = True # Default, will be overridden from GUI self.use_spacy_segmentation = True # Default, will be overridden from GUI
# Set split pattern based on language and subtitle mode # Set split pattern based on language and subtitle mode
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
self.voice_cache = {} # Cache for loaded voices
def load_voice_cached(self, voice_name, tts):
"""Load voice with caching to avoid reloading same voice.
Args:
voice_name: Voice name or formula string
tts: TTS pipeline instance
Returns:
Loaded voice tensor or voice name string
"""
# Check cache first
if voice_name in self.voice_cache:
return self.voice_cache[voice_name]
# Load voice
if "*" in voice_name:
loaded_voice = get_new_voice(tts, voice_name, self.use_gpu)
else:
loaded_voice = voice_name
# Cache it
self.voice_cache[voice_name] = loaded_voice
return loaded_voice
def _stream_audio_in_chunks( def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing" self, segments, process_func, progress_prefix="Processing"
@@ -524,6 +553,26 @@ class ConversionThread(QThread):
# Clean up text using utility function # Clean up text using utility function
text = clean_text(text) text = clean_text(text)
# Apply word substitutions if enabled
if getattr(self, "word_substitutions_enabled", False):
from abogen.word_substitution import apply_word_substitutions
self.log_updated.emit("Applying word substitutions...")
substitutions_list = getattr(self, "word_substitutions_list", "")
case_sensitive = getattr(self, "case_sensitive_substitutions", False)
replace_caps = getattr(self, "replace_all_caps", False)
replace_nums = getattr(self, "replace_numerals", False)
fix_punct = getattr(self, "fix_nonstandard_punctuation", False)
text = apply_word_substitutions(
text,
substitutions_list,
case_sensitive,
replace_caps,
replace_nums,
fix_punct,
)
# --- Chapter splitting logic --- # --- Chapter splitting logic ---
# Use pre-compiled pattern for better performance # Use pre-compiled pattern for better performance
@@ -550,6 +599,42 @@ class ConversionThread(QThread):
chapters = [("text", text)] chapters = [("text", text)]
total_chapters = len(chapters) total_chapters = len(chapters)
# --- Voice marker splitting logic ---
# Split each chapter by voice markers, preserving voice state across chapters
chapters_with_voices = []
current_voice = self.voice # Start with default voice
total_valid_markers = 0
total_invalid_markers = 0
for chapter_name, chapter_text in chapters:
# Use current_voice as the starting voice for this chapter
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(chapter_text, current_voice)
chapters_with_voices.append((chapter_name, voice_segments))
# Update current_voice so next chapter continues with this voice
current_voice = last_voice
# Track total valid/invalid markers
total_valid_markers += valid_count
total_invalid_markers += invalid_count
# Log voice marker information with accurate counts
total_markers = total_valid_markers + total_invalid_markers
if total_markers > 0:
if total_invalid_markers == 0:
# All markers were valid
self.log_updated.emit(
(f"\nDetected {total_markers} voice marker(s) - all valid", "grey")
)
else:
# Some markers were invalid
self.log_updated.emit(
(f"\nDetected {total_markers} voice marker(s) - {total_valid_markers} valid, {total_invalid_markers} invalid (using previous voice)", "orange")
)
# Replace chapters with the new structure
chapters = chapters_with_voices
# For text files with chapters, prompt user for options if not already set # For text files with chapters, prompt user for options if not already set
is_txt_file = not self.is_direct_text and ( is_txt_file = not self.is_direct_text and (
self.file_name.lower().endswith(".txt") self.file_name.lower().endswith(".txt")
@@ -842,7 +927,7 @@ class ConversionThread(QThread):
] ]
srt_index = 1 # SRT numbering fix for chapter-only mode srt_index = 1 # SRT numbering fix for chapter-only mode
# Instead of processing the whole text, process by chapter # Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1):
chapter_out_path = None chapter_out_path = None
chapter_out_file = None chapter_out_file = None
chapter_ffmpeg_proc = None chapter_ffmpeg_proc = None
@@ -862,11 +947,6 @@ class ConversionThread(QThread):
if merge_chapters_at_end: if merge_chapters_at_end:
chapter_time["start"] = current_time chapter_time["start"] = current_time
# Check if the voice is a formula and load it if necessary
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
# Prepare per-chapter output file if needed # Prepare per-chapter output file if needed
if save_chapters_separately and total_chapters > 1: if save_chapters_separately and total_chapters > 1:
# First pass: keep alphanumeric, spaces, hyphens, and underscores # First pass: keep alphanumeric, spaces, hyphens, and underscores
@@ -986,286 +1066,302 @@ class ConversionThread(QThread):
chapter_subtitle_path = None chapter_subtitle_path = None
chapter_subtitle_file = None chapter_subtitle_file = None
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Only non-English languages use spaCy for pre-segmentation
# English uses spaCy only for subtitle generation (post-TTS)
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
# spaCy is also disabled when input is a subtitle file
is_subtitle_input = (
not self.is_direct_text
and self.file_name
and os.path.splitext(self.file_name)[1].lower()
in [".srt", ".ass", ".vtt"]
)
use_spacy = (
getattr(self, "use_spacy_segmentation", False)
and self.subtitle_mode not in ["Disabled", "Line"]
and not is_subtitle_input
)
spacy_sentences = None
active_split_pattern = self.split_pattern
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
# Pre-load spaCy model for English if it will be needed for subtitle generation # Process each voice segment within the chapter
if ( for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
use_spacy # Load voice for this segment (with caching)
and self.lang_code in ["a", "b"] try:
and self.subtitle_mode in ["Sentence", "Sentence + Comma"] loaded_voice = self.load_voice_cached(voice_name, tts)
): if segment_idx > 0:
from abogen.spacy_utils import get_spacy_model voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
nlp = get_spacy_model( except Exception:
self.lang_code,
log_callback=lambda msg: self.log_updated.emit(msg),
)
if nlp:
self.log_updated.emit( self.log_updated.emit(
( (f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
"grey",
)
) )
if segment_idx == 0:
loaded_voice = self.load_voice_cached(self.voice, tts)
if use_spacy and self.lang_code not in ["a", "b"]: # Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Non-English: use spaCy for pre-TTS segmentation # Only non-English languages use spaCy for pre-segmentation
self.log_updated.emit( # English uses spaCy only for subtitle generation (post-TTS)
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") # spaCy is disabled when subtitle mode is "Disabled" or "Line"
# spaCy is also disabled when input is a subtitle file
is_subtitle_input = (
not self.is_direct_text
and self.file_name
and os.path.splitext(self.file_name)[1].lower()
in [".srt", ".ass", ".vtt"]
) )
from abogen.spacy_utils import segment_sentences use_spacy = (
getattr(self, "use_spacy_segmentation", False)
spacy_sentences = segment_sentences( and self.subtitle_mode not in ["Disabled", "Line"]
chapter_text, and not is_subtitle_input
self.lang_code,
log_callback=lambda msg: self.log_updated.emit(msg),
) )
if spacy_sentences: spacy_sentences = None
self.log_updated.emit( active_split_pattern = self.split_pattern
( spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
"grey",
)
)
# For Sentence + Comma mode, still split on commas within spaCy sentences
if self.subtitle_mode == "Sentence + Comma":
active_split_pattern = r"(?<=[{}]){}|\n+".format(
self.PUNCTUATION_COMMAS, spacing_pattern
)
else:
active_split_pattern = (
"\n" # Use newline splitting for Sentence mode
)
else:
self.log_updated.emit(
("\nspaCy: Fallback to default segmentation...", "grey")
)
# Process text - either as spaCy sentences or as single text # Pre-load spaCy model for English if it will be needed for subtitle generation
text_segments = spacy_sentences if spacy_sentences else [chapter_text] if (
use_spacy
# Print active split pattern used by the TTS engine once for this batch and self.lang_code in ["a", "b"]
try: and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
print(f"Using split pattern: {active_split_pattern!r}")
except Exception:
# Print must never break processing
print("Using split pattern: (unprintable)")
for text_segment in text_segments:
for result in tts(
text_segment,
voice=loaded_voice,
speed=self.speed,
split_pattern=active_split_pattern,
): ):
# Print the result for debugging from abogen.spacy_utils import get_spacy_model
# print(f"Result: {result}")
if self.cancel_requested: nlp = get_spacy_model(
if chapter_out_file: self.lang_code,
chapter_out_file.close() log_callback=lambda msg: self.log_updated.emit(msg),
if merged_out_file:
merged_out_file.close()
self.conversion_finished.emit("Cancelled", None)
return
current_segment += 1
grapheme_len = len(result.graphemes)
self.processed_char_count += grapheme_len
# Log progress with both character counts and the graphemes content
self.log_updated.emit(
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
) )
if nlp:
self.log_updated.emit(
(
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
"grey",
)
)
chunk_dur = len(result.audio) / rate if use_spacy and self.lang_code not in ["a", "b"]:
chunk_start = current_time # Non-English: use spaCy for pre-TTS segmentation
# Write audio directly to merged file ONLY if merging self.log_updated.emit(
if merge_chapters_at_end and merged_out_file: ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
merged_out_file.write(result.audio) )
elif merge_chapters_at_end and ffmpeg_proc: from abogen.spacy_utils import segment_sentences
if hasattr(result.audio, "numpy"):
audio_bytes = ( spacy_sentences = segment_sentences(
result.audio.numpy().astype("float32").tobytes() segment_text,
self.lang_code,
log_callback=lambda msg: self.log_updated.emit(msg),
)
if spacy_sentences:
self.log_updated.emit(
(
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
"grey",
)
)
# For Sentence + Comma mode, still split on commas within spaCy sentences
if self.subtitle_mode == "Sentence + Comma":
active_split_pattern = r"(?<=[{}]){}|\n+".format(
self.PUNCTUATION_COMMAS, spacing_pattern
) )
else: else:
audio_bytes = result.audio.astype("float32").tobytes() active_split_pattern = (
ffmpeg_proc.stdin.write(audio_bytes) "\n" # Use newline splitting for Sentence mode
if chapter_out_file:
chapter_out_file.write(result.audio)
elif chapter_ffmpeg_proc:
if hasattr(result.audio, "numpy"):
audio_bytes = (
result.audio.numpy().astype("float32").tobytes()
) )
else: else:
audio_bytes = result.audio.astype("float32").tobytes() self.log_updated.emit(
chapter_ffmpeg_proc.stdin.write(audio_bytes) ("\nspaCy: Fallback to default segmentation...", "grey")
# Subtitle logic )
if self.subtitle_mode != "Disabled":
tokens_list = getattr(result, "tokens", [])
# Fallback for languages without token support (non-English) # Process text - either as spaCy sentences or as single text
# Create a single token representing the entire segment duration text_segments = spacy_sentences if spacy_sentences else [segment_text]
if not tokens_list and result.graphemes:
class FakeToken: # Print active split pattern used by the TTS engine once for this batch
def __init__(self, text, start, end): try:
self.text = text print(f"Using split pattern: {active_split_pattern!r}")
self.start_ts = start except Exception:
self.end_ts = end # Print must never break processing
self.whitespace = "" print("Using split pattern: (unprintable)")
tokens_list = [ for text_segment in text_segments:
FakeToken(result.graphemes, 0, chunk_dur) for result in tts(
] text_segment,
voice=loaded_voice,
speed=self.speed,
split_pattern=active_split_pattern,
):
# Print the result for debugging
# print(f"Result: {result}")
if self.cancel_requested:
if chapter_out_file:
chapter_out_file.close()
if merged_out_file:
merged_out_file.close()
self.conversion_finished.emit("Cancelled", None)
return
current_segment += 1
grapheme_len = len(result.graphemes)
self.processed_char_count += grapheme_len
# Log progress with both character counts and the graphemes content
self.log_updated.emit(
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
)
tokens_with_timestamps = [] chunk_dur = len(result.audio) / rate
chapter_tokens_with_timestamps = [] chunk_start = current_time
# Write audio directly to merged file ONLY if merging
if merge_chapters_at_end and merged_out_file:
merged_out_file.write(result.audio)
elif merge_chapters_at_end and ffmpeg_proc:
if hasattr(result.audio, "numpy"):
audio_bytes = (
result.audio.numpy().astype("float32").tobytes()
)
else:
audio_bytes = result.audio.astype("float32").tobytes()
ffmpeg_proc.stdin.write(audio_bytes)
if chapter_out_file:
chapter_out_file.write(result.audio)
elif chapter_ffmpeg_proc:
if hasattr(result.audio, "numpy"):
audio_bytes = (
result.audio.numpy().astype("float32").tobytes()
)
else:
audio_bytes = result.audio.astype("float32").tobytes()
chapter_ffmpeg_proc.stdin.write(audio_bytes)
# Subtitle logic
if self.subtitle_mode != "Disabled":
tokens_list = getattr(result, "tokens", [])
# Process every token, regardless of text or timestamps # Fallback for languages without token support (non-English)
for tok in tokens_list: # Create a single token representing the entire segment duration
tokens_with_timestamps.append( if not tokens_list and result.graphemes:
{
"start": chunk_start + (tok.start_ts or 0), class FakeToken:
"end": chunk_start + (tok.end_ts or 0), def __init__(self, text, start, end):
"text": tok.text, self.text = text
"whitespace": tok.whitespace, self.start_ts = start
} self.end_ts = end
) self.whitespace = ""
if chapter_out_file or chapter_ffmpeg_proc:
chapter_tokens_with_timestamps.append( tokens_list = [
FakeToken(result.graphemes, 0, chunk_dur)
]
tokens_with_timestamps = []
chapter_tokens_with_timestamps = []
# Process every token, regardless of text or timestamps
for tok in tokens_list:
tokens_with_timestamps.append(
{ {
"start": chapter_current_time "start": chunk_start + (tok.start_ts or 0),
+ (tok.start_ts or 0), "end": chunk_start + (tok.end_ts or 0),
"end": chapter_current_time
+ (tok.end_ts or 0),
"text": tok.text, "text": tok.text,
"whitespace": tok.whitespace, "whitespace": tok.whitespace,
} }
) )
# Process tokens according to subtitle mode if chapter_out_file or chapter_ffmpeg_proc:
# Global subtitle processing ONLY if merging chapter_tokens_with_timestamps.append(
{
"start": chapter_current_time
+ (tok.start_ts or 0),
"end": chapter_current_time
+ (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
)
# Process tokens according to subtitle mode
# Global subtitle processing ONLY if merging
if merge_chapters_at_end:
# Incremental subtitle writing for merged output
new_entries = []
self._process_subtitle_tokens(
tokens_with_timestamps,
new_entries,
self.max_subtitle_words,
fallback_end_time=chunk_start + chunk_dur,
)
if merged_subtitle_file:
subtitle_format = getattr(
self, "subtitle_format", "srt"
)
if "ass" in subtitle_format:
for start, end, text in new_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
merged_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
)
else:
for entry in new_entries:
start, end, text = entry
merged_subtitle_file.write(
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
merged_srt_index += 1
# Per-chapter subtitle processing for both file and ffmpeg_proc
if chapter_out_file or chapter_ffmpeg_proc:
new_chapter_entries = []
self._process_subtitle_tokens(
chapter_tokens_with_timestamps,
new_chapter_entries,
self.max_subtitle_words,
fallback_end_time=chapter_current_time + chunk_dur,
)
if chapter_subtitle_file:
subtitle_format = getattr(
self, "subtitle_format", "srt"
)
if "ass" in subtitle_format:
for start, end, text in new_chapter_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
chapter_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
)
else:
for entry in new_chapter_entries:
start, end, text = entry
chapter_subtitle_file.write(
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
chapter_srt_index += 1
if merge_chapters_at_end: if merge_chapters_at_end:
# Incremental subtitle writing for merged output current_time += chunk_dur
new_entries = [] if chapter_out_file or chapter_ffmpeg_proc:
self._process_subtitle_tokens( chapter_current_time += chunk_dur
tokens_with_timestamps, else:
new_entries, if chapter_out_file or chapter_ffmpeg_proc:
self.max_subtitle_words, chapter_current_time += chunk_dur
fallback_end_time=chunk_start + chunk_dur, # Calculate percentage based on characters processed
) percent = min(
if merged_subtitle_file: int(
subtitle_format = getattr( self.processed_char_count / self.total_char_count * 100
self, "subtitle_format", "srt" ),
) 99,
if "ass" in subtitle_format:
for start, end, text in new_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
merged_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
)
else:
for entry in new_entries:
start, end, text = entry
merged_subtitle_file.write(
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
merged_srt_index += 1
# Per-chapter subtitle processing for both file and ffmpeg_proc
if chapter_out_file or chapter_ffmpeg_proc:
new_chapter_entries = []
self._process_subtitle_tokens(
chapter_tokens_with_timestamps,
new_chapter_entries,
self.max_subtitle_words,
fallback_end_time=chapter_current_time + chunk_dur,
)
if chapter_subtitle_file:
subtitle_format = getattr(
self, "subtitle_format", "srt"
)
if "ass" in subtitle_format:
for start, end, text in new_chapter_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
chapter_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
)
else:
for entry in new_chapter_entries:
start, end, text = entry
chapter_subtitle_file.write(
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
chapter_srt_index += 1
if merge_chapters_at_end:
current_time += chunk_dur
if chapter_out_file or chapter_ffmpeg_proc:
chapter_current_time += chunk_dur
else:
if chapter_out_file or chapter_ffmpeg_proc:
chapter_current_time += chunk_dur
# Calculate percentage based on characters processed
percent = min(
int(
self.processed_char_count / self.total_char_count * 100
),
99,
)
# Calculate ETR based on characters processed
etr_str = "Processing..."
chars_done = self.processed_char_count
elapsed = time.time() - self.etr_start_time
# Calculate ETR if enough data is available
if (
chars_done > 0 and elapsed > 0.5
): # Check elapsed > 0.5 to avoid instability
avg_time_per_char = elapsed / chars_done
remaining = (
self.total_char_count - self.processed_char_count
) )
if remaining > 0:
secs = avg_time_per_char * remaining
h = int(secs // 3600)
m = int((secs % 3600) // 60)
s = int(secs % 60)
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
# Update progress more frequently (after each result) # Calculate ETR based on characters processed
self.progress_updated.emit(percent, etr_str) etr_str = "Processing..."
chars_done = self.processed_char_count
elapsed = time.time() - self.etr_start_time
# Calculate ETR if enough data is available
if (
chars_done > 0 and elapsed > 0.5
): # Check elapsed > 0.5 to avoid instability
avg_time_per_char = elapsed / chars_done
remaining = (
self.total_char_count - self.processed_char_count
)
if remaining > 0:
secs = avg_time_per_char * remaining
h = int(secs // 3600)
m = int((secs % 3600) // 60)
s = int(secs % 60)
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
# Update progress more frequently (after each result)
self.progress_updated.emit(percent, etr_str)
# 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:
+241 -7
View File
@@ -74,7 +74,7 @@ from abogen.subtitle_utils import (
calculate_text_length, calculate_text_length,
) )
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
from abogen.pyqt.book_handler import HandlerDialog from abogen.pyqt.book_handler import HandlerDialog
from abogen.constants import ( from abogen.constants import (
PROGRAM_NAME, PROGRAM_NAME,
@@ -665,6 +665,11 @@ class TextboxDialog(QDialog):
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
button_layout.addWidget(self.insert_chapter_btn) button_layout.addWidget(self.insert_chapter_btn)
self.insert_voice_btn = QPushButton("Insert Voice Marker", self)
self.insert_voice_btn.setToolTip("Insert a voice change marker at the cursor position")
self.insert_voice_btn.clicked.connect(self.insert_voice_marker)
button_layout.addWidget(self.insert_voice_btn)
self.cancel_button = QPushButton("Cancel", self) self.cancel_button = QPushButton("Cancel", self)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
@@ -767,6 +772,23 @@ class TextboxDialog(QDialog):
self.update_char_count() self.update_char_count()
self.text_edit.setFocus() self.text_edit.setFocus()
def insert_voice_marker(self):
"""Insert a voice marker template at cursor position."""
cursor = self.text_edit.textCursor()
# Use the currently selected voice as the default
try:
parent_window = self.parent()
if parent_window and hasattr(parent_window, 'selected_voice'):
default_voice = parent_window.selected_voice or "af_heart"
else:
default_voice = "af_heart"
except Exception:
default_voice = "af_heart"
cursor.insertText(f"\n<<VOICE:{default_voice}>>\n")
self.text_edit.setTextCursor(cursor)
self.update_char_count()
self.text_edit.setFocus()
def migrate_subtitle_format(config): def migrate_subtitle_format(config):
"""Convert old subtitle_format values to new internal keys.""" """Convert old subtitle_format values to new internal keys."""
@@ -783,6 +805,108 @@ def migrate_subtitle_format(config):
save_config(config) save_config(config)
class WordSubstitutionsDialog(QDialog):
"""Dialog for configuring word substitutions and text preprocessing options."""
def __init__(
self,
parent=None,
initial_list="",
initial_case_sensitive=False,
initial_caps=False,
initial_numerals=False,
initial_punctuation=False,
):
super().__init__(parent)
self.setWindowTitle("Word Substitutions Settings")
self.setWindowFlags(
Qt.WindowType.Window
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
self.resize(600, 500)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(
"Enter word substitutions (one per line) in format: Word|NewWord\n"
" - If nothing after |, the word will be erased completely\n"
" - Substitutions match whole words only (e.g., \"tree\" won't match \"trees\" but will match \"tree's\")\n"
" - By default, matching is case-insensitive (e.g., \"gonna\" matches \"Gonna\", \"GONNA\", etc.)",
self,
)
instructions.setStyleSheet(
"padding: 10px; background-color: #f0f0f0; border-radius: 5px;"
)
instructions.setWordWrap(True)
layout.addWidget(instructions)
# Text edit area
self.text_edit = QTextEdit(self)
self.text_edit.setAcceptRichText(False)
self.text_edit.setPlaceholderText("Word|NewWord")
self.text_edit.setPlainText(initial_list)
layout.addWidget(self.text_edit)
# Checkboxes
self.case_sensitive_checkbox = QCheckBox(
"Case-sensitive word matching", self
)
self.case_sensitive_checkbox.setChecked(initial_case_sensitive)
layout.addWidget(self.case_sensitive_checkbox)
self.caps_checkbox = QCheckBox("Replace ALL CAPS with lowercase", self)
self.caps_checkbox.setChecked(initial_caps)
layout.addWidget(self.caps_checkbox)
self.numerals_checkbox = QCheckBox(
"Replace Numerals with Words (e.g., 309 \u2192 three hundred and nine)", self
)
self.numerals_checkbox.setChecked(initial_numerals)
layout.addWidget(self.numerals_checkbox)
self.punctuation_checkbox = QCheckBox(
"Fix Nonstandard Punctuation (curly quotes and other Unicode punctuation that may affect how words sound)",
self,
)
self.punctuation_checkbox.setChecked(initial_punctuation)
layout.addWidget(self.punctuation_checkbox)
# Buttons
button_layout = QHBoxLayout()
self.cancel_button = QPushButton("Cancel", self)
self.cancel_button.clicked.connect(self.reject)
self.ok_button = QPushButton("OK", self)
self.ok_button.setDefault(True)
self.ok_button.clicked.connect(self.accept)
button_layout.addStretch()
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.ok_button)
layout.addLayout(button_layout)
def get_substitutions_list(self):
"""Get the substitutions list as plain text."""
return self.text_edit.toPlainText()
def get_case_sensitive(self):
"""Get whether case-sensitive matching is enabled."""
return self.case_sensitive_checkbox.isChecked()
def get_replace_all_caps(self):
"""Get whether ALL CAPS replacement is enabled."""
return self.caps_checkbox.isChecked()
def get_replace_numerals(self):
"""Get whether numeral-to-word conversion is enabled."""
return self.numerals_checkbox.isChecked()
def get_fix_nonstandard_punctuation(self):
"""Get whether nonstandard punctuation fixing is enabled."""
return self.punctuation_checkbox.isChecked()
class abogen(QWidget): class abogen(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -833,6 +957,19 @@ class abogen(QWidget):
self.use_silent_gaps = self.config.get("use_silent_gaps", True) self.use_silent_gaps = self.config.get("use_silent_gaps", True)
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
# Word substitution settings
self.word_substitutions_enabled = self.config.get(
"word_substitutions_enabled", False
)
self.word_substitutions_list = self.config.get("word_substitutions_list", "")
self.case_sensitive_substitutions = self.config.get(
"case_sensitive_substitutions", False
)
self.replace_all_caps = self.config.get("replace_all_caps", False)
self.replace_numerals = self.config.get("replace_numerals", False)
self.fix_nonstandard_punctuation = self.config.get(
"fix_nonstandard_punctuation", False
)
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
@@ -1071,6 +1208,35 @@ class abogen(QWidget):
subtitle_layout.addWidget(self.subtitle_combo) subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout) controls_layout.addLayout(subtitle_layout)
# Word Substitutions section
word_sub_layout = QHBoxLayout()
word_sub_layout.setSpacing(7)
word_sub_label = QLabel("Word Substitutions:", self)
word_sub_layout.addWidget(word_sub_label)
self.word_sub_combo = QComboBox(self)
self.word_sub_combo.addItems(["Disabled", "Enabled"])
self.word_sub_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }"
)
self.word_sub_combo.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.word_sub_combo.setCurrentText(
"Enabled" if self.word_substitutions_enabled else "Disabled"
)
self.word_sub_combo.currentTextChanged.connect(self.on_word_sub_changed)
word_sub_layout.addWidget(self.word_sub_combo)
self.btn_word_sub_settings = QPushButton("Settings", self)
self.btn_word_sub_settings.setFixedSize(80, 36)
self.btn_word_sub_settings.setStyleSheet("QPushButton { padding: 6px 12px; }")
self.btn_word_sub_settings.clicked.connect(self.show_word_sub_dialog)
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
word_sub_layout.addWidget(self.btn_word_sub_settings)
controls_layout.addLayout(word_sub_layout)
# Output voice format # Output voice format
format_layout = QHBoxLayout() format_layout = QHBoxLayout()
format_layout.setSpacing(7) format_layout.setSpacing(7)
@@ -2015,15 +2181,37 @@ class abogen(QWidget):
self.subtitle_speed_method = getattr( self.subtitle_speed_method = getattr(
queued_item, "subtitle_speed_method", "tts" queued_item, "subtitle_speed_method", "tts"
) )
# Word substitution settings
self.word_substitutions_enabled = getattr(
queued_item, "word_substitutions_enabled", False
)
self.word_substitutions_list = getattr(
queued_item, "word_substitutions_list", ""
)
self.case_sensitive_substitutions = getattr(
queued_item, "case_sensitive_substitutions", False
)
self.replace_all_caps = getattr(queued_item, "replace_all_caps", False)
self.replace_numerals = getattr(queued_item, "replace_numerals", False)
self.fix_nonstandard_punctuation = getattr(
queued_item, "fix_nonstandard_punctuation", False
)
# This ensures that if conversion.py (or utils) reads from config/disk # This ensures that if conversion.py (or utils) reads from config/disk
# instead of using passed arguments, it sees the correct queue values. # instead of using passed arguments, it sees the correct queue values.
self.config["replace_single_newlines"] = self.replace_single_newlines self.config["replace_single_newlines"] = self.replace_single_newlines
self.config["subtitle_mode"] = self.subtitle_mode self.config["subtitle_mode"] = self.subtitle_mode
self.config["selected_format"] = self.selected_format self.config["selected_format"] = self.selected_format
self.config["use_silent_gaps"] = self.use_silent_gaps self.config["use_silent_gaps"] = self.use_silent_gaps
self.config["subtitle_speed_method"] = self.subtitle_speed_method self.config["subtitle_speed_method"] = self.subtitle_speed_method
# Word substitution settings
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
self.config["word_substitutions_list"] = self.word_substitutions_list
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
self.config["replace_all_caps"] = self.replace_all_caps
self.config["replace_numerals"] = self.replace_numerals
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
# 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:
@@ -2179,6 +2367,21 @@ class abogen(QWidget):
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
# Pass use_spacy_segmentation setting # Pass use_spacy_segmentation setting
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
# Pass word substitution settings
self.conversion_thread.word_substitutions_enabled = (
self.word_substitutions_enabled
)
self.conversion_thread.word_substitutions_list = (
self.word_substitutions_list
)
self.conversion_thread.case_sensitive_substitutions = (
self.case_sensitive_substitutions
)
self.conversion_thread.replace_all_caps = self.replace_all_caps
self.conversion_thread.replace_numerals = self.replace_numerals
self.conversion_thread.fix_nonstandard_punctuation = (
self.fix_nonstandard_punctuation
)
# Pass separate_chapters_format setting # Pass separate_chapters_format setting
self.conversion_thread.separate_chapters_format = ( self.conversion_thread.separate_chapters_format = (
self.separate_chapters_format self.separate_chapters_format
@@ -2927,6 +3130,41 @@ class abogen(QWidget):
self.config["use_gpu"] = self.use_gpu self.config["use_gpu"] = self.use_gpu
save_config(self.config) save_config(self.config)
def on_word_sub_changed(self, text):
"""Handle word substitution dropdown change."""
self.word_substitutions_enabled = text == "Enabled"
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
# Save to config
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
save_config(self.config)
def show_word_sub_dialog(self):
"""Show word substitutions settings dialog."""
dialog = WordSubstitutionsDialog(
self,
initial_list=self.word_substitutions_list,
initial_case_sensitive=self.case_sensitive_substitutions,
initial_caps=self.replace_all_caps,
initial_numerals=self.replace_numerals,
initial_punctuation=self.fix_nonstandard_punctuation,
)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.word_substitutions_list = dialog.get_substitutions_list()
self.case_sensitive_substitutions = dialog.get_case_sensitive()
self.replace_all_caps = dialog.get_replace_all_caps()
self.replace_numerals = dialog.get_replace_numerals()
self.fix_nonstandard_punctuation = dialog.get_fix_nonstandard_punctuation()
# Save all settings to config
self.config["word_substitutions_list"] = self.word_substitutions_list
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
self.config["replace_all_caps"] = self.replace_all_caps
self.config["replace_numerals"] = self.replace_numerals
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
save_config(self.config)
def cleanup_conversion_thread(self): def cleanup_conversion_thread(self):
# Stop conversion thread # Stop conversion thread
if ( if (
@@ -2991,8 +3229,6 @@ class abogen(QWidget):
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file""" """Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
# Check if this is a timestamp detection (-1) or chapter detection # Check if this is a timestamp detection (-1) or chapter detection
if chapter_count == -1: if chapter_count == -1:
from abogen.conversion import TimestampDetectionDialog
dialog = TimestampDetectionDialog(parent=self) dialog = TimestampDetectionDialog(parent=self)
dialog.setWindowModality(Qt.WindowModality.ApplicationModal) dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
@@ -3007,8 +3243,6 @@ class abogen(QWidget):
return return
# Normal chapter detection # Normal chapter detection
from abogen.conversion import ChapterOptionsDialog
dialog = ChapterOptionsDialog(chapter_count, parent=self) dialog = ChapterOptionsDialog(chapter_count, parent=self)
dialog.setWindowModality(Qt.WindowModality.ApplicationModal) dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
+21
View File
@@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [
"replace_single_newlines", "replace_single_newlines",
"use_silent_gaps", "use_silent_gaps",
"subtitle_speed_method", "subtitle_speed_method",
"word_substitutions_enabled",
"word_substitutions_list",
"case_sensitive_substitutions",
"replace_all_caps",
"replace_numerals",
"fix_nonstandard_punctuation",
] ]
@@ -474,6 +480,21 @@ class QueueManager(QDialog):
attrs["subtitle_speed_method"] = getattr( attrs["subtitle_speed_method"] = getattr(
parent, "subtitle_speed_method", "tts" parent, "subtitle_speed_method", "tts"
) )
# word substitutions
attrs["word_substitutions_enabled"] = getattr(
parent, "word_substitutions_enabled", False
)
attrs["word_substitutions_list"] = getattr(
parent, "word_substitutions_list", ""
)
attrs["case_sensitive_substitutions"] = getattr(
parent, "case_sensitive_substitutions", False
)
attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False)
attrs["replace_numerals"] = getattr(parent, "replace_numerals", False)
attrs["fix_nonstandard_punctuation"] = getattr(
parent, "fix_nonstandard_punctuation", False
)
# book handler options # book handler options
attrs["save_chapters_separately"] = getattr( attrs["save_chapters_separately"] = getattr(
parent, "save_chapters_separately", None parent, "save_chapters_separately", None
+7
View File
@@ -19,3 +19,10 @@ class QueuedItem:
save_base_path: str = None save_base_path: str = None
save_chapters_separately: bool = None save_chapters_separately: bool = None
merge_chapters_at_end: bool = None merge_chapters_at_end: bool = None
# Word Substitution fields
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
+125 -2
View File
@@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N") _ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n") _ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>") _CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<<VOICE:(.*?)>>")
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE) _WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL) _VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL) _VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
@@ -31,17 +33,19 @@ _LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]")
def clean_subtitle_text(text): def clean_subtitle_text(text):
"""Remove chapter markers and metadata tags from subtitle text.""" """Remove chapter markers, voice markers, and metadata tags from subtitle text."""
# Use pre-compiled patterns for better performance # Use pre-compiled patterns for better performance
text = _METADATA_TAG_PATTERN.sub("", text) text = _METADATA_TAG_PATTERN.sub("", text)
text = _CHAPTER_MARKER_PATTERN.sub("", text) text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
return text.strip() return text.strip()
def calculate_text_length(text): def calculate_text_length(text):
# Use pre-compiled patterns for better performance # Use pre-compiled patterns for better performance
# Ignore chapter markers and metadata patterns in a single pass # Ignore chapter markers, voice markers, and metadata patterns in a single pass
text = _CHAPTER_MARKER_PATTERN.sub("", text) text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
text = _METADATA_TAG_PATTERN.sub("", text) text = _METADATA_TAG_PATTERN.sub("", text)
# Ignore newlines and leading/trailing spaces # Ignore newlines and leading/trailing spaces
text = text.replace("\n", "").strip() text = text.replace("\n", "").strip()
@@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True):
sanitized = sanitized[:255].rstrip(". ") sanitized = sanitized[:255].rstrip(". ")
return sanitized return sanitized
def validate_voice_name(voice_name):
"""Validate voice name against VOICES_INTERNAL list (case-insensitive).
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
Args:
voice_name: Voice name or formula string to validate
Returns:
Tuple of (is_valid, invalid_voice_name):
- is_valid: True if all voices in the name/formula are valid
- invalid_voice_name: The first invalid voice found, or None if all valid
"""
from abogen.constants import VOICES_INTERNAL
# Create case-insensitive lookup set (done once per call)
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
voice_name = voice_name.strip()
# Check if it's a formula (contains *)
if "*" in voice_name:
# Extract voice names from formula
voices = voice_name.split("+")
for term in voices:
if "*" in term:
base_voice = term.split("*")[0].strip()
# Case-insensitive comparison
if base_voice.lower() not in voice_lookup_lower:
return False, base_voice
return True, None
else:
# Single voice - case-insensitive comparison
if voice_name.lower() not in voice_lookup_lower:
return False, voice_name
return True, None
def split_text_by_voice_markers(text, default_voice):
"""Split text by voice markers, returning list of (voice, text) tuples.
IMPORTANT: Returns the last voice used so it can persist across chapters.
Voice names are normalized to lowercase to match VOICES_INTERNAL.
Args:
text: Text potentially containing <<VOICE:name>> markers
default_voice: Voice to use if no markers found or before first marker
Returns:
Tuple of (segments_list, last_voice_used, valid_count, invalid_count):
- segments_list: List of (voice_name, segment_text) tuples
- last_voice_used: The voice that should continue into next chapter
- valid_count: Number of valid voice markers processed
- invalid_count: Number of invalid voice markers skipped
"""
from abogen.constants import VOICES_INTERNAL
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
if not voice_splits:
# No voice markers, return entire text with default voice
return [(default_voice, text)], default_voice, 0, 0
segments = []
current_voice = default_voice
valid_markers = 0
invalid_markers = 0
# Text before first marker uses default voice
first_start = voice_splits[0].start()
if first_start > 0:
intro_text = text[:first_start].strip()
if intro_text:
segments.append((current_voice, intro_text))
# Process each voice marker
for idx, match in enumerate(voice_splits):
voice_name = match.group(1).strip()
start = match.end()
end = voice_splits[idx + 1].start() if idx + 1 < len(voice_splits) else len(text)
segment_text = text[start:end].strip()
# Validate voice name
is_valid, invalid_voice = validate_voice_name(voice_name)
if is_valid:
# Normalize to lowercase to match canonical form
# Handle both single voices and formulas
if "*" in voice_name:
# Normalize each voice in the formula
normalized_parts = []
for part in voice_name.split("+"):
part = part.strip()
if "*" in part:
voice_part, weight = part.split("*", 1)
# Find the canonical (lowercase) voice name
voice_part_lower = voice_part.strip().lower()
canonical_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower),
voice_part.strip()
)
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
current_voice = " + ".join(normalized_parts)
else:
# Find the canonical (lowercase) voice name
voice_name_lower = voice_name.lower()
current_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower),
voice_name
)
valid_markers += 1
else:
# Invalid voice - stay with previous voice
invalid_markers += 1
if segment_text:
segments.append((current_voice, segment_text))
# Return segments, last voice, and counts
return segments, current_voice, valid_markers, invalid_markers
+6 -1
View File
@@ -1023,8 +1023,13 @@ class EpubExtractor:
if not html: if not html:
return "" return ""
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["p", "div"]):
# Add line breaks after block-level elements to ensure pauses in speech
for tag in soup.find_all(
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
):
tag.append("\n\n") tag.append("\n\n")
for ol in soup.find_all("ol"): for ol in soup.find_all("ol"):
start_attr = ol.get("start") start_attr = ol.get("start")
try: try:
+37 -12
View File
@@ -17,10 +17,43 @@ _preview_pipeline_lock = threading.Lock()
def _select_device() -> str: def _select_device() -> str:
import platform import platform
try:
import torch # type: ignore[import-not-found]
except Exception:
return "cpu"
system = platform.system() system = platform.system()
if system == "Darwin" and platform.processor() == "arm": if system == "Darwin" and platform.processor() == "arm":
return "mps" try:
return "cuda" if torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
try:
if torch.cuda.is_available():
return "cuda"
except Exception:
pass
return "cpu"
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
devices: List[str] = ["cpu"]
if use_gpu:
preferred = _select_device()
if preferred != "cpu":
devices.insert(0, preferred)
last_error: Optional[Exception] = None
for device in devices:
try:
return get_preview_pipeline(language, device), device != "cpu"
except Exception as exc:
last_error = exc
raise RuntimeError("Preview pipeline is unavailable") from last_error
def _to_float32(audio_segment) -> np.ndarray: def _to_float32(audio_segment) -> np.ndarray:
@@ -115,15 +148,7 @@ def generate_preview_audio(
total_steps=supertonic_total_steps, total_steps=supertonic_total_steps,
) )
else: else:
device = "cpu" pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
if use_gpu:
try:
device = _select_device()
except Exception:
device = "cpu"
use_gpu = False
pipeline = get_preview_pipeline(language, device)
if pipeline is None: if pipeline is None:
raise RuntimeError("Preview pipeline is unavailable") raise RuntimeError("Preview pipeline is unavailable")
@@ -131,7 +156,7 @@ def generate_preview_audio(
if voice_spec and "*" in voice_spec: if voice_spec and "*" in voice_spec:
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
+254
View File
@@ -0,0 +1,254 @@
"""
Word substitution module for text-to-speech preprocessing.
This module provides functionality to:
- Replace words/phrases with custom text
- Convert ALL CAPS to lowercase
- Convert numerals to words
- Fix nonstandard punctuation for TTS compatibility
All substitutions preserve special markers (chapter, voice, metadata, timestamps).
"""
import re
from abogen.subtitle_utils import (
_CHAPTER_MARKER_PATTERN,
_VOICE_MARKER_PATTERN,
_METADATA_TAG_PATTERN,
_TIMESTAMP_ONLY_PATTERN,
)
def apply_word_substitutions(
text,
substitutions_list_str,
case_sensitive=False,
replace_all_caps=False,
replace_numerals=False,
fix_nonstandard_punctuation=False,
):
"""
Apply word substitutions to text while preserving markers.
Args:
text: Input text
substitutions_list_str: Newline-separated "Word|NewWord" pairs
case_sensitive: If True, match words case-sensitively
replace_all_caps: Convert ALL CAPS words to lowercase
replace_numerals: Convert numbers to words
fix_nonstandard_punctuation: Fix curly quotes, em/en dashes, etc.
Returns:
Modified text
"""
# Apply nonstandard punctuation fixes FIRST (if enabled)
if fix_nonstandard_punctuation:
text = fix_punctuation(text)
# Parse substitutions list
substitutions = parse_substitutions_list(substitutions_list_str)
# Split text into segments (markers vs content)
segments = split_text_preserving_markers(text)
# Process each segment
processed_segments = []
for segment_type, segment_text in segments:
if segment_type == "marker":
# Preserve markers unchanged
processed_segments.append(segment_text)
else:
# Apply substitutions to content
processed_text = segment_text
# Apply word substitutions
if substitutions:
processed_text = apply_word_replacements(
processed_text, substitutions, case_sensitive
)
# Apply ALL CAPS conversion
if replace_all_caps:
processed_text = convert_all_caps_to_lowercase(processed_text)
# Apply numeral conversion
if replace_numerals:
processed_text = convert_numerals_to_words(processed_text)
processed_segments.append(processed_text)
return "".join(processed_segments)
def parse_substitutions_list(substitutions_str):
"""
Parse newline-separated "Word|NewWord" format.
Args:
substitutions_str: String with substitutions, one per line
Returns:
List of tuples: [(word, replacement), ...]
"""
substitutions = []
for line in substitutions_str.strip().split("\n"):
line = line.strip()
if not line or "|" not in line:
continue
parts = line.split("|", 1)
if len(parts) == 2:
word = parts[0].strip()
replacement = parts[1].strip()
if word: # Only add if word is not empty
substitutions.append((word, replacement))
return substitutions
def split_text_preserving_markers(text):
"""
Split text into segments alternating between markers and content.
Args:
text: Input text with potential markers
Returns:
List of tuples: [("marker"|"content", text), ...]
"""
# Combined pattern for all markers and timestamps
marker_pattern = re.compile(
r"(<<CHAPTER_MARKER:[^>]*>>|<<VOICE:[^>]*>>|<<METADATA_[^:]+:[^>]*>>|\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)"
)
segments = []
last_end = 0
for match in marker_pattern.finditer(text):
# Content before marker
if match.start() > last_end:
segments.append(("content", text[last_end : match.start()]))
# Marker itself
segments.append(("marker", match.group(0)))
last_end = match.end()
# Remaining content after last marker
if last_end < len(text):
segments.append(("content", text[last_end:]))
return segments
def apply_word_replacements(text, substitutions, case_sensitive=False):
"""
Apply word substitutions using whole-word matching.
Args:
text: Input text
substitutions: List of (word, replacement) tuples
case_sensitive: If True, match case-sensitively
Returns:
Text with substitutions applied
"""
for word, replacement in substitutions:
# Use word boundaries for exact matching
# Escape special regex characters
escaped_word = re.escape(word)
pattern = re.compile(
r"\b" + escaped_word + r"\b",
0 if case_sensitive else re.IGNORECASE,
)
text = pattern.sub(replacement, text)
return text
def convert_all_caps_to_lowercase(text):
"""
Convert ALL CAPS words to lowercase.
Args:
text: Input text
Returns:
Text with ALL CAPS converted to lowercase
"""
def replace_caps(match):
word = match.group(0)
# Convert to lowercase
return word.lower()
# Match words that are ALL CAPS (2+ letters)
pattern = re.compile(r"\b[A-Z]{2,}\b")
return pattern.sub(replace_caps, text)
def convert_numerals_to_words(text):
"""
Convert numerals to words using num2words library.
Args:
text: Input text
Returns:
Text with numerals converted to words
"""
try:
from num2words import num2words
except ImportError:
# If num2words not available, return unchanged
return text
def replace_number(match):
try:
number = int(match.group(0))
# Convert to words in English
return num2words(number)
except Exception:
# If conversion fails, return original
return match.group(0)
# Match integers (but not timestamps or other patterns)
# Negative lookbehind/ahead to avoid timestamps
pattern = re.compile(r"(?<!\d:)\b\d+\b(?!:\d)")
return pattern.sub(replace_number, text)
def fix_punctuation(text):
"""
Convert nonstandard punctuation to standard equivalents.
This helps TTS engines pronounce words correctly by converting:
- Curly quotes to straight quotes
- Ellipsis to three periods
Args:
text: Input text
Returns:
Text with nonstandard punctuation fixed
"""
# Define replacements
replacements = {
# Curly double quotes
"\u201c": '"', # Left double quotation mark
"\u201d": '"', # Right double quotation mark
"\u201e": '"', # Double low-9 quotation mark
# Curly single quotes
"\u2018": "'", # Left single quotation mark
"\u2019": "'", # Right single quotation mark
"\u201a": "'", # Single low-9 quotation mark
"\u201b": "'", # Single high-reversed-9 quotation mark
# Other punctuation
"\u2026": "...", # Ellipsis
}
# Apply all replacements
for old_char, new_char in replacements.items():
text = text.replace(old_char, new_char)
return text
+5 -4
View File
@@ -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]
@@ -96,8 +99,6 @@ exclude = [
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["abogen"] packages = ["abogen"]
[tool.hatch.build]
include = ["abogen/webui/templates/**", "abogen/webui/static/**"]
[tool.hatch.version] [tool.hatch.version]
path = "abogen/VERSION" path = "abogen/VERSION"
Generated
+2888
View File
File diff suppressed because it is too large Load Diff