mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Compare commits
23
Commits
a299947bb1
...
28998e1e5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28998e1e5c | ||
|
|
8a220a936c | ||
|
|
ccc2cdb166 | ||
|
|
79ff7e4682 | ||
|
|
2a54b8fdf1 | ||
|
|
68e5adb091 | ||
|
|
c4870eece6 | ||
|
|
8144a7a507 | ||
|
|
f38700025a | ||
|
|
804517f5b2 | ||
|
|
5d30903149 | ||
|
|
476063bc3d | ||
|
|
079e185108 | ||
|
|
69c398ebf0 | ||
|
|
dbe73254a4 | ||
|
|
64e8a8f4e6 | ||
|
|
aec3462f1f | ||
|
|
1193185833 | ||
|
|
a99cf58c79 | ||
|
|
fe62b6b44c | ||
|
|
0e216f3786 | ||
|
|
380cdee0cb | ||
|
|
c76cf74efc |
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+76
-62
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user