Compare commits

..
23 Commits
Author SHA1 Message Date
Artem Akymenko 28998e1e5c refactor: delete redundant _prepare_project_layout wrapper
resolve_project_layout() from domain already handles mkdir.
Callers now use the domain function directly.

Tests: 1253 passed
2026-07-20 10:10:50 +00:00
Artem Akymenko 8a220a936c refactor: extract extract_metadata_for_file() domain function, delete PyQt wrapper
domain/metadata_extraction.py gains extract_metadata_for_file() combining
read_text_for_metadata + extract_metadata_from_text. PyQt _extract_metadata_dict
deleted, calls replaced with domain function.

Tests: 1253 passed
2026-07-20 09:49:33 +00:00
Artem Akymenko ccc2cdb166 refactor: replace manual suffix loop with resolve_unique_path (#4)
PyQt output path resolution now uses resolve_unique_path() from domain
instead of a hand-rolled counter loop. Output path logic is now fully
shared via domain functions.

Tests: 1253 passed
2026-07-20 09:27:31 +00:00
Artem Akymenko 79ff7e4682 refactor: delete _process_subtitle_tokens wrapper, use domain function directly (#6)
PyQt now calls process_subtitle_tokens() from domain instead of a thin
wrapper that just forwarded self.subtitle_mode/lang_code/use_spacy.

Tests: 1253 passed
2026-07-20 09:22:31 +00:00
Artem Akymenko 2a54b8fdf1 refactor: extract synthesize_text() domain function (#2)
Combines TTSContext.normalize() + run_tts_segment_loop() into a single
domain function. Both UIs call synthesize_text() instead of inlining
normalize → TTS loop. UI-specific concerns (provider resolution,
progress display, cancellation) stay in the UI layer.

Tests: 1253 passed
2026-07-20 09:12:40 +00:00
Artem Akymenko 68e5adb091 refactor: consolidate voice resolution via resolve_voice_choice (#3)
Chapter and chunk loops now call resolve_voice_choice() instead of
inlining _resolve_voice_target + cache check + resolve_voice.
Reduces 3 duplicated voice resolution blocks to 1 closure.

Tests: 1253 passed
2026-07-20 09:05:26 +00:00
Artem Akymenko c4870eece6 refactor: extract TTSContext dataclass for normalization parameters (#5)
Bundles pronunciation_rules, heteronym_rules, normalization_overrides,
usage_counter, and split_pattern into a single TTSContext dataclass.
Both UIs create it once and use tts_context.normalize() instead of
threading 5 separate parameters through prepare_text_for_tts calls.

Tests: 1253 passed
2026-07-20 09:00:55 +00:00
Artem Akymenko 8144a7a507 refactor: unify intro/outro through domain; extract subtitle writer creation
- domain/intro_outro.py: resolve_intro(), resolve_outro() return IntroOutroSpec
- Both UIs call domain for text building + voice spec resolution
- PyQt uses resolve_intro/resolve_outro instead of direct calls
- infrastructure/subtitle_writer.py: resolve_subtitle_format(), make_subtitle_writer()
- Deleted duplicate _create_subtitle_writer() from WebUI
- Deleted duplicate _subtitle_alignment_from_format() from PyQt
- domain/conversion_engine.py: run_tts_segment_loop() for TTS iteration
- VoiceCache class in domain/voice_loader.py used by both UIs
- Tests: 1253 passed
2026-07-20 08:32:55 +00:00
Artem Akymenko f38700025a unify voice caching: VoiceCache class used by both WebUI and PyQt
- domain/voice_loader.py: VoiceCache class now used by both UIs;
  resolve_voice() and load_voice_cached() accept VoiceCache or plain dict;
  added hasattr(pipeline, 'load_single_voice') safety check from WebUI
- conversion_runner.py: replaced local _resolve_voice() with domain's
  resolve_voice(); voice_cache changed from Dict to VoiceCache instance;
  all cache access uses VoiceCache.get()/set() API
- pyqt/conversion.py: self.voice_cache changed from Dict to VoiceCache
- debug_tts_runner.py: imports resolve_voice from domain instead of
  removed _resolve_voice from conversion_runner
2026-07-20 08:02:34 +00:00
Artem Akymenko 804517f5b2 extract subtitle writer creation: resolve_subtitle_format() + make_subtitle_writer()
- infrastructure/subtitle_writer.py: add resolve_subtitle_format() that
  maps format strings (e.g. 'ass_centered_narrow') to (extension, alignment),
  and make_subtitle_writer() convenience that resolves + creates writer or None
- conversion_runner.py: replace _create_subtitle_writer() with make_subtitle_writer()
- pyqt/conversion.py: replace _subtitle_alignment_from_format() and 3 manual
  create_subtitle_writer() call sites with resolve_subtitle_format()/make_subtitle_writer()
2026-07-20 07:52:15 +00:00
Artem Akymenko 5d30903149 extract conversion_engine: shared TTS segment iteration loop for WebUI and PyQt
- domain/conversion_engine.py: run_tts_segment_loop() with CancelChecker,
  SegmentStats, SegmentInfo protocols; on_segment callback for per-segment
  subtitle processing; process_and_write_subtitles() helper
- conversion_runner.py: emit_text() delegates TTS iteration to engine
- pyqt/conversion.py: inner tts_segments loop replaced with engine call,
  on_segment handles dual merged+chapter subtitle writers
- routes/utils/settings.py: re-exports load_settings, coerce_int/float,
  llm_ready, settings_defaults from domain for backward compat
2026-07-20 07:35:58 +00:00
Artem Akymenko 476063bc3d refactor: move load_settings() to domain, simplify settings.py
- load_settings() now in domain/settings_core.py (shared by all UIs)
- settings.py delegates to domain instead of reimplementing
- settings.py: 456 → 430 lines
2026-07-20 06:56:42 +00:00
Artem Akymenko 079e185108 refactor: simplify normalize_setting_value() via Setting.normalizer
- Added normalizer callable to Setting dataclass
- Moved special-case logic (_norm_save_mode, _norm_voice_spec, etc.)
  into registry entries as normalizers
- normalize_setting_value() reduced from 25 lines to 10 lines
- Single dispatch: normalizer → coerce → fallback
2026-07-20 06:48:06 +00:00
Artem Akymenko 69c398ebf0 refactor(pyqt): replace hardcoded config defaults with SETTINGS_REGISTRY
gui.py now reads defaults from all_settings_defaults() instead of
hardcoding values like 50, True, 'wav', etc. One source of truth
for all settings across Web UI and Desktop GUI.
2026-07-19 15:57:49 +00:00
Artem Akymenko dbe73254a4 refactor: add SETTINGS_REGISTRY contract to domain/settings_core.py
- Setting dataclass: key, type, default, min/max, valid_values, scope
- 72 settings total: 54 shared, 18 PyQt-only
- validate_setting() checks types and ranges
- Setting.coerce() handles type conversion with bounds
- settings_defaults() / all_settings_defaults() derived from registry
- BOOLEAN_SETTINGS, FLOAT_SETTINGS, INT_SETTINGS now auto-derived
- 17 tests validating schema, coercion, and validation
2026-07-19 13:24:52 +00:00
Artem Akymenko 64e8a8f4e6 refactor: extract shared settings_core and Flask route logic to domain/services 2026-07-19 15:52:35 +03:00
Artem Akymenko aec3462f1f refactor: both UIs use shared tts_segments() from domain/conversion_pipeline.py
- domain/conversion_pipeline.py: add tts_segments() for pre-normalized text;
  emit_text_segments() now delegates to tts_segments() internally
- pyqt/conversion.py: inner TTS loop replaced with tts_segments() iterator;
  removed FakeToken import (handled by domain)
- webui/conversion_runner.py: emit_text() inner loop replaced with
  tts_segments() iterator; removed FakeToken import
- Both UIs now share the same TTS emission logic — normalization + backend
  invocation + segment iteration + token extraction
- +3 tests for tts_segments (no-normalization, chunk_start, basic)
- 1188 tests pass
2026-07-19 11:47:14 +00:00
Artem Akymenko 1193185833 refactor: create domain/conversion_pipeline.py with shared TTS emission loop
- domain/conversion_pipeline.py: emit_text_segments() — generator yielding
  SegmentResult for each TTS segment; emit_text_to_sinks() — convenience
  wrapper handling audio writing + token accumulation + subtitle flushing
- Both WebUI and PyQt can call these instead of reimplementing the TTS loop
- Caller provides backend, voice, speed, split_pattern; domain handles
  normalization, TTS invocation, token extraction
- +7 tests (segment yielding, empty audio skip, chunk_start, tokens, fallback)
- 1185 tests pass
2026-07-19 10:31:58 +00:00
Artem Akymenko a99cf58c79 refactor: consolidate voice formula building into voice_formulas.py
- voice_formulas.py: add pairs_to_formula() as canonical implementation
- webui/routes/utils/voice.py: formula_from_profile() and pairs_to_formula()
  now delegate to voice_formulas.pairs_to_formula()
- pyqt/gui.py: get_voice_formula() now uses voice_formulas.pairs_to_formula()
  instead of inline string formatting
- Eliminates 3 duplicate implementations of voice*weight formula building
- +9 tests
- 1178 tests pass
2026-07-19 10:02:14 +00:00
Artem Akymenko fe62b6b44c refactor: extract book metadata logic from PyQt to domain
- domain/metadata_extraction.py: add format_metadata_tags(),
  extract_book_metadata_epub(), extract_book_metadata_pdf(),
  extract_book_metadata_markdown(), _save_cover_to_cache()
- pyqt/book_handler.py: _extract_book_metadata() reduced from ~165 lines
  to ~10 lines by delegating to domain; _format_metadata_tags() reduced
  from ~55 lines to ~15 lines; ebooklib/fitz imports moved to domain
- +16 tests (format_metadata_tags, save_cover, markdown extraction)
- 1169 tests pass
2026-07-19 09:51:50 +00:00
Artem Akymenko 0e216f3786 refactor: extract _process_subtitle_file domain logic to shared modules
- domain/subtitle_processor.py: parse_subtitle_file, format_time_range,
  speed_up_audio, fit_audio_to_duration (moved from audio_buffer),
  process_subtitle_entries (core TTS loop with cancel/log/progress callbacks)
- domain/audio_buffer.py: add fit_audio_to_duration, ffmpeg_time_stretch
- domain/output_paths.py: add resolve_unique_path (collision-safe filename)
- pyqt/conversion.py: _process_subtitle_file reduced from ~350 to ~100 lines
  by delegating to domain functions; removed 4 unused subtitle parser imports
- +33 tests (8 resolve_unique_path, 14 subtitle_processor, 8 audio_buffer,
  3 format_time_range)
- 1153 tests pass
2026-07-19 09:34:17 +00:00
Artem Akymenko 380cdee0cb refactor(pyqt): use create_pipeline_for_job() in LoadPipelineThread
Replace direct create_pipeline() call with domain function for
consistent provider validation and device resolution.
2026-07-19 07:57:09 +00:00
Artem Akymenko c76cf74efc refactor: unify duplicated logic between WebUI and PyQt
domain/output_paths.py:
- Add sanitize_filename_for_chapter() with OS safety + smart truncation
- Keep existing slugify() for backward compatibility

domain/text_chapters.py (NEW):
- parse_chapters_from_text() combines intro preservation (PyQt) + clean_text (WebUI)

PyQt/conversion.py:
- Replace 3x copy-pasted ASS/SRT headers with create_subtitle_writer()
- Replace inline M4B muxing with ExportService.embed_m4b_metadata()
- Replace inline chapter splitting with parse_chapters_from_text()
- Replace inline chapter filename sanitization with sanitize_filename_for_chapter()
- Remove unused _CHAPTER_MARKER_SEARCH_PATTERN import

Tests: 1152 passed (+21 new)
2026-07-19 07:50:02 +00:00
41 changed files with 4086 additions and 1888 deletions
+67
View File
@@ -170,3 +170,70 @@ def samples_for_duration(duration_seconds: float, sample_rate: int = SAMPLE_RATE
if duration_seconds <= 0: if duration_seconds <= 0:
return 0 return 0
return int(round(duration_seconds * sample_rate)) return int(round(duration_seconds * sample_rate))
def fit_audio_to_duration(
audio: np.ndarray,
target_duration: float,
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Pad or trim audio to match target duration.
Args:
audio: Input audio buffer.
target_duration: Desired duration in seconds.
sample_rate: Sample rate in Hz.
Returns:
Audio buffer of exact length target_duration * sample_rate.
"""
target_samples = int(target_duration * sample_rate)
if len(audio) < target_samples:
padding = np.zeros(target_samples - len(audio), dtype="float32")
return np.concatenate([audio, padding])
return audio[:target_samples]
def ffmpeg_time_stretch(
audio: np.ndarray,
speed_factor: float,
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Time-stretch audio using FFmpeg's atempo filter.
Args:
audio: Input audio buffer (float32).
speed_factor: Speed multiplier (>1.0 = faster).
sample_rate: Sample rate in Hz.
Returns:
Time-stretched audio buffer.
"""
import math
import subprocess
import static_ffmpeg
if speed_factor <= 1.0 or audio.size == 0:
return audio
static_ffmpeg.add_paths()
num_stages = max(1, int(math.ceil(math.log(speed_factor) / math.log(2.0))))
tempo = speed_factor ** (1.0 / num_stages)
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
proc = subprocess.Popen(
[
"ffmpeg", "-y",
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"-i", "pipe:0",
"-filter:a", filter_str,
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, _ = proc.communicate(input=audio.tobytes())
return np.frombuffer(out, dtype="float32")
+237
View File
@@ -0,0 +1,237 @@
"""Shared TTS iteration loop used by both WebUI and PyQt conversion runners.
The core pattern is identical across both UIs:
for seg in tts_segments(text, backend, voice, speed, split_pattern, current_time):
check_cancel()
update_progress(seg)
write_audio(seg, sink)
accumulate_subtitles(seg)
After the loop, the caller processes accumulated subtitle tokens.
This module provides ``run_tts_segment_loop`` which encapsulates that
iteration, and ``synthesize_text`` which adds normalization on top —
the single entry point both UIs should call for text-to-speech.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional, Protocol
from abogen.domain.audio_sink import AudioSink
from abogen.domain.conversion_pipeline import tts_segments
from abogen.domain.normalization import TTSContext
from abogen.domain.progress import calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens
class CancelChecker(Protocol):
"""Returns True if conversion has been cancelled."""
def __call__(self) -> bool: ...
@dataclass
class SegmentStats:
"""Running statistics updated per TTS segment."""
processed_chars: int = 0
current_time: float = 0.0
etr_start_time: float = field(default_factory=time.time)
total_characters: int = 0
@dataclass
class SegmentInfo:
"""Read-only info about a TTS segment, passed to on_segment callback."""
graphemes: str
audio: Any
tokens: list
duration: float
chunk_start: float
def run_tts_segment_loop(
*,
text: str,
backend: Any,
voice: Any,
speed: float,
split_pattern: str,
stats: SegmentStats,
check_cancel: CancelChecker,
on_progress: Callable[[int, str], None],
chapter_sink: Optional[AudioSink] = None,
audio_sink: Optional[AudioSink] = None,
preview_callback: Optional[Callable[[str], None]] = None,
on_segment: Optional[Callable[[SegmentInfo], None]] = None,
subtitle_mode: str = "Disabled",
max_subtitle_words: int = 5,
lang_code: str = "a",
use_spacy_segmentation: bool = False,
) -> tuple[int, list]:
"""Run the core TTS segment iteration loop.
Args:
text: Normalized text to synthesize.
backend: TTS pipeline instance (Kokoro or Supertonic).
voice: Voice name/id for the backend.
speed: Speech speed multiplier.
split_pattern: Regex pattern used by the TTS engine for sentence splitting.
stats: Running character/timing stats (mutated in place).
check_cancel: Called each segment; if it returns True, iteration stops.
on_progress: Called with (percent, etr_str) after each segment.
chapter_sink: Optional audio sink for the current chapter.
audio_sink: Optional audio sink for the merged output.
preview_callback: Called with a short preview string per segment.
on_segment: Called with a SegmentInfo for each segment *before*
audio is written. Useful for callers that need per-segment
subtitle processing (e.g. PyQt dual-writer pattern).
When provided, the default subtitle accumulation is skipped.
subtitle_mode: Subtitle mode string (e.g. "Disabled", "Sentence").
max_subtitle_words: Max words per subtitle entry.
lang_code: Language code for subtitle processing.
use_spacy_segmentation: Whether spaCy sentence boundaries are active.
Returns:
Tuple of (segment_count, accumulated_subtitle_tokens).
The caller is responsible for processing subtitle tokens via
``process_subtitle_tokens`` and writing entries to subtitle writers.
"""
local_segments = 0
accumulated_tokens: list[dict] = []
for seg in tts_segments(
text,
backend=backend,
voice=voice,
speed=speed,
split_pattern=split_pattern,
current_time=stats.current_time,
):
if check_cancel():
break
local_segments += 1
stats.processed_chars += len(seg.graphemes)
# Progress
if stats.total_characters:
percent = min(int(stats.processed_chars / stats.total_characters * 100), 99)
else:
percent = 0 if stats.processed_chars == 0 else 99
etr_str = calc_etr_str(
time.time() - stats.etr_start_time,
stats.processed_chars,
stats.total_characters,
)
on_progress(percent, etr_str)
# Preview / log
if preview_callback:
preview_callback(seg.graphemes or "[silence]")
# Per-segment callback (for callers needing segment-level access)
if on_segment:
info = SegmentInfo(
graphemes=seg.graphemes,
audio=seg.audio,
tokens=list(seg.tokens) if seg.tokens else [],
duration=seg.duration,
chunk_start=getattr(seg, "chunk_start", stats.current_time),
)
on_segment(info)
# Write audio
if chapter_sink:
chapter_sink.write(seg.audio)
if audio_sink:
audio_sink.write(seg.audio)
# Accumulate subtitle tokens (default path; skipped if on_segment handles it)
if not on_segment and subtitle_mode != "Disabled" and seg.tokens:
accumulated_tokens.extend(seg.tokens)
# Update timing
if audio_sink:
stats.current_time += seg.duration
return local_segments, accumulated_tokens
def process_and_write_subtitles(
accumulated_tokens: list[dict],
subtitle_writer: Any,
*,
subtitle_mode: str,
max_subtitle_words: int,
lang_code: str,
use_spacy_segmentation: bool,
fallback_end_time: float,
) -> None:
"""Process accumulated subtitle tokens and write entries to a subtitle writer.
This is the standard subtitle post-processing step shared by both UIs.
"""
if not accumulated_tokens or not subtitle_writer:
return
new_entries: list[tuple] = []
process_subtitle_tokens(
accumulated_tokens,
new_entries,
max_subtitle_words,
subtitle_mode,
lang_code,
use_spacy_segmentation=use_spacy_segmentation,
fallback_end_time=fallback_end_time,
)
for start, end, text in new_entries:
subtitle_writer.write_entry(start=start, end=end, text=text)
def synthesize_text(
*,
text: str,
tts_context: TTSContext,
backend: Any,
voice: Any,
speed: float,
stats: SegmentStats,
check_cancel: CancelChecker,
on_progress: Callable[[int, str], None],
chapter_sink: Optional[AudioSink] = None,
audio_sink: Optional[AudioSink] = None,
preview_callback: Optional[Callable[[str], None]] = None,
on_segment: Optional[Callable[[SegmentInfo], None]] = None,
subtitle_mode: str = "Disabled",
max_subtitle_words: int = 5,
lang_code: str = "a",
use_spacy_segmentation: bool = False,
split_pattern_override: Optional[str] = None,
) -> tuple[int, list]:
"""Normalize text and run TTS — the single entry point for both UIs.
Combines TTSContext.normalize() + run_tts_segment_loop() into one call.
UI-specific concerns (provider resolution, progress display) stay in the UI.
"""
normalized = tts_context.normalize(text)
return run_tts_segment_loop(
text=normalized,
backend=backend,
voice=voice,
speed=speed,
split_pattern=split_pattern_override or tts_context.split_pattern,
stats=stats,
check_cancel=check_cancel,
on_progress=on_progress,
chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=preview_callback,
on_segment=on_segment,
subtitle_mode=subtitle_mode,
max_subtitle_words=max_subtitle_words,
lang_code=lang_code,
use_spacy_segmentation=use_spacy_segmentation,
)
+242
View File
@@ -0,0 +1,242 @@
"""Shared TTS emission pipeline.
Provides the core TTS emission loop used by both WebUI and PyQt conversion runners.
The caller handles audio I/O, progress reporting, and subtitle writing.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterator, List, Optional
import numpy as np
from abogen.domain.audio_helpers import to_float32
from abogen.domain.normalization import prepare_text_for_tts
from abogen.domain.tokens import FakeToken
from abogen.domain.audio_buffer import SAMPLE_RATE
logger = logging.getLogger(__name__)
@dataclass
class SegmentResult:
"""One TTS segment emitted by the pipeline."""
graphemes: str
audio: np.ndarray
duration: float
chunk_start: float
tokens: List[Dict[str, Any]] = field(default_factory=list)
def tts_segments(
text: str,
*,
backend: Any,
voice: Any,
speed: float,
split_pattern: str,
current_time: float = 0.0,
) -> Iterator[SegmentResult]:
"""Invoke TTS backend on (already normalized) text and yield SegmentResults.
Use this when you've already normalized the text yourself (e.g. after
spaCy sentence segmentation). For raw text, use emit_text_segments() instead.
Args:
text: Already-normalized text to synthesize.
backend: TTS pipeline callable.
voice: Resolved voice.
speed: TTS speed multiplier.
split_pattern: Regex pattern for sentence splitting.
current_time: Current position in the audio timeline (seconds).
Yields:
SegmentResult for each non-empty TTS segment.
"""
segment_iter = backend(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
)
chunk_start = current_time
for segment in segment_iter:
graphemes_raw = getattr(segment, "graphemes", "") or ""
graphemes = graphemes_raw.strip()
audio = to_float32(getattr(segment, "audio", None))
if audio.size == 0:
continue
duration = len(audio) / SAMPLE_RATE
tokens_list = getattr(segment, "tokens", [])
if not tokens_list and graphemes:
tokens_list = [FakeToken(graphemes, 0, duration)]
tokens = [
{
"start": chunk_start + (tok.start_ts or 0),
"end": chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
for tok in tokens_list
]
yield SegmentResult(
graphemes=graphemes,
audio=audio,
duration=duration,
chunk_start=chunk_start,
tokens=tokens,
)
chunk_start += duration
def emit_text_segments(
text: str,
*,
backend: Any,
voice: Any,
speed: float,
split_pattern: str,
current_time: float = 0.0,
# normalization
heteronym_rules: Any = None,
pronunciation_rules: Any = None,
normalization_overrides: Any = None,
usage_counter: Optional[Dict[str, int]] = None,
) -> Iterator[SegmentResult]:
"""Normalize text and yield SegmentResults from the TTS backend.
This is the innermost TTS emission loop shared by both UIs. It handles:
1. Text normalization (heteronym + pronunciation rules)
2. TTS backend invocation
3. Segment iteration with token extraction
The caller is responsible for:
- Writing audio to sinks
- Accumulating tokens for subtitle processing
- Progress tracking and cancellation
- Error handling
Args:
text: Raw text to synthesize.
backend: TTS pipeline callable (kokoro or supertonic).
voice: Resolved voice for TTS.
speed: TTS speed multiplier.
split_pattern: Regex pattern for sentence splitting.
current_time: Current position in the audio timeline (seconds).
heteronym_rules: Compiled heteronym rules.
pronunciation_rules: Compiled pronunciation rules.
normalization_overrides: User normalization overrides.
usage_counter: Counter for normalization statistics.
Yields:
SegmentResult for each non-empty TTS segment.
"""
source_text = str(text or "")
normalized = prepare_text_for_tts(
source_text,
heteronym_rules=heteronym_rules,
pronunciation_rules=pronunciation_rules,
normalization_overrides=normalization_overrides,
usage_counter=usage_counter,
)
yield from tts_segments(
normalized,
backend=backend,
voice=voice,
speed=speed,
split_pattern=split_pattern,
current_time=current_time,
)
def emit_text_to_sinks(
text: str,
*,
backend: Any,
voice: Any,
speed: float,
split_pattern: str,
current_time: float = 0.0,
# sinks
audio_sink: Any = None,
chapter_sink: Any = None,
# subtitle
subtitle_writer: Any = None,
subtitle_mode: str = "Disabled",
subtitle_lang: str = "a",
max_subtitle_words: int = 50,
use_spacy_segmentation: bool = True,
# normalization
heteronym_rules: Any = None,
pronunciation_rules: Any = None,
normalization_overrides: Any = None,
usage_counter: Optional[Dict[str, int]] = None,
) -> tuple[int, float, List[Dict[str, Any]]]:
"""Emit TTS audio for text, writing to sinks and collecting subtitle tokens.
Convenience wrapper around emit_text_segments() that handles audio writing
and token accumulation. Returns stats for the caller to update progress.
Returns:
Tuple of (segments_emitted, new_current_time, accumulated_tokens).
"""
from abogen.domain.subtitle_generation import process_subtitle_tokens
segments_emitted = 0
accumulated_tokens: List[Dict[str, Any]] = []
for seg in emit_text_segments(
text,
backend=backend,
voice=voice,
speed=speed,
split_pattern=split_pattern,
current_time=current_time,
heteronym_rules=heteronym_rules,
pronunciation_rules=pronunciation_rules,
normalization_overrides=normalization_overrides,
usage_counter=usage_counter,
):
segments_emitted += 1
# Write audio
if chapter_sink:
chapter_sink.write(seg.audio)
if audio_sink:
audio_sink.write(seg.audio)
# Collect tokens
accumulated_tokens.extend(seg.tokens)
# Flush subtitle tokens
if subtitle_writer and accumulated_tokens:
_use_spacy = subtitle_mode not in ("Disabled", "Line")
new_entries: List[tuple] = []
process_subtitle_tokens(
accumulated_tokens,
new_entries,
max_subtitle_words,
subtitle_mode,
subtitle_lang,
use_spacy_segmentation=_use_spacy,
fallback_end_time=current_time + sum(t["end"] - t["start"] for t in accumulated_tokens if accumulated_tokens),
)
for start, end, text_entry in new_entries:
subtitle_writer.write_entry(start=start, end=end, text=text_entry)
new_time = current_time
if accumulated_tokens:
new_time = max(t["end"] for t in accumulated_tokens)
return segments_emitted, new_time, accumulated_tokens
+83
View File
@@ -0,0 +1,83 @@
"""Intro/outro text building and voice resolution for audiobook conversion.
Both UIs (WebUI and Desktop) need to:
1. Build intro/outro text from book metadata
2. Resolve which voice to use for intro/outro synthesis
This module provides the shared domain logic. The actual TTS synthesis
and audio writing remain UI-specific.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from abogen.domain.title_builder import build_title_intro_text, build_outro_text
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
@dataclass
class IntroOutroSpec:
"""Resolved intro or outro specification ready for TTS synthesis."""
text: str
voice_spec: str
enabled: bool
def resolve_intro(
metadata: Optional[Dict[str, Any]],
original_filename: str,
read_title_intro: bool,
base_voice_spec: str,
job_voice: str,
voice_cache_keys: list[str],
) -> IntroOutroSpec:
"""Resolve the intro specification from job settings and metadata.
Returns an IntroOutroSpec with text and voice_spec populated,
or enabled=False if intro is disabled or text cannot be built.
"""
if not read_title_intro:
return IntroOutroSpec(text="", voice_spec="", enabled=False)
text = build_title_intro_text(metadata, original_filename)
if not text:
return IntroOutroSpec(text="", voice_spec="", enabled=False)
voice_spec = resolve_fallback_voice_spec(
base_voice_spec, job_voice, voice_cache_keys
)
if not voice_spec:
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
def resolve_outro(
metadata: Optional[Dict[str, Any]],
original_filename: str,
read_closing_outro: bool,
base_voice_spec: str,
job_voice: str,
voice_cache_keys: list[str],
) -> IntroOutroSpec:
"""Resolve the outro specification from job settings and metadata.
Returns an IntroOutroSpec with text and voice_spec populated,
or enabled=False if outro is disabled or text cannot be built.
"""
if not read_closing_outro:
return IntroOutroSpec(text="", voice_spec="", enabled=False)
text = build_outro_text(metadata, original_filename)
if not text:
return IntroOutroSpec(text="", voice_spec="", enabled=False)
voice_spec = resolve_fallback_voice_spec(
base_voice_spec, job_voice, voice_cache_keys
)
if not voice_spec:
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
+316 -3
View File
@@ -1,16 +1,20 @@
"""Metadata extraction and processing utilities. """Metadata extraction and processing utilities.
This module provides functions for extracting metadata from text content This module provides functions for extracting metadata from text content,
and generating ffmpeg metadata arguments. formatting metadata tags for TTS embedding, and generating ffmpeg metadata arguments.
""" """
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
import os import os
import re import re
import uuid
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]: def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]:
@@ -189,3 +193,312 @@ def read_text_for_metadata(
return f.read() return f.read()
except Exception: except Exception:
return "" return ""
def extract_metadata_for_file(
file_path: str,
is_direct_text: bool = False,
) -> Dict[str, Optional[str]]:
"""Extract metadata dict from a file or direct text.
Convenience function combining read_text_for_metadata + extract_metadata_from_text.
Returns empty dict on any error.
"""
try:
text = read_text_for_metadata(
file_path=file_path,
is_direct_text=is_direct_text,
direct_text=file_path if is_direct_text else None,
)
if text:
return extract_metadata_from_text(text) or {}
except Exception:
pass
return {}
def format_metadata_tags(
metadata: Dict[str, Any],
filename: str,
chapter_count: int,
file_type: str,
cover_bytes: Optional[bytes] = None,
cache_dir: Optional[str] = None,
) -> str:
"""Format metadata tags for insertion into TTS text.
Builds <<METADATA_KEY:value>> tags that are later parsed by
extract_metadata_from_text() and fed to ffmpeg.
Args:
metadata: Dict with keys like 'title', 'authors' (list),
'publication_year', 'description', 'cover_image' (bytes).
filename: Fallback filename (without extension) for title/album.
chapter_count: Number of chapters/pages.
file_type: 'epub', 'pdf', or 'markdown'.
cover_bytes: Optional cover image bytes to save to cache.
cache_dir: Directory for cover cache (uses default if None).
Returns:
Newline-joined string of <<METADATA_KEY:value>> tags.
"""
title = metadata.get("title") or filename
authors = metadata.get("authors") or ["Unknown"]
authors_text = ", ".join(authors) if isinstance(authors, list) else str(authors)
year = metadata.get("publication_year") or str(datetime.datetime.now().year)
chapter_label = "Chapters" if file_type in ("epub", "markdown") else "Pages"
chapter_text = f"{chapter_count} {chapter_label}"
tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{authors_text}>>",
f"<<METADATA_COMPOSER:Narrator>>",
f"<<METADATA_GENRE:Audiobook>>",
]
cover_path = _save_cover_to_cache(cover_bytes, cache_dir)
if cover_path:
tags.append(f"<<METADATA_COVER_PATH:{cover_path}>>")
return "\n".join(tags)
def _save_cover_to_cache(
cover_bytes: Optional[bytes],
cache_dir: Optional[str] = None,
) -> Optional[str]:
"""Save cover image bytes to cache directory.
Args:
cover_bytes: Raw image bytes (e.g. JPEG/PNG).
cache_dir: Directory to save to. If None, returns None.
Returns:
Normalized path to saved cover file, or None on failure.
"""
if not cover_bytes:
return None
if cache_dir is None:
return None
try:
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
return cover_path
except Exception as e:
logger.warning("Failed to save cover image: %s", e)
return None
def extract_book_metadata_epub(book: Any) -> Dict[str, Any]:
"""Extract metadata from an opened ebooklib EPUB book.
Args:
book: An opened ebooklib EPUB book object.
Returns:
Dict with keys: title, authors, description, publisher,
publication_year, cover_image (bytes or None).
"""
import ebooklib
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
try:
title_items = book.get_metadata("DC", "title")
if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0]
except Exception as e:
logger.warning("Error extracting title metadata: %s", e)
try:
author_items = book.get_metadata("DC", "creator")
if author_items:
metadata["authors"] = [
author[0] for author in author_items if len(author) > 0
]
except Exception as e:
logger.warning("Error extracting author metadata: %s", e)
try:
desc_items = book.get_metadata("DC", "description")
if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0]
except Exception as e:
logger.warning("Error extracting description metadata: %s", e)
try:
publisher_items = book.get_metadata("DC", "publisher")
if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logger.warning("Error extracting publisher metadata: %s", e)
try:
date_items = book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logger.warning("Error extracting publication date metadata: %s", e)
for item in book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content()
break
if not metadata["cover_image"]:
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
if "cover" in item.get_name().lower():
metadata["cover_image"] = item.get_content()
break
return metadata
def extract_book_metadata_pdf(pdf_doc: Any) -> Dict[str, Any]:
"""Extract metadata from an opened PyMuPDF document.
Args:
pdf_doc: An opened fitz.Document object.
Returns:
Dict with keys: title, authors, description, publisher,
publication_year, cover_image (bytes or None).
"""
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
pdf_info = pdf_doc.metadata
if pdf_info:
metadata["title"] = pdf_info.get("title", None)
author = pdf_info.get("author", None)
if author:
metadata["authors"] = [author]
metadata["description"] = pdf_info.get("subject", None)
keywords = pdf_info.get("keywords", None)
if keywords:
if metadata["description"]:
metadata["description"] += f"\n\nKeywords: {keywords}"
else:
metadata["description"] = f"Keywords: {keywords}"
metadata["publisher"] = pdf_info.get("creator", None)
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(pdf_doc) > 0:
try:
import fitz
pix = pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
metadata["cover_image"] = pix.tobytes("png")
except Exception:
pass
return metadata
def extract_book_metadata_markdown(
markdown_text: str,
markdown_toc: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Extract metadata from markdown frontmatter and first heading.
Args:
markdown_text: Raw markdown text content.
markdown_toc: Optional table of contents list (each item has
'level' and 'name' keys).
Returns:
Dict with keys: title, authors, description, publication_year.
cover_image is always None for markdown.
"""
metadata: Dict[str, Any] = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
if not markdown_text:
return metadata
frontmatter_match = re.match(
r"^---\s*\n(.*?)\n---\s*\n", markdown_text, re.DOTALL
)
if frontmatter_match:
try:
frontmatter = frontmatter_match.group(1)
title_match = re.search(
r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if title_match:
metadata["title"] = title_match.group(1).strip().strip("\"'")
author_match = re.search(
r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if author_match:
metadata["authors"] = [
author_match.group(1).strip().strip("\"'")
]
desc_match = re.search(
r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if desc_match:
metadata["description"] = (
desc_match.group(1).strip().strip("\"'")
)
date_match = re.search(
r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if date_match:
date_str = date_match.group(1).strip().strip("\"'")
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
except Exception as e:
logger.warning("Error parsing markdown frontmatter: %s", e)
if not metadata["title"] and markdown_toc:
first_h1 = next(
(h for h in markdown_toc if h.get("level") == 1), None
)
if first_h1:
metadata["title"] = first_h1.get("name")
return metadata
+99
View File
@@ -0,0 +1,99 @@
"""OPDS metadata normalization.
Normalizes metadata keys from various OPDS/Calibre sources into
a canonical set of overrides for the audiobook conversion pipeline.
"""
from __future__ import annotations
from typing import Any, Dict, Mapping
def normalize_opds_metadata(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Normalize OPDS/Calibre metadata into canonical override keys.
Takes a metadata payload with various key aliases (e.g. 'series'/'series_name',
'tags'/'keywords', 'authors'/'creator') and returns a dict with canonical
keys set.
Args:
metadata_payload: Raw metadata dict from OPDS/Calibre import.
Returns:
Dict with canonical metadata keys (series, series_index, tags,
description, subtitle, publisher, authors).
"""
metadata_overrides: Dict[str, Any] = {}
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (list, tuple, set)):
parts = [str(item).strip() for item in value if item is not None]
return ", ".join(part for part in parts if part)
return str(value).strip()
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
series_name = str(raw_series or "").strip()
if series_name:
metadata_overrides["series"] = series_name
metadata_overrides.setdefault("series_name", series_name)
series_index_value = (
metadata_payload.get("series_index")
or metadata_payload.get("series_position")
or metadata_payload.get("series_sequence")
or metadata_payload.get("book_number")
)
if series_index_value is not None:
series_index_text = str(series_index_value).strip()
if series_index_text:
metadata_overrides.setdefault("series_index", series_index_text)
metadata_overrides.setdefault("series_position", series_index_text)
metadata_overrides.setdefault("series_sequence", series_index_text)
metadata_overrides.setdefault("book_number", series_index_text)
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
if tags_value:
tags_text = _stringify(tags_value)
if tags_text:
metadata_overrides.setdefault("tags", tags_text)
metadata_overrides.setdefault("keywords", tags_text)
metadata_overrides.setdefault("genre", tags_text)
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
if description_value:
description_text = _stringify(description_value)
if description_text:
metadata_overrides.setdefault("description", description_text)
metadata_overrides.setdefault("summary", description_text)
subtitle_value = (
metadata_payload.get("subtitle")
or metadata_payload.get("sub_title")
or metadata_payload.get("calibre_subtitle")
)
if subtitle_value:
subtitle_text = _stringify(subtitle_value)
if subtitle_text:
metadata_overrides.setdefault("subtitle", subtitle_text)
publisher_value = metadata_payload.get("publisher")
if publisher_value:
publisher_text = _stringify(publisher_value)
if publisher_text:
metadata_overrides.setdefault("publisher", publisher_text)
authors_value = (
metadata_payload.get("authors")
or metadata_payload.get("author")
or metadata_payload.get("creator")
or metadata_payload.get("dc_creator")
)
if authors_value:
authors_text = _stringify(authors_value)
if authors_text:
metadata_overrides.setdefault("authors", authors_text)
metadata_overrides.setdefault("author", authors_text)
return metadata_overrides
+29
View File
@@ -5,10 +5,14 @@ and the comprehensive ``prepare_text_for_tts`` that chains all three normalizati
stages used during conversion: heteronym rules → pronunciation rules → pipeline stages used during conversion: heteronym rules → pronunciation rules → pipeline
normalization. The latter is the single entry point that both the Web UI and normalization. The latter is the single entry point that both the Web UI and
PyQt Desktop GUI should use. PyQt Desktop GUI should use.
Also provides ``TTSContext`` — a dataclass bundling all pre-compiled normalization
resources so they can be created once and passed as a single object.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional from typing import Any, Dict, List, Mapping, Optional
from abogen.kokoro_text_normalization import ( from abogen.kokoro_text_normalization import (
@@ -24,6 +28,31 @@ from abogen.normalization_settings import (
_BASE_APOSTROPHE_CONFIG = ApostropheConfig() _BASE_APOSTROPHE_CONFIG = ApostropheConfig()
@dataclass
class TTSContext:
"""Bundles pre-compiled normalization resources for TTS processing.
Created once per conversion job and passed to ``prepare_text_for_tts``
instead of threading 5 separate parameters.
"""
split_pattern: str = r"(?<=[.!?\-])\s+"
pronunciation_rules: Optional[List[Dict[str, Any]]] = None
heteronym_rules: Optional[List[Dict[str, Any]]] = None
normalization_overrides: Optional[Mapping[str, Any]] = None
usage_counter: Dict[str, int] = field(default_factory=dict)
def normalize(self, text: str) -> str:
"""Shorthand: normalize text using this context's compiled rules."""
return prepare_text_for_tts(
text,
heteronym_rules=self.heteronym_rules,
pronunciation_rules=self.pronunciation_rules,
normalization_overrides=self.normalization_overrides,
usage_counter=self.usage_counter,
)
def normalize_text_for_pipeline( def normalize_text_for_pipeline(
text: str, text: str,
*, *,
+89
View File
@@ -6,16 +6,28 @@ and computing project folder layouts.
from __future__ import annotations from __future__ import annotations
import os
import platform
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple from typing import Any, Callable, List, Optional, Tuple
from abogen.subtitle_utils import sanitize_name_for_os
from abogen.text_extractor import ExtractedChapter from abogen.text_extractor import ExtractedChapter
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+") _OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
# OS-specific illegal characters for filenames
_WINDOWS_ILLEGAL_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_UNIX_CONTROL_CHARS_RE = re.compile(r'[\x00-\x1f]')
_RESERVED_NAMES = frozenset(
{"CON", "PRN", "AUX", "NUL"}
| {f"COM{i}" for i in range(1, 10)}
| {f"LPT{i}" for i in range(1, 10)}
)
def slugify(title: str, index: int) -> str: def slugify(title: str, index: int) -> str:
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_") sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
@@ -24,6 +36,46 @@ def slugify(title: str, index: int) -> str:
return sanitized[:80] return sanitized[:80]
def sanitize_filename_for_chapter(title: str, index: int, max_len: int = 80) -> str:
"""Sanitize a chapter name for use as a filename component.
Combines character sanitization, OS safety, and smart truncation
at word boundaries. Prepends zero-padded index prefix.
Args:
title: Raw chapter title.
index: 1-based chapter number for prefix.
max_len: Maximum length of the sanitized portion (excluding prefix).
Returns:
Sanitized string like "01_the_beginning".
"""
# Remove non-word/non-space/non-hyphen chars, then collapse spaces/hyphens
sanitized = re.sub(r"[^\w\s\-]", "", title)
sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_")
if not sanitized:
sanitized = f"chapter_{index:02d}"
# OS-specific sanitization
system = platform.system()
if system == "Windows":
sanitized = _WINDOWS_ILLEGAL_CHARS_RE.sub("_", sanitized)
sanitized = sanitized.rstrip(". ")
base = sanitized.split(".")[0].upper()
if base in _RESERVED_NAMES:
sanitized = f"_{sanitized}"
# Linux: only NUL is truly illegal, but control chars are problematic
sanitized = _UNIX_CONTROL_CHARS_RE.sub("_", sanitized)
# Smart truncation at word boundary
if len(sanitized) > max_len:
pos = sanitized[:max_len].rfind("_")
sanitized = sanitized[: pos if pos > 0 else max_len].rstrip("_")
return f"{index:02d}_{sanitized}"
def sanitize_output_stem(name: str) -> str: def sanitize_output_stem(name: str) -> str:
base = Path(name or "").stem base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_") sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
@@ -89,3 +141,40 @@ def resolve_project_layout(
return project_root, audio_dir, subtitle_dir, metadata_dir return project_root, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None return project_root, project_root, project_root, None
def resolve_unique_path(
parent_dir: str,
base_name: str,
extension: str,
allowed_extensions: Optional[set] = None,
) -> str:
"""Find a unique file path by appending _2, _3, etc. on collision.
Args:
parent_dir: Directory to check for collisions.
base_name: Base filename (without extension).
extension: File extension (without dot).
allowed_extensions: Set of extensions to check against.
If None, checks any existing file/dir with same name.
Returns:
Full path without extension (e.g. "/path/to/name_2").
"""
sanitized = sanitize_name_for_os(base_name, is_folder=True)
counter = 1
while True:
suffix = f"_{counter}" if counter > 1 else ""
candidate = os.path.join(parent_dir, f"{sanitized}{suffix}")
if allowed_extensions is not None:
file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir))
clash = any(
name == f"{sanitized}{suffix}"
and ext[1:].lower() in allowed_extensions
for name, ext in file_parts
)
else:
clash = os.path.exists(candidate)
if not clash:
return candidate
counter += 1
+107
View File
@@ -0,0 +1,107 @@
"""Pipeline creation, caching and lifecycle management.
Provides a unified interface for creating and managing TTS pipelines
across all UI layers (WebUI, PyQt, CLI).
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from abogen.domain.device import select_device
from abogen.domain.voice_resolution import initialize_voice_cache
from abogen.tts_plugin.utils import create_pipeline, is_plugin_registered
def resolve_device(use_gpu: bool) -> str:
"""Determine compute device from job and global config flags."""
from abogen.utils import load_config
cfg = load_config()
if use_gpu and cfg.get("use_gpu", True):
return select_device()
return "cpu"
def create_pipeline_for_job(
provider: str,
language: str,
use_gpu: bool,
) -> Any:
"""Create a TTS pipeline with proper device selection.
Handles provider validation, GPU decision, and plugin checks.
"""
provider = str(provider or "kokoro").strip().lower() or "kokoro"
if not is_plugin_registered(provider):
provider = "kokoro"
if provider == "supertonic":
return create_pipeline("supertonic")
device = resolve_device(use_gpu)
return create_pipeline("kokoro", lang_code=language, device=device)
def dispose_pipelines(pipelines: Dict[str, Any]) -> None:
"""Dispose all pipelines in a dict and clear it."""
for p in pipelines.values():
try:
p.dispose()
except Exception:
pass
pipelines.clear()
class PipelinePool:
"""Cache and manage TTS pipelines by provider.
Usage::
pool = PipelinePool()
backend = pool.get("kokoro", "en", use_gpu=True)
# ... use backend ...
pool.dispose_all()
"""
def __init__(self) -> None:
self._pipelines: Dict[str, Any] = {}
self._voice_cache_initialized = False
def get(
self,
provider: str,
language: str,
use_gpu: bool,
*,
job: Any = None,
) -> Any:
"""Get or create a cached pipeline for the given provider.
Args:
provider: TTS provider name ("kokoro" or "supertonic").
language: Language code (for kokoro).
use_gpu: Whether GPU acceleration is requested.
job: Optional job object for voice cache initialization.
"""
provider = str(provider or "kokoro").strip().lower() or "kokoro"
if not is_plugin_registered(provider):
provider = "kokoro"
existing = self._pipelines.get(provider)
if existing is not None:
return existing
pipeline = create_pipeline_for_job(provider, language, use_gpu)
self._pipelines[provider] = pipeline
if provider == "kokoro" and not self._voice_cache_initialized and job is not None:
initialize_voice_cache(job)
self._voice_cache_initialized = True
return pipeline
def dispose_all(self) -> None:
"""Dispose all cached pipelines."""
dispose_pipelines(self._pipelines)
self._voice_cache_initialized = False
+580
View File
@@ -0,0 +1,580 @@
"""Shared settings core.
Defines the SETTINGS_REGISTRY — the single source of truth for all settings.
Every setting has a key, type, default, validation rules, and UI scope.
Both Web UI and Desktop GUI must reference this registry.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS,
)
from abogen.tts_plugin.utils import get_default_voice
from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT,
environment_llm_defaults,
)
# ── Schema ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Setting:
"""Contract for a single setting.
Attributes:
key: Config dict key (e.g. "output_format").
type_: Python type (bool, int, float, str, list).
default: Default value or callable returning one.
min_value: Minimum for numeric types.
max_value: Maximum for numeric types.
valid_values: Allowed values for str types (None = any).
gui_only: True if only used by PyQt Desktop GUI.
web_only: True if only used by Web UI.
normalizer: Optional callable(value, default) -> normalized_value.
description: Human-readable explanation.
"""
key: str
type_: type
default: Any
min_value: float | None = None
max_value: float | None = None
valid_values: tuple[Any, ...] | None = None
gui_only: bool = False
web_only: bool = False
normalizer: Callable | None = None
description: str = ""
def coerce(self, value: Any, fallback: Any | None = None) -> Any:
"""Coerce value to the declared type, returning fallback on failure."""
fb = fallback if fallback is not None else self.default
if self.type_ is bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
if value is None:
return fb
return bool(value)
if self.type_ is int:
try:
v = int(value)
except (TypeError, ValueError):
return fb
if self.min_value is not None:
v = max(int(self.min_value), v)
if self.max_value is not None:
v = min(int(self.max_value), v)
return v
if self.type_ is float:
try:
v = float(value)
except (TypeError, ValueError):
return fb
if self.min_value is not None:
v = max(self.min_value, v)
if self.max_value is not None:
v = min(self.max_value, v)
return v
if self.type_ is str:
if isinstance(value, str):
v = value.strip()
if self.valid_values and v not in self.valid_values:
return fb
return v
return fb
if self.type_ is list:
if isinstance(value, (list, tuple, set)):
return list(value)
return fb
return value
# ── Normalizers (used by Setting.normalizer) ─────────────────────────
def _norm_save_mode(value: Any, default: str) -> str:
if isinstance(value, str):
if value in SAVE_MODE_LABELS:
return value
if value in LEGACY_SAVE_MODE_MAP:
return LEGACY_SAVE_MODE_MAP[value]
return default
def _norm_voice_spec(value: Any, default: str) -> str:
if isinstance(value, str):
text = value.strip()
if not text:
return default
spec, profile_name = split_profile_spec(text)
if profile_name:
return f"speaker:{profile_name}"
return spec
return default
def _norm_speaker_spec(value: Any, default: str) -> str:
if isinstance(value, str):
text = value.strip()
if not text:
return ""
spec, profile_name = split_profile_spec(text)
if profile_name:
return f"speaker:{profile_name}"
return spec
return ""
def _norm_language_list(value: Any, default: list) -> list:
if isinstance(value, (list, tuple, set)):
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
if isinstance(value, str):
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
return default
def _norm_stripped_str(value: Any, default: str) -> str:
return str(value or "").strip()
def _norm_prompt(value: Any, default: str) -> str:
candidate = str(value or "").strip()
return candidate if candidate else default
# ── Registry ─────────────────────────────────────────────────────────
def _default_output_format() -> str:
return "wav"
def _default_save_mode() -> str:
return "default_output" if has_output_override() else "save_next_to_input"
def _default_llm(key: str) -> str:
return environment_llm_defaults().get(key, "")
SETTINGS_REGISTRY: list[Setting] = [
# ── Core output ──────────────────────────────────────────────
Setting("output_format", str, "wav",
valid_values=tuple(SUPPORTED_SOUND_FORMATS),
description="Audio output format"),
Setting("subtitle_format", str, "srt",
valid_values=tuple(item[0] for item in SUBTITLE_FORMATS),
description="Subtitle file format"),
Setting("save_mode", str, _default_save_mode,
normalizer=_norm_save_mode,
description="Where to save output files"),
Setting("separate_chapters_format", str, "wav",
valid_values=("wav", "flac", "mp3", "opus"),
description="Format for separately saved chapters"),
Setting("chunk_level", str, "paragraph",
valid_values=("paragraph", "sentence"),
description="Text chunking granularity"),
# ── Voice ────────────────────────────────────────────────────
Setting("default_speaker", str, "",
normalizer=_norm_speaker_spec,
description="Default speaker name"),
Setting("default_voice", str, lambda: get_default_voice("kokoro"),
normalizer=_norm_voice_spec,
description="Default TTS voice"),
Setting("speed", float, 1.0, min_value=0.5, max_value=3.0,
gui_only=True,
description="TTS speed multiplier"),
Setting("supertonic_total_steps", int, 5, min_value=2, max_value=15,
description="SuperTonic processing steps"),
Setting("supertonic_speed", float, 1.0, min_value=0.7, max_value=2.0,
description="SuperTonic speed"),
# ── Chapter handling ─────────────────────────────────────────
Setting("silence_between_chapters", float, 2.0, min_value=0.0,
description="Silence gap between chapters (seconds)"),
Setting("chapter_intro_delay", float, 0.5, min_value=0.0,
description="Delay after chapter heading (seconds)"),
Setting("read_title_intro", bool, False,
description="Read chapter title as intro"),
Setting("read_closing_outro", bool, True,
description="Read closing/outro text"),
Setting("normalize_chapter_opening_caps", bool, True,
description="Normalize chapter opening caps"),
Setting("auto_prefix_chapter_titles", bool, True,
description="Auto-prefix chapter titles"),
Setting("save_chapters_separately", bool, False,
description="Save each chapter as separate file"),
Setting("merge_chapters_at_end", bool, True,
description="Merge chapters into single file"),
Setting("save_as_project", bool, False,
description="Save as editable project"),
Setting("generate_epub3", bool, False,
description="Generate EPUB3 output"),
# ── GPU / performance ────────────────────────────────────────
Setting("use_gpu", bool, True,
description="Use GPU acceleration"),
# ── Text processing ──────────────────────────────────────────
Setting("replace_single_newlines", bool, False,
description="Replace single newlines with spaces"),
Setting("max_subtitle_words", int, 50, min_value=1, max_value=500,
description="Max words per subtitle"),
Setting("enable_entity_recognition", bool, True,
description="Enable entity recognition"),
# ── Speaker analysis ─────────────────────────────────────────
Setting("speaker_analysis_threshold", int, 3, min_value=1, max_value=25,
description="Speaker analysis threshold"),
Setting("speaker_pronunciation_sentence", str, "This is {{name}} speaking.",
description="Template for pronunciation samples"),
Setting("speaker_random_languages", list, [],
normalizer=_norm_language_list,
description="Languages for random speaker assignment"),
# ── LLM ──────────────────────────────────────────────────────
Setting("llm_base_url", str, lambda: _default_llm("llm_base_url"),
normalizer=_norm_stripped_str,
description="LLM API base URL"),
Setting("llm_api_key", str, lambda: _default_llm("llm_api_key"),
normalizer=_norm_stripped_str,
description="LLM API key"),
Setting("llm_model", str, lambda: _default_llm("llm_model"),
normalizer=_norm_stripped_str,
description="LLM model name"),
Setting("llm_timeout", float, lambda: _default_llm("llm_timeout") or 30.0,
min_value=1.0,
description="LLM request timeout"),
Setting("llm_prompt", str, lambda: _default_llm("llm_prompt") or DEFAULT_LLM_PROMPT,
normalizer=_norm_prompt,
description="LLM normalization prompt"),
Setting("llm_context_mode", str, lambda: _default_llm("llm_context_mode") or "sentence",
valid_values=("sentence",),
description="LLM context mode"),
# ── Normalization (booleans) ─────────────────────────────────
Setting("normalization_numbers", bool, True,
description="Convert grouped numbers to words"),
Setting("normalization_currency", bool, True,
description="Convert currency symbols"),
Setting("normalization_footnotes", bool, True,
description="Remove footnote indicators"),
Setting("normalization_titles", bool, True,
description="Expand titles and suffixes"),
Setting("normalization_terminal", bool, True,
description="Ensure terminal punctuation"),
Setting("normalization_phoneme_hints", bool, True,
description="Add phoneme hints for possessives"),
Setting("normalization_caps_quotes", bool, True,
description="Convert ALL CAPS in quotes"),
Setting("normalization_internet_slang", bool, False,
description="Expand internet slang"),
Setting("normalization_apostrophes_contractions", bool, True,
description="Expand contractions"),
Setting("normalization_apostrophes_plural_possessives", bool, True,
description="Collapse plural possessives"),
Setting("normalization_apostrophes_sibilant_possessives", bool, True,
description="Mark sibilant possessives"),
Setting("normalization_apostrophes_decades", bool, True,
description="Expand decades"),
Setting("normalization_apostrophes_leading_elisions", bool, True,
description="Expand leading elisions"),
Setting("normalization_contraction_aux_be", bool, True,
description="Expand auxiliary 'be'"),
Setting("normalization_contraction_aux_have", bool, True,
description="Expand auxiliary 'have'"),
Setting("normalization_contraction_modal_will", bool, True,
description="Expand modal 'will'"),
Setting("normalization_contraction_modal_would", bool, True,
description="Expand modal 'would'"),
Setting("normalization_contraction_negation_not", bool, True,
description="Expand negation 'not'"),
Setting("normalization_contraction_let_us", bool, True,
description="Expand 'let's'"),
# ── Normalization (strings) ──────────────────────────────────
Setting("normalization_apostrophe_mode", str, "spacy",
valid_values=("off", "spacy", "llm"),
description="Apostrophe handling mode"),
Setting("normalization_numbers_year_style", str, "american",
valid_values=("american", "off"),
description="Year style for number normalization"),
# ── PyQt GUI-only ────────────────────────────────────────────
Setting("theme", str, "system",
gui_only=True,
description="UI theme"),
Setting("check_updates", bool, True,
gui_only=True,
description="Check for updates on startup"),
Setting("subtitle_mode", str, "Sentence",
gui_only=True,
description="Subtitle display mode"),
Setting("selected_format", str, "wav",
gui_only=True,
description="Last selected audio format"),
Setting("selected_voice", str, "af_heart",
gui_only=True,
description="Last selected voice"),
Setting("selected_profile_name", str, None,
gui_only=True,
description="Last selected profile name"),
Setting("log_window_max_lines", int, 2000, min_value=100,
gui_only=True,
description="Max lines in log window"),
Setting("use_silent_gaps", bool, True,
gui_only=True,
description="Use silent gaps between chunks"),
Setting("subtitle_speed_method", str, "tts",
gui_only=True,
valid_values=("tts", "ffmpeg"),
description="Speed adjustment method for subtitles"),
Setting("use_spacy_segmentation", bool, True,
gui_only=True,
description="Use spaCy for sentence segmentation"),
Setting("word_substitutions_enabled", bool, False,
gui_only=True,
description="Enable word substitutions"),
Setting("word_substitutions_list", str, "",
gui_only=True,
description="Word substitutions list"),
Setting("case_sensitive_substitutions", bool, False,
gui_only=True,
description="Case-sensitive substitutions"),
Setting("replace_all_caps", bool, False,
gui_only=True,
description="Replace ALL CAPS text"),
Setting("replace_numerals", bool, False,
gui_only=True,
description="Replace numerals with words"),
Setting("fix_nonstandard_punctuation", bool, False,
gui_only=True,
description="Fix nonstandard punctuation"),
Setting("queue_override_settings", bool, False,
gui_only=True,
description="Override settings per queue item"),
Setting("disable_kokoro_internet", bool, False,
description="Disable Kokoro internet access"),
]
# ── Registry helpers ─────────────────────────────────────────────────
_REGISTRY_BY_KEY: dict[str, Setting] = {s.key: s for s in SETTINGS_REGISTRY}
SETTING_KEYS: frozenset[str] = frozenset(_REGISTRY_BY_KEY.keys())
GUI_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.gui_only)
WEB_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.web_only)
SHARED_KEYS: frozenset[str] = SETTING_KEYS - GUI_ONLY_KEYS - WEB_ONLY_KEYS
BOOLEAN_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is bool)
FLOAT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is float)
INT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is int)
# Backward-compatible aliases (used by existing code)
_NORMALIZATION_BOOLEAN_KEYS: frozenset[str] = frozenset(
s.key for s in SETTINGS_REGISTRY
if s.type_ is bool and s.key.startswith("normalization_")
)
_NORMALIZATION_STRING_KEYS: frozenset[str] = frozenset(
s.key for s in SETTINGS_REGISTRY
if s.type_ is str and s.key.startswith("normalization_")
)
def get_setting(key: str) -> Setting | None:
"""Look up a setting by key."""
return _REGISTRY_BY_KEY.get(key)
def has_output_override() -> bool:
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
# ── Defaults ─────────────────────────────────────────────────────────
def settings_defaults() -> Dict[str, Any]:
"""Default values for all shared settings (excludes gui_only)."""
result: Dict[str, Any] = {}
for s in SETTINGS_REGISTRY:
if s.gui_only:
continue
result[s.key] = s.default() if callable(s.default) else s.default
return result
def all_settings_defaults() -> Dict[str, Any]:
"""Default values for ALL settings (including gui_only)."""
result: Dict[str, Any] = {}
for s in SETTINGS_REGISTRY:
result[s.key] = s.default() if callable(s.default) else s.default
return result
def load_settings() -> Dict[str, Any]:
"""Load and normalize settings from config file."""
from abogen.utils import load_config
defaults = settings_defaults()
cfg = load_config() or {}
settings: Dict[str, Any] = {}
for key, default in defaults.items():
raw_value = cfg.get(key, default)
settings[key] = normalize_setting_value(key, raw_value, defaults)
return settings
# ── Normalization (delegates to Setting.coerce) ──────────────────────
def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
"""Normalize a single setting value using the registry schema."""
setting = _REGISTRY_BY_KEY.get(key)
if setting is None:
return value if value is not None else defaults.get(key)
fallback = defaults.get(key, setting.default() if callable(setting.default) else setting.default)
if setting.normalizer is not None:
return setting.normalizer(value, fallback)
return setting.coerce(value, fallback)
def validate_setting(key: str, value: Any) -> tuple[bool, str]:
"""Validate a setting value against its schema. Returns (ok, error_message)."""
setting = _REGISTRY_BY_KEY.get(key)
if setting is None:
return False, f"Unknown setting: {key}"
if setting.type_ is str and setting.valid_values is not None:
v = str(value or "").strip()
if v and v not in setting.valid_values:
return False, f"Invalid value '{v}' for {key}. Allowed: {setting.valid_values}"
if setting.type_ is int:
try:
iv = int(value)
except (TypeError, ValueError):
return False, f"Invalid integer value for {key}: {value!r}"
if setting.min_value is not None and iv < setting.min_value:
return False, f"{key} must be >= {setting.min_value}, got {iv}"
if setting.max_value is not None and iv > setting.max_value:
return False, f"{key} must be <= {setting.max_value}, got {iv}"
if setting.type_ is float:
try:
fv = float(value)
except (TypeError, ValueError):
return False, f"Invalid float value for {key}: {value!r}"
if setting.min_value is not None and fv < setting.min_value:
return False, f"{key} must be >= {setting.min_value}, got {fv}"
if setting.max_value is not None and fv > setting.max_value:
return False, f"{key} must be <= {setting.max_value}, got {fv}"
return True, ""
# ── Constants (backward-compatible) ──────────────────────────────────
SAVE_MODE_LABELS = {
"save_next_to_input": "Save next to input file",
"save_to_desktop": "Save to Desktop",
"choose_output_folder": "Choose output folder",
"default_output": "Use default save location",
}
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
CHUNK_LEVEL_OPTIONS = [
{"value": "paragraph", "label": "Paragraphs"},
{"value": "sentence", "label": "Sentences"},
]
CHUNK_LEVEL_VALUES = frozenset(option["value"] for option in CHUNK_LEVEL_OPTIONS)
DEFAULT_ANALYSIS_THRESHOLD = 3
# ── Coercion helpers (backward-compatible, delegate to Setting.coerce) ──
def coerce_bool(value: Any, default: bool) -> bool:
return Setting("_", bool, default).coerce(value, default)
def coerce_float(value: Any, default: float) -> float:
return Setting("_", float, default).coerce(value, default)
def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
return Setting("_", int, default, min_value=minimum, max_value=maximum).coerce(value, default)
def split_profile_spec(value: Any) -> tuple[str, str | None]:
"""Split 'speaker:Name' or 'profile:Name' into (raw, name)."""
text = str(value or "").strip()
if not text:
return "", None
lowered = text.lower()
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
_, _, remainder = text.partition(":")
name = remainder.strip()
return "", name or None
return text, None
def normalize_save_mode(value: Any, default: str) -> str:
return _norm_save_mode(value, default)
# ── LLM helpers ──────────────────────────────────────────────────────
_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
def llm_ready(settings: Mapping[str, Any]) -> bool:
base_url = str(settings.get("llm_base_url") or "").strip()
return bool(base_url)
def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
if not template:
return ""
def _replace(match: re.Match[str]) -> str:
key = match.group(1)
return context.get(key, "")
return _PROMPT_TOKEN_RE.sub(_replace, template)
# ── Integration defaults ─────────────────────────────────────────────
def integration_defaults() -> Dict[str, Dict[str, Any]]:
"""Default values for integration settings."""
return {
"calibre_opds": {
"enabled": False,
"base_url": "",
"username": "",
"password": "",
"verify_ssl": True,
},
"audiobookshelf": {
"enabled": False,
"base_url": "",
"api_token": "",
"library_id": "",
"collection_id": "",
"folder_id": "",
"verify_ssl": True,
"send_cover": True,
"send_chapters": True,
"send_subtitles": False,
"auto_send": False,
"timeout": 30.0,
},
}
+279
View File
@@ -0,0 +1,279 @@
"""Subtitle-to-audio processing pipeline.
Converts subtitle files (SRT/ASS/VTT/timestamp text) into audio by
generating TTS for each entry and mixing into a buffer.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple
import numpy as np
from abogen.domain.audio_buffer import (
create_silence,
fit_audio_to_duration,
ffmpeg_time_stretch,
mix_audio,
normalize_audio,
SAMPLE_RATE,
)
from abogen.domain.audio_helpers import to_float32
from abogen.domain.progress import calc_etr_str
from abogen.subtitle_utils import (
parse_ass_file,
parse_srt_file,
parse_vtt_file,
parse_timestamp_text_file,
)
logger = logging.getLogger(__name__)
@dataclass
class SubtitleEntry:
"""A single subtitle entry with timing."""
start: float
end: Optional[float]
text: str
def parse_subtitle_file(
file_path: str,
is_timestamp_text: bool = False,
) -> List[Tuple[float, Optional[float], str]]:
"""Parse a subtitle file into (start, end, text) tuples.
Args:
file_path: Path to subtitle file.
is_timestamp_text: Whether to treat as timestamp text file.
Returns:
List of (start_time, end_time, text) tuples.
"""
if is_timestamp_text:
return parse_timestamp_text_file(file_path)
import os
ext = os.path.splitext(file_path)[1].lower()
if ext == ".srt":
return parse_srt_file(file_path)
elif ext == ".vtt":
return parse_vtt_file(file_path)
else:
return parse_ass_file(file_path)
def format_time_range(
start: float,
end: Optional[float],
is_auto_end: bool = False,
) -> str:
"""Format a time range for display in logs.
Args:
start: Start time in seconds.
end: End time in seconds, or None.
is_auto_end: Whether end time is auto-detected.
Returns:
Formatted string like "00:01:23,456 - 00:01:25,789" or "00:01:23 - AUTO".
"""
def _fmt(seconds: float) -> str:
h = int(seconds // 3600)
m = int(seconds % 3600 // 60)
s = int(seconds % 60)
ms = int((seconds - int(seconds)) * 1000)
result = f"{h:02d}:{m:02d}:{s:02d}"
if ms > 0:
result += f",{ms:03d}"
return result
if is_auto_end or end is None:
return f"{_fmt(start)} - AUTO"
return f"{_fmt(start)} - {_fmt(end)}"
def speed_up_audio(
audio: np.ndarray,
speed_factor: float,
method: str = "tts",
*,
backend: Any = None,
text: str = "",
voice: Any = None,
base_speed: float = 1.0,
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Speed up audio to fit a time window.
Args:
audio: Input audio buffer.
speed_factor: Required speed multiplier.
method: "ffmpeg" for time-stretch, "tts" for regeneration.
backend: TTS backend (required if method="tts").
text: Text to regenerate (required if method="tts").
voice: Voice to use for regeneration.
base_speed: Base speed for TTS.
sample_rate: Sample rate.
Returns:
Speed-adjusted audio buffer.
"""
if speed_factor <= 1.0:
return audio
if method == "ffmpeg":
logger.info("FFmpeg time-stretch: %.2fx", speed_factor)
return ffmpeg_time_stretch(audio, speed_factor, sample_rate)
# TTS regeneration
if backend is None:
return audio
new_speed = base_speed * speed_factor
logger.info("Regenerating at %.2fx speed", new_speed)
results = [
r for r in backend(text, voice=voice, speed=new_speed, split_pattern=None)
]
chunks = [r.audio for r in results]
if not chunks:
return audio
return np.concatenate([to_float32(c) for c in chunks])
def process_subtitle_entries(
subtitles: List[Tuple[float, Optional[float], str]],
*,
backend: Any,
voice: Any,
speed: float = 1.0,
cancel_check: Callable[[], bool] = lambda: False,
log_callback: Optional[Callable[[str], None]] = None,
progress_callback: Optional[Callable[[int, str], None]] = None,
replace_newlines: bool = True,
use_gaps: bool = False,
is_timestamp_text: bool = False,
subtitle_speed_method: str = "tts",
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Process subtitle entries: generate TTS for each and mix into buffer.
This is the core domain logic for subtitle-to-audio conversion.
UI-specific concerns (signals, widgets) are handled via callbacks.
Args:
subtitles: List of (start, end, text) tuples.
backend: TTS pipeline callable.
voice: Resolved voice for TTS.
speed: TTS speed.
cancel_check: Returns True if processing should stop.
log_callback: Called with log messages.
progress_callback: Called with (percent, etr_string).
replace_newlines: Replace \\n with spaces in text.
use_gaps: Whether to use silent gaps between subtitles.
is_timestamp_text: Whether input is timestamp text.
subtitle_speed_method: "ffmpeg" or "tts" for speed adjustment.
sample_rate: Audio sample rate.
Returns:
Mixed audio buffer (float32).
"""
if not subtitles:
return np.array([], dtype="float32")
max_end = max((end for _, end, _ in subtitles if end is not None), default=0)
buffer_samples = int(max_end * sample_rate) + sample_rate
audio_buffer = np.zeros(buffer_samples, dtype="float32")
etr_start = time.time()
total = len(subtitles)
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
if cancel_check():
break
processed_text = text.replace("\n", " ") if replace_newlines else text
next_start = (
subtitles[idx][0]
if (use_gaps and idx < total)
else float("inf")
)
subtitle_duration = None if end_time is None else end_time - start_time
is_auto_end = is_timestamp_text or (use_gaps and idx == total) or end_time is None
if log_callback:
log_callback(
f"\n[{idx}/{total}] {format_time_range(start_time, end_time, is_auto_end)}: {processed_text}"
)
# Generate TTS
results = [
r for r in backend(
processed_text, voice=voice, speed=speed, split_pattern=None
)
if not cancel_check()
]
if cancel_check():
break
audio_chunks = [r.audio for r in results]
full_audio = (
np.concatenate([to_float32(a) for a in audio_chunks])
if audio_chunks
else np.zeros(int((subtitle_duration or 0) * sample_rate), dtype="float32")
)
audio_duration = len(full_audio) / sample_rate
# Timing adjustment
if is_timestamp_text:
end_time = start_time + audio_duration
subtitle_duration = audio_duration
elif use_gaps:
end_time = min(start_time + audio_duration, next_start)
subtitle_duration = end_time - start_time
elif subtitle_duration is None:
subtitle_duration = audio_duration
end_time = start_time + audio_duration
# Speed up if needed
speedup_threshold = next_start - start_time if use_gaps else subtitle_duration
if audio_duration > speedup_threshold and speedup_threshold > 0:
speed_factor = audio_duration / speedup_threshold
full_audio = speed_up_audio(
full_audio, speed_factor,
method=subtitle_speed_method,
backend=backend, text=processed_text,
voice=voice, base_speed=speed,
sample_rate=sample_rate,
)
audio_duration = len(full_audio) / sample_rate
# Adjust duration after speed change
if use_gaps:
end_time = min(start_time + audio_duration, next_start)
subtitle_duration = end_time - start_time
elif subtitle_duration is None:
subtitle_duration = audio_duration
end_time = start_time + audio_duration
# Pad or trim to subtitle duration
full_audio = fit_audio_to_duration(full_audio, subtitle_duration, sample_rate)
# Mix into buffer
start_sample = int(start_time * sample_rate)
audio_buffer = mix_audio(audio_buffer, full_audio, start_sample)
# Progress
if progress_callback:
percent = min(int(idx / total * 100), 99)
etr = calc_etr_str(time.time() - etr_start, idx, total)
progress_callback(percent, etr)
# Normalize if needed
if np.abs(audio_buffer).max() > 1.0:
logger.info("Normalizing audio (peak: %.2f)", np.abs(audio_buffer).max())
audio_buffer = normalize_audio(audio_buffer)
return audio_buffer
+59
View File
@@ -0,0 +1,59 @@
"""Chapter parsing from raw text.
Provides a unified function for splitting text by chapter markers,
used by both WebUI and PyQt conversion runners.
"""
from __future__ import annotations
import re
from typing import List, Tuple
from abogen.subtitle_utils import clean_text
_CHAPTER_MARKER_RE = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
def parse_chapters_from_text(
text: str,
default_title: str = "text",
clean: bool = True,
) -> List[Tuple[str, str]]:
"""Split raw text into chapters using chapter marker patterns.
Preserves content before the first marker as "Introduction" if present.
Optionally applies clean_text() to each chapter segment.
Args:
text: Raw text possibly containing <<CHAPTER_MARKER:Title>> markers.
default_title: Fallback title when no markers are found.
clean: Whether to apply clean_text() to each segment.
Returns:
List of (title, text) tuples.
"""
matches = list(_CHAPTER_MARKER_RE.finditer(text))
if not matches:
cleaned = clean_text(text) if clean else text
return [(default_title, cleaned)]
chapters: List[Tuple[str, str]] = []
# Preserve content before first marker as "Introduction"
first_start = matches[0].start()
if first_start > 0:
intro_text = text[:first_start].strip()
if intro_text:
chapters.append(("Introduction", clean_text(intro_text) if clean else intro_text))
for idx, match in enumerate(matches):
start = match.end()
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
chapter_name = match.group(1).strip() or default_title
chapter_text = text[start:end].strip()
if clean:
chapter_text = clean_text(chapter_text)
chapters.append((chapter_name, chapter_text))
return chapters
+21 -13
View File
@@ -62,10 +62,10 @@ def resolve_voice(
# Check cache first # Check cache first
if cache and cache.contains(voice_spec): if cache and cache.contains(voice_spec):
return cache.get(voice_spec) return cache.get(voice_spec)
# Load voice # Load voice
if "*" in voice_spec: if "*" in voice_spec:
if pipeline is None: if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_spec return voice_spec
loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu) loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu)
else: else:
@@ -82,35 +82,43 @@ def load_voice_cached(
voice_name: str, voice_name: str,
pipeline: Any, pipeline: Any,
use_gpu: bool, use_gpu: bool,
cache: Optional[Dict[str, Any]] = None, cache: Any = None,
) -> Any: ) -> Any:
"""Load voice with caching (compatibility wrapper for PyQt). """Load voice with caching (compatibility wrapper for PyQt).
This function maintains backward compatibility with the PyQt interface This function maintains backward compatibility with the PyQt interface
while using the unified voice loading logic. while using the unified voice loading logic.
Args: Args:
voice_name: Voice name or formula string. voice_name: Voice name or formula string.
pipeline: TTS pipeline instance. pipeline: TTS pipeline instance.
use_gpu: Whether to use GPU. use_gpu: Whether to use GPU.
cache: Optional dict to use as cache (instead of VoiceCache). cache: Optional VoiceCache or dict to use as cache.
Returns: Returns:
Loaded voice tensor or voice name string. Loaded voice tensor or voice name string.
""" """
# Use dict cache if provided (for backward compatibility) # Check cache (supports both VoiceCache and plain dict)
if cache is not None: if cache is not None:
if voice_name in cache: if isinstance(cache, VoiceCache):
if cache.contains(voice_name):
return cache.get(voice_name)
elif voice_name in cache:
return cache[voice_name] return cache[voice_name]
# Load voice # Load voice
if "*" in voice_name: if "*" in voice_name:
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_name
loaded_voice = get_new_voice(pipeline, voice_name, use_gpu) loaded_voice = get_new_voice(pipeline, voice_name, use_gpu)
else: else:
loaded_voice = voice_name loaded_voice = voice_name
# Cache it # Cache it
if cache is not None: if cache is not None:
cache[voice_name] = loaded_voice if isinstance(cache, VoiceCache):
cache.set(voice_name, loaded_voice)
else:
cache[voice_name] = loaded_voice
return loaded_voice return loaded_voice
+35 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping, Optional, Tuple, Set from typing import Any, Dict, Mapping, Optional, Tuple, Set
from abogen.voice_formulas import extract_voice_ids, get_new_voice from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
@@ -94,4 +94,37 @@ def coerce_truthy(value: Any, default: bool = True) -> bool:
return value.lower() not in {"false", "0", "no", "off", ""} return value.lower() not in {"false", "0", "no", "off", ""}
if value is None: if value is None:
return default return default
return bool(value) return bool(value)
def resolve_voice_target(
raw_spec: str,
normalized_profiles: Dict[str, Dict[str, Any]],
*,
job_voice: str = "M1",
job_tts_provider: str = "kokoro",
job_supertonic_total_steps: int = 5,
job_speed: float = 1.0,
) -> Tuple[str, str, Optional[float], Optional[int]]:
"""Resolve a raw voice spec into (provider, voice_spec, speed_override, steps_override).
Pure function — all dependencies are passed as parameters.
"""
spec = str(raw_spec or "").strip()
speaker_name, _ = split_speaker_reference(spec)
if speaker_name and speaker_name in normalized_profiles:
entry = normalized_profiles[speaker_name]
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
if provider == "supertonic":
voice = str(entry.get("voice") or job_voice or "M1").strip() or "M1"
steps = int(entry.get("total_steps") or job_supertonic_total_steps or 5)
speed = float(entry.get("speed") or job_speed or 1.0)
return "supertonic", supertonic_voice_from_spec(voice, job_voice), speed, steps
formula = formula_from_kokoro_entry(entry)
return "kokoro", formula or spec, None, None
fallback_provider = str(job_tts_provider or "kokoro").strip().lower() or "kokoro"
inferred = infer_provider_from_spec(spec, fallback=fallback_provider)
if inferred == "supertonic":
return "supertonic", supertonic_voice_from_spec(spec, job_voice), None, None
return "kokoro", spec, None, None
+2 -3
View File
@@ -344,7 +344,6 @@ class ExportService:
) -> None: ) -> None:
"""Upload to Audiobookshelf.""" """Upload to Audiobookshelf."""
if config is None: if config is None:
# Load from job or global config
cfg = getattr(job, "_abs_config", None) cfg = getattr(job, "_abs_config", None)
if cfg is None: if cfg is None:
from abogen.utils import load_config from abogen.utils import load_config
@@ -367,7 +366,7 @@ class ExportService:
if log_callback: if log_callback:
log_callback("Audiobookshelf upload skipped: not configured", "warning") log_callback("Audiobookshelf upload skipped: not configured", "warning")
return return
if not config.base_url or not config.api_token or not config.library_id: if not config.base_url or not config.api_token or not config.library_id:
if log_callback: if log_callback:
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning") log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
@@ -376,7 +375,7 @@ class ExportService:
if log_callback: if log_callback:
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning") log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
return return
if not audio_path.exists(): if not audio_path.exists():
if log_callback: if log_callback:
log_callback("Audiobookshelf upload skipped: audio output not found", "warning") log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
+69 -2
View File
@@ -272,14 +272,14 @@ def create_subtitle_writer(
fmt = SubtitleFormat(format.lower()) fmt = SubtitleFormat(format.lower())
mode = SubtitleMode(mode) mode = SubtitleMode(mode)
align = SubtitleAlignment(alignment.lower()) align = SubtitleAlignment(alignment.lower())
config = SubtitleConfig( config = SubtitleConfig(
format=fmt, format=fmt,
mode=mode, mode=mode,
alignment=align, alignment=align,
max_words=max_words, max_words=max_words,
) )
if fmt == SubtitleFormat.SRT: if fmt == SubtitleFormat.SRT:
return SrtWriter(path, config) return SrtWriter(path, config)
elif fmt == SubtitleFormat.VTT: elif fmt == SubtitleFormat.VTT:
@@ -290,6 +290,71 @@ def create_subtitle_writer(
raise ValueError(f"Unsupported subtitle format: {format}") raise ValueError(f"Unsupported subtitle format: {format}")
def resolve_subtitle_format(
subtitle_format: str | None,
subtitle_mode: str,
) -> tuple[str, str]:
"""Resolve a subtitle_format setting string to (file_extension, alignment).
Handles the PyQt convention where format strings encode alignment
(e.g. ``"ass_centered_narrow"`` → extension ``"ass"``, alignment
``"center_narrow"``).
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
Returns:
Tuple of (file_extension, alignment) suitable for
:func:`create_subtitle_writer`.
"""
fmt = (subtitle_format or "srt").lower()
if subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
fmt = "ass"
if "ass" in fmt:
extension = "ass"
if "centered_narrow" in fmt:
alignment = "center_narrow"
elif "centered" in fmt:
alignment = "center"
elif "narrow" in fmt:
alignment = "narrow"
else:
alignment = "left"
else:
extension = fmt if fmt in ("srt", "vtt") else "srt"
alignment = "left"
return extension, alignment
def make_subtitle_writer(
audio_path: Path,
subtitle_format: str | None,
subtitle_mode: str,
max_words: int = 50,
) -> SubtitleWriter | None:
"""Convenience: resolve format and create a writer, or return None if disabled.
Returns ``None`` when ``subtitle_mode`` is ``"Disabled"`` or the
format is unsupported.
"""
if subtitle_mode == "Disabled":
return None
extension, alignment = resolve_subtitle_format(subtitle_format, subtitle_mode)
try:
return create_subtitle_writer(
audio_path.with_suffix(f".{extension}"),
extension,
subtitle_mode,
alignment=alignment,
max_words=max_words,
)
except (ValueError, KeyError):
return None
__all__ = [ __all__ = [
"SubtitleFormat", "SubtitleFormat",
"SubtitleMode", "SubtitleMode",
@@ -300,4 +365,6 @@ __all__ = [
"VttWriter", "VttWriter",
"AssWriter", "AssWriter",
"create_subtitle_writer", "create_subtitle_writer",
"resolve_subtitle_format",
"make_subtitle_writer",
] ]
+20 -207
View File
@@ -29,6 +29,12 @@ from abogen.utils import (
get_resource_path, get_resource_path,
) )
from abogen.book_parser import get_book_parser from abogen.book_parser import get_book_parser
from abogen.domain.metadata_extraction import (
extract_book_metadata_epub,
extract_book_metadata_pdf,
extract_book_metadata_markdown,
format_metadata_tags,
)
from abogen.subtitle_utils import ( from abogen.subtitle_utils import (
clean_text, clean_text,
@@ -948,169 +954,14 @@ class HandlerDialog(QDialog):
self.previewEdit.setHtml(html_content) self.previewEdit.setHtml(html_content)
def _extract_book_metadata(self): def _extract_book_metadata(self):
metadata = {
"title": None,
"authors": [],
"description": None,
"cover_image": None,
"publisher": None,
"publication_year": None,
}
if self.parser.file_type == "epub": if self.parser.file_type == "epub":
try: return extract_book_metadata_epub(self.book)
title_items = self.book.get_metadata("DC", "title")
if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0]
except Exception as e:
logging.warning(f"Error extracting title metadata: {e}")
try:
author_items = self.book.get_metadata("DC", "creator")
if author_items:
metadata["authors"] = [
author[0] for author in author_items if len(author) > 0
]
except Exception as e:
logging.warning(f"Error extracting author metadata: {e}")
try:
desc_items = self.book.get_metadata("DC", "description")
if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0]
except Exception as e:
logging.warning(f"Error extracting description metadata: {e}")
try:
publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logging.warning(f"Error extracting publisher metadata: {e}")
# Try to extract publication year
try:
date_items = self.book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
# Try to extract just the year from the date string
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logging.warning(f"Error extracting publication date metadata: {e}")
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content()
break
if not metadata["cover_image"]:
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
if "cover" in item.get_name().lower():
metadata["cover_image"] = item.get_content()
break
elif self.parser.file_type == "markdown": elif self.parser.file_type == "markdown":
# Extract metadata from markdown frontmatter or first heading return extract_book_metadata_markdown(
if self.markdown_text: self.markdown_text, self.markdown_toc
# Try to extract YAML frontmatter )
frontmatter_match = re.match(
r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL
)
if frontmatter_match:
try:
frontmatter = frontmatter_match.group(1)
# Simple YAML-like parsing for common fields
title_match = re.search(
r"^title:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if title_match:
metadata["title"] = (
title_match.group(1).strip().strip("\"'")
)
author_match = re.search(
r"^author:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if author_match:
metadata["authors"] = [
author_match.group(1).strip().strip("\"'")
]
desc_match = re.search(
r"^description:\s*(.+)$",
frontmatter,
re.MULTILINE | re.IGNORECASE,
)
if desc_match:
metadata["description"] = (
desc_match.group(1).strip().strip("\"'")
)
date_match = re.search(
r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
)
if date_match:
date_str = date_match.group(1).strip().strip("\"'")
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
except Exception as e:
logging.warning(f"Error parsing markdown frontmatter: {e}")
# Fallback: use first H1 header as title if no frontmatter title
if not metadata["title"] and self.markdown_toc:
# Find the first level 1 header
first_h1 = next(
(h for h in self.markdown_toc if h["level"] == 1), None
)
if first_h1:
metadata["title"] = first_h1["name"]
else: else:
pdf_info = self.pdf_doc.metadata return extract_book_metadata_pdf(self.pdf_doc)
if pdf_info:
metadata["title"] = pdf_info.get("title", None)
author = pdf_info.get("author", None)
if author:
metadata["authors"] = [author]
metadata["description"] = pdf_info.get("subject", None)
keywords = pdf_info.get("keywords", None)
if keywords:
if metadata["description"]:
metadata["description"] += f"\n\nKeywords: {keywords}"
else:
metadata["description"] = f"Keywords: {keywords}"
metadata["publisher"] = pdf_info.get("creator", None)
# Try to extract publication date from PDF metadata
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r"D:(\d{4})", date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(self.pdf_doc) > 0:
try:
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
metadata["cover_image"] = pix.tobytes("png")
except Exception:
pass
return metadata
def get_selected_text(self): def get_selected_text(self):
# If a background loader thread is running, wait for it to finish to # If a background loader thread is running, wait for it to finish to
@@ -1136,59 +987,21 @@ class HandlerDialog(QDialog):
def _format_metadata_tags(self): def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text""" """Format metadata tags for insertion at the beginning of the text"""
import datetime
from abogen.utils import get_user_cache_path from abogen.utils import get_user_cache_path
metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0] filename = os.path.splitext(os.path.basename(self.book_path))[0]
current_year = str(datetime.datetime.now().year) chapter_count = len(self.checked_chapters)
cache_dir = get_user_cache_path()
# Get values with fallbacks return format_metadata_tags(
title = metadata.get("title") or filename self.book_metadata,
authors = metadata.get("authors") or ["Unknown"] filename,
authors_text = ", ".join(authors) chapter_count,
album_artist = authors_text or "Unknown" self.parser.file_type,
year = ( cover_bytes=self.book_metadata.get("cover_image"),
metadata.get("publication_year") or current_year cache_dir=cache_dir,
) # Use publication year if available
# Count chapters/pages
total_chapters = len(self.checked_chapters)
chapter_text = (
f"{total_chapters} {'Chapters' if self.parser.file_type == 'epub' else 'Pages'}"
) )
# Handle cover image
cover_tag = ""
if metadata.get("cover_image"):
try:
import uuid
cache_dir = get_user_cache_path()
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(metadata["cover_image"])
cover_tag = f"<<METADATA_COVER_PATH:{cover_path}>>"
except Exception as e:
logging.warning(f"Failed to save cover image: {e}")
# Format metadata tags
metadata_tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>",
f"<<METADATA_COMPOSER:Narrator>>",
f"<<METADATA_GENRE:Audiobook>>",
]
if cover_tag:
metadata_tags.append(cover_tag)
return "\n".join(metadata_tags)
def _get_markdown_selected_text(self): def _get_markdown_selected_text(self):
"""Get selected text from markdown chapters""" """Get selected text from markdown chapters"""
all_checked_identifiers = set() all_checked_identifiers = set()
+270 -721
View File
File diff suppressed because it is too large Load Diff
+76 -62
View File
@@ -7,7 +7,7 @@ import base64
import re import re
from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem from abogen.pyqt.queued_item import QueuedItem
from abogen.domain.device import select_device as _select_device
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation import hashlib # Added for cache path generation
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -91,6 +91,10 @@ from abogen.tts_plugin.utils import get_voices
import threading import threading
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
from abogen.domain.settings_core import all_settings_defaults
# Module-level default cache for use outside __init__
_DEFAULTS = all_settings_defaults()
# Import ctypes for Windows-specific taskbar icon # Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -912,9 +916,10 @@ class abogen(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.config = load_config() self.config = load_config()
self.apply_theme(self.config.get("theme", "system")) _d = all_settings_defaults()
self.apply_theme(self.config.get("theme", _d["theme"]))
migrate_subtitle_format(self.config) migrate_subtitle_format(self.config)
self.check_updates = self.config.get("check_updates", True) self.check_updates = self.config.get("check_updates", _d["check_updates"])
self.save_option = self.config.get("save_option", "Save next to input file") self.save_option = self.config.get("save_option", "Save next to input file")
self.selected_output_folder = self.config.get("selected_output_folder", None) self.selected_output_folder = self.config.get("selected_output_folder", None)
self.selected_file = self.selected_file_type = self.selected_book_path = None self.selected_file = self.selected_file_type = self.selected_book_path = None
@@ -922,7 +927,7 @@ class abogen(QWidget):
None # Add new variable to track the displayed file path None # Add new variable to track the displayed file path
) )
# Max log lines # Max log lines
self.log_window_max_lines = self.config.get("log_window_max_lines", 2000) self.log_window_max_lines = self.config.get("log_window_max_lines", _d["log_window_max_lines"])
self.selected_chapters = set() self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None self.last_output_path = None
@@ -937,40 +942,28 @@ class abogen(QWidget):
self.selected_voice = None self.selected_voice = None
self.selected_lang = None self.selected_lang = None
else: else:
self.selected_voice = self.config.get("selected_voice", "af_heart") self.selected_voice = self.config.get("selected_voice", _d["selected_voice"])
self.selected_lang = self.selected_voice[0] if self.selected_voice else None self.selected_lang = self.selected_voice[0] if self.selected_voice else None
self.is_converting = False self.is_converting = False
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") self.subtitle_mode = self.config.get("subtitle_mode", _d["subtitle_mode"])
self.max_subtitle_words = self.config.get( self.max_subtitle_words = self.config.get("max_subtitle_words", _d["max_subtitle_words"])
"max_subtitle_words", 50 self.silence_duration = self.config.get("silence_duration", _d.get("silence_between_chapters", 2.0))
) # Default max words per subtitle self.selected_format = self.config.get("selected_format", _d["selected_format"])
self.silence_duration = self.config.get( self.separate_chapters_format = self.config.get("separate_chapters_format", _d["separate_chapters_format"])
"silence_duration", 2.0 self.use_gpu = self.config.get("use_gpu", _d["use_gpu"])
) # Default silence duration self.replace_single_newlines = self.config.get("replace_single_newlines", _d.get("replace_single_newlines", True))
self.selected_format = self.config.get("selected_format", "wav") self.use_silent_gaps = self.config.get("use_silent_gaps", _d["use_silent_gaps"])
self.separate_chapters_format = self.config.get( self.subtitle_speed_method = self.config.get("subtitle_speed_method", _d["subtitle_speed_method"])
"separate_chapters_format", "wav" self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", _d["use_spacy_segmentation"])
) # Format for individual chapter files self.read_title_intro = self.config.get("read_title_intro", _d.get("read_title_intro", False))
self.use_gpu = self.config.get( self.read_closing_outro = self.config.get("read_closing_outro", _d.get("read_closing_outro", True))
"use_gpu", True # Load GPU setting with default True
)
self.replace_single_newlines = self.config.get("replace_single_newlines", True)
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
# Word substitution settings # Word substitution settings
self.word_substitutions_enabled = self.config.get( self.word_substitutions_enabled = self.config.get("word_substitutions_enabled", _d["word_substitutions_enabled"])
"word_substitutions_enabled", False self.word_substitutions_list = self.config.get("word_substitutions_list", _d["word_substitutions_list"])
) self.case_sensitive_substitutions = self.config.get("case_sensitive_substitutions", _d["case_sensitive_substitutions"])
self.word_substitutions_list = self.config.get("word_substitutions_list", "") self.replace_all_caps = self.config.get("replace_all_caps", _d["replace_all_caps"])
self.case_sensitive_substitutions = self.config.get( self.replace_numerals = self.config.get("replace_numerals", _d["replace_numerals"])
"case_sensitive_substitutions", False self.fix_nonstandard_punctuation = self.config.get("fix_nonstandard_punctuation", _d["fix_nonstandard_punctuation"])
)
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
@@ -998,7 +991,7 @@ class abogen(QWidget):
self.current_queue_index = 0 self.current_queue_index = 0
self.initUI() self.initUI()
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) self.speed_slider.setValue(int(self.config.get("speed", _d["speed"]) * 100))
self.update_speed_label() self.update_speed_label()
# Set initial selection: prefer profile, else voice # Set initial selection: prefer profile, else voice
idx = -1 idx = -1
@@ -2161,7 +2154,7 @@ class abogen(QWidget):
) )
# CHECK GLOBAL OVERRIDE SETTING # CHECK GLOBAL OVERRIDE SETTING
if not self.config.get("queue_override_settings", False): if not self.config.get("queue_override_settings", _DEFAULTS["queue_override_settings"]):
self.selected_lang = queued_item.lang_code self.selected_lang = queued_item.lang_code
self.speed_slider.setValue(int(queued_item.speed * 100)) self.speed_slider.setValue(int(queued_item.speed * 100))
@@ -2235,11 +2228,10 @@ class abogen(QWidget):
self.current_queue_index = 0 # Reset for next time self.current_queue_index = 0 # Reset for next time
def get_voice_formula(self) -> str: def get_voice_formula(self) -> str:
from abogen.voice_formulas import pairs_to_formula
if self.mixed_voice_state: if self.mixed_voice_state:
formula_components = [ return pairs_to_formula(self.mixed_voice_state) or ""
f"{name}*{weight}" for name, weight in self.mixed_voice_state
]
return " + ".join(filter(None, formula_components))
else: else:
return self.selected_voice return self.selected_voice
@@ -2403,6 +2395,9 @@ class abogen(QWidget):
self.conversion_thread.merge_chapters_at_end = getattr( self.conversion_thread.merge_chapters_at_end = getattr(
self, "merge_chapters_at_end", True self, "merge_chapters_at_end", True
) )
# Pass intro/outro settings
self.conversion_thread.read_title_intro = self.read_title_intro
self.conversion_thread.read_closing_outro = self.read_closing_outro
self.conversion_thread.progress_updated.connect(self.update_progress) self.conversion_thread.progress_updated.connect(self.update_progress)
self.conversion_thread.log_updated.connect(self.update_log) self.conversion_thread.log_updated.connect(self.update_log)
self.conversion_thread.conversion_finished.connect( self.conversion_thread.conversion_finished.connect(
@@ -2427,15 +2422,9 @@ class abogen(QWidget):
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
self.update_log("Loading modules...") self.update_log("Loading modules...")
# Determine device based on GPU availability
if gpu_ok:
device = _select_device()
else:
device = "cpu"
lang_code = self.selected_lang or "a" lang_code = self.selected_lang or "a"
load_thread = LoadPipelineThread( load_thread = LoadPipelineThread(
pipeline_loaded_callback, lang_code=lang_code, device=device pipeline_loaded_callback, lang_code=lang_code, use_gpu=gpu_ok
) )
load_thread.start() load_thread.start()
@@ -2447,7 +2436,7 @@ class abogen(QWidget):
return return
# Check if override was active (this determines which settings were ACTUALLY used) # Check if override was active (this determines which settings were ACTUALLY used)
override_active = self.config.get("queue_override_settings", False) override_active = self.config.get("queue_override_settings", _DEFAULTS["queue_override_settings"])
# If override is ON, capture the global settings that were used for processing # If override is ON, capture the global settings that were used for processing
if override_active: if override_active:
@@ -2873,15 +2862,9 @@ class abogen(QWidget):
) )
self.loading_movie.start() self.loading_movie.start()
# Determine device based on GPU availability
if self.gpu_ok:
device = _select_device()
else:
device = "cpu"
lang = self.selected_lang or "a" lang = self.selected_lang or "a"
load_thread = LoadPipelineThread( load_thread = LoadPipelineThread(
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device self._on_pipeline_loaded_for_preview, lang_code=lang, use_gpu=self.gpu_ok
) )
load_thread.start() load_thread.start()
@@ -3429,7 +3412,7 @@ class abogen(QWidget):
app.installEventFilter(app._dark_titlebar_event_filter) app.installEventFilter(app._dark_titlebar_event_filter)
# Save config if changed # Save config if changed
if self.config.get("theme", "system") != theme: if self.config.get("theme", _DEFAULTS["theme"]) != theme:
self.config["theme"] = theme self.config["theme"] = theme
save_config(self.config) save_config(self.config)
@@ -3451,7 +3434,7 @@ class abogen(QWidget):
] ]
# Get current theme from config, default to "system" # Get current theme from config, default to "system"
current_theme = self.config.get("theme", "system") current_theme = self.config.get("theme", _DEFAULTS["theme"])
for value, text in theme_options: for value, text in theme_options:
theme_action = QAction(text, self) theme_action = QAction(text, self)
theme_action.setCheckable(True) theme_action.setCheckable(True)
@@ -3582,6 +3565,27 @@ class abogen(QWidget):
# Add separator # Add separator
menu.addSeparator() menu.addSeparator()
# Add title intro option
self.title_intro_action = QAction("Read title intro before first chapter", self)
self.title_intro_action.setCheckable(True)
self.title_intro_action.setChecked(self.read_title_intro)
self.title_intro_action.triggered.connect(
lambda checked: self.toggle_read_title_intro(checked)
)
menu.addAction(self.title_intro_action)
# Add closing outro option
self.closing_outro_action = QAction("Read closing outro after last chapter", self)
self.closing_outro_action.setCheckable(True)
self.closing_outro_action.setChecked(self.read_closing_outro)
self.closing_outro_action.triggered.connect(
lambda checked: self.toggle_read_closing_outro(checked)
)
menu.addAction(self.closing_outro_action)
# Add separator
menu.addSeparator()
# Add "Pre-download models and voices for offline use" option # Add "Pre-download models and voices for offline use" option
predownload_action = QAction( predownload_action = QAction(
"Pre-download models and voices for offline use", self "Pre-download models and voices for offline use", self
@@ -3593,7 +3597,7 @@ class abogen(QWidget):
disable_kokoro_action = QAction("Disable Kokoro's internet access", self) disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
disable_kokoro_action.setCheckable(True) disable_kokoro_action.setCheckable(True)
disable_kokoro_action.setChecked( disable_kokoro_action.setChecked(
self.config.get("disable_kokoro_internet", False) self.config.get("disable_kokoro_internet", _DEFAULTS["disable_kokoro_internet"])
) )
disable_kokoro_action.triggered.connect( disable_kokoro_action.triggered.connect(
lambda checked: self.toggle_kokoro_internet_access(checked) lambda checked: self.toggle_kokoro_internet_access(checked)
@@ -3603,7 +3607,7 @@ class abogen(QWidget):
# Add check for updates option # Add check for updates option
check_updates_action = QAction("Check for updates at startup", self) check_updates_action = QAction("Check for updates at startup", self)
check_updates_action.setCheckable(True) check_updates_action.setCheckable(True)
check_updates_action.setChecked(self.config.get("check_updates", True)) check_updates_action.setChecked(self.config.get("check_updates", _DEFAULTS["check_updates"]))
check_updates_action.triggered.connect(self.toggle_check_updates) check_updates_action.triggered.connect(self.toggle_check_updates)
menu.addAction(check_updates_action) menu.addAction(check_updates_action)
@@ -3658,6 +3662,16 @@ class abogen(QWidget):
self.config["use_spacy_segmentation"] = enabled self.config["use_spacy_segmentation"] = enabled
save_config(self.config) save_config(self.config)
def toggle_read_title_intro(self, enabled):
self.read_title_intro = enabled
self.config["read_title_intro"] = enabled
save_config(self.config)
def toggle_read_closing_outro(self, enabled):
self.read_closing_outro = enabled
self.config["read_closing_outro"] = enabled
save_config(self.config)
def restart_app(self): def restart_app(self):
import sys import sys
@@ -4229,7 +4243,7 @@ Categories=AudioVideo;Audio;Utility;
"""Open a dialog to set the maximum words per subtitle""" """Open a dialog to set the maximum words per subtitle"""
from PyQt6.QtWidgets import QInputDialog from PyQt6.QtWidgets import QInputDialog
current_value = self.config.get("max_subtitle_words", 50) current_value = self.config.get("max_subtitle_words", _DEFAULTS["max_subtitle_words"])
value, ok = QInputDialog.getInt( value, ok = QInputDialog.getInt(
self, self,
@@ -4257,7 +4271,7 @@ Categories=AudioVideo;Audio;Utility;
def set_silence_between_chapters(self): def set_silence_between_chapters(self):
"""Open a dialog to set the silence duration between chapters""" """Open a dialog to set the silence duration between chapters"""
current_value = self.config.get("silence_duration", 2.0) current_value = self.config.get("silence_duration", _DEFAULTS.get("silence_between_chapters", 2.0))
dlg = QInputDialog(self) dlg = QInputDialog(self)
dlg.setWindowTitle("Silence Duration (seconds)") dlg.setWindowTitle("Silence Duration (seconds)")
+5 -5
View File
@@ -530,18 +530,18 @@ def prevent_sleep_end():
class LoadPipelineThread(Thread): class LoadPipelineThread(Thread):
def __init__(self, callback, lang_code="a", device="cpu"): def __init__(self, callback, lang_code="a", use_gpu=True):
super().__init__() super().__init__()
self.callback = callback self.callback = callback
self.lang_code = lang_code self.lang_code = lang_code
self.device = device self.use_gpu = use_gpu
def run(self): def run(self):
try: try:
from abogen.tts_plugin.utils import create_pipeline from abogen.domain.pipeline_factory import create_pipeline_for_job
backend = create_pipeline( backend = create_pipeline_for_job(
"kokoro", lang_code=self.lang_code, device=self.device "kokoro", language=self.lang_code, use_gpu=self.use_gpu
) )
self.callback(backend, None) self.callback(backend, None)
except Exception as e: except Exception as e:
+28 -1
View File
@@ -1,5 +1,5 @@
import re import re
from typing import List, Tuple from typing import Iterable, List, Optional, Tuple
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
@@ -72,6 +72,33 @@ def parse_voice_formula(pipeline, formula):
return weighted_sum return weighted_sum
def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
"""Build a voice formula string from (voice_name, weight) pairs.
Normalizes weights to sum to 1.0 and formats as "voice1*0.5+voice2*0.5".
Args:
pairs: Iterable of (voice_name, weight) tuples. Zero-weight entries
are filtered out.
Returns:
Formula string, or None if no valid entries.
"""
voices = [(voice, float(weight)) for voice, weight in pairs if weight is not None and float(weight) > 0]
if not voices:
return None
total = sum(weight for _, weight in voices)
if total <= 0:
return None
def _format_value(value: float) -> str:
normalized = value / total if total else 0.0
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
return "+".join(parts)
def calculate_sum_from_formula(formula): def calculate_sum_from_formula(formula):
weights = re.findall(r"\* *([\d.]+)", formula) weights = re.findall(r"\* *([\d.]+)", formula)
total_sum = sum(float(weight) for weight in weights) total_sum = sum(float(weight) for weight in weights)
+107 -194
View File
@@ -32,8 +32,7 @@ from abogen.utils import (
get_user_output_path, get_user_output_path,
) )
from abogen.voice_profiles import load_profiles, normalize_profile_entry from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.llm_client import LLMClientError from abogen.infrastructure.subtitle_writer import make_subtitle_writer
from abogen.infrastructure.subtitle_writer import create_subtitle_writer
from abogen.domain.chapter_titles import ( from abogen.domain.chapter_titles import (
simplify_heading_text as _simplify_heading_text, simplify_heading_text as _simplify_heading_text,
headings_equivalent as _headings_equivalent, headings_equivalent as _headings_equivalent,
@@ -52,6 +51,7 @@ from abogen.domain.metadata_helpers import (
extract_series_metadata as _extract_series_metadata, extract_series_metadata as _extract_series_metadata,
format_series_sentence as _format_series_sentence, format_series_sentence as _format_series_sentence,
) )
from abogen.domain.intro_outro import resolve_intro, resolve_outro
from abogen.domain.title_builder import ( from abogen.domain.title_builder import (
build_title_intro_text as _build_title_intro_text, build_title_intro_text as _build_title_intro_text,
build_outro_text as _build_outro_text, build_outro_text as _build_outro_text,
@@ -69,7 +69,7 @@ from abogen.domain.pronunciation import (
apply_pronunciation_rules as _apply_pronunciation_rules, apply_pronunciation_rules as _apply_pronunciation_rules,
merge_pronunciation_overrides as _merge_pronunciation_overrides, merge_pronunciation_overrides as _merge_pronunciation_overrides,
) )
from abogen.domain.normalization import prepare_text_for_tts from abogen.domain.normalization import TTSContext
from abogen.domain.voice_resolution import ( from abogen.domain.voice_resolution import (
spec_to_voice_ids as _spec_to_voice_ids, spec_to_voice_ids as _spec_to_voice_ids,
job_voice_fallback as _job_voice_fallback, job_voice_fallback as _job_voice_fallback,
@@ -77,7 +77,6 @@ from abogen.domain.voice_resolution import (
initialize_voice_cache as _initialize_voice_cache, initialize_voice_cache as _initialize_voice_cache,
chapter_voice_spec as _chapter_voice_spec, chapter_voice_spec as _chapter_voice_spec,
chunk_voice_spec as _chunk_voice_spec, chunk_voice_spec as _chunk_voice_spec,
resolve_fallback_voice_spec as _resolve_fallback_voice_spec,
) )
from abogen.domain.chapter_overrides import apply_chapter_overrides as _apply_chapter_overrides from abogen.domain.chapter_overrides import apply_chapter_overrides as _apply_chapter_overrides
from abogen.domain.metadata_merge import merge_metadata as _merge_metadata from abogen.domain.metadata_merge import merge_metadata as _merge_metadata
@@ -106,7 +105,7 @@ from abogen.domain.output_paths import (
from abogen.domain.device import select_device as _select_device from abogen.domain.device import select_device as _select_device
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.progress import ProgressTracker, calc_etr_str from abogen.domain.progress import ProgressTracker, calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens
from abogen.domain.audio_helpers import ( from abogen.domain.audio_helpers import (
build_ffmpeg_command as _build_ffmpeg_command, build_ffmpeg_command as _build_ffmpeg_command,
to_float32 as _to_float32, to_float32 as _to_float32,
@@ -117,8 +116,9 @@ from abogen.domain.audio_buffer import (
SAMPLE_RATE, SAMPLE_RATE,
) )
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
from abogen.domain.tokens import FakeToken
from abogen.domain.pipeline_factory import PipelinePool from abogen.domain.pipeline_factory import PipelinePool
from abogen.domain.conversion_engine import synthesize_text, process_and_write_subtitles, SegmentStats
from abogen.domain.voice_loader import VoiceCache, resolve_voice
from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target
@@ -216,11 +216,11 @@ def run_conversion_job(job: Job) -> None:
if provider == "kokoro": if provider == "kokoro":
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu) choice = resolve_voice(resolved, kokoro_backend, job.use_gpu, cache=voice_cache)
else: else:
choice = resolved choice = resolved
voice_cache[cache_key] = choice voice_cache.set(cache_key, choice)
return provider, resolved, choice, speed, steps return provider, resolved, choice, speed, steps
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
@@ -241,6 +241,14 @@ def run_conversion_job(job: Job) -> None:
f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.", f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.",
level="debug", level="debug",
) )
tts_context = TTSContext(
split_pattern=job_split_pattern,
pronunciation_rules=pronunciation_rules,
heteronym_rules=heteronym_sentence_rules,
normalization_overrides=getattr(job, "normalization_overrides", None),
usage_counter=usage_counter,
)
for override_entry in pronunciation_overrides or []: for override_entry in pronunciation_overrides or []:
if not isinstance(override_entry, Mapping): if not isinstance(override_entry, Mapping):
continue continue
@@ -315,7 +323,11 @@ def run_conversion_job(job: Job) -> None:
_apply_newline_policy(extraction.chapters, job.replace_single_newlines) _apply_newline_policy(extraction.chapters, job.replace_single_newlines)
base_output_dir = _prepare_output_dir(job) base_output_dir = _prepare_output_dir(job)
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, base_output_dir) project_root, audio_dir, subtitle_dir, metadata_dir = _resolve_project_layout(
original_filename=job.original_filename,
save_as_project=job.save_as_project,
base_dir=base_output_dir,
)
if job.output_format.lower() == "m4b" and not job.merge_chapters_at_end: if job.output_format.lower() == "m4b" and not job.merge_chapters_at_end:
job.add_log( job.add_log(
@@ -338,7 +350,18 @@ def run_conversion_job(job: Job) -> None:
cancel_check=lambda: job.cancel_requested, cancel_check=lambda: job.cancel_requested,
) )
) )
subtitle_writer = _create_subtitle_writer(job, audio_path) subtitle_writer = make_subtitle_writer(
audio_path,
job.subtitle_format,
job.subtitle_mode or "Line",
max_words=job.max_subtitle_words,
)
if subtitle_writer is None and job.subtitle_mode != "Disabled":
fmt = (job.subtitle_format or "srt").lower()
if job.subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning")
else:
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning")
job.result.audio_path = audio_path job.result.audio_path = audio_path
if subtitle_writer: if subtitle_writer:
job.result.subtitle_paths.append(subtitle_writer.path) job.result.subtitle_paths.append(subtitle_writer.path)
@@ -349,7 +372,7 @@ def run_conversion_job(job: Job) -> None:
chapter_dir.mkdir(parents=True, exist_ok=True) chapter_dir.mkdir(parents=True, exist_ok=True)
base_voice_spec = _job_voice_fallback(job) base_voice_spec = _job_voice_fallback(job)
voice_cache: Dict[str, Any] = {} voice_cache = VoiceCache()
base_provider, base_voice_resolved, _, _ = _resolve_voice_target( base_provider, base_voice_resolved, _, _ = _resolve_voice_target(
base_voice_spec, normalized_profiles, base_voice_spec, normalized_profiles,
job_voice=getattr(job, "voice", "M1"), job_voice=getattr(job, "voice", "M1"),
@@ -357,7 +380,7 @@ def run_conversion_job(job: Job) -> None:
) )
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved: if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu) voice_cache.set(f"kokoro:{base_voice_resolved}", resolve_voice(base_voice_resolved, kokoro_backend, job.use_gpu))
processed_chars = 0 processed_chars = 0
current_time = 0.0 current_time = 0.0
etr_start_time = time.time() etr_start_time = time.time()
@@ -374,22 +397,20 @@ def run_conversion_job(job: Job) -> None:
intro_voice_choice: Any = None intro_voice_choice: Any = None
intro_speed: Optional[float] = None intro_speed: Optional[float] = None
intro_steps: Optional[int] = None intro_steps: Optional[int] = None
if read_title_intro: intro_spec = resolve_intro(
book_intro_text = _build_title_intro_text(job.metadata_tags, job.original_filename) job.metadata_tags, job.original_filename, read_title_intro,
if book_intro_text: base_voice_spec, getattr(job, "voice", "M1"), list(voice_cache.keys()),
preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}" )
job.add_log(f"Title intro enabled: {preview}", level="debug") if intro_spec.enabled:
book_intro_text = intro_spec.text
preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}"
job.add_log(f"Title intro enabled: {preview}", level="debug")
intro_voice_spec = _resolve_fallback_voice_spec( intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
base_voice_spec, job.voice, list(voice_cache.keys()) intro_spec.voice_spec
) )
elif read_title_intro:
if intro_voice_spec: job.add_log("Title intro enabled but no usable metadata was found.", level="debug")
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
intro_voice_spec
)
else:
job.add_log("Title intro enabled but no usable metadata was found.", level="debug")
intro_emitted = False intro_emitted = False
def emit_text( def emit_text(
@@ -404,113 +425,72 @@ def run_conversion_job(job: Job) -> None:
supertonic_steps_override: Optional[int] = None, supertonic_steps_override: Optional[int] = None,
) -> int: ) -> int:
nonlocal processed_chars, current_time nonlocal processed_chars, current_time
if split_pattern is None:
split_pattern = job_split_pattern
source_text = str(text or "") source_text = str(text or "")
try:
normalized = prepare_text_for_tts(
source_text,
heteronym_rules=heteronym_sentence_rules,
pronunciation_rules=pronunciation_rules,
normalization_overrides=getattr(job, "normalization_overrides", None),
usage_counter=usage_counter,
)
except LLMClientError as exc:
job.add_log(f"LLM normalization failed: {exc}", level="error")
raise
local_segments = 0
provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro" provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro"
if provider == "supertonic": if provider == "supertonic":
supertonic_pipeline = pipeline_pool.get("supertonic", job.language, job.use_gpu, job=job) supertonic_pipeline = pipeline_pool.get("supertonic", job.language, job.use_gpu, job=job)
voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1")) voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1"))
segment_iter = supertonic_pipeline( backend = supertonic_pipeline
normalized, resolved_voice = voice_name
voice=voice_name, effective_speed = float(speed_override if speed_override is not None else job.speed)
speed=float(speed_override if speed_override is not None else job.speed),
split_pattern=split_pattern,
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
)
else: else:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
segment_iter = kokoro_backend( backend = kokoro_backend
normalized, resolved_voice = voice_choice
voice=voice_choice, effective_speed = float(speed_override if speed_override is not None else job.speed)
speed=float(speed_override if speed_override is not None else job.speed),
split_pattern=split_pattern,
)
try: try:
# Accumulate tokens for subtitle processing (token-level grouping) stats = SegmentStats(
accumulated_tokens: List[dict] = [] processed_chars=processed_chars,
current_time=current_time,
etr_start_time=etr_start_time,
total_characters=job.total_characters or 0,
)
prefix = f"{preview_prefix} · " if preview_prefix else ""
for segment in segment_iter: def _on_progress(pct: int, etr: str) -> None:
canceller() nonlocal processed_chars
graphemes_raw = getattr(segment, "graphemes", "") or "" processed_chars = stats.processed_chars
graphemes = graphemes_raw.strip()
audio = _to_float32(getattr(segment, "audio", None))
if audio.size == 0:
continue
local_segments += 1
if chapter_sink:
chapter_sink.write(audio)
if audio_sink:
audio_sink.write(audio)
duration = len(audio) / SAMPLE_RATE
chunk_start = current_time
processed_chars += len(graphemes)
job.processed_characters = processed_chars job.processed_characters = processed_chars
if job.total_characters: if stats.total_characters:
job.progress = min(processed_chars / job.total_characters, 0.999) job.progress = min(processed_chars / stats.total_characters, 0.999)
job.etr_str = calc_etr_str(
time.time() - etr_start_time,
processed_chars,
job.total_characters,
)
else: else:
job.progress = 0.0 if processed_chars == 0 else 0.999 job.progress = 0.0 if processed_chars == 0 else 0.999
job.etr_str = etr
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") def _preview(text: str) -> None:
prefix = f"{preview_prefix} · " if preview_prefix else "" job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or ''}: {text[:80]}")
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or ''}: {preview_text[:80]}")
# Accumulate tokens from this segment for subtitle processing local_segments, accumulated_tokens = synthesize_text(
if subtitle_writer and audio_sink: text=source_text,
tokens_list = getattr(segment, "tokens", []) tts_context=tts_context,
backend=backend,
voice=resolved_voice,
speed=effective_speed,
stats=stats,
check_cancel=canceller,
on_progress=_on_progress,
chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=_preview,
subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled",
max_subtitle_words=job.max_subtitle_words,
lang_code=job.language,
use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"),
)
current_time = stats.current_time
# Fallback for languages without token support: create a single token
if not tokens_list and graphemes:
tokens_list = [FakeToken(graphemes, 0, duration)]
for tok in tokens_list:
accumulated_tokens.append({
"start": chunk_start + (tok.start_ts or 0),
"end": chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
})
if audio_sink:
current_time += duration
# Flush accumulated tokens through process_subtitle_tokens
if subtitle_writer and audio_sink and accumulated_tokens: if subtitle_writer and audio_sink and accumulated_tokens:
_use_spacy = job.subtitle_mode not in ("Disabled", "Line") process_and_write_subtitles(
new_entries: List[tuple] = []
process_subtitle_tokens(
accumulated_tokens, accumulated_tokens,
new_entries, subtitle_writer,
job.max_subtitle_words, subtitle_mode=job.subtitle_mode,
job.subtitle_mode, max_subtitle_words=job.max_subtitle_words,
job.language, lang_code=job.language,
use_spacy_segmentation=_use_spacy, use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"),
fallback_end_time=current_time, fallback_end_time=current_time,
) )
for start, end, text in new_entries:
subtitle_writer.write_entry(start=start, end=end, text=text)
except OverflowError as exc: except OverflowError as exc:
job.add_log( job.add_log(
@@ -554,22 +534,9 @@ def run_conversion_job(job: Job) -> None:
if not chapter_voice_spec: if not chapter_voice_spec:
chapter_voice_spec = base_voice_spec chapter_voice_spec = base_voice_spec
chapter_provider, chapter_voice_resolved, chapter_speed, chapter_steps = _resolve_voice_target( chapter_provider, chapter_voice_resolved, voice_choice, chapter_speed, chapter_steps = resolve_voice_choice(
chapter_voice_spec, normalized_profiles, chapter_voice_spec
job_voice=getattr(job, "voice", "M1"),
job_tts_provider=getattr(job, "tts_provider", "kokoro"),
job_supertonic_total_steps=getattr(job, "supertonic_total_steps", 5),
job_speed=getattr(job, "speed", 1.0),
) )
chapter_cache_key = f"{chapter_provider}:{chapter_voice_resolved}" if chapter_voice_resolved else chapter_provider
if chapter_provider == "kokoro":
voice_choice = voice_cache.get(chapter_cache_key)
if voice_choice is None:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu)
voice_cache[chapter_cache_key] = voice_choice
else:
voice_choice = chapter_voice_resolved
chapter_audio_path: Optional[Path] = None chapter_audio_path: Optional[Path] = None
segments_emitted = 0 segments_emitted = 0
@@ -698,26 +665,9 @@ def run_conversion_job(job: Job) -> None:
chunk_steps_use = chapter_steps chunk_steps_use = chapter_steps
chunk_voice_choice = voice_choice chunk_voice_choice = voice_choice
else: else:
chunk_provider, chunk_voice_resolved, chunk_speed_use, chunk_steps_use = _resolve_voice_target( chunk_provider, chunk_voice_resolved, chunk_voice_choice, chunk_speed_use, chunk_steps_use = resolve_voice_choice(
chunk_voice_spec, normalized_profiles, chunk_voice_spec
job_voice=getattr(job, "voice", "M1"),
job_tts_provider=getattr(job, "tts_provider", "kokoro"),
job_supertonic_total_steps=getattr(job, "supertonic_total_steps", 5),
job_speed=getattr(job, "speed", 1.0),
) )
chunk_cache_key = f"{chunk_provider}:{chunk_voice_resolved}" if chunk_voice_resolved else chunk_provider
if chunk_provider == "kokoro":
chunk_voice_choice = voice_cache.get(chunk_cache_key)
if chunk_voice_choice is None:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
chunk_voice_choice = _resolve_voice(
kokoro_backend,
chunk_voice_resolved,
job.use_gpu,
)
voice_cache[chunk_cache_key] = chunk_voice_choice
else:
chunk_voice_choice = chunk_voice_resolved
chunk_start = current_time chunk_start = current_time
emitted = emit_text( emitted = emit_text(
@@ -839,17 +789,17 @@ def run_conversion_job(job: Job) -> None:
chapter_markers.append(marker) chapter_markers.append(marker)
if getattr(job, "read_closing_outro", True): if getattr(job, "read_closing_outro", True):
outro_text = _build_outro_text(job.metadata_tags, job.original_filename) outro_spec = resolve_outro(
outro_voice_spec = _resolve_fallback_voice_spec( job.metadata_tags, job.original_filename, True,
base_voice_spec, job.voice, list(voice_cache.keys()) base_voice_spec, getattr(job, "voice", "M1"), list(voice_cache.keys()),
) )
if outro_text and outro_voice_spec: if outro_spec.enabled:
outro_start_time = current_time outro_start_time = current_time
outro_audio_path: Optional[Path] = None outro_audio_path: Optional[Path] = None
outro_segments = 0 outro_segments = 0
outro_index = total_chapters + 1 outro_index = total_chapters + 1
outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_voice_spec) outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_spec.voice_spec)
with ExitStack() as outro_sink_stack: with ExitStack() as outro_sink_stack:
chapter_sink: Optional[AudioSink] = None chapter_sink: Optional[AudioSink] = None
@@ -868,7 +818,7 @@ def run_conversion_job(job: Job) -> None:
) )
outro_segments = emit_text( outro_segments = emit_text(
outro_text, outro_spec.text,
voice_choice=outro_voice_choice, voice_choice=outro_voice_choice,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
preview_prefix="Outro", preview_prefix="Outro",
@@ -879,7 +829,7 @@ def run_conversion_job(job: Job) -> None:
outro_end_time = current_time outro_end_time = current_time
if outro_segments > 0: if outro_segments > 0:
job.add_log(f"Appended outro sequence: {outro_text}") job.add_log(f"Appended outro sequence: {outro_spec.text}")
if outro_audio_path is not None: if outro_audio_path is not None:
job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path
chapter_paths.append(outro_audio_path) chapter_paths.append(outro_audio_path)
@@ -889,7 +839,7 @@ def run_conversion_job(job: Job) -> None:
"title": "Outro", "title": "Outro",
"start": outro_start_time, "start": outro_start_time,
"end": outro_end_time, "end": outro_end_time,
"voice": outro_voice_spec, "voice": outro_spec.voice_spec,
} }
) )
else: else:
@@ -908,8 +858,8 @@ def run_conversion_job(job: Job) -> None:
"generate_epub3": job.generate_epub3, "generate_epub3": job.generate_epub3,
} }
if usage_counter: if tts_context.usage_counter:
_record_override_usage(job, usage_counter, override_token_map) _record_override_usage(job, tts_context.usage_counter, override_token_map)
if metadata_dir: if metadata_dir:
metadata_dir.mkdir(parents=True, exist_ok=True) metadata_dir.mkdir(parents=True, exist_ok=True)
@@ -1072,43 +1022,6 @@ def _prepare_output_dir(job: Job) -> Path:
return directory return directory
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
base_dir.mkdir(parents=True, exist_ok=True)
return _resolve_project_layout(
original_filename=job.original_filename,
save_as_project=job.save_as_project,
base_dir=base_dir,
)
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
if "*" in voice_spec:
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_spec
return get_new_voice(pipeline, voice_spec, use_gpu)
return voice_spec
def _create_subtitle_writer(job: Job, audio_path: Path):
if job.subtitle_mode == "Disabled":
return None
fmt = (job.subtitle_format or "srt").lower()
if job.subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning")
fmt = "ass"
try:
return create_subtitle_writer(
audio_path.with_suffix(f".{fmt}"),
fmt,
job.subtitle_mode or "Line",
)
except (ValueError, KeyError):
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning")
return None
def _make_canceller(job: Job) -> Callable[[], None]: def _make_canceller(job: Job) -> Callable[[], None]:
def _cancel() -> None: def _cancel() -> None:
+3 -2
View File
@@ -14,7 +14,8 @@ from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, _select_device, _to_float32, _spec_to_voice_ids
from abogen.domain.voice_loader import resolve_voice
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
@@ -176,7 +177,7 @@ def run_debug_tts_wavs(
pass pass
pipeline = _load_pipeline(language, use_gpu) pipeline = _load_pipeline(language, use_gpu)
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu) voice_choice = resolve_voice(voice_spec, pipeline, use_gpu)
apostrophe_config = build_apostrophe_config(settings=settings) apostrophe_config = build_apostrophe_config(settings=settings)
normalization_settings = dict(settings) normalization_settings = dict(settings)
+1 -77
View File
@@ -281,83 +281,7 @@ def api_speaker_preview() -> ResponseReturnValue:
# --- Integration Routes --- # --- Integration Routes ---
def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]: from abogen.domain.metadata_overrides import normalize_opds_metadata as _opds_metadata_overrides
metadata_overrides: Dict[str, Any] = {}
def _stringify_metadata_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (list, tuple, set)):
parts = [str(item).strip() for item in value if item is not None]
parts = [part for part in parts if part]
return ", ".join(parts)
return str(value).strip()
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
series_name = str(raw_series or "").strip()
if series_name:
metadata_overrides["series"] = series_name
metadata_overrides.setdefault("series_name", series_name)
series_index_value = (
metadata_payload.get("series_index")
or metadata_payload.get("series_position")
or metadata_payload.get("series_sequence")
or metadata_payload.get("book_number")
)
if series_index_value is not None:
series_index_text = str(series_index_value).strip()
if series_index_text:
metadata_overrides.setdefault("series_index", series_index_text)
metadata_overrides.setdefault("series_position", series_index_text)
metadata_overrides.setdefault("series_sequence", series_index_text)
metadata_overrides.setdefault("book_number", series_index_text)
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
if tags_value:
tags_text = _stringify_metadata_value(tags_value)
if tags_text:
metadata_overrides.setdefault("tags", tags_text)
metadata_overrides.setdefault("keywords", tags_text)
metadata_overrides.setdefault("genre", tags_text)
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
if description_value:
description_text = _stringify_metadata_value(description_value)
if description_text:
metadata_overrides.setdefault("description", description_text)
metadata_overrides.setdefault("summary", description_text)
subtitle_value = (
metadata_payload.get("subtitle")
or metadata_payload.get("sub_title")
or metadata_payload.get("calibre_subtitle")
)
if subtitle_value:
subtitle_text = _stringify_metadata_value(subtitle_value)
if subtitle_text:
metadata_overrides.setdefault("subtitle", subtitle_text)
publisher_value = metadata_payload.get("publisher")
if publisher_value:
publisher_text = _stringify_metadata_value(publisher_value)
if publisher_text:
metadata_overrides.setdefault("publisher", publisher_text)
# Author mapping: Abogen templates look for either 'authors' or 'author'.
authors_value = (
metadata_payload.get("authors")
or metadata_payload.get("author")
or metadata_payload.get("creator")
or metadata_payload.get("dc_creator")
)
if authors_value:
authors_text = _stringify_metadata_value(authors_value)
if authors_text:
metadata_overrides.setdefault("authors", authors_text)
metadata_overrides.setdefault("author", authors_text)
return metadata_overrides
@api_bp.get("/integrations/calibre-opds/feed") @api_bp.get("/integrations/calibre-opds/feed")
def api_calibre_opds_feed() -> ResponseReturnValue: def api_calibre_opds_feed() -> ResponseReturnValue:
+43 -76
View File
@@ -8,8 +8,8 @@ from flask.typing import ResponseReturnValue
from abogen.webui.service import ( from abogen.webui.service import (
JobStatus, JobStatus,
load_audiobookshelf_chapters,
build_audiobookshelf_metadata, build_audiobookshelf_metadata,
load_audiobookshelf_chapters,
) )
from abogen.webui.routes.utils.service import get_service from abogen.webui.routes.utils.service import get_service
from abogen.webui.routes.utils.form import render_jobs_panel from abogen.webui.routes.utils.form import render_jobs_panel
@@ -22,15 +22,22 @@ from abogen.webui.routes.utils.epub import (
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
stored_integration_config, stored_integration_config,
build_audiobookshelf_config, build_audiobookshelf_config,
coerce_bool,
) )
from abogen.webui.routes.utils.common import existing_paths from abogen.webui.routes.utils.common import existing_paths
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfUploadError from abogen.infrastructure.exporters import ExportService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
jobs_bp = Blueprint("jobs", __name__) jobs_bp = Blueprint("jobs", __name__)
def _resolve_cover(job: Any, config: Any) -> Optional[Path]:
"""Resolve cover image path if enabled."""
if not config.send_cover or not job.cover_image_path:
return None
cover = job.cover_image_path if isinstance(job.cover_image_path, Path) else Path(str(job.cover_image_path))
return cover if cover.exists() else None
@jobs_bp.get("/<job_id>") @jobs_bp.get("/<job_id>")
def job_detail(job_id: str) -> ResponseReturnValue: def job_detail(job_id: str) -> ResponseReturnValue:
job = get_service().get_job(job_id) job = get_service().get_job(job_id)
@@ -98,24 +105,18 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
return _panel_response() return _panel_response()
settings = stored_integration_config("audiobookshelf") settings = stored_integration_config("audiobookshelf")
if not settings or not coerce_bool(settings.get("enabled"), False): if not settings or not settings.get("enabled"):
job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning") job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning")
service._persist_state() service._persist_state()
return _panel_response() return _panel_response()
config = build_audiobookshelf_config(settings) config = build_audiobookshelf_config(settings)
if config is None: if config is None:
job.add_log( job.add_log("Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", level="warning")
"Audiobookshelf upload skipped: configure base URL, API token, and library ID first.",
level="warning",
)
service._persist_state() service._persist_state()
return _panel_response() return _panel_response()
if not config.folder_id: if not config.folder_id:
job.add_log( job.add_log("Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.", level="warning")
"Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.",
level="warning",
)
service._persist_state() service._persist_state()
return _panel_response() return _panel_response()
@@ -125,83 +126,49 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
service._persist_state() service._persist_state()
return _panel_response() return _panel_response()
cover_path = None
if config.send_cover and job.cover_image_path:
cover_candidate = job.cover_image_path
if not isinstance(cover_candidate, Path):
cover_candidate = Path(str(cover_candidate))
if cover_candidate.exists():
cover_path = cover_candidate
subtitles = existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None
metadata = build_audiobookshelf_metadata(job)
display_title = metadata.get("title") or audio_path.stem
overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true" overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true"
try: if not overwrite_requested:
client = AudiobookshelfClient(config) from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfUploadError
except ValueError as exc: metadata = build_audiobookshelf_metadata(job)
job.add_log(f"Audiobookshelf configuration error: {exc}", level="error") display_title = metadata.get("title") or audio_path.stem
service._persist_state()
return _panel_response()
try:
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
service._persist_state()
return _panel_response()
if existing_items and not overwrite_requested:
job.add_log(
f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.",
level="warning",
)
service._persist_state()
if request.headers.get("HX-Request"):
detail = {
"jobId": job.id,
"title": display_title,
"url": url_for("jobs.send_job_to_audiobookshelf", job_id=job.id),
"target": request.headers.get("HX-Target") or "#jobs-panel",
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
}
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
return Response("", status=204, headers=headers)
return _panel_response()
if existing_items and overwrite_requested:
try: try:
client.delete_items(existing_items) existing_items = AudiobookshelfClient(config).find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc: except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error") job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
service._persist_state() service._persist_state()
return _panel_response() return _panel_response()
else: if existing_items:
job.add_log( job.add_log(f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.", level="warning")
f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.", service._persist_state()
level="info", if request.headers.get("HX-Request"):
) detail = {
"jobId": job.id,
"title": display_title,
"url": url_for("jobs.send_job_to_audiobookshelf", job_id=job.id),
"target": request.headers.get("HX-Target") or "#jobs-panel",
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
}
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
return Response("", status=204, headers=headers)
return _panel_response()
job.add_log("Audiobookshelf upload triggered manually.", level="info") job.add_log("Audiobookshelf upload triggered manually.", level="info")
export_svc = ExportService()
try: try:
client.upload_audiobook( export_svc.upload_audiobookshelf(
job,
audio_path, audio_path,
metadata=metadata, existing_paths(job.result.subtitle_paths),
cover_path=cover_path, load_audiobookshelf_chapters(job) if config.send_chapters else None,
chapters=chapters, build_audiobookshelf_metadata(job),
subtitles=subtitles, cover_path=_resolve_cover(job, config),
config=config,
log_callback=lambda msg, lvl="info": job.add_log(msg, level=lvl),
) )
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf upload failed: {exc}", level="error")
except Exception as exc: except Exception as exc:
job.add_log(f"Audiobookshelf integration error: {exc}", level="error") job.add_log(f"Audiobookshelf integration error: {exc}", level="error")
else: service._persist_state()
job.add_log("Audiobookshelf upload queued.", level="success")
finally:
service._persist_state()
return _panel_response() return _panel_response()
@jobs_bp.post("/clear-finished") @jobs_bp.post("/clear-finished")
+3 -146
View File
@@ -8,22 +8,15 @@ from flask.typing import ResponseReturnValue
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
load_settings, load_settings,
load_integration_settings,
save_settings, save_settings,
stored_integration_config,
coerce_bool,
coerce_int,
SAVE_MODE_LABELS, SAVE_MODE_LABELS,
llm_ready, llm_ready,
_NORMALIZATION_BOOLEAN_KEYS,
_NORMALIZATION_STRING_KEYS,
_DEFAULT_ANALYSIS_THRESHOLD,
) )
from abogen.webui.routes.utils.common import extract_checkbox
from abogen.webui.routes.utils.voice import template_options from abogen.webui.routes.utils.voice import template_options
from abogen.webui.services.settings_service import apply_form_to_settings
from abogen.webui.debug_tts_runner import run_debug_tts_wavs from abogen.webui.debug_tts_runner import run_debug_tts_wavs
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
from abogen.utils import get_user_output_path, load_config from abogen.utils import get_user_output_path
settings_bp = Blueprint("settings", __name__) settings_bp = Blueprint("settings", __name__)
@@ -38,143 +31,7 @@ _NORMALIZATION_SAMPLES = {
@settings_bp.post("/update") @settings_bp.post("/update")
def update_settings() -> ResponseReturnValue: def update_settings() -> ResponseReturnValue:
current = load_settings() current = load_settings()
form = request.form apply_form_to_settings(current, request.form)
# General settings
current["language"] = (form.get("language") or "en").strip()
current["default_speaker"] = (form.get("default_speaker") or "").strip()
current["default_voice"] = (form.get("default_voice") or "").strip()
try:
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
except (TypeError, ValueError):
pass
try:
current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0)))))
except (TypeError, ValueError):
pass
current["output_format"] = (form.get("output_format") or "mp3").strip()
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip()
current["save_mode"] = (form.get("save_mode") or "save_next_to_input").strip()
current["replace_single_newlines"] = coerce_bool(form.get("replace_single_newlines"), False)
current["use_gpu"] = coerce_bool(form.get("use_gpu"), False)
current["save_chapters_separately"] = coerce_bool(form.get("save_chapters_separately"), False)
current["merge_chapters_at_end"] = coerce_bool(form.get("merge_chapters_at_end"), True)
current["save_as_project"] = coerce_bool(form.get("save_as_project"), False)
current["separate_chapters_format"] = (form.get("separate_chapters_format") or "wav").strip()
try:
current["silence_between_chapters"] = max(0.0, float(form.get("silence_between_chapters", 2.0)))
except ValueError:
pass
try:
current["chapter_intro_delay"] = max(0.0, float(form.get("chapter_intro_delay", 0.5)))
except ValueError:
pass
current["read_title_intro"] = coerce_bool(form.get("read_title_intro"), False)
current["read_closing_outro"] = coerce_bool(form.get("read_closing_outro"), True)
current["normalize_chapter_opening_caps"] = coerce_bool(form.get("normalize_chapter_opening_caps"), True)
current["auto_prefix_chapter_titles"] = coerce_bool(form.get("auto_prefix_chapter_titles"), True)
try:
current["max_subtitle_words"] = max(1, int(form.get("max_subtitle_words", 50)))
except ValueError:
pass
current["chunk_level"] = (form.get("chunk_level") or "paragraph").strip()
current["generate_epub3"] = coerce_bool(form.get("generate_epub3"), False)
current["speaker_analysis_threshold"] = coerce_int(
form.get("speaker_analysis_threshold"),
_DEFAULT_ANALYSIS_THRESHOLD,
minimum=1,
maximum=25,
)
# Normalization settings
for key in _NORMALIZATION_BOOLEAN_KEYS:
current[key] = extract_checkbox(form, key, bool(current.get(key, True)))
for key in _NORMALIZATION_STRING_KEYS:
if hasattr(form, "__contains__") and key in form:
current[key] = (form.get(key) or "").strip()
# Integrations
# `load_settings()` returns only the general settings subset and intentionally
# does not include stored integrations. Seed them from the stored config so
# saving unrelated settings cannot wipe credentials/tokens.
current_integrations: dict[str, dict[str, Any]] = {}
cfg = load_config() or {}
stored_integrations = cfg.get("integrations")
if isinstance(stored_integrations, Mapping):
for name, payload in stored_integrations.items():
if isinstance(name, str) and isinstance(payload, Mapping):
current_integrations[name] = dict(payload)
# Ensure known integrations are loaded even if the config is still in legacy format.
for name in ("audiobookshelf", "calibre_opds"):
stored = stored_integration_config(name)
if stored and name not in current_integrations:
current_integrations[name] = dict(stored)
current["integrations"] = current_integrations
# Audiobookshelf
abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
abs_url = (form.get("audiobookshelf_base_url") or "").strip()
abs_token = (form.get("audiobookshelf_api_token") or "").strip()
abs_library = (form.get("audiobookshelf_library_id") or "").strip()
abs_folder = (form.get("audiobookshelf_folder_id") or "").strip()
abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), True)
abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), False)
abs_cover = coerce_bool(form.get("audiobookshelf_send_cover"), True)
abs_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), True)
abs_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), False)
try:
abs_timeout = max(1.0, float(form.get("audiobookshelf_timeout", 30.0)))
except ValueError:
abs_timeout = 30.0
# Preserve existing token if not provided and not cleared
if not abs_token and not coerce_bool(form.get("audiobookshelf_api_token_clear"), False):
existing_abs = current["integrations"].get("audiobookshelf", {})
abs_token = existing_abs.get("api_token", "")
current["integrations"]["audiobookshelf"] = {
"enabled": abs_enabled,
"base_url": abs_url,
"api_token": abs_token,
"library_id": abs_library,
"folder_id": abs_folder,
"verify_ssl": abs_verify,
"auto_send": abs_auto_send,
"send_cover": abs_cover,
"send_chapters": abs_chapters,
"send_subtitles": abs_subtitles,
"timeout": abs_timeout,
}
# Calibre OPDS
calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
calibre_url = (form.get("calibre_opds_base_url") or "").strip()
calibre_user = (form.get("calibre_opds_username") or "").strip()
calibre_pass = (form.get("calibre_opds_password") or "").strip()
calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), True)
# Preserve existing password if not provided and not cleared
if not calibre_pass and not coerce_bool(form.get("calibre_opds_password_clear"), False):
existing_calibre = current["integrations"].get("calibre_opds", {})
calibre_pass = existing_calibre.get("password", "")
current["integrations"]["calibre_opds"] = {
"enabled": calibre_enabled,
"base_url": calibre_url,
"username": calibre_user,
"password": calibre_pass,
"verify_ssl": calibre_verify,
}
save_settings(current) save_settings(current)
flash("Settings updated successfully.", "success") flash("Settings updated successfully.", "success")
return redirect(url_for("settings.settings_page")) return redirect(url_for("settings.settings_page"))
+1 -22
View File
@@ -1,32 +1,11 @@
from typing import Any, Optional, Tuple, Iterable, List, Mapping from typing import Any, Optional, Tuple, Iterable, List, Mapping
from pathlib import Path from pathlib import Path
from abogen.domain.settings_core import coerce_bool, split_profile_spec # noqa: F401
def coerce_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
if value is None:
return default
return bool(value)
def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]:
text = str(value or "").strip()
if not text:
return "", None
lowered = text.lower()
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
_, _, remainder = text.partition(":")
name = remainder.strip()
return "", name or None
return text, None
def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]: def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]:
"""Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:').""" """Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:')."""
return split_profile_spec(value) return split_profile_spec(value)
+25 -329
View File
@@ -1,108 +1,24 @@
import os import os
import re
from typing import Any, Dict, Mapping, Optional from typing import Any, Dict, Mapping, Optional
from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS,
)
from abogen.tts_plugin.utils import get_default_voice
from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT,
environment_llm_defaults,
)
from abogen.utils import load_config, save_config
from abogen.integrations.calibre_opds import CalibreOPDSClient from abogen.integrations.calibre_opds import CalibreOPDSClient
from abogen.integrations.audiobookshelf import AudiobookshelfConfig from abogen.integrations.audiobookshelf import AudiobookshelfConfig
from abogen.webui.routes.utils.common import split_profile_spec, coerce_bool from abogen.utils import load_config, save_config
from abogen.domain.settings_core import (
SAVE_MODE_LABELS = { CHUNK_LEVEL_OPTIONS,
"save_next_to_input": "Save next to input file", CHUNK_LEVEL_VALUES,
"save_to_desktop": "Save to Desktop", DEFAULT_ANALYSIS_THRESHOLD,
"choose_output_folder": "Choose output folder", SAVE_MODE_LABELS,
"default_output": "Use default save location", _NORMALIZATION_BOOLEAN_KEYS,
} _NORMALIZATION_STRING_KEYS,
coerce_bool,
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()} coerce_float,
coerce_int,
_CHUNK_LEVEL_OPTIONS = [ integration_defaults,
{"value": "paragraph", "label": "Paragraphs"}, load_settings,
{"value": "sentence", "label": "Sentences"}, llm_ready,
] settings_defaults,
)
_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
_DEFAULT_ANALYSIS_THRESHOLD = 3
_APOSTROPHE_MODE_OPTIONS = [
{"value": "off", "label": "Off"},
{"value": "spacy", "label": "spaCy (built-in)"},
{"value": "llm", "label": "LLM assisted"},
]
_NORMALIZATION_BOOLEAN_KEYS = {
"normalization_numbers",
"normalization_titles",
"normalization_terminal",
"normalization_phoneme_hints",
"normalization_caps_quotes",
"normalization_currency",
"normalization_footnotes",
"normalization_internet_slang",
"normalization_apostrophes_contractions",
"normalization_apostrophes_plural_possessives",
"normalization_apostrophes_sibilant_possessives",
"normalization_apostrophes_decades",
"normalization_apostrophes_leading_elisions",
"normalization_contraction_aux_be",
"normalization_contraction_aux_have",
"normalization_contraction_modal_will",
"normalization_contraction_modal_would",
"normalization_contraction_negation_not",
"normalization_contraction_let_us",
}
_NORMALIZATION_STRING_KEYS = {
"normalization_numbers_year_style",
"normalization_apostrophe_mode",
}
BOOLEAN_SETTINGS = {
"replace_single_newlines",
"use_gpu",
"save_chapters_separately",
"merge_chapters_at_end",
"save_as_project",
"generate_epub3",
"enable_entity_recognition",
"read_title_intro",
"read_closing_outro",
"auto_prefix_chapter_titles",
"normalize_chapter_opening_caps",
"normalization_numbers",
"normalization_titles",
"normalization_terminal",
"normalization_phoneme_hints",
"normalization_caps_quotes",
"normalization_currency",
"normalization_footnotes",
"normalization_internet_slang",
"normalization_apostrophes_contractions",
"normalization_apostrophes_plural_possessives",
"normalization_apostrophes_sibilant_possessives",
"normalization_apostrophes_decades",
"normalization_apostrophes_leading_elisions",
"normalization_contraction_aux_be",
"normalization_contraction_aux_have",
"normalization_contraction_modal_will",
"normalization_contraction_modal_would",
"normalization_contraction_negation_not",
"normalization_contraction_let_us",
}
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"}
INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
_NORMALIZATION_GROUPS = [ _NORMALIZATION_GROUPS = [
{ {
@@ -136,236 +52,16 @@ _NORMALIZATION_GROUPS = [
} }
] ]
_APOSTROPHE_MODE_OPTIONS = [
{"value": "off", "label": "Off"},
{"value": "spacy", "label": "spaCy (built-in)"},
{"value": "llm", "label": "LLM assisted"},
]
def integration_defaults() -> Dict[str, Dict[str, Any]]: # Backward-compatible aliases for modules still referencing old underscore-prefixed names
return { _DEFAULT_ANALYSIS_THRESHOLD = DEFAULT_ANALYSIS_THRESHOLD
"calibre_opds": { _CHUNK_LEVEL_OPTIONS = CHUNK_LEVEL_OPTIONS
"enabled": False, _CHUNK_LEVEL_VALUES = CHUNK_LEVEL_VALUES
"base_url": "",
"username": "",
"password": "",
"verify_ssl": True,
},
"audiobookshelf": {
"enabled": False,
"base_url": "",
"api_token": "",
"library_id": "",
"collection_id": "",
"folder_id": "",
"verify_ssl": True,
"send_cover": True,
"send_chapters": True,
"send_subtitles": False,
"auto_send": False,
"timeout": 30.0,
},
}
def has_output_override() -> bool:
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
def settings_defaults() -> Dict[str, Any]:
llm_env_defaults = environment_llm_defaults()
return {
"output_format": "wav",
"subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_speaker": "",
"default_voice": get_default_voice("kokoro"),
"supertonic_total_steps": 5,
"supertonic_speed": 1.0,
"replace_single_newlines": False,
"use_gpu": True,
"save_chapters_separately": False,
"merge_chapters_at_end": True,
"save_as_project": False,
"separate_chapters_format": "wav",
"silence_between_chapters": 2.0,
"chapter_intro_delay": 0.5,
"read_title_intro": False,
"read_closing_outro": True,
"normalize_chapter_opening_caps": True,
"max_subtitle_words": 50,
"chunk_level": "paragraph",
"enable_entity_recognition": True,
"generate_epub3": False,
"auto_prefix_chapter_titles": True,
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
"speaker_random_languages": [],
"llm_base_url": llm_env_defaults.get("llm_base_url", ""),
"llm_api_key": llm_env_defaults.get("llm_api_key", ""),
"llm_model": llm_env_defaults.get("llm_model", ""),
"llm_timeout": llm_env_defaults.get("llm_timeout", 30.0),
"llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT),
"llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"),
"normalization_numbers": True,
"normalization_currency": True,
"normalization_footnotes": True,
"normalization_titles": True,
"normalization_terminal": True,
"normalization_phoneme_hints": True,
"normalization_caps_quotes": True,
"normalization_internet_slang": False,
"normalization_apostrophes_contractions": True,
"normalization_apostrophes_plural_possessives": True,
"normalization_apostrophes_sibilant_possessives": True,
"normalization_apostrophes_decades": True,
"normalization_apostrophes_leading_elisions": True,
"normalization_apostrophe_mode": "spacy",
"normalization_numbers_year_style": "american",
"normalization_contraction_aux_be": True,
"normalization_contraction_aux_have": True,
"normalization_contraction_modal_will": True,
"normalization_contraction_modal_would": True,
"normalization_contraction_negation_not": True,
"normalization_contraction_let_us": True,
}
def llm_ready(settings: Mapping[str, Any]) -> bool:
base_url = str(settings.get("llm_base_url") or "").strip()
return bool(base_url)
_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
if not template:
return ""
def _replace(match: re.Match[str]) -> str:
key = match.group(1)
return context.get(key, "")
return _PROMPT_TOKEN_RE.sub(_replace, template)
def coerce_float(value: Any, default: float) -> float:
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return default
def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(minimum, min(parsed, maximum))
def normalize_save_mode(value: Any, default: str) -> str:
if isinstance(value, str):
if value in SAVE_MODE_LABELS:
return value
if value in LEGACY_SAVE_MODE_MAP:
return LEGACY_SAVE_MODE_MAP[value]
return default
def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
if key in BOOLEAN_SETTINGS:
return coerce_bool(value, defaults[key])
if key in FLOAT_SETTINGS:
return coerce_float(value, defaults[key])
if key in INT_SETTINGS:
return coerce_int(value, defaults[key])
if key == "save_mode":
return normalize_save_mode(value, defaults[key])
if key == "output_format":
return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
if key == "subtitle_format":
valid = {item[0] for item in SUBTITLE_FORMATS}
return value if value in valid else defaults[key]
if key == "separate_chapters_format":
if isinstance(value, str):
normalized = value.lower()
if normalized in {"wav", "flac", "mp3", "opus"}:
return normalized
return defaults[key]
if key == "default_voice":
if isinstance(value, str):
text = value.strip()
if not text:
return defaults[key]
spec, profile_name = split_profile_spec(text)
if profile_name:
return f"speaker:{profile_name}"
return spec
return defaults[key]
if key == "default_speaker":
if isinstance(value, str):
text = value.strip()
if not text:
return ""
spec, profile_name = split_profile_spec(text)
if profile_name:
return f"speaker:{profile_name}"
return spec
return ""
if key == "chunk_level":
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
return value
return defaults[key]
if key == "normalization_apostrophe_mode":
if isinstance(value, str):
normalized_mode = value.strip().lower()
if normalized_mode in {"off", "spacy", "llm"}:
return normalized_mode
return defaults[key]
if key == "normalization_numbers_year_style":
if isinstance(value, str):
normalized_style = value.strip().lower()
if normalized_style in {"american", "off"}:
return normalized_style
return defaults[key]
if key == "llm_context_mode":
if isinstance(value, str):
normalized_scope = value.strip().lower()
if normalized_scope == "sentence":
return normalized_scope
return defaults[key]
if key == "llm_prompt":
candidate = str(value or "").strip()
return candidate if candidate else defaults[key]
if key in {"llm_base_url", "llm_api_key", "llm_model"}:
return str(value or "").strip()
if key == "speaker_random_languages":
if isinstance(value, (list, tuple, set)):
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
if isinstance(value, str):
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
return defaults.get(key, [])
if key == "supertonic_total_steps":
try:
steps = int(value)
except (TypeError, ValueError):
return defaults.get(key, 5)
return max(2, min(15, steps))
if key == "supertonic_speed":
try:
speed = float(value)
except (TypeError, ValueError):
return defaults.get(key, 1.0)
return max(0.7, min(2.0, speed))
return value if value is not None else defaults.get(key)
def load_settings() -> Dict[str, Any]:
defaults = settings_defaults()
cfg = load_config() or {}
settings: Dict[str, Any] = {}
for key, default in defaults.items():
raw_value = cfg.get(key, default)
settings[key] = normalize_setting_value(key, raw_value, defaults)
return settings
def load_integration_settings() -> Dict[str, Dict[str, Any]]: def load_integration_settings() -> Dict[str, Dict[str, Any]]:
+5 -23
View File
@@ -548,19 +548,12 @@ def prepare_speaker_metadata(
def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]: def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
from abogen.voice_formulas import pairs_to_formula
voices = entry.get("voices") or [] voices = entry.get("voices") or []
if not voices: if not voices:
return None return None
total = sum(weight for _, weight in voices) return pairs_to_formula(voices)
if total <= 0:
return None
def _format_weight(value: float) -> str:
normalized = value / total if total else 0.0
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
return "+".join(parts) if parts else None
def template_options() -> Dict[str, Any]: def template_options() -> Dict[str, Any]:
@@ -710,19 +703,8 @@ def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]: def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0] from abogen.voice_formulas import pairs_to_formula as _pairs_to_formula
if not voices: return _pairs_to_formula(pairs)
return None
total = sum(weight for _, weight in voices)
if total <= 0:
return None
def _format_value(value: float) -> str:
normalized = value / total if total else 0.0
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
return "+".join(parts)
def profiles_payload() -> Dict[str, Any]: def profiles_payload() -> Dict[str, Any]:
+165
View File
@@ -0,0 +1,165 @@
"""Settings form-to-dict mapping.
Pure functions that convert form data into a settings dict.
No Flask dependencies testable without a request context.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
def apply_form_to_settings(current: dict, form: Mapping[str, Any]) -> dict:
"""Apply form data to a settings dict.
Pure function: takes a current settings dict and a form-like mapping,
returns the updated settings dict. No Flask dependencies.
Args:
current: Current settings dict (will be mutated).
form: Form-like mapping (e.g. request.form.to_dict()).
Returns:
Updated settings dict (same object as input).
"""
from abogen.domain.settings_core import (
coerce_bool,
coerce_int,
DEFAULT_ANALYSIS_THRESHOLD,
_NORMALIZATION_BOOLEAN_KEYS,
_NORMALIZATION_STRING_KEYS,
)
from abogen.webui.routes.utils.settings import stored_integration_config
from abogen.webui.routes.utils.common import extract_checkbox
from abogen.utils import load_config
# General settings
current["language"] = (form.get("language") or "en").strip()
current["default_speaker"] = (form.get("default_speaker") or "").strip()
current["default_voice"] = (form.get("default_voice") or "").strip()
try:
current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5)))))
except (TypeError, ValueError):
pass
try:
current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0)))))
except (TypeError, ValueError):
pass
current["output_format"] = (form.get("output_format") or "mp3").strip()
current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip()
current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip()
current["save_mode"] = (form.get("save_mode") or "save_next_to_input").strip()
current["replace_single_newlines"] = coerce_bool(form.get("replace_single_newlines"), False)
current["use_gpu"] = coerce_bool(form.get("use_gpu"), False)
current["save_chapters_separately"] = coerce_bool(form.get("save_chapters_separately"), False)
current["merge_chapters_at_end"] = coerce_bool(form.get("merge_chapters_at_end"), True)
current["save_as_project"] = coerce_bool(form.get("save_as_project"), False)
current["separate_chapters_format"] = (form.get("separate_chapters_format") or "wav").strip()
try:
current["silence_between_chapters"] = max(0.0, float(form.get("silence_between_chapters", 2.0)))
except ValueError:
pass
try:
current["chapter_intro_delay"] = max(0.0, float(form.get("chapter_intro_delay", 0.5)))
except ValueError:
pass
current["read_title_intro"] = coerce_bool(form.get("read_title_intro"), False)
current["read_closing_outro"] = coerce_bool(form.get("read_closing_outro"), True)
current["normalize_chapter_opening_caps"] = coerce_bool(form.get("normalize_chapter_opening_caps"), True)
current["auto_prefix_chapter_titles"] = coerce_bool(form.get("auto_prefix_chapter_titles"), True)
try:
current["max_subtitle_words"] = max(1, int(form.get("max_subtitle_words", 50)))
except ValueError:
pass
current["chunk_level"] = (form.get("chunk_level") or "paragraph").strip()
current["generate_epub3"] = coerce_bool(form.get("generate_epub3"), False)
current["speaker_analysis_threshold"] = coerce_int(
form.get("speaker_analysis_threshold"),
DEFAULT_ANALYSIS_THRESHOLD,
minimum=1,
maximum=25,
)
# Normalization settings
for key in _NORMALIZATION_BOOLEAN_KEYS:
current[key] = extract_checkbox(form, key, bool(current.get(key, True)))
for key in _NORMALIZATION_STRING_KEYS:
if key in form:
current[key] = (form.get(key) or "").strip()
# Integrations — seed from stored config to prevent wiping credentials
current_integrations: dict[str, dict[str, Any]] = {}
cfg = load_config() or {}
stored_integrations = cfg.get("integrations")
if isinstance(stored_integrations, Mapping):
for name, payload in stored_integrations.items():
if isinstance(name, str) and isinstance(payload, Mapping):
current_integrations[name] = dict(payload)
for name in ("audiobookshelf", "calibre_opds"):
stored = stored_integration_config(name)
if stored and name not in current_integrations:
current_integrations[name] = dict(stored)
current["integrations"] = current_integrations
# Audiobookshelf
abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
abs_url = (form.get("audiobookshelf_base_url") or "").strip()
abs_token = (form.get("audiobookshelf_api_token") or "").strip()
abs_library = (form.get("audiobookshelf_library_id") or "").strip()
abs_folder = (form.get("audiobookshelf_folder_id") or "").strip()
abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), True)
abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), False)
abs_cover = coerce_bool(form.get("audiobookshelf_send_cover"), True)
abs_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), True)
abs_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), False)
try:
abs_timeout = max(1.0, float(form.get("audiobookshelf_timeout", 30.0)))
except ValueError:
abs_timeout = 30.0
if not abs_token and not coerce_bool(form.get("audiobookshelf_api_token_clear"), False):
existing_abs = current["integrations"].get("audiobookshelf", {})
abs_token = existing_abs.get("api_token", "")
current["integrations"]["audiobookshelf"] = {
"enabled": abs_enabled,
"base_url": abs_url,
"api_token": abs_token,
"library_id": abs_library,
"folder_id": abs_folder,
"verify_ssl": abs_verify,
"auto_send": abs_auto_send,
"send_cover": abs_cover,
"send_chapters": abs_chapters,
"send_subtitles": abs_subtitles,
"timeout": abs_timeout,
}
# Calibre OPDS
calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
calibre_url = (form.get("calibre_opds_base_url") or "").strip()
calibre_user = (form.get("calibre_opds_username") or "").strip()
calibre_pass = (form.get("calibre_opds_password") or "").strip()
calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), True)
if not calibre_pass and not coerce_bool(form.get("calibre_opds_password_clear"), False):
existing_calibre = current["integrations"].get("calibre_opds", {})
calibre_pass = existing_calibre.get("password", "")
current["integrations"]["calibre_opds"] = {
"enabled": calibre_enabled,
"base_url": calibre_url,
"username": calibre_user,
"password": calibre_pass,
"verify_ssl": calibre_verify,
}
return current
+59
View File
@@ -0,0 +1,59 @@
"""Tests for domain/audio_buffer.py — fit_audio_to_duration, ffmpeg_time_stretch."""
import numpy as np
import pytest
from abogen.domain.audio_buffer import fit_audio_to_duration, ffmpeg_time_stretch, SAMPLE_RATE
class TestFitAudioToDuration:
def test_exact_length(self):
audio = np.ones(24000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
def test_shorter_pads_with_zeros(self):
audio = np.ones(12000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
assert result[0] == 1.0
assert result[12000] == 0.0
def test_longer_trims(self):
audio = np.ones(48000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
assert result[-1] == 1.0
def test_empty_input(self):
result = fit_audio_to_duration(np.array([], dtype="float32"), 0.5, SAMPLE_RATE)
assert len(result) == 12000
assert np.all(result == 0.0)
def test_output_dtype(self):
audio = np.ones(100, dtype="float32")
result = fit_audio_to_duration(audio, 0.5, SAMPLE_RATE)
assert result.dtype == np.float32
class TestFfmpegTimeStretch:
def test_no_stretch_below_threshold(self):
audio = np.ones(24000, dtype="float32")
result = ffmpeg_time_stretch(audio, 0.8, SAMPLE_RATE)
np.testing.assert_array_equal(result, audio)
def test_no_stretch_at_exactly_one(self):
audio = np.ones(24000, dtype="float32")
result = ffmpeg_time_stretch(audio, 1.0, SAMPLE_RATE)
np.testing.assert_array_equal(result, audio)
def test_empty_audio(self):
result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE)
assert len(result) == 0
def test_stretch_reduces_duration(self):
audio = np.random.randn(48000).astype("float32")
result = ffmpeg_time_stretch(audio, 2.0, SAMPLE_RATE)
assert len(result) < len(audio)
assert len(result) > 0
assert result.dtype == np.float32
+183
View File
@@ -0,0 +1,183 @@
"""Tests for domain/conversion_pipeline.py — tts_segments, emit_text_segments."""
from dataclasses import dataclass, field
from typing import Any, List, Optional
from unittest.mock import MagicMock
import numpy as np
import pytest
from abogen.domain.conversion_pipeline import tts_segments, emit_text_segments, SegmentResult
@dataclass
class FakeSegment:
graphemes: str
audio: Any
tokens: list = field(default_factory=list)
@dataclass
class FakeTokenObj:
text: str
start_ts: float
end_ts: float
whitespace: str = ""
def make_backend(segments):
"""Create a mock TTS backend that yields FakeSegments."""
def backend(text, voice=None, speed=1.0, split_pattern=None):
for seg in segments:
yield seg
return backend
class TestEmitTextSegments:
def test_yields_segments(self):
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("Hello", audio)]
results = list(emit_text_segments(
"Hello world",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert len(results) == 1
assert results[0].graphemes == "Hello"
assert results[0].duration == 1.0
def test_skips_empty_audio(self):
segments = [
FakeSegment("Hello", np.ones(24000, dtype="float32")),
FakeSegment("", np.array([], dtype="float32")),
FakeSegment("World", np.ones(12000, dtype="float32")),
]
results = list(emit_text_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert len(results) == 2
assert results[0].graphemes == "Hello"
assert results[1].graphemes == "World"
def test_chunk_start_increments(self):
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("A", audio), FakeSegment("B", audio)]
results = list(emit_text_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
current_time=5.0,
))
assert results[0].chunk_start == 5.0
assert results[1].chunk_start == 6.0
def test_tokens_extracted(self):
token = FakeTokenObj("Hello", 0.0, 0.5, " ")
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("Hello", audio, [token])]
results = list(emit_text_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
current_time=2.0,
))
assert len(results[0].tokens) == 1
assert results[0].tokens[0]["start"] == 2.0
assert results[0].tokens[0]["end"] == 2.5
assert results[0].tokens[0]["text"] == "Hello"
def test_fake_token_fallback(self):
"""When no tokens provided, creates a single FakeToken for the segment."""
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("Hello", audio)] # No tokens
results = list(emit_text_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert len(results[0].tokens) == 1
assert results[0].tokens[0]["text"] == "Hello"
def test_empty_text(self):
results = list(emit_text_segments(
"",
backend=make_backend([]),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert len(results) == 0
def test_segment_result_fields(self):
audio = np.ones(48000, dtype="float32")
segments = [FakeSegment("Test", audio)]
results = list(emit_text_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
seg = results[0]
assert isinstance(seg, SegmentResult)
assert seg.graphemes == "Test"
assert seg.duration == 2.0
assert len(seg.audio) == 48000
class TestTtsSegments:
def test_yields_segments_from_normalized_text(self):
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("Hello", audio)]
results = list(tts_segments(
"Already normalized text",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert len(results) == 1
assert results[0].graphemes == "Hello"
def test_no_normalization_performed(self):
"""tts_segments should NOT normalize — it passes text directly to backend."""
received_texts = []
def capture_backend(text, voice=None, speed=1.0, split_pattern=None):
received_texts.append(text)
yield FakeSegment("ok", np.ones(24000, dtype="float32"))
list(tts_segments(
"Raw unnormalized text",
backend=capture_backend,
voice="A",
speed=1.0,
split_pattern=r"\s+",
))
assert received_texts[0] == "Raw unnormalized text"
def test_chunk_start_increments(self):
audio = np.ones(24000, dtype="float32")
segments = [FakeSegment("A", audio), FakeSegment("B", audio)]
results = list(tts_segments(
"test",
backend=make_backend(segments),
voice="A",
speed=1.0,
split_pattern=r"\s+",
current_time=10.0,
))
assert results[0].chunk_start == 10.0
assert results[1].chunk_start == 11.0
+114
View File
@@ -0,0 +1,114 @@
"""Tests for domain/metadata_extraction.py — format_metadata_tags, extract_book_metadata_*."""
import os
import pytest
from abogen.domain.metadata_extraction import (
extract_book_metadata_markdown,
format_metadata_tags,
_save_cover_to_cache,
)
class TestFormatMetadataTags:
def test_basic_epub(self):
metadata = {
"title": "My Book",
"authors": ["Author One", "Author Two"],
"publication_year": "2023",
}
result = format_metadata_tags(metadata, "fallback", 10, "epub")
assert "<<METADATA_TITLE:My Book>>" in result
assert "<<METADATA_ARTIST:Author One, Author Two>>" in result
assert "<<METADATA_ALBUM:My Book (10 Chapters)>>" in result
assert "<<METADATA_YEAR:2023>>" in result
assert "<<METADATA_GENRE:Audiobook>>" in result
def test_pdf_uses_pages(self):
metadata = {"title": "PDF Doc", "authors": ["Writer"]}
result = format_metadata_tags(metadata, "doc", 50, "pdf")
assert "50 Pages" in result
def test_markdown_uses_chapters(self):
metadata = {"title": "MD Doc"}
result = format_metadata_tags(metadata, "doc", 3, "markdown")
assert "3 Chapters" in result
def test_fallback_title(self):
metadata = {}
result = format_metadata_tags(metadata, "fallback_name", 1, "epub")
assert "<<METADATA_TITLE:fallback_name>>" in result
def test_unknown_authors(self):
metadata = {"authors": []}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "<<METADATA_ARTIST:Unknown>>" in result
def test_cover_bytes_saved(self, tmp_path):
metadata = {"title": "With Cover"}
cover = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # Fake image bytes
result = format_metadata_tags(
metadata, "file", 1, "epub",
cover_bytes=cover, cache_dir=str(tmp_path),
)
assert "<<METADATA_COVER_PATH:" in result
# Verify file was created
cover_files = list(tmp_path.glob("cover_*.jpg"))
assert len(cover_files) == 1
assert cover_files[0].read_bytes() == cover
def test_no_cover_bytes(self):
metadata = {"title": "No Cover"}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "METADATA_COVER_PATH" not in result
def test_authors_as_string(self):
metadata = {"authors": "Single Author"}
result = format_metadata_tags(metadata, "file", 1, "epub")
assert "<<METADATA_ARTIST:Single Author>>" in result
class TestSaveCoverToCache:
def test_saves_file(self, tmp_path):
data = b"\x89PNG" + b"\x00" * 50
result = _save_cover_to_cache(data, str(tmp_path))
assert result is not None
assert os.path.exists(result)
assert open(result, "rb").read() == data
def test_none_bytes(self, tmp_path):
assert _save_cover_to_cache(None, str(tmp_path)) is None
def test_none_cache_dir(self):
assert _save_cover_to_cache(b"data", None) is None
def test_returns_normalized_path(self, tmp_path):
result = _save_cover_to_cache(b"data", str(tmp_path))
assert result == os.path.normpath(result)
class TestExtractBookMetadataMarkdown:
def test_frontmatter(self):
text = "---\ntitle: Test Title\nauthor: Test Author\ndate: 2024\n---\n\nBody"
result = extract_book_metadata_markdown(text)
assert result["title"] == "Test Title"
assert result["authors"] == ["Test Author"]
assert result["publication_year"] == "2024"
def test_fallback_to_h1(self, ):
text = "# My Heading\n\nSome content"
toc = [{"level": 1, "name": "My Heading"}]
result = extract_book_metadata_markdown(text, toc)
assert result["title"] == "My Heading"
def test_empty_text(self):
result = extract_book_metadata_markdown("")
assert result["title"] is None
assert result["authors"] == []
def test_frontmatter_with_quotes(self):
text = '---\ntitle: "Quoted Title"\nauthor: \'Quoted Author\'\n---\n\nBody'
result = extract_book_metadata_markdown(text)
assert result["title"] == "Quoted Title"
assert result["authors"] == ["Quoted Author"]
+52
View File
@@ -0,0 +1,52 @@
"""Tests for domain/metadata_overrides.py and webui/services/settings_service.py."""
import pytest
from abogen.domain.metadata_overrides import normalize_opds_metadata
class TestNormalizeOpdsMetadata:
def test_series_mapping(self):
result = normalize_opds_metadata({"series": "My Series", "series_index": 3})
assert result["series"] == "My Series"
assert result["series_name"] == "My Series"
assert result["series_index"] == "3"
assert result["series_position"] == "3"
def test_series_name_alias(self):
result = normalize_opds_metadata({"series_name": "Alt Series"})
assert result["series"] == "Alt Series"
assert result["series_name"] == "Alt Series"
def test_tags_to_keywords(self):
result = normalize_opds_metadata({"tags": "sci-fi, action"})
assert result["tags"] == "sci-fi, action"
assert result["keywords"] == "sci-fi, action"
assert result["genre"] == "sci-fi, action"
def test_description_summary(self):
result = normalize_opds_metadata({"description": "A great book"})
assert result["description"] == "A great book"
assert result["summary"] == "A great book"
def test_authors_creator(self):
result = normalize_opds_metadata({"creator": "Author Name"})
assert result["authors"] == "Author Name"
assert result["author"] == "Author Name"
def test_subtitle_aliases(self):
result = normalize_opds_metadata({"calibre_subtitle": "Sub Title"})
assert result["subtitle"] == "Sub Title"
def test_empty_payload(self):
result = normalize_opds_metadata({})
assert result == {}
def test_list_authors(self):
result = normalize_opds_metadata({"authors": ["Alice", "Bob"]})
assert result["authors"] == "Alice, Bob"
def test_none_values_filtered(self):
result = normalize_opds_metadata({"series": None, "tags": ""})
assert "series" not in result
assert "tags" not in result
+51
View File
@@ -0,0 +1,51 @@
"""Tests for domain/output_paths.py — resolve_unique_path."""
import os
import tempfile
from abogen.domain.output_paths import resolve_unique_path
class TestResolveUniquePath:
def test_no_collision(self, tmp_path):
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter")
assert not os.path.exists(result)
def test_collision_appends_counter(self, tmp_path):
(tmp_path / "chapter.srt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_multiple_collisions(self, tmp_path):
(tmp_path / "chapter.srt").touch()
(tmp_path / "chapter_2.srt").touch()
(tmp_path / "chapter_3.srt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_4")
def test_no_allowed_extensions_skips_files(self, tmp_path):
(tmp_path / "chapter.txt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter")
def test_sanitizes_name(self, tmp_path):
# On Windows, ":" is illegal; on Linux it's allowed.
# Just verify the function doesn't crash and returns a valid path.
result = resolve_unique_path(str(tmp_path), "My Chapter: Part 1", "srt")
assert os.path.dirname(result) == str(tmp_path)
def test_directory_collision(self, tmp_path):
(tmp_path / "chapter").mkdir()
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_case_insensitive_extension(self, tmp_path):
(tmp_path / "chapter.SRT").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_unrelated_extensions_no_collision(self, tmp_path):
(tmp_path / "chapter.mp3").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter")
+49
View File
@@ -0,0 +1,49 @@
"""Tests for sanitize_filename_for_chapter in domain/output_paths.py"""
from __future__ import annotations
from abogen.domain.output_paths import sanitize_filename_for_chapter
class TestSanitizeFilenameForChapter:
def test_basic_title(self):
result = sanitize_filename_for_chapter("The Beginning", 1)
assert result == "01_The_Beginning"
def test_special_chars_removed(self):
result = sanitize_filename_for_chapter("Ch. 1: Hello!", 2)
assert result.startswith("02_")
assert "Ch" in result
assert "Hello" in result
def test_empty_title_uses_fallback(self):
result = sanitize_filename_for_chapter("", 3)
assert result == "03_chapter_03"
def test_index_prefix_zero_padded(self):
result = sanitize_filename_for_chapter("Test", 10)
assert result.startswith("10_")
def test_long_title_truncated_at_word_boundary(self):
long_title = "a" * 50 + "_" + "b" * 50
result = sanitize_filename_for_chapter(long_title, 1, max_len=60)
# Should truncate at word boundary
suffix = result[3:] # Remove "01_"
assert len(suffix) <= 60
def test_custom_max_len(self):
result = sanitize_filename_for_chapter("Hello World", 1, max_len=5)
suffix = result[3:] # Remove "01_"
assert len(suffix) <= 5
def test_hyphens_and_spaces_collapsed(self):
result = sanitize_filename_for_chapter("mid-night story", 1)
assert result == "01_mid_night_story"
def test_whitespace_collapsed(self):
result = sanitize_filename_for_chapter("hello world", 1)
assert "hello_world" in result
def test_leading_trailing_underscores_stripped(self):
result = sanitize_filename_for_chapter(" hello ", 1)
assert result == "01_hello"
+169
View File
@@ -0,0 +1,169 @@
"""Tests for domain/subtitle_processor.py — parse_subtitle_file, format_time_range, process_subtitle_entries."""
import os
import tempfile
from dataclasses import dataclass
from typing import Optional
from unittest.mock import MagicMock
import numpy as np
import pytest
from abogen.domain.subtitle_processor import (
parse_subtitle_file,
format_time_range,
speed_up_audio,
process_subtitle_entries,
)
# --- format_time_range tests ---
class TestFormatTimeRange:
def test_basic_range(self):
result = format_time_range(0.0, 5.0)
assert result == "00:00:00 - 00:00:05"
def test_with_milliseconds(self):
result = format_time_range(1.5, 3.123)
assert "00:00:01,500" in result
assert "00:00:03,123" in result
def test_auto_end(self):
result = format_time_range(10.0, 15.0, is_auto_end=True)
assert result == "00:00:10 - AUTO"
def test_none_end(self):
result = format_time_range(5.0, None)
assert result == "00:00:05 - AUTO"
def test_hours(self):
result = format_time_range(3661.0, 3665.0)
assert result == "01:01:01 - 01:01:05"
# --- parse_subtitle_file tests ---
class TestParseSubtitleFile:
def test_parse_srt(self, tmp_path):
srt = tmp_path / "test.srt"
srt.write_text(
"1\n00:00:01,000 --> 00:00:03,000\nHello\n\n"
"2\n00:00:04,000 --> 00:00:06,000\nWorld\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(srt))
assert len(result) == 2
assert result[0][2] == "Hello"
assert result[1][2] == "World"
def test_parse_vtt(self, tmp_path):
vtt = tmp_path / "test.vtt"
vtt.write_text(
"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello\n\n"
"00:00:04.000 --> 00:00:06.000\nWorld\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(vtt))
assert len(result) == 2
def test_parse_timestamp_text(self, tmp_path):
ts = tmp_path / "test.txt"
ts.write_text(
"[00:00:01] Hello\n[00:00:04] World\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(ts), is_timestamp_text=True)
assert len(result) >= 1
# --- speed_up_audio tests ---
class TestSpeedUpAudio:
def test_no_change_below_threshold(self):
audio = np.ones(24000, dtype="float32")
result = speed_up_audio(audio, 0.8, method="ffmpeg")
np.testing.assert_array_equal(result, audio)
def test_empty_audio(self):
result = speed_up_audio(np.array([], dtype="float32"), 2.0, method="ffmpeg")
assert len(result) == 0
# --- process_subtitle_entries tests ---
@dataclass
class FakeResult:
audio: np.ndarray
def fake_backend(text, voice=None, speed=1.0, split_pattern=None):
length = int(len(text) * 2400 * speed)
audio = np.random.randn(length).astype("float32") * 0.1
return [FakeResult(audio=audio)]
class TestProcessSubtitleEntries:
def test_empty_subtitles(self):
result = process_subtitle_entries(
[], backend=fake_backend, voice=None
)
assert len(result) == 0
def test_single_entry(self):
subtitles = [(0.0, 3.0, "Hello")]
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None
)
assert len(result) > 0
assert result.dtype == np.float32
def test_cancel_check(self):
subtitles = [(0.0, 5.0, "Hello"), (5.0, 10.0, "World")]
counter = [0]
def cancel():
counter[0] += 1
return counter[0] > 1
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
cancel_check=cancel,
)
# Buffer is pre-allocated but only first entry processed before cancel
assert result is not None
# Second entry should not have been mixed in (no audio at 5-10s)
assert np.max(np.abs(result[int(5.0 * 24000):])) == 0.0
def test_log_callback_called(self):
subtitles = [(0.0, 3.0, "Hello")]
logs = []
process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
log_callback=logs.append,
)
assert len(logs) >= 1
assert "Hello" in logs[0]
def test_progress_callback_called(self):
subtitles = [(0.0, 3.0, "Hello")]
progress = []
process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
progress_callback=lambda p, e: progress.append((p, e)),
)
assert len(progress) == 1
assert progress[0][0] == 99
def test_multiple_entries_mixed(self):
subtitles = [
(0.0, 2.0, "First"),
(2.0, 4.0, "Second"),
(4.0, 6.0, "Third"),
]
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None
)
assert len(result) > 0
# Buffer should be at least as long as the last subtitle end
assert len(result) >= int(6.0 * 24000)
+86
View File
@@ -0,0 +1,86 @@
"""Tests for domain/text_chapters.py"""
from __future__ import annotations
from abogen.domain.text_chapters import parse_chapters_from_text
class TestParseChaptersFromText:
def test_no_markers_returns_single_chapter(self):
result = parse_chapters_from_text("Hello world")
assert len(result) == 1
assert result[0][0] == "text"
assert result[0][1] == "Hello world"
def test_no_markers_custom_default_title(self):
result = parse_chapters_from_text("Hello", default_title="intro")
assert result[0][0] == "intro"
def test_single_marker(self):
text = "<<CHAPTER_MARKER:Chapter 1>>\nSome text here"
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 1
assert result[0][0] == "Chapter 1"
assert result[0][1] == "Some text here"
def test_multiple_markers(self):
text = (
"<<CHAPTER_MARKER:Chapter 1>>\nText 1\n"
"<<CHAPTER_MARKER:Chapter 2>>\nText 2\n"
)
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 2
assert result[0][0] == "Chapter 1"
assert result[0][1] == "Text 1"
assert result[1][0] == "Chapter 2"
assert result[1][1] == "Text 2"
def test_intro_preserved_before_first_marker(self):
text = "Introduction text\n<<CHAPTER_MARKER:Chapter 1>>\nMain text"
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 2
assert result[0][0] == "Introduction"
assert result[0][1] == "Introduction text"
assert result[1][0] == "Chapter 1"
def test_empty_intro_not_added(self):
text = "<<CHAPTER_MARKER:Chapter 1>>\nText"
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 1
assert result[0][0] == "Chapter 1"
def test_clean_text_applied_by_default(self):
text = "<<CHAPTER_MARKER:Ch 1>>\n some messy text "
result = parse_chapters_from_text(text, clean=True)
# clean_text normalizes whitespace
assert "some" in result[0][1]
assert "messy" in result[0][1]
def test_clean_disabled(self):
text = "<<CHAPTER_MARKER:Ch 1>>\n some text "
result = parse_chapters_from_text(text, clean=False)
assert result[0][1] == "some text"
def test_empty_marker_title_uses_default(self):
text = "<<CHAPTER_MARKER:>>\nSome text"
result = parse_chapters_from_text(text, clean=False)
assert result[0][0] == "text"
def test_case_insensitive_markers(self):
text = "<<chapter_marker:Chapter 1>>\nText"
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 1
assert result[0][0] == "Chapter 1"
def test_empty_text(self):
result = parse_chapters_from_text("")
assert len(result) == 1
assert result[0][0] == "text"
assert result[0][1] == ""
def test_only_markers_no_text(self):
text = "<<CHAPTER_MARKER:Ch 1>><<CHAPTER_MARKER:Ch 2>>"
result = parse_chapters_from_text(text, clean=False)
assert len(result) == 2
assert result[0][0] == "Ch 1"
assert result[1][0] == "Ch 2"
+119
View File
@@ -0,0 +1,119 @@
"""Tests for domain/settings_core.py — SETTINGS_REGISTRY contract."""
from abogen.domain.settings_core import (
SETTINGS_REGISTRY,
SETTING_KEYS,
GUI_ONLY_KEYS,
SHARED_KEYS,
BOOLEAN_SETTINGS,
FLOAT_SETTINGS,
INT_SETTINGS,
get_setting,
validate_setting,
settings_defaults,
all_settings_defaults,
coerce_bool,
coerce_int,
coerce_float,
Setting,
)
class TestSettingSchema:
def test_coerce_bool_from_str(self):
s = Setting("x", bool, False)
assert s.coerce("true") is True
assert s.coerce("false") is False
assert s.coerce("1") is True
assert s.coerce("on") is True
assert s.coerce(None) is False
def test_coerce_int_with_bounds(self):
s = Setting("x", int, 5, min_value=2, max_value=10)
assert s.coerce(7) == 7
assert s.coerce(1) == 2 # clamped to min
assert s.coerce(20) == 10 # clamped to max
assert s.coerce("abc") == 5 # fallback to default
def test_coerce_float_with_bounds(self):
s = Setting("x", float, 1.0, min_value=0.5, max_value=3.0)
assert s.coerce(2.5) == 2.5
assert s.coerce(0.1) == 0.5
assert s.coerce(5.0) == 3.0
def test_coerce_str_valid_values(self):
s = Setting("x", str, "a", valid_values=("a", "b", "c"))
assert s.coerce("a") == "a"
assert s.coerce("b") == "b"
assert s.coerce("x") == "a" # invalid, fallback
assert s.coerce("") == "a" # empty, fallback
def test_coerce_list(self):
s = Setting("x", list, [])
assert s.coerce([1, 2]) == [1, 2]
assert s.coerce((1, 2)) == [1, 2]
assert s.coerce(None) == []
class TestRegistry:
def test_no_duplicate_keys(self):
keys = [s.key for s in SETTINGS_REGISTRY]
assert len(keys) == len(set(keys))
def test_all_settings_have_valid_type(self):
valid_types = {bool, int, float, str, list}
for s in SETTINGS_REGISTRY:
assert s.type_ in valid_types, f"{s.key} has invalid type {s.type_}"
def test_min_less_than_max(self):
for s in SETTINGS_REGISTRY:
if s.min_value is not None and s.max_value is not None:
assert s.min_value <= s.max_value, f"{s.key}: min > max"
def test_default_matches_type(self):
for s in SETTINGS_REGISTRY:
default = s.default() if callable(s.default) else s.default
if default is not None:
assert isinstance(default, s.type_), (
f"{s.key}: default {default!r} is not {s.type_.__name__}"
)
def test_settings_excludes_gui_only(self):
d = settings_defaults()
for key in GUI_ONLY_KEYS:
assert key not in d, f"gui_only key '{key}' should not be in settings_defaults()"
def test_all_settings_includes_gui_only(self):
d = all_settings_defaults()
for s in SETTINGS_REGISTRY:
assert s.key in d, f"missing key '{s.key}' in all_settings_defaults()"
def test_coercion_consistency(self):
"""Every boolean setting should coerce 'true' to True."""
for s in SETTINGS_REGISTRY:
if s.type_ is bool:
assert s.coerce("true") is True, f"{s.key} should coerce 'true' to True"
class TestValidation:
def test_valid_output_format(self):
ok, msg = validate_setting("output_format", "wav")
assert ok
def test_invalid_output_format(self):
ok, msg = validate_setting("output_format", "xxx")
assert not ok
assert "xxx" in msg
def test_valid_int_range(self):
ok, _ = validate_setting("silence_between_chapters", 2.0)
assert ok
def test_invalid_int_below_min(self):
ok, msg = validate_setting("silence_between_chapters", -1)
assert not ok
def test_unknown_setting(self):
ok, msg = validate_setting("nonexistent_key", "value")
assert not ok
assert "Unknown" in msg
+94
View File
@@ -0,0 +1,94 @@
"""Tests for webui/services/settings_service.py — form→settings mapping."""
from collections import OrderedDict
from abogen.webui.services.settings_service import apply_form_to_settings
def _form(**kwargs: str | None) -> dict[str, str | None]:
return OrderedDict(kwargs)
def _base() -> dict:
return {
"language": "en",
"default_speaker": "",
"default_voice": "",
"supertonic_total_steps": 5,
"supertonic_speed": 1.0,
"output_format": "mp3",
"subtitle_mode": "Disabled",
"subtitle_format": "srt",
"save_mode": "save_next_to_input",
"replace_single_newlines": False,
"use_gpu": False,
"save_chapters_separately": False,
"merge_chapters_at_end": True,
"save_as_project": False,
"separate_chapters_format": "wav",
"silence_between_chapters": 2.0,
"chapter_intro_delay": 0.5,
"read_title_intro": False,
"read_closing_outro": True,
"normalize_chapter_opening_caps": True,
"auto_prefix_chapter_titles": True,
"max_subtitle_words": 50,
"chunk_level": "paragraph",
"generate_epub3": False,
"speaker_analysis_threshold": 15,
"integrations": {},
}
class TestApplyFormToSettings:
def test_general_fields(self):
settings = _base()
form = _form(language="fr", default_speaker="af_heart", output_format="wav")
apply_form_to_settings(settings, form)
assert settings["language"] == "fr"
assert settings["default_speaker"] == "af_heart"
assert settings["output_format"] == "wav"
def test_numeric_fields(self):
settings = _base()
form = _form(supertonic_total_steps="10", supertonic_speed="1.5", max_subtitle_words="30")
apply_form_to_settings(settings, form)
assert settings["supertonic_total_steps"] == 10
assert settings["supertonic_speed"] == 1.5
assert settings["max_subtitle_words"] == 30
def test_numeric_clamping(self):
settings = _base()
form = _form(supertonic_total_steps="100", supertonic_speed="5.0", max_subtitle_words="0")
apply_form_to_settings(settings, form)
assert settings["supertonic_total_steps"] == 15
assert settings["supertonic_speed"] == 2.0
assert settings["max_subtitle_words"] == 1
def test_boolean_checkboxes(self):
settings = _base()
form = _form(use_gpu="on", save_chapters_separately="on")
apply_form_to_settings(settings, form)
assert settings["use_gpu"] is True
assert settings["save_chapters_separately"] is True
def test_default_values_preserved(self):
settings = _base()
form = _form()
apply_form_to_settings(settings, form)
assert settings["silence_between_chapters"] == 2.0
assert settings["chunk_level"] == "paragraph"
def test_empty_form_keeps_general_defaults(self):
settings = _base()
apply_form_to_settings(settings, _form())
# General fields should stay at their base values
assert settings["language"] == "en"
assert settings["output_format"] == "mp3"
assert settings["chunk_level"] == "paragraph"
assert settings["silence_between_chapters"] == 2.0
def test_language_whitespace_trimmed(self):
settings = _base()
apply_form_to_settings(settings, _form(language=" de "))
assert settings["language"] == "de"
+44
View File
@@ -0,0 +1,44 @@
"""Tests for voice_formulas.py — pairs_to_formula."""
from abogen.voice_formulas import pairs_to_formula
class TestPairsToFormula:
def test_basic_pair(self):
result = pairs_to_formula([("A", 1.0), ("B", 1.0)])
assert result == "A*0.5+B*0.5"
def test_unequal_weights(self):
result = pairs_to_formula([("A", 3.0), ("B", 1.0)])
assert result == "A*0.75+B*0.25"
def test_single_voice(self):
result = pairs_to_formula([("A", 1.0)])
assert result == "A*1"
def test_filters_zero_weight(self):
result = pairs_to_formula([("A", 1.0), ("B", 0.0)])
assert result == "A*1"
def test_all_zero_returns_none(self):
result = pairs_to_formula([("A", 0.0), ("B", 0.0)])
assert result is None
def test_empty_returns_none(self):
result = pairs_to_formula([])
assert result is None
def test_none_values_filtered(self):
result = pairs_to_formula([("A", 1.0), ("B", None)])
assert result is not None
def test_weight_normalization(self):
result = pairs_to_formula([("A", 2.0), ("B", 2.0)])
assert result == "A*0.5+B*0.5"
def test_three_voices(self):
result = pairs_to_formula([("A", 1.0), ("B", 1.0), ("C", 1.0)])
assert "A*" in result
assert "B*" in result
assert "C*" in result
assert "+" in result