mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
24
Commits
b7026a666d
...
7fef9c1d93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fef9c1d93 | ||
|
|
56cfd0810d | ||
|
|
7bd3177241 | ||
|
|
d5c2a81733 | ||
|
|
514e29a761 | ||
|
|
86042a3315 | ||
|
|
50d75eb2fc | ||
|
|
ae9ab70421 | ||
|
|
4364276a5b | ||
|
|
914e77de46 | ||
|
|
1d7a2aeed6 | ||
|
|
a26e02b017 | ||
|
|
c94347b33b | ||
|
|
b7a48e3204 | ||
|
|
feb38a24ec | ||
|
|
f63590932d | ||
|
|
7777e58f1d | ||
|
|
364c179bd6 | ||
|
|
60ba01557e | ||
|
|
39eac9b032 | ||
|
|
1499a3b426 | ||
|
|
013c80b92c | ||
|
|
62f42a9f79 | ||
|
|
2c4d13bf56 |
@@ -0,0 +1,118 @@
|
|||||||
|
"""Audio helper utilities.
|
||||||
|
|
||||||
|
Functions for building ffmpeg commands, converting audio formats,
|
||||||
|
and applying chapter metadata to MP4 files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
|
|
||||||
|
def build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
|
||||||
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
|
base = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"f32le",
|
||||||
|
"-ar",
|
||||||
|
str(SAMPLE_RATE),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
]
|
||||||
|
if fmt == "mp3":
|
||||||
|
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
|
||||||
|
elif fmt == "opus":
|
||||||
|
base += ["-c:a", "libopus", "-b:a", "24000"]
|
||||||
|
elif fmt == "m4b":
|
||||||
|
base += ["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]
|
||||||
|
else:
|
||||||
|
base += ["-c:a", "copy"]
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
svc = ExportService()
|
||||||
|
base.extend(svc._metadata_to_ffmpeg_args(metadata))
|
||||||
|
base.append(str(path))
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def to_float32(audio_segment) -> np.ndarray:
|
||||||
|
if audio_segment is None:
|
||||||
|
return np.zeros(0, dtype="float32")
|
||||||
|
|
||||||
|
tensor = audio_segment
|
||||||
|
if hasattr(tensor, "detach"):
|
||||||
|
tensor = tensor.detach()
|
||||||
|
if hasattr(tensor, "cpu"):
|
||||||
|
try:
|
||||||
|
tensor = tensor.cpu()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(tensor, "numpy"):
|
||||||
|
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||||
|
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_m4b_chapters_with_mutagen(
|
||||||
|
audio_path: Path,
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
"""Apply chapter atoms to an MP4/M4B file using mutagen.
|
||||||
|
|
||||||
|
Returns True if chapters were written, False otherwise.
|
||||||
|
Raises ImportError if mutagen is not installed.
|
||||||
|
"""
|
||||||
|
if not chapters:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import]
|
||||||
|
|
||||||
|
mp4 = MP4(str(audio_path))
|
||||||
|
|
||||||
|
chapter_objects: List[MP4Chapter] = []
|
||||||
|
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||||
|
start_raw = entry.get("start")
|
||||||
|
if start_raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start_seconds = max(0.0, float(start_raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title_value = entry.get("title")
|
||||||
|
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||||
|
|
||||||
|
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||||
|
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||||
|
|
||||||
|
end_raw = entry.get("end")
|
||||||
|
if end_raw is not None:
|
||||||
|
try:
|
||||||
|
end_seconds = float(end_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
end_seconds = None
|
||||||
|
if end_seconds is not None and end_seconds > start_seconds:
|
||||||
|
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||||
|
|
||||||
|
chapter_objects.append(chapter_atom)
|
||||||
|
|
||||||
|
if not chapter_objects:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
mp4.chapters = cast(Any, chapter_objects)
|
||||||
|
mp4.save()
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
from abogen.domain.voice_utils import coerce_truthy
|
||||||
|
|
||||||
|
|
||||||
|
def apply_chapter_overrides(
|
||||||
|
extracted: List[ExtractedChapter],
|
||||||
|
overrides: List[Dict[str, Any]],
|
||||||
|
) -> Tuple[List[ExtractedChapter], Dict[str, str], List[str]]:
|
||||||
|
if not overrides:
|
||||||
|
return [], {}, []
|
||||||
|
|
||||||
|
selected: List[ExtractedChapter] = []
|
||||||
|
metadata_updates: Dict[str, str] = {}
|
||||||
|
diagnostics: List[str] = []
|
||||||
|
|
||||||
|
for position, payload in enumerate(overrides):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
diagnostics.append(
|
||||||
|
f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled = coerce_truthy(payload.get("enabled", True))
|
||||||
|
payload["enabled"] = enabled
|
||||||
|
if not enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata_payload = payload.get("metadata") or {}
|
||||||
|
if isinstance(metadata_payload, dict):
|
||||||
|
for key, value in metadata_payload.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
metadata_updates[str(key)] = str(value)
|
||||||
|
|
||||||
|
base: Optional[ExtractedChapter] = None
|
||||||
|
idx_candidate = payload.get("index")
|
||||||
|
idx_normalized: Optional[int] = None
|
||||||
|
if isinstance(idx_candidate, int):
|
||||||
|
idx_normalized = idx_candidate
|
||||||
|
elif isinstance(idx_candidate, str):
|
||||||
|
try:
|
||||||
|
idx_normalized = int(idx_candidate)
|
||||||
|
except ValueError:
|
||||||
|
idx_normalized = None
|
||||||
|
if idx_normalized is not None and 0 <= idx_normalized < len(extracted):
|
||||||
|
base = extracted[idx_normalized]
|
||||||
|
payload["index"] = idx_normalized
|
||||||
|
|
||||||
|
if base is None:
|
||||||
|
source_title = payload.get("source_title")
|
||||||
|
if isinstance(source_title, str):
|
||||||
|
base = next((chapter for chapter in extracted if chapter.title == source_title), None)
|
||||||
|
|
||||||
|
if base is None:
|
||||||
|
candidate_title = payload.get("title")
|
||||||
|
if isinstance(candidate_title, str):
|
||||||
|
base = next((chapter for chapter in extracted if chapter.title == candidate_title), None)
|
||||||
|
|
||||||
|
text_override = payload.get("text")
|
||||||
|
if text_override is not None:
|
||||||
|
text_value = str(text_override)
|
||||||
|
elif base is not None:
|
||||||
|
text_value = base.text
|
||||||
|
else:
|
||||||
|
diagnostics.append(
|
||||||
|
f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
title_override = payload.get("title")
|
||||||
|
if title_override is not None:
|
||||||
|
title_value = str(title_override)
|
||||||
|
elif base is not None:
|
||||||
|
title_value = base.title
|
||||||
|
else:
|
||||||
|
title_value = f"Chapter {position + 1}"
|
||||||
|
|
||||||
|
if base and not payload.get("source_title"):
|
||||||
|
payload["source_title"] = base.title
|
||||||
|
|
||||||
|
payload["title"] = title_value
|
||||||
|
payload["text"] = text_value
|
||||||
|
payload["characters"] = len(text_value)
|
||||||
|
payload.setdefault("order", payload.get("order", position))
|
||||||
|
|
||||||
|
selected.append(ExtractedChapter(title=title_value, text=text_value))
|
||||||
|
|
||||||
|
return selected, metadata_updates, diagnostics
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
_HEADING_NUMBER_PREFIX_RE = re.compile(
|
||||||
|
r"^\s*(?P<number>(?:\d+|[ivxlcdm]+))(?P<suffix>(?:[\s.:;-].*)?)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ACRONYM_ALLOWLIST = {
|
||||||
|
"AI", "API", "CPU", "DIY", "GPU", "HTML", "HTTP", "HTTPS", "ID",
|
||||||
|
"JSON", "MP3", "MP4", "M4B", "NASA", "OCR", "PDF", "SQL", "TV",
|
||||||
|
"TTS", "UK", "UN", "UFO", "OK", "URL", "USA", "US", "VR",
|
||||||
|
}
|
||||||
|
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
|
||||||
|
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
|
||||||
|
|
||||||
|
|
||||||
|
def simplify_heading_text(text: str) -> str:
|
||||||
|
raw = str(text or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
simplified = _HEADING_SANITIZE_RE.sub("", raw)
|
||||||
|
if simplified.startswith("chapter"):
|
||||||
|
simplified = simplified[7:]
|
||||||
|
return simplified
|
||||||
|
|
||||||
|
|
||||||
|
def headings_equivalent(left: str, right: str) -> bool:
|
||||||
|
simple_left = simplify_heading_text(left)
|
||||||
|
simple_right = simplify_heading_text(right)
|
||||||
|
if not simple_left or not simple_right:
|
||||||
|
return False
|
||||||
|
if simple_left == simple_right:
|
||||||
|
return True
|
||||||
|
if simple_right.startswith(simple_left):
|
||||||
|
return True
|
||||||
|
if simple_left.startswith(simple_right):
|
||||||
|
return True
|
||||||
|
if len(simple_left) > 5 and simple_left in simple_right:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def strip_duplicate_heading_line(text: str, heading: str) -> Tuple[str, bool]:
|
||||||
|
source_text = str(text or "")
|
||||||
|
if not source_text:
|
||||||
|
return source_text, False
|
||||||
|
normalized_heading = simplify_heading_text(heading)
|
||||||
|
if not normalized_heading:
|
||||||
|
return source_text, False
|
||||||
|
lines = source_text.splitlines()
|
||||||
|
new_lines: List[str] = []
|
||||||
|
removed = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not removed and stripped:
|
||||||
|
if headings_equivalent(stripped, heading):
|
||||||
|
removed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if not removed:
|
||||||
|
return source_text, False
|
||||||
|
while new_lines and not new_lines[0].strip():
|
||||||
|
new_lines.pop(0)
|
||||||
|
return "\n".join(new_lines), True
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_caps_word(word: str) -> str:
|
||||||
|
upper = word.upper()
|
||||||
|
letters = [char for char in upper if char.isalpha()]
|
||||||
|
if not letters:
|
||||||
|
return word
|
||||||
|
if upper in _ACRONYM_ALLOWLIST:
|
||||||
|
return word
|
||||||
|
if len(letters) <= 1:
|
||||||
|
return word
|
||||||
|
if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7:
|
||||||
|
return word
|
||||||
|
|
||||||
|
parts = re.split(r"(['\-\u2019])", word)
|
||||||
|
normalized_parts: List[str] = []
|
||||||
|
for part in parts:
|
||||||
|
if part in {"'", "-", "\u2019"}:
|
||||||
|
normalized_parts.append(part)
|
||||||
|
continue
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
normalized_parts.append(part[0].upper() + part[1:].lower())
|
||||||
|
return "".join(normalized_parts) or word
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_chapter_opening_caps(text: str) -> Tuple[str, bool]:
|
||||||
|
if not text:
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
leading_len = len(text) - len(text.lstrip())
|
||||||
|
leading = text[:leading_len]
|
||||||
|
working = text[leading_len:]
|
||||||
|
if not working:
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
builder: List[str] = []
|
||||||
|
pos = 0
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
while pos < len(working):
|
||||||
|
char = working[pos]
|
||||||
|
if char in "\r\n":
|
||||||
|
builder.append(working[pos:])
|
||||||
|
pos = len(working)
|
||||||
|
break
|
||||||
|
if char.isspace():
|
||||||
|
builder.append(char)
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
if char.islower():
|
||||||
|
builder.append(working[pos:])
|
||||||
|
pos = len(working)
|
||||||
|
break
|
||||||
|
if not char.isalpha():
|
||||||
|
builder.append(char)
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = _CAPS_WORD_RE.match(working, pos)
|
||||||
|
if not match:
|
||||||
|
builder.append(char)
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
word = match.group(0)
|
||||||
|
if any(ch.islower() for ch in word):
|
||||||
|
builder.append(working[pos:])
|
||||||
|
pos = len(working)
|
||||||
|
break
|
||||||
|
|
||||||
|
normalized = normalize_caps_word(word)
|
||||||
|
if normalized != word:
|
||||||
|
changed = True
|
||||||
|
builder.append(normalized)
|
||||||
|
pos = match.end()
|
||||||
|
|
||||||
|
if pos < len(working):
|
||||||
|
builder.append(working[pos:])
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
return leading + "".join(builder), True
|
||||||
|
|
||||||
|
|
||||||
|
def format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str:
|
||||||
|
base = str(title or "").strip()
|
||||||
|
if not base:
|
||||||
|
return f"Chapter {index}" if apply_prefix else ""
|
||||||
|
if not apply_prefix:
|
||||||
|
return base
|
||||||
|
lowered = base.lower()
|
||||||
|
if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()):
|
||||||
|
return base
|
||||||
|
match = _HEADING_NUMBER_PREFIX_RE.match(base)
|
||||||
|
if match:
|
||||||
|
number = match.group("number") or ""
|
||||||
|
suffix = match.group("suffix") or ""
|
||||||
|
cleaned_suffix = suffix.lstrip(" .,:;-_ \t\u2013\u2014\u00b7\u2022")
|
||||||
|
if cleaned_suffix:
|
||||||
|
return f"Chapter {number}. {cleaned_suffix}"
|
||||||
|
return f"Chapter {number}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def apply_chapter_text_transforms(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
heading_text: str,
|
||||||
|
raw_title: str,
|
||||||
|
strip_heading: bool,
|
||||||
|
normalize_caps: bool,
|
||||||
|
) -> Tuple[str, bool, bool]:
|
||||||
|
"""Strip duplicate heading and normalize opening caps.
|
||||||
|
|
||||||
|
Returns ``(text, heading_removed, caps_changed)``.
|
||||||
|
The caller is responsible for state updates (pending flags, logging,
|
||||||
|
dict mutation, ``continue``).
|
||||||
|
"""
|
||||||
|
heading_removed = False
|
||||||
|
caps_changed = False
|
||||||
|
|
||||||
|
if strip_heading and heading_text:
|
||||||
|
text, heading_removed = strip_duplicate_heading_line(text, heading_text)
|
||||||
|
if not heading_removed and raw_title:
|
||||||
|
match = _HEADING_NUMBER_PREFIX_RE.match(raw_title)
|
||||||
|
if match:
|
||||||
|
number = match.group("number")
|
||||||
|
if number:
|
||||||
|
text, heading_removed = strip_duplicate_heading_line(text, number)
|
||||||
|
|
||||||
|
if normalize_caps and text:
|
||||||
|
text, caps_changed = normalize_chapter_opening_caps(text)
|
||||||
|
|
||||||
|
return text, heading_removed, caps_changed
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Chunk processing utilities.
|
||||||
|
|
||||||
|
Functions for grouping chunks, recording override usage, and selecting
|
||||||
|
text for TTS synthesis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||||
|
|
||||||
|
from abogen.pronunciation_store import increment_usage
|
||||||
|
|
||||||
|
|
||||||
|
def safe_int(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||||
|
grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||||
|
for entry in chunks or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chapter_index = int(entry.get("chapter_index", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
chapter_index = 0
|
||||||
|
grouped[chapter_index].append(dict(entry))
|
||||||
|
|
||||||
|
for chapter_index, items in grouped.items():
|
||||||
|
items.sort(key=lambda payload: safe_int(payload.get("chunk_index")))
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def record_override_usage(
|
||||||
|
job: Any,
|
||||||
|
usage_counter: Mapping[str, int],
|
||||||
|
token_map: Mapping[str, str],
|
||||||
|
) -> None:
|
||||||
|
if not usage_counter:
|
||||||
|
return
|
||||||
|
|
||||||
|
language = getattr(job, "language", "") or "a"
|
||||||
|
for normalized, amount in usage_counter.items():
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
token_value = token_map.get(normalized, normalized)
|
||||||
|
try:
|
||||||
|
increment_usage(language=language, token=token_value, amount=int(amount))
|
||||||
|
except Exception: # pragma: no cover - defensive logging
|
||||||
|
job.add_log(f"Failed to record usage for override {token_value}", level="warning")
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text_for_tts(entry: Mapping[str, Any]) -> str:
|
||||||
|
"""Choose the best source text for synthesis.
|
||||||
|
|
||||||
|
We must prefer the raw chunk text (``text`` / ``original_text``) so
|
||||||
|
manual/pronunciation overrides can match against the original tokens
|
||||||
|
(e.g. censored words like ``Unfu*k``). ``normalized_text`` may have
|
||||||
|
already been run through ``normalize_for_pipeline``, which can remove
|
||||||
|
punctuation and prevent overrides from triggering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
return ""
|
||||||
|
return str(
|
||||||
|
entry.get("text")
|
||||||
|
or entry.get("original_text")
|
||||||
|
or entry.get("normalized_text")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import platform as _platform
|
||||||
|
|
||||||
|
|
||||||
|
def select_device() -> str:
|
||||||
|
"""Return the best available compute device (``"mps"``, ``"cuda"``, or ``"cpu"``).
|
||||||
|
|
||||||
|
Checks ``torch`` availability at runtime so this can be called from
|
||||||
|
any context without requiring torch at import time.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import torch # type: ignore[import-not-found]
|
||||||
|
except Exception:
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
system = _platform.system()
|
||||||
|
if system == "Darwin" and _platform.processor() == "arm":
|
||||||
|
try:
|
||||||
|
if torch.backends.mps.is_available(): # type: ignore[union-attr]
|
||||||
|
return "mps"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if torch.cuda.is_available(): # type: ignore[union-attr]
|
||||||
|
return "cuda"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
|
||||||
|
|
||||||
|
_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500}
|
||||||
|
_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160}
|
||||||
|
_STRUCTURAL_KEYWORDS = (
|
||||||
|
"preface",
|
||||||
|
"prologue",
|
||||||
|
"introduction",
|
||||||
|
"foreword",
|
||||||
|
"epilogue",
|
||||||
|
"afterword",
|
||||||
|
"appendix",
|
||||||
|
"acknowledgment",
|
||||||
|
"acknowledgement",
|
||||||
|
)
|
||||||
|
_STRUCTURAL_MIN_LENGTH = 120
|
||||||
|
_MAX_SHORT_CHAPTERS = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChapterFilterResult:
|
||||||
|
kept: List[ExtractedChapter]
|
||||||
|
skipped: List[Tuple[str, int]]
|
||||||
|
|
||||||
|
|
||||||
|
def infer_file_type(path: Path) -> str:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".epub":
|
||||||
|
return "epub"
|
||||||
|
if suffix in {".md", ".markdown"}:
|
||||||
|
return "markdown"
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
if suffix == ".txt":
|
||||||
|
return "text"
|
||||||
|
return suffix.lstrip(".") or "text"
|
||||||
|
|
||||||
|
|
||||||
|
def looks_structural(title: str) -> bool:
|
||||||
|
lowered = title.strip().lower()
|
||||||
|
if not lowered:
|
||||||
|
return False
|
||||||
|
return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def chapter_label(file_type: str) -> str:
|
||||||
|
return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages"
|
||||||
|
|
||||||
|
|
||||||
|
def auto_select_relevant_chapters(
|
||||||
|
chapters: List[ExtractedChapter],
|
||||||
|
file_type: str,
|
||||||
|
) -> ChapterFilterResult:
|
||||||
|
if not chapters:
|
||||||
|
return ChapterFilterResult(kept=[], skipped=[])
|
||||||
|
|
||||||
|
normalized = file_type.lower()
|
||||||
|
threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0)
|
||||||
|
min_short = _MIN_SHORT_CONTENT.get(normalized, 0)
|
||||||
|
|
||||||
|
kept: List[ExtractedChapter] = []
|
||||||
|
skipped: List[Tuple[str, int]] = []
|
||||||
|
short_kept = 0
|
||||||
|
|
||||||
|
for chapter in chapters:
|
||||||
|
stripped = chapter.text.strip()
|
||||||
|
length = len(stripped)
|
||||||
|
if length == 0:
|
||||||
|
skipped.append((chapter.title, length))
|
||||||
|
continue
|
||||||
|
|
||||||
|
keep = False
|
||||||
|
if threshold == 0:
|
||||||
|
keep = True
|
||||||
|
elif length >= threshold:
|
||||||
|
keep = True
|
||||||
|
elif not kept:
|
||||||
|
keep = True
|
||||||
|
elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS:
|
||||||
|
keep = True
|
||||||
|
short_kept += 1
|
||||||
|
elif looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH:
|
||||||
|
keep = True
|
||||||
|
|
||||||
|
if keep:
|
||||||
|
kept.append(chapter)
|
||||||
|
else:
|
||||||
|
skipped.append((chapter.title, length))
|
||||||
|
|
||||||
|
if kept:
|
||||||
|
return ChapterFilterResult(kept=kept, skipped=skipped)
|
||||||
|
|
||||||
|
longest_idx = None
|
||||||
|
longest_length = 0
|
||||||
|
for idx, chapter in enumerate(chapters):
|
||||||
|
stripped = chapter.text.strip()
|
||||||
|
if stripped and len(stripped) > longest_length:
|
||||||
|
longest_length = len(stripped)
|
||||||
|
longest_idx = idx
|
||||||
|
|
||||||
|
if longest_idx is not None:
|
||||||
|
longest = chapters[longest_idx]
|
||||||
|
fallback_skipped = [
|
||||||
|
(chapter.title, len(chapter.text.strip()))
|
||||||
|
for idx, chapter in enumerate(chapters)
|
||||||
|
if idx != longest_idx and chapter.text.strip()
|
||||||
|
]
|
||||||
|
return ChapterFilterResult(kept=[longest], skipped=fallback_skipped)
|
||||||
|
|
||||||
|
return ChapterFilterResult(kept=[], skipped=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
def update_metadata_for_chapter_count(
|
||||||
|
metadata: Dict[str, Any], count: int, file_type: str
|
||||||
|
) -> None:
|
||||||
|
if not metadata or count <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages"
|
||||||
|
metadata["chapter_count"] = str(count)
|
||||||
|
|
||||||
|
pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)")
|
||||||
|
replacement = f"({count} {label})"
|
||||||
|
for key in ("album", "ALBUM"):
|
||||||
|
value = metadata.get(key)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
metadata[key] = pattern.sub(replacement, value)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
_SERIES_NAME_KEYS = (
|
||||||
|
"series",
|
||||||
|
"series_name",
|
||||||
|
"series_title",
|
||||||
|
)
|
||||||
|
_SERIES_NUMBER_KEYS = (
|
||||||
|
"series_index",
|
||||||
|
"series_position",
|
||||||
|
"series_sequence",
|
||||||
|
"book_number",
|
||||||
|
"series_number",
|
||||||
|
)
|
||||||
|
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
||||||
|
normalized: Dict[str, str] = {}
|
||||||
|
if not values:
|
||||||
|
return normalized
|
||||||
|
for key, value in values.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
normalized[str(key).casefold()] = text
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def format_author_sentence(raw: Optional[str]) -> str:
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
normalized = str(raw).strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
if lowered in {"unknown", "various"}:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
working = normalized.replace("&", " and ")
|
||||||
|
segments = [segment.strip() for segment in working.split(",") if segment.strip()]
|
||||||
|
tokens: List[str] = []
|
||||||
|
|
||||||
|
if segments:
|
||||||
|
for segment in segments:
|
||||||
|
parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()]
|
||||||
|
if parts:
|
||||||
|
tokens.extend(parts)
|
||||||
|
else:
|
||||||
|
tokens.append(segment)
|
||||||
|
else:
|
||||||
|
parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()]
|
||||||
|
tokens.extend(parts or [normalized])
|
||||||
|
|
||||||
|
cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}]
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
if len(cleaned) == 1:
|
||||||
|
return f"By {cleaned[0]}"
|
||||||
|
if len(cleaned) == 2:
|
||||||
|
return f"By {cleaned[0]} and {cleaned[1]}"
|
||||||
|
return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_sentence(text: str) -> str:
|
||||||
|
cleaned = text.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
if cleaned[-1] in ".!?":
|
||||||
|
return cleaned
|
||||||
|
return f"{cleaned}."
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_series_number(value: Any) -> Optional[str]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
candidate = text.replace(",", ".")
|
||||||
|
if candidate.replace(".", "", 1).isdigit():
|
||||||
|
if "." in candidate:
|
||||||
|
normalized = candidate.rstrip("0").rstrip(".")
|
||||||
|
return normalized or "0"
|
||||||
|
try:
|
||||||
|
return str(int(candidate))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
match = _SERIES_NUMBER_RE.search(candidate)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
normalized = match.group(0)
|
||||||
|
if "." in normalized:
|
||||||
|
normalized = normalized.rstrip("0").rstrip(".")
|
||||||
|
return normalized or "0"
|
||||||
|
try:
|
||||||
|
return str(int(normalized))
|
||||||
|
except ValueError:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def extract_series_metadata(values: Mapping[str, str]) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
series_name: Optional[str] = None
|
||||||
|
for key in _SERIES_NAME_KEYS:
|
||||||
|
raw = values.get(key)
|
||||||
|
if raw:
|
||||||
|
cleaned = str(raw).strip()
|
||||||
|
if cleaned:
|
||||||
|
series_name = cleaned
|
||||||
|
break
|
||||||
|
|
||||||
|
series_number: Optional[str] = None
|
||||||
|
for key in _SERIES_NUMBER_KEYS:
|
||||||
|
raw = values.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
normalized = normalize_series_number(raw)
|
||||||
|
if normalized:
|
||||||
|
series_number = normalized
|
||||||
|
break
|
||||||
|
|
||||||
|
return series_name, series_number
|
||||||
|
|
||||||
|
|
||||||
|
def format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
|
||||||
|
if not series_name or not series_number:
|
||||||
|
return ""
|
||||||
|
name = series_name.strip()
|
||||||
|
number = series_number.strip()
|
||||||
|
if not name or not number:
|
||||||
|
return ""
|
||||||
|
article = "the " if not name.lower().startswith("the ") else ""
|
||||||
|
phrase = f"Book {number} of {article}{name}"
|
||||||
|
return re.sub(r"\s+", " ", phrase).strip()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metadata(
|
||||||
|
extracted: Optional[Dict[str, Any]],
|
||||||
|
overrides: Optional[Dict[str, Any]],
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
merged: Dict[str, str] = {}
|
||||||
|
if extracted:
|
||||||
|
for key, value in extracted.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
merged[str(key)] = str(value)
|
||||||
|
if overrides:
|
||||||
|
for key, value in overrides.items():
|
||||||
|
key_str = str(key)
|
||||||
|
if value is None:
|
||||||
|
merged.pop(key_str, None)
|
||||||
|
else:
|
||||||
|
merged[key_str] = str(value)
|
||||||
|
return merged
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Text normalization convenience helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
from abogen.kokoro_text_normalization import (
|
||||||
|
ApostropheConfig,
|
||||||
|
normalize_for_pipeline as _normalize_for_pipeline,
|
||||||
|
)
|
||||||
|
from abogen.normalization_settings import (
|
||||||
|
build_apostrophe_config,
|
||||||
|
get_runtime_settings,
|
||||||
|
apply_overrides as _apply_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BASE_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text_for_pipeline(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Normalize text using runtime settings with optional overrides."""
|
||||||
|
runtime_settings = get_runtime_settings()
|
||||||
|
if normalization_overrides:
|
||||||
|
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||||
|
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
|
||||||
|
return _normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Output path resolution utilities.
|
||||||
|
|
||||||
|
Pure functions for resolving output directories, building file paths,
|
||||||
|
and computing project folder layouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
|
||||||
|
|
||||||
|
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(title: str, index: int) -> str:
|
||||||
|
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
|
||||||
|
if not sanitized:
|
||||||
|
sanitized = f"chapter_{index:02d}"
|
||||||
|
return sanitized[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_output_stem(name: str) -> str:
|
||||||
|
base = Path(name or "").stem
|
||||||
|
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
|
||||||
|
return sanitized or "output"
|
||||||
|
|
||||||
|
|
||||||
|
def output_timestamp_token() -> str:
|
||||||
|
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||||
|
sanitized = sanitize_output_stem(original_name)
|
||||||
|
return directory / f"{sanitized}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||||
|
if not replace_single_newlines:
|
||||||
|
return
|
||||||
|
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
|
||||||
|
for chapter in chapters:
|
||||||
|
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_output_directory(
|
||||||
|
*,
|
||||||
|
save_mode: str,
|
||||||
|
stored_path: Path,
|
||||||
|
output_folder: Optional[str],
|
||||||
|
desktop_dir: Optional[Path],
|
||||||
|
user_output_path: Optional[Path],
|
||||||
|
user_cache_outputs: Optional[Path],
|
||||||
|
) -> Path:
|
||||||
|
if save_mode == "Save to Desktop" and desktop_dir:
|
||||||
|
return desktop_dir
|
||||||
|
if save_mode == "Save next to input file":
|
||||||
|
return stored_path.parent
|
||||||
|
if save_mode == "Choose output folder" and output_folder:
|
||||||
|
return Path(output_folder)
|
||||||
|
if save_mode == "Use default save location" and user_output_path:
|
||||||
|
return user_output_path
|
||||||
|
return user_cache_outputs or Path(".")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_project_layout(
|
||||||
|
*,
|
||||||
|
original_filename: str,
|
||||||
|
save_as_project: bool,
|
||||||
|
base_dir: Path,
|
||||||
|
timestamp_fn: Callable[[], str] = output_timestamp_token,
|
||||||
|
sanitize_fn: Callable[[str, int], str] = sanitize_output_stem,
|
||||||
|
) -> Tuple[Path, Path, Path, Optional[Path]]:
|
||||||
|
sanitized = sanitize_fn(original_filename, 0)
|
||||||
|
folder_name = f"{timestamp_fn()}_{sanitized}"
|
||||||
|
project_root = base_dir / folder_name
|
||||||
|
project_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if save_as_project:
|
||||||
|
audio_dir = project_root / "audio"
|
||||||
|
subtitle_dir = project_root / "subtitles"
|
||||||
|
metadata_dir = project_root / "metadata"
|
||||||
|
for directory in (audio_dir, subtitle_dir, metadata_dir):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||||
|
|
||||||
|
return project_root, project_root, project_root, None
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Pronunciation rule compilation and application.
|
||||||
|
|
||||||
|
Pure functions for compiling token-level and sentence-level pronunciation
|
||||||
|
overrides into regex patterns, applying them to text, and merging multiple
|
||||||
|
override sources with precedence rules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||||
|
|
||||||
|
from abogen.entity_analysis import normalize_token as normalize_entity_token
|
||||||
|
from abogen.entity_analysis import normalize_manual_override_token
|
||||||
|
|
||||||
|
|
||||||
|
def compile_pronunciation_rules(
|
||||||
|
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not overrides:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for entry in overrides:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||||
|
if not pronunciation_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
token_values: List[str] = []
|
||||||
|
token_raw = entry.get("token")
|
||||||
|
if token_raw:
|
||||||
|
token_value = str(token_raw).strip()
|
||||||
|
if token_value:
|
||||||
|
token_values.append(token_value)
|
||||||
|
normalized_raw = entry.get("normalized")
|
||||||
|
if normalized_raw:
|
||||||
|
normalized_value = str(normalized_raw).strip()
|
||||||
|
if normalized_value:
|
||||||
|
token_values.append(normalized_value)
|
||||||
|
if token_raw and not token_values:
|
||||||
|
fallback = normalize_entity_token(str(token_raw))
|
||||||
|
if fallback:
|
||||||
|
token_values.append(fallback)
|
||||||
|
|
||||||
|
if not token_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
usage_normalized = str(entry.get("normalized") or "").strip()
|
||||||
|
if not usage_normalized and token_values:
|
||||||
|
usage_normalized = normalize_entity_token(token_values[0]) or token_values[0]
|
||||||
|
usage_token = str(entry.get("token") or token_values[0])
|
||||||
|
|
||||||
|
for token_value in token_values:
|
||||||
|
key = token_value.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": usage_normalized,
|
||||||
|
"replacement": pronunciation_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: len(item["token"]), reverse=True)
|
||||||
|
compiled: List[Dict[str, Any]] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
token_value = candidate["token"]
|
||||||
|
pronunciation_value = candidate["replacement"]
|
||||||
|
escaped = re.escape(token_value)
|
||||||
|
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
|
||||||
|
compiled.append(
|
||||||
|
{
|
||||||
|
"pattern": pattern,
|
||||||
|
"replacement": pronunciation_value,
|
||||||
|
"normalized": candidate.get("normalized") or token_value,
|
||||||
|
"token": candidate.get("token") or token_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def compile_heteronym_sentence_rules(
|
||||||
|
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not overrides:
|
||||||
|
return []
|
||||||
|
|
||||||
|
compiled: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for entry in overrides:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
sentence = str(entry.get("sentence") or "").strip()
|
||||||
|
if not sentence:
|
||||||
|
continue
|
||||||
|
choice = str(entry.get("choice") or "").strip()
|
||||||
|
if not choice:
|
||||||
|
continue
|
||||||
|
|
||||||
|
replacement_sentence = ""
|
||||||
|
options = entry.get("options")
|
||||||
|
if isinstance(options, list):
|
||||||
|
for opt in options:
|
||||||
|
if not isinstance(opt, Mapping):
|
||||||
|
continue
|
||||||
|
if str(opt.get("key") or "").strip() == choice:
|
||||||
|
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
|
||||||
|
break
|
||||||
|
if not replacement_sentence:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rule_key = f"{sentence}\n{choice}".casefold()
|
||||||
|
if rule_key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rule_key)
|
||||||
|
|
||||||
|
parts = [p for p in re.split(r"\s+", sentence) if p]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
pattern_text = r"\s+".join(re.escape(p) for p in parts)
|
||||||
|
pattern = re.compile(pattern_text)
|
||||||
|
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
|
||||||
|
|
||||||
|
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
|
||||||
|
if not text or not rules:
|
||||||
|
return text
|
||||||
|
result = text
|
||||||
|
for rule in rules:
|
||||||
|
pattern = rule["pattern"]
|
||||||
|
replacement = rule["replacement"]
|
||||||
|
result = pattern.sub(replacement, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pronunciation_rules(
|
||||||
|
text: str,
|
||||||
|
rules: List[Dict[str, Any]],
|
||||||
|
usage_counter: Optional[Dict[str, int]] = None,
|
||||||
|
) -> str:
|
||||||
|
if not text or not rules:
|
||||||
|
return text
|
||||||
|
|
||||||
|
result = text
|
||||||
|
for rule in rules:
|
||||||
|
pattern = rule["pattern"]
|
||||||
|
pronunciation_value = rule["replacement"]
|
||||||
|
usage_key = str(rule.get("normalized") or "").strip()
|
||||||
|
|
||||||
|
def _replacement(match: re.Match[str]) -> str:
|
||||||
|
suffix = match.group("possessive") or ""
|
||||||
|
if usage_counter is not None and usage_key:
|
||||||
|
usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1
|
||||||
|
return pronunciation_value + suffix
|
||||||
|
|
||||||
|
result = pattern.sub(_replacement, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Return pronunciation override entries, ensuring manual overrides are included.
|
||||||
|
|
||||||
|
Pending jobs keep both ``manual_overrides`` and ``pronunciation_overrides``, but the
|
||||||
|
latter can be stale if the UI didn't resync before enqueue. During conversion,
|
||||||
|
we must merge manual overrides so they always apply (before TTS).
|
||||||
|
|
||||||
|
Precedence: manual overrides win over existing entries for the same normalized key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
collected: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
existing = getattr(job, "pronunciation_overrides", None)
|
||||||
|
if isinstance(existing, list):
|
||||||
|
for entry in existing:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(entry.get("token") or "").strip()
|
||||||
|
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(entry.get("voice") or "").strip() or None,
|
||||||
|
"notes": str(entry.get("notes") or "").strip() or None,
|
||||||
|
"context": str(entry.get("context") or "").strip() or None,
|
||||||
|
"source": str(entry.get("source") or "pronunciation"),
|
||||||
|
"language": getattr(job, "language", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
speakers = getattr(job, "speakers", None)
|
||||||
|
if isinstance(speakers, dict):
|
||||||
|
for payload in speakers.values():
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(payload.get("token") or "").strip()
|
||||||
|
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = normalize_entity_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(
|
||||||
|
payload.get("resolved_voice")
|
||||||
|
or payload.get("voice")
|
||||||
|
or getattr(job, "voice", "")
|
||||||
|
).strip()
|
||||||
|
or None,
|
||||||
|
"notes": None,
|
||||||
|
"context": None,
|
||||||
|
"source": "speaker",
|
||||||
|
"language": getattr(job, "language", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
manual = getattr(job, "manual_overrides", None)
|
||||||
|
if isinstance(manual, list):
|
||||||
|
for entry in manual:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
token_value = str(entry.get("token") or "").strip()
|
||||||
|
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||||
|
if not token_value or not pronunciation_value:
|
||||||
|
continue
|
||||||
|
normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
collected[normalized] = {
|
||||||
|
"token": token_value,
|
||||||
|
"normalized": normalized,
|
||||||
|
"pronunciation": pronunciation_value,
|
||||||
|
"voice": str(entry.get("voice") or "").strip() or None,
|
||||||
|
"notes": str(entry.get("notes") or "").strip() or None,
|
||||||
|
"context": str(entry.get("context") or "").strip() or None,
|
||||||
|
"source": str(entry.get("source") or "manual"),
|
||||||
|
"language": getattr(job, "language", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
return list(collected.values())
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Unified split pattern logic extracted from 3 copies."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
PUNCTUATION_SENTENCE = r".!?。!?"
|
||||||
|
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
|
||||||
|
|
||||||
|
|
||||||
|
def get_split_pattern(language: str, subtitle_mode: str) -> str:
|
||||||
|
"""Get the appropriate split pattern based on language and subtitle mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
language: Language code (a, b, e, f, etc.)
|
||||||
|
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Split pattern string
|
||||||
|
"""
|
||||||
|
# For English, always use newline splitting only
|
||||||
|
if language in ("a", "b"):
|
||||||
|
return "\n"
|
||||||
|
|
||||||
|
# Determine spacing pattern based on language
|
||||||
|
spacing = r"\s*" if language in ("z", "j") else r"\s+"
|
||||||
|
|
||||||
|
# For CJK languages, when subtitle mode is Disabled or Line, prefer
|
||||||
|
# punctuation-based splitting instead of plain newline splitting.
|
||||||
|
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"):
|
||||||
|
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||||
|
|
||||||
|
if subtitle_mode == "Line":
|
||||||
|
return "\n"
|
||||||
|
elif subtitle_mode == "Sentence":
|
||||||
|
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||||
|
elif subtitle_mode == "Sentence + Comma":
|
||||||
|
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
|
||||||
|
else:
|
||||||
|
return r"\n+"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from .metadata_helpers import (
|
||||||
|
ensure_sentence,
|
||||||
|
extract_series_metadata,
|
||||||
|
format_author_sentence,
|
||||||
|
format_series_sentence,
|
||||||
|
normalize_metadata_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_title_intro_text(
|
||||||
|
metadata: Optional[Mapping[str, Any]],
|
||||||
|
fallback_basename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build the title introduction text from metadata."""
|
||||||
|
normalized = normalize_metadata_map(metadata)
|
||||||
|
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||||
|
title = (
|
||||||
|
normalized.get("title")
|
||||||
|
or normalized.get("book_title")
|
||||||
|
or normalized.get("album")
|
||||||
|
or fallback_title
|
||||||
|
)
|
||||||
|
if not title:
|
||||||
|
title = fallback_title
|
||||||
|
subtitle = normalized.get("subtitle") or normalized.get("sub_title")
|
||||||
|
if subtitle and title and subtitle.casefold() == title.casefold():
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
|
author_value = ""
|
||||||
|
for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"):
|
||||||
|
value = normalized.get(candidate)
|
||||||
|
if value:
|
||||||
|
author_value = value
|
||||||
|
break
|
||||||
|
|
||||||
|
series_name, series_number = extract_series_metadata(normalized)
|
||||||
|
series_sentence = format_series_sentence(series_name, series_number)
|
||||||
|
|
||||||
|
sentences: List[str] = []
|
||||||
|
if series_sentence:
|
||||||
|
sentences.append(ensure_sentence(series_sentence))
|
||||||
|
if title:
|
||||||
|
sentences.append(ensure_sentence(title))
|
||||||
|
if subtitle:
|
||||||
|
sentences.append(ensure_sentence(subtitle))
|
||||||
|
author_sentence = format_author_sentence(author_value)
|
||||||
|
if author_sentence:
|
||||||
|
sentences.append(ensure_sentence(author_sentence))
|
||||||
|
return " ".join(sentences).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_outro_text(
|
||||||
|
metadata: Optional[Mapping[str, Any]],
|
||||||
|
fallback_basename: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build the outro/closing text from metadata."""
|
||||||
|
normalized = normalize_metadata_map(metadata)
|
||||||
|
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||||
|
title = (
|
||||||
|
normalized.get("title")
|
||||||
|
or normalized.get("book_title")
|
||||||
|
or normalized.get("album")
|
||||||
|
or fallback_title
|
||||||
|
)
|
||||||
|
author_value = ""
|
||||||
|
for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"):
|
||||||
|
value = normalized.get(candidate)
|
||||||
|
if value:
|
||||||
|
author_value = value
|
||||||
|
break
|
||||||
|
author_sentence = format_author_sentence(author_value)
|
||||||
|
authors_fragment = (
|
||||||
|
author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if title and authors_fragment:
|
||||||
|
closing_line = f"The end of {title} from {authors_fragment}"
|
||||||
|
elif title:
|
||||||
|
closing_line = f"The end of {title}"
|
||||||
|
elif authors_fragment:
|
||||||
|
closing_line = f"The end from {authors_fragment}"
|
||||||
|
else:
|
||||||
|
closing_line = "The end"
|
||||||
|
|
||||||
|
series_name, series_number = extract_series_metadata(normalized)
|
||||||
|
series_sentence = format_series_sentence(series_name, series_number)
|
||||||
|
|
||||||
|
sentences: List[str] = [ensure_sentence(closing_line)]
|
||||||
|
if series_sentence:
|
||||||
|
sentences.append(ensure_sentence(series_sentence))
|
||||||
|
|
||||||
|
return " ".join(sentence for sentence in sentences if sentence).strip()
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Voice resolution helpers.
|
||||||
|
|
||||||
|
Functions for resolving voice specifications, collecting required voice IDs,
|
||||||
|
and determining the voice to use for chapters and chunks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, Set
|
||||||
|
|
||||||
|
from abogen.tts_plugin.utils import get_voices, get_default_voice
|
||||||
|
from abogen.voice_formulas import extract_voice_ids
|
||||||
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
|
|
||||||
|
|
||||||
|
def spec_to_voice_ids(spec: Any) -> Set[str]:
|
||||||
|
text = str(spec or "").strip()
|
||||||
|
if not text:
|
||||||
|
return set()
|
||||||
|
if text == "__custom_mix":
|
||||||
|
return set()
|
||||||
|
if "*" in text:
|
||||||
|
try:
|
||||||
|
return set(extract_voice_ids(text))
|
||||||
|
except ValueError:
|
||||||
|
return set()
|
||||||
|
if text in get_voices("kokoro"):
|
||||||
|
return {text}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def job_voice_fallback(job: Any) -> str:
|
||||||
|
base = str(getattr(job, "voice", "") or "").strip()
|
||||||
|
if base and base != "__custom_mix":
|
||||||
|
return base
|
||||||
|
|
||||||
|
speakers = getattr(job, "speakers", None)
|
||||||
|
if isinstance(speakers, dict):
|
||||||
|
narrator = speakers.get("narrator")
|
||||||
|
if isinstance(narrator, dict):
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
value = narrator.get(key)
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
if candidate and candidate != "__custom_mix":
|
||||||
|
return candidate
|
||||||
|
for payload in speakers.values() or []:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
value = payload.get(key)
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
if candidate and candidate != "__custom_mix":
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
for chapter in getattr(job, "chapters", []) or []:
|
||||||
|
if not isinstance(chapter, dict):
|
||||||
|
continue
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
candidate = str(chapter.get(key) or "").strip()
|
||||||
|
if candidate and candidate != "__custom_mix":
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_required_voice_ids(job: Any) -> Set[str]:
|
||||||
|
voices: Set[str] = set()
|
||||||
|
voices.update(spec_to_voice_ids(job.voice))
|
||||||
|
voices.update(spec_to_voice_ids(job_voice_fallback(job)))
|
||||||
|
|
||||||
|
for chapter in getattr(job, "chapters", []) or []:
|
||||||
|
if not isinstance(chapter, dict):
|
||||||
|
continue
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
voices.update(spec_to_voice_ids(chapter.get(key)))
|
||||||
|
|
||||||
|
for chunk in getattr(job, "chunks", []) or []:
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
continue
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
voices.update(spec_to_voice_ids(chunk.get(key)))
|
||||||
|
|
||||||
|
speakers = getattr(job, "speakers", {})
|
||||||
|
if isinstance(speakers, dict):
|
||||||
|
for payload in speakers.values() or []:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
voices.update(spec_to_voice_ids(payload.get(key)))
|
||||||
|
|
||||||
|
voices.update(get_voices("kokoro"))
|
||||||
|
return voices
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_voice_cache(job: Any) -> None:
|
||||||
|
try:
|
||||||
|
targets = collect_required_voice_ids(job)
|
||||||
|
downloaded, errors = ensure_voice_assets(
|
||||||
|
targets,
|
||||||
|
on_progress=lambda message: job.add_log(message, level="debug"),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
job.add_log(f"Voice cache unavailable: {exc}", level="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if downloaded:
|
||||||
|
job.add_log(
|
||||||
|
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
for voice_id, error in errors.items():
|
||||||
|
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
|
||||||
|
|
||||||
|
|
||||||
|
def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str:
|
||||||
|
if not override:
|
||||||
|
return job_voice_fallback(job)
|
||||||
|
|
||||||
|
resolved = str(override.get("resolved_voice", "")).strip()
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
formula = str(override.get("voice_formula", "")).strip()
|
||||||
|
if formula:
|
||||||
|
return formula
|
||||||
|
|
||||||
|
voice = str(override.get("voice", "")).strip()
|
||||||
|
if voice:
|
||||||
|
return voice
|
||||||
|
|
||||||
|
return job_voice_fallback(job)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
value = chunk.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
speaker_id = chunk.get("speaker_id")
|
||||||
|
speakers = getattr(job, "speakers", None)
|
||||||
|
if isinstance(speakers, dict) and speaker_id in speakers:
|
||||||
|
speaker_entry = speakers.get(speaker_id) or {}
|
||||||
|
if isinstance(speaker_entry, dict):
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
value = speaker_entry.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
profile_formula = speaker_entry.get("voice_formula")
|
||||||
|
if profile_formula:
|
||||||
|
return str(profile_formula)
|
||||||
|
|
||||||
|
profile_name = chunk.get("voice_profile")
|
||||||
|
if profile_name:
|
||||||
|
if isinstance(speakers, dict):
|
||||||
|
speaker_entry = speakers.get(profile_name)
|
||||||
|
if isinstance(speaker_entry, dict):
|
||||||
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
|
value = speaker_entry.get(key)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
|
return job_voice_fallback(job)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_fallback_voice_spec(
|
||||||
|
base_spec: str,
|
||||||
|
job_voice: str,
|
||||||
|
voice_cache_keys: list[str],
|
||||||
|
provider: str = "kokoro",
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the voice spec for intro/outro with a priority fallback chain.
|
||||||
|
|
||||||
|
Priority: base_spec → job_voice → first voice_cache key → default voice.
|
||||||
|
``"__custom_mix"`` is treated as empty (it is not a usable voice spec).
|
||||||
|
"""
|
||||||
|
spec = base_spec or job_voice
|
||||||
|
if spec == "__custom_mix":
|
||||||
|
spec = job_voice or ""
|
||||||
|
if not spec:
|
||||||
|
for key in voice_cache_keys:
|
||||||
|
if key and key != "__custom_mix":
|
||||||
|
spec = key.split(":", 1)[-1]
|
||||||
|
break
|
||||||
|
if not spec:
|
||||||
|
spec = get_default_voice(provider)
|
||||||
|
return spec
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Optional, Tuple, Set
|
||||||
|
|
||||||
|
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||||
|
from abogen.tts_plugin.utils import get_voices
|
||||||
|
|
||||||
|
|
||||||
|
def infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||||
|
"""Infer TTS provider from voice specification."""
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
if raw.upper() == raw and raw.replace("_", "").isalnum():
|
||||||
|
return "supertonic"
|
||||||
|
if raw == "__custom_mix" or "*" in raw or "+" in raw:
|
||||||
|
return "kokoro"
|
||||||
|
if raw in get_voices("kokoro"):
|
||||||
|
return "kokoro"
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
||||||
|
"""Normalize a voice specification for Supertonic.
|
||||||
|
|
||||||
|
This function only performs Supertonic-specific normalization (uppercase conversion
|
||||||
|
and fallback handling). Backend resolution is handled by the registry.
|
||||||
|
"""
|
||||||
|
raw = str(spec or "").strip()
|
||||||
|
fallback_raw = str(fallback or "").strip()
|
||||||
|
|
||||||
|
# Normalize to uppercase for Supertonic voice IDs
|
||||||
|
upper = raw.upper() if raw else ""
|
||||||
|
|
||||||
|
# If empty or contains formula characters, use fallback
|
||||||
|
if not upper or "*" in upper or "+" in upper:
|
||||||
|
upper = fallback_raw.upper() if fallback_raw else ""
|
||||||
|
|
||||||
|
# If still empty, use default Supertonic voice
|
||||||
|
if not upper or "*" in upper or "+" in upper:
|
||||||
|
upper = "M1"
|
||||||
|
|
||||||
|
return upper
|
||||||
|
|
||||||
|
|
||||||
|
def split_speaker_reference(value: Any) -> Tuple[Optional[str], str]:
|
||||||
|
"""Parse speaker/profile reference from string.
|
||||||
|
|
||||||
|
Expected format: "speaker:name" or "profile:name"
|
||||||
|
Returns (name, original) or (None, original) if not a valid reference.
|
||||||
|
"""
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw or ":" not in raw:
|
||||||
|
return None, raw
|
||||||
|
prefix, remainder = raw.split(":", 1)
|
||||||
|
prefix = prefix.strip().lower()
|
||||||
|
if prefix not in {"speaker", "profile"}:
|
||||||
|
return None, raw
|
||||||
|
name = remainder.strip()
|
||||||
|
return (name or None), raw
|
||||||
|
|
||||||
|
|
||||||
|
def formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
||||||
|
"""Build voice formula string from kokoro entry."""
|
||||||
|
voices = entry.get("voices") or []
|
||||||
|
if not voices:
|
||||||
|
return ""
|
||||||
|
total = 0.0
|
||||||
|
parts: list[tuple[str, float]] = []
|
||||||
|
for item in voices:
|
||||||
|
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
name = str(item[0] or "").strip()
|
||||||
|
try:
|
||||||
|
weight = float(item[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if name and weight > 0:
|
||||||
|
parts.append((name, weight))
|
||||||
|
total += weight
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
normalized = [(name, weight / total) for name, weight in parts]
|
||||||
|
return " + ".join(f"{name}*{weight:.6f}" for name, weight in normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_truthy(value: Any, default: bool = True) -> bool:
|
||||||
|
"""Coerce a value to boolean with default."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() not in {"false", "0", "no", "off", ""}
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return bool(value)
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Mapping, Sequence
|
||||||
|
|
||||||
|
import static_ffmpeg
|
||||||
|
|
||||||
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
|
from abogen.integrations.audiobookshelf import (
|
||||||
|
AudiobookshelfClient,
|
||||||
|
AudiobookshelfConfig,
|
||||||
|
AudiobookshelfUploadError,
|
||||||
|
)
|
||||||
|
from abogen.utils import create_process
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExportConfig:
|
||||||
|
"""Configuration for export operations."""
|
||||||
|
ffmpeg_path: str = "ffmpeg"
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ExportService:
|
||||||
|
"""Unified service for audiobook exports (M4B, FFMETADATA, EPUB3, Audiobookshelf)."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[ExportConfig] = None):
|
||||||
|
self.config = config or ExportConfig()
|
||||||
|
static_ffmpeg.add_paths()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# FFMETADATA
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_ffmetadata(
|
||||||
|
self,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Render FFMETADATA content."""
|
||||||
|
lines = [";FFMETADATA1"]
|
||||||
|
|
||||||
|
for key, value in (metadata or {}).items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
key_str = str(key).strip()
|
||||||
|
if not key_str:
|
||||||
|
continue
|
||||||
|
lines.append(f"{key_str}={self._escape_ffmetadata_value(value)}")
|
||||||
|
|
||||||
|
for chapter in chapters or []:
|
||||||
|
start = chapter.get("start")
|
||||||
|
end = chapter.get("end")
|
||||||
|
if start is None or end is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start_ms = max(0, int(round(float(start) * 1000)))
|
||||||
|
end_ms = int(round(float(end) * 1000))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if end_ms <= start_ms:
|
||||||
|
end_ms = start_ms + 1
|
||||||
|
lines.append("[CHAPTER]")
|
||||||
|
lines.append("TIMEBASE=1/1000")
|
||||||
|
lines.append(f"START={start_ms}")
|
||||||
|
lines.append(f"END={end_ms}")
|
||||||
|
title = chapter.get("title")
|
||||||
|
if title:
|
||||||
|
lines.append(f"title={self._escape_ffmetadata_value(title)}")
|
||||||
|
voice = chapter.get("voice")
|
||||||
|
if voice:
|
||||||
|
lines.append(f"voice={self._escape_ffmetadata_value(voice)}")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_ffmetadata_value(value: Any) -> str:
|
||||||
|
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
|
||||||
|
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
|
||||||
|
return escaped
|
||||||
|
|
||||||
|
def write_ffmetadata_file(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Write FFMETADATA file to temp location."""
|
||||||
|
content = self.render_ffmetadata(metadata, chapters)
|
||||||
|
if content.strip() == ";FFMETADATA1":
|
||||||
|
return None
|
||||||
|
|
||||||
|
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
suffix=".ffmeta",
|
||||||
|
delete=False,
|
||||||
|
dir=str(directory),
|
||||||
|
) as handle:
|
||||||
|
handle.write(content)
|
||||||
|
return Path(handle.name)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# M4B Export
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def embed_m4b_metadata(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
cover_mime: Optional[str] = None,
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
||||||
|
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||||
|
|
||||||
|
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
|
||||||
|
|
||||||
|
if ffmetadata_path:
|
||||||
|
cmd.extend(["-f", "ffmetadata", "-i", str(ffmetadata_path)])
|
||||||
|
|
||||||
|
if cover_path and cover_path.exists():
|
||||||
|
cmd.extend(["-i", str(cover_path)])
|
||||||
|
cmd.extend(["-map", "0:a"])
|
||||||
|
cmd.extend(["-map", "1:v:0", "-c:v:0", "mjpeg", "-disposition:v:0", "attached_pic"])
|
||||||
|
if cover_mime:
|
||||||
|
cmd.extend(["-metadata:s:v:0", f"mimetype={cover_mime}"])
|
||||||
|
cmd.extend(["-metadata:s:v:0", "title=Cover Art"])
|
||||||
|
else:
|
||||||
|
cmd.extend(["-map", "0:a"])
|
||||||
|
|
||||||
|
cmd.extend(["-c:a", "copy"])
|
||||||
|
|
||||||
|
if ffmetadata_path:
|
||||||
|
cmd.extend(["-map_metadata", "1", "-map_chapters", "1"])
|
||||||
|
else:
|
||||||
|
cmd.extend(["-map_metadata", "0"])
|
||||||
|
|
||||||
|
if metadata_args:
|
||||||
|
cmd.extend(metadata_args)
|
||||||
|
|
||||||
|
cmd.extend(["-movflags", "+faststart+use_metadata_tags"])
|
||||||
|
|
||||||
|
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
|
||||||
|
if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}:
|
||||||
|
cmd.extend(["-f", "mp4"])
|
||||||
|
cmd.append(str(temp_output))
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Embedding metadata into M4B output")
|
||||||
|
|
||||||
|
process = create_process(cmd, text=True)
|
||||||
|
return_code = process.wait()
|
||||||
|
|
||||||
|
if ffmetadata_path and ffmetadata_path.exists():
|
||||||
|
try:
|
||||||
|
ffmetadata_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if return_code != 0:
|
||||||
|
if temp_output.exists():
|
||||||
|
temp_output.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
|
||||||
|
|
||||||
|
temp_output.replace(audio_path)
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Embedded metadata and chapters into M4B output", "info")
|
||||||
|
|
||||||
|
# Apply chapters via Mutagen for better compatibility
|
||||||
|
self._apply_m4b_chapters_mutagen(audio_path, chapters, log_callback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
||||||
|
args = []
|
||||||
|
for key, value in (metadata or {}).items():
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
key_str = str(key).strip()
|
||||||
|
if not key_str:
|
||||||
|
continue
|
||||||
|
normalized_key = key_str.lower()
|
||||||
|
if normalized_key == "year":
|
||||||
|
ffmpeg_key = "date"
|
||||||
|
else:
|
||||||
|
ffmpeg_key = key_str
|
||||||
|
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||||
|
return args
|
||||||
|
|
||||||
|
def _apply_m4b_chapters_mutagen(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply chapter atoms using Mutagen."""
|
||||||
|
if not chapters:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fractions import Fraction
|
||||||
|
from mutagen.mp4 import MP4, MP4Chapter
|
||||||
|
except ImportError:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Unable to write MP4 chapter atoms because mutagen is not installed.", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
mp4 = MP4(str(audio_path))
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to open m4b for chapter embedding: {exc}", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
chapter_objects = []
|
||||||
|
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||||
|
start_raw = entry.get("start")
|
||||||
|
if start_raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start_seconds = max(0.0, float(start_raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title_value = entry.get("title")
|
||||||
|
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||||
|
|
||||||
|
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||||
|
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||||
|
|
||||||
|
end_raw = entry.get("end")
|
||||||
|
if end_raw is not None:
|
||||||
|
try:
|
||||||
|
end_seconds = float(end_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
end_seconds = None
|
||||||
|
if end_seconds is not None and end_seconds > start_seconds:
|
||||||
|
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||||
|
|
||||||
|
chapter_objects.append(chapter_atom)
|
||||||
|
|
||||||
|
if not chapter_objects:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
mp4.chapters = chapter_objects
|
||||||
|
mp4.save()
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to persist MP4 chapter atoms: {exc}", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Applied {len(chapter_objects)} chapter markers via mutagen", "info")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# EPUB3 Export
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def export_epub3(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
book_id: str,
|
||||||
|
extraction: Any, # ExtractionResult
|
||||||
|
metadata_tags: Dict[str, Any],
|
||||||
|
chapter_markers: Sequence[Dict[str, Any]],
|
||||||
|
chunk_markers: Sequence[Dict[str, Any]],
|
||||||
|
chunks: Iterable[Dict[str, Any]],
|
||||||
|
audio_path: Path,
|
||||||
|
speaker_mode: str = "single",
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
cover_mime: Optional[str] = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export EPUB3 with media overlays."""
|
||||||
|
return build_epub3_package(
|
||||||
|
output_path=output_path,
|
||||||
|
book_id=book_id,
|
||||||
|
extraction=extraction,
|
||||||
|
metadata_tags=metadata_tags,
|
||||||
|
chapter_markers=chapter_markers,
|
||||||
|
chunk_markers=chunk_markers,
|
||||||
|
chunks=chunks,
|
||||||
|
audio_path=audio_path,
|
||||||
|
speaker_mode=speaker_mode,
|
||||||
|
cover_image_path=cover_path,
|
||||||
|
cover_image_mime=cover_mime,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Audiobookshelf Integration
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
||||||
|
"""Build Audiobookshelf metadata from job."""
|
||||||
|
tags = self._normalize_metadata_casefold(getattr(job, "metadata_tags", {}))
|
||||||
|
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
||||||
|
|
||||||
|
title = self._first_nonempty(
|
||||||
|
tags.get("title"),
|
||||||
|
tags.get("book_title"),
|
||||||
|
tags.get("name"),
|
||||||
|
tags.get("album"),
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
authors = self._split_people_field(
|
||||||
|
tags.get("authors")
|
||||||
|
or tags.get("author")
|
||||||
|
or tags.get("album_artist")
|
||||||
|
or tags.get("artist")
|
||||||
|
)
|
||||||
|
narrators = self._split_people_field(tags.get("narrators") or tags.get("narrator"))
|
||||||
|
description = self._first_nonempty(
|
||||||
|
tags.get("description"), tags.get("summary"), tags.get("comment")
|
||||||
|
)
|
||||||
|
genres = self._split_simple_list(tags.get("genre"))
|
||||||
|
keywords = self._split_simple_list(tags.get("tags") or tags.get("keywords"))
|
||||||
|
language = self._first_nonempty(tags.get("language"), tags.get("lang")) or getattr(job, "language", "") or ""
|
||||||
|
series_name = self._first_nonempty(
|
||||||
|
tags.get("series"),
|
||||||
|
tags.get("series_name"),
|
||||||
|
tags.get("seriesname"),
|
||||||
|
tags.get("series_title"),
|
||||||
|
tags.get("seriestitle"),
|
||||||
|
)
|
||||||
|
|
||||||
|
series_sequence = None
|
||||||
|
for key in ("series_index", "series_position", "series_sequence", "series_number", "seriesnumber", "book_number", "booknumber"):
|
||||||
|
raw = tags.get(key)
|
||||||
|
normalized = self._normalize_series_sequence(raw)
|
||||||
|
if normalized:
|
||||||
|
series_sequence = normalized
|
||||||
|
break
|
||||||
|
if not series_name:
|
||||||
|
series_sequence = None
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"subtitle": tags.get("subtitle"),
|
||||||
|
"authors": authors,
|
||||||
|
"narrators": narrators,
|
||||||
|
"description": description,
|
||||||
|
"publisher": tags.get("publisher"),
|
||||||
|
"genres": genres,
|
||||||
|
"tags": keywords,
|
||||||
|
"language": language,
|
||||||
|
"publishedYear": self._extract_year(
|
||||||
|
tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")
|
||||||
|
),
|
||||||
|
"seriesName": series_name,
|
||||||
|
"seriesSequence": series_sequence,
|
||||||
|
"isbn": self._first_nonempty(tags.get("isbn"), tags.get("asin")),
|
||||||
|
}
|
||||||
|
|
||||||
|
published_date = self._first_nonempty(
|
||||||
|
tags.get("published"), tags.get("publication_date"), tags.get("date")
|
||||||
|
)
|
||||||
|
if published_date:
|
||||||
|
data["publishedDate"] = published_date
|
||||||
|
|
||||||
|
rating_text = self._first_nonempty(tags.get("rating"), tags.get("my_rating"))
|
||||||
|
if rating_text:
|
||||||
|
try:
|
||||||
|
data["rating"] = float(str(rating_text).strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
rating_max_text = self._first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
|
||||||
|
if rating_max_text:
|
||||||
|
try:
|
||||||
|
data["ratingMax"] = float(str(rating_max_text).strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove empty values
|
||||||
|
cleaned = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple)) and not value:
|
||||||
|
continue
|
||||||
|
cleaned[key] = value
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""Load chapters from job artifacts for Audiobookshelf."""
|
||||||
|
metadata_ref = job.result.artifacts.get("metadata") if getattr(job, "result", None) else None
|
||||||
|
if not metadata_ref:
|
||||||
|
return None
|
||||||
|
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
chapters = payload.get("chapters")
|
||||||
|
if not isinstance(chapters, list):
|
||||||
|
return None
|
||||||
|
cleaned = []
|
||||||
|
for entry in chapters:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
title = self._first_nonempty(entry.get("title"), entry.get("original_title"))
|
||||||
|
start = entry.get("start")
|
||||||
|
end = entry.get("end")
|
||||||
|
if title is None or not isinstance(start, (int, float)):
|
||||||
|
continue
|
||||||
|
chapter_payload = {"title": title, "start": float(start)}
|
||||||
|
if isinstance(end, (int, float)):
|
||||||
|
chapter_payload["end"] = float(end)
|
||||||
|
cleaned.append(chapter_payload)
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
def upload_audiobookshelf(
|
||||||
|
self,
|
||||||
|
job: Any,
|
||||||
|
audio_path: Path,
|
||||||
|
subtitle_paths: List[Path],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
config: Optional[AudiobookshelfConfig] = None,
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Upload to Audiobookshelf."""
|
||||||
|
if config is None:
|
||||||
|
# Load from job or global config
|
||||||
|
cfg = getattr(job, "_abs_config", None)
|
||||||
|
if cfg is None:
|
||||||
|
from abogen.utils import load_config
|
||||||
|
global_cfg = load_config() or {}
|
||||||
|
abs_cfg = global_cfg.get("audiobookshelf")
|
||||||
|
if isinstance(abs_cfg, Mapping):
|
||||||
|
config = AudiobookshelfConfig(
|
||||||
|
base_url=str(abs_cfg.get("base_url") or "").strip(),
|
||||||
|
api_token=str(abs_cfg.get("api_token") or "").strip(),
|
||||||
|
library_id=str(abs_cfg.get("library_id") or "").strip(),
|
||||||
|
collection_id=(str(abs_cfg.get("collection_id") or "").strip() or None),
|
||||||
|
folder_id=str(abs_cfg.get("folder_id") or "").strip(),
|
||||||
|
verify_ssl=self._coerce_bool(abs_cfg.get("verify_ssl"), True),
|
||||||
|
send_cover=self._coerce_bool(abs_cfg.get("send_cover"), True),
|
||||||
|
send_chapters=self._coerce_bool(abs_cfg.get("send_chapters"), True),
|
||||||
|
send_subtitles=self._coerce_bool(abs_cfg.get("send_subtitles"), False),
|
||||||
|
timeout=float(abs_cfg.get("timeout", 3600.0)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: not configured", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not config.base_url or not config.api_token or not config.library_id:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
|
||||||
|
return
|
||||||
|
if not config.folder_id:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not audio_path.exists():
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_subtitles = [p for p in subtitle_paths if p.exists()] if config.send_subtitles else None
|
||||||
|
chapters_to_send = chapters if config.send_chapters else None
|
||||||
|
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
|
||||||
|
display_title = metadata.get("title") or audio_path.stem
|
||||||
|
try:
|
||||||
|
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Audiobookshelf lookup failed: {exc}", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if existing_items:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", "info")
|
||||||
|
try:
|
||||||
|
client.delete_items(existing_items)
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to remove existing item(s): {exc}", "warning")
|
||||||
|
|
||||||
|
cover_to_send = cover_path
|
||||||
|
if config.send_cover and cover_to_send:
|
||||||
|
if isinstance(cover_to_send, str):
|
||||||
|
cover_to_send = Path(cover_to_send)
|
||||||
|
if not cover_to_send.exists():
|
||||||
|
cover_to_send = None
|
||||||
|
|
||||||
|
client.upload_audiobook(
|
||||||
|
audio_path,
|
||||||
|
metadata=metadata,
|
||||||
|
cover_path=cover_to_send,
|
||||||
|
chapters=chapters_to_send,
|
||||||
|
subtitles=existing_subtitles,
|
||||||
|
)
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload queued.", "info")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||||
|
normalized = {}
|
||||||
|
if not values:
|
||||||
|
return normalized
|
||||||
|
for key, value in values.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
key_text = str(key).strip().lower()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
normalized[key_text] = value
|
||||||
|
else:
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
normalized[key_text] = text
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_people_field(raw: Any) -> List[str]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple, set)):
|
||||||
|
results = []
|
||||||
|
for item in raw:
|
||||||
|
results.extend(ExportService._split_people_field(item))
|
||||||
|
return results
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
import re
|
||||||
|
tokens = [token.strip() for token in re.split(r"[;,/&]|\band\b", text, flags=re.IGNORECASE) if token.strip()]
|
||||||
|
seen = set()
|
||||||
|
ordered = []
|
||||||
|
for token in tokens:
|
||||||
|
key = token.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ordered.append(token)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_simple_list(raw: Any) -> List[str]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple, set)):
|
||||||
|
results = []
|
||||||
|
for item in raw:
|
||||||
|
results.extend(ExportService._split_simple_list(item))
|
||||||
|
return results
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
import re
|
||||||
|
tokens = [token.strip() for token in re.split(r"[;,\n]", text) if token.strip()]
|
||||||
|
seen = set()
|
||||||
|
ordered = []
|
||||||
|
for token in tokens:
|
||||||
|
key = token.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ordered.append(token)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _first_nonempty(*values: Any) -> Optional[str]:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
items = list(value)
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
value = items[0]
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_year(raw: Optional[str]) -> Optional[int]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
import re
|
||||||
|
match = re.search(r"(19|20)\d{2}", text)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return int(match.group(0))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if 0 < parsed < 3000:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_series_sequence(raw: Any) -> Optional[str]:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
if isinstance(raw, float) and (raw != raw or raw == float("inf") or raw == float("-inf")):
|
||||||
|
return None
|
||||||
|
text = str(raw)
|
||||||
|
else:
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
candidate = text.replace(",", ".")
|
||||||
|
import re
|
||||||
|
match = re.search(r"\d+(?:\.\d+)?", candidate)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
normalized = match.group(0)
|
||||||
|
if "." in normalized:
|
||||||
|
normalized = normalized.rstrip("0").rstrip(".")
|
||||||
|
return normalized or "0"
|
||||||
|
try:
|
||||||
|
return str(int(normalized))
|
||||||
|
except ValueError:
|
||||||
|
cleaned = normalized.lstrip("0")
|
||||||
|
return cleaned or "0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
if lowered in {"true", "1", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if lowered in {"false", "0", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExportConfig",
|
||||||
|
"ExportService",
|
||||||
|
]
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, TextIO
|
||||||
|
|
||||||
|
from abogen.subtitle_utils import clean_subtitle_text
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleFormat(Enum):
|
||||||
|
SRT = "srt"
|
||||||
|
ASS = "ass"
|
||||||
|
VTT = "vtt"
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleMode(Enum):
|
||||||
|
DISABLED = "Disabled"
|
||||||
|
LINE = "Line"
|
||||||
|
SENTENCE = "Sentence"
|
||||||
|
SENTENCE_COMMA = "Sentence + Comma"
|
||||||
|
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleAlignment(Enum):
|
||||||
|
LEFT = "left"
|
||||||
|
CENTER = "center"
|
||||||
|
NARROW = "narrow"
|
||||||
|
CENTER_NARROW = "center_narrow"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SubtitleConfig:
|
||||||
|
"""Configuration for subtitle writer."""
|
||||||
|
format: SubtitleFormat
|
||||||
|
mode: SubtitleMode
|
||||||
|
alignment: SubtitleAlignment = SubtitleAlignment.LEFT
|
||||||
|
max_words: int = 50
|
||||||
|
highlight_color: str = "&H00FFFF00" # ASS highlight color
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleWriter(ABC):
|
||||||
|
"""Abstract base class for subtitle writers."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, config: SubtitleConfig):
|
||||||
|
self.path = path
|
||||||
|
self.config = config
|
||||||
|
self._file: Optional[TextIO] = None
|
||||||
|
self._index = 0
|
||||||
|
self._opened = False
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Open the subtitle file and write header."""
|
||||||
|
if self._opened:
|
||||||
|
return
|
||||||
|
self._file = open(self.path, "w", encoding="utf-8", errors="replace")
|
||||||
|
self._write_header()
|
||||||
|
self._opened = True
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _write_header(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def write_entry(
|
||||||
|
self,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write a subtitle entry."""
|
||||||
|
if not self._opened:
|
||||||
|
self.open()
|
||||||
|
|
||||||
|
text = clean_subtitle_text(text)
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._index += 1
|
||||||
|
self._write_entry(self._index, start, end, text, voice)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _write_entry(
|
||||||
|
self,
|
||||||
|
index: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the subtitle file."""
|
||||||
|
if self._file:
|
||||||
|
self._file.close()
|
||||||
|
self._file = None
|
||||||
|
self._opened = False
|
||||||
|
|
||||||
|
def __enter__(self) -> "SubtitleWriter":
|
||||||
|
self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SrtWriter(SubtitleWriter):
|
||||||
|
"""SRT subtitle writer."""
|
||||||
|
|
||||||
|
def _write_header(self) -> None:
|
||||||
|
pass # SRT has no header
|
||||||
|
|
||||||
|
def _write_entry(
|
||||||
|
self,
|
||||||
|
index: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
start_str = self._format_time(start)
|
||||||
|
end_str = self._format_time(end)
|
||||||
|
|
||||||
|
if voice:
|
||||||
|
text = f"[{voice}] {text}"
|
||||||
|
|
||||||
|
self._file.write(f"{index}\n")
|
||||||
|
self._file.write(f"{start_str} --> {end_str}\n")
|
||||||
|
self._file.write(f"{text}\n\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_time(seconds: float) -> str:
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
millis = int((seconds - int(seconds)) * 1000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
class VttWriter(SubtitleWriter):
|
||||||
|
"""WebVTT subtitle writer."""
|
||||||
|
|
||||||
|
def _write_header(self) -> None:
|
||||||
|
self._file.write("WEBVTT\n\n")
|
||||||
|
|
||||||
|
def _write_entry(
|
||||||
|
self,
|
||||||
|
index: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
start_str = self._format_time(start)
|
||||||
|
end_str = self._format_time(end)
|
||||||
|
|
||||||
|
if voice:
|
||||||
|
text = f"[{voice}] {text}"
|
||||||
|
|
||||||
|
self._file.write(f"{index}\n")
|
||||||
|
self._file.write(f"{start_str} --> {end_str}\n")
|
||||||
|
self._file.write(f"{text}\n\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_time(seconds: float) -> str:
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = seconds % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ".")
|
||||||
|
|
||||||
|
|
||||||
|
class AssWriter(SubtitleWriter):
|
||||||
|
"""ASS subtitle writer with karaoke highlighting support."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, config: SubtitleConfig):
|
||||||
|
super().__init__(path, config)
|
||||||
|
self._is_centered = config.alignment in (SubtitleAlignment.CENTER, SubtitleAlignment.CENTER_NARROW)
|
||||||
|
self._is_narrow = config.alignment in (SubtitleAlignment.NARROW, SubtitleAlignment.CENTER_NARROW)
|
||||||
|
|
||||||
|
def _write_header(self) -> None:
|
||||||
|
margin = "90" if self._is_narrow else "10"
|
||||||
|
alignment = "5" if self._is_centered else "2"
|
||||||
|
|
||||||
|
self._file.write("[Script Info]\n")
|
||||||
|
self._file.write("Title: Generated by Abogen\n")
|
||||||
|
self._file.write("ScriptType: v4.00+\n\n")
|
||||||
|
|
||||||
|
# Styles
|
||||||
|
self._file.write("[V4+ Styles]\n")
|
||||||
|
self._file.write(
|
||||||
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
|
||||||
|
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
|
||||||
|
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
|
||||||
|
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||||
|
# Karaoke style with highlighting
|
||||||
|
self._file.write(
|
||||||
|
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||||
|
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n"
|
||||||
|
)
|
||||||
|
self._file.write(
|
||||||
|
f"Style: Highlight,Arial,24,&H0000FFFF,&H00808080,&H00000000,&H00404040,"
|
||||||
|
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._file.write(
|
||||||
|
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||||
|
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._file.write("[Events]\n")
|
||||||
|
self._file.write(
|
||||||
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_entry(
|
||||||
|
self,
|
||||||
|
index: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
start_str = self._format_time(start)
|
||||||
|
end_str = self._format_time(end)
|
||||||
|
|
||||||
|
if voice:
|
||||||
|
text = f"[{voice}] {text}"
|
||||||
|
|
||||||
|
style = "Default"
|
||||||
|
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||||
|
# Add karaoke tags for highlighting
|
||||||
|
text = self._add_karaoke_tags(text)
|
||||||
|
style = "Highlight"
|
||||||
|
|
||||||
|
alignment_tag = r"{\an5}" if self._is_centered else ""
|
||||||
|
self._file.write(
|
||||||
|
f"Dialogue: 0,{start_str},{end_str},{style},,0,0,0,,{alignment_tag}{text}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_karaoke_tags(self, text: str) -> str:
|
||||||
|
"""Add karaoke highlighting tags to text."""
|
||||||
|
# Simple word-level karaoke timing
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# This is a simplified version - real karaoke needs per-word timing
|
||||||
|
# For now, just return the text with the highlight color
|
||||||
|
return r"{\k100}" + r"{\k100}".join(words) + r"{\k0}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_time(seconds: float) -> str:
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
minutes = int((seconds % 3600) // 60)
|
||||||
|
secs = seconds % 60
|
||||||
|
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_subtitle_writer(
|
||||||
|
path: Path,
|
||||||
|
format: str,
|
||||||
|
mode: str,
|
||||||
|
alignment: str = "left",
|
||||||
|
max_words: int = 50,
|
||||||
|
) -> SubtitleWriter:
|
||||||
|
"""Factory function to create subtitle writer."""
|
||||||
|
fmt = SubtitleFormat(format.lower())
|
||||||
|
mode = SubtitleMode(mode)
|
||||||
|
align = SubtitleAlignment(alignment.lower())
|
||||||
|
|
||||||
|
config = SubtitleConfig(
|
||||||
|
format=fmt,
|
||||||
|
mode=mode,
|
||||||
|
alignment=align,
|
||||||
|
max_words=max_words,
|
||||||
|
)
|
||||||
|
|
||||||
|
if fmt == SubtitleFormat.SRT:
|
||||||
|
return SrtWriter(path, config)
|
||||||
|
elif fmt == SubtitleFormat.VTT:
|
||||||
|
return VttWriter(path, config)
|
||||||
|
elif fmt == SubtitleFormat.ASS:
|
||||||
|
return AssWriter(path, config)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported subtitle format: {format}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SubtitleFormat",
|
||||||
|
"SubtitleMode",
|
||||||
|
"SubtitleAlignment",
|
||||||
|
"SubtitleConfig",
|
||||||
|
"SubtitleWriter",
|
||||||
|
"SrtWriter",
|
||||||
|
"VttWriter",
|
||||||
|
"AssWriter",
|
||||||
|
"create_subtitle_writer",
|
||||||
|
]
|
||||||
+68
-181
@@ -14,7 +14,6 @@ from abogen.utils import (
|
|||||||
)
|
)
|
||||||
from abogen.constants import (
|
from abogen.constants import (
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
SAMPLE_VOICE_TEXTS,
|
|
||||||
COLORS,
|
COLORS,
|
||||||
CHAPTER_OPTIONS_COUNTDOWN,
|
CHAPTER_OPTIONS_COUNTDOWN,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
@@ -22,11 +21,18 @@ from abogen.constants import (
|
|||||||
SUPPORTED_SUBTITLE_FORMATS,
|
SUPPORTED_SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
from abogen.voice_formulas import get_new_voice
|
from abogen.voice_formulas import get_new_voice
|
||||||
|
from abogen.infrastructure.subtitle_writer import _format_timestamp
|
||||||
|
from abogen.domain.split_pattern import get_split_pattern
|
||||||
|
from abogen.domain.output_paths import (
|
||||||
|
resolve_output_directory,
|
||||||
|
build_output_path,
|
||||||
|
sanitize_output_stem,
|
||||||
|
)
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
|
||||||
import abogen.hf_tracker as hf_tracker
|
import abogen.hf_tracker as hf_tracker
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
import threading # for efficient waiting
|
import threading # for efficient waiting
|
||||||
import subprocess
|
import subprocess
|
||||||
import platform
|
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
_USER_RESPONSE_TIMEOUT = (
|
_USER_RESPONSE_TIMEOUT = (
|
||||||
@@ -43,10 +49,7 @@ from abogen.subtitle_utils import (
|
|||||||
get_sample_voice_text,
|
get_sample_voice_text,
|
||||||
sanitize_name_for_os,
|
sanitize_name_for_os,
|
||||||
_CHAPTER_MARKER_SEARCH_PATTERN,
|
_CHAPTER_MARKER_SEARCH_PATTERN,
|
||||||
_VOICE_MARKER_PATTERN,
|
split_text_by_voice_markers
|
||||||
_VOICE_MARKER_SEARCH_PATTERN,
|
|
||||||
split_text_by_voice_markers,
|
|
||||||
validate_voice_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class CountdownDialog(QDialog):
|
class CountdownDialog(QDialog):
|
||||||
@@ -216,40 +219,6 @@ class ConversionThread(QThread):
|
|||||||
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
|
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
|
||||||
PUNCTUATION_COMMAS = ",,、"
|
PUNCTUATION_COMMAS = ",,、"
|
||||||
|
|
||||||
def _get_split_pattern(self, lang_code, subtitle_mode):
|
|
||||||
"""
|
|
||||||
Get the appropriate split pattern based on language and subtitle mode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lang_code: Language code (a, b, e, f, etc.)
|
|
||||||
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Split pattern string
|
|
||||||
"""
|
|
||||||
# For English, always use newline splitting only
|
|
||||||
if lang_code in ["a", "b"]:
|
|
||||||
return "\n"
|
|
||||||
|
|
||||||
# Determine spacing pattern based on language
|
|
||||||
spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+"
|
|
||||||
|
|
||||||
# For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer
|
|
||||||
# punctuation-based splitting instead of plain newline splitting.
|
|
||||||
if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]:
|
|
||||||
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
|
|
||||||
|
|
||||||
if subtitle_mode == "Line":
|
|
||||||
return "\n"
|
|
||||||
elif subtitle_mode == "Sentence":
|
|
||||||
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
|
|
||||||
elif subtitle_mode == "Sentence + Comma":
|
|
||||||
return r"(?<=[{}]){}|\n+".format(
|
|
||||||
self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return r"\n+" # Default to line breaks
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
file_name,
|
file_name,
|
||||||
@@ -298,7 +267,7 @@ class ConversionThread(QThread):
|
|||||||
self.silence_duration = 2.0 # Default value, will be overridden from GUI
|
self.silence_duration = 2.0 # Default value, will be overridden from GUI
|
||||||
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
||||||
# Set split pattern based on language and subtitle mode
|
# Set split pattern based on language and subtitle mode
|
||||||
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
|
self.split_pattern = get_split_pattern(lang_code, subtitle_mode)
|
||||||
self.voice_cache = {} # Cache for loaded voices
|
self.voice_cache = {} # Cache for loaded voices
|
||||||
|
|
||||||
def load_voice_cached(self, voice_name, tts):
|
def load_voice_cached(self, voice_name, tts):
|
||||||
@@ -676,15 +645,17 @@ class ConversionThread(QThread):
|
|||||||
base_path = self.display_path if self.display_path else self.file_name
|
base_path = self.display_path if self.display_path else self.file_name
|
||||||
|
|
||||||
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
||||||
# Sanitize base_name for folder/file creation based on OS
|
|
||||||
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
|
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
|
||||||
|
|
||||||
if self.save_option == "Save to Desktop":
|
parent_dir = resolve_output_directory(
|
||||||
parent_dir = user_desktop_dir()
|
save_mode=self.save_option,
|
||||||
elif self.save_option == "Save next to input file":
|
stored_path=Path(base_path),
|
||||||
parent_dir = os.path.dirname(base_path)
|
output_folder=getattr(self, "output_folder", None),
|
||||||
else:
|
desktop_dir=Path(user_desktop_dir()),
|
||||||
parent_dir = self.output_folder or os.getcwd()
|
user_output_path=None,
|
||||||
|
user_cache_outputs=Path(os.getcwd()),
|
||||||
|
)
|
||||||
|
parent_dir = str(parent_dir)
|
||||||
# Ensure the output folder exists, error if it doesn't
|
# Ensure the output folder exists, error if it doesn't
|
||||||
if not os.path.exists(parent_dir):
|
if not os.path.exists(parent_dir):
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
@@ -754,77 +725,40 @@ class ConversionThread(QThread):
|
|||||||
format=self.output_format,
|
format=self.output_format,
|
||||||
)
|
)
|
||||||
ffmpeg_proc = None
|
ffmpeg_proc = None
|
||||||
elif self.output_format == "m4b":
|
elif self.output_format in ("m4b", "opus"):
|
||||||
# Real-time M4B generation using FFmpeg pipe
|
# Real-time generation using FFmpeg pipe
|
||||||
static_ffmpeg.add_paths()
|
static_ffmpeg.add_paths()
|
||||||
merged_out_file = None
|
merged_out_file = None
|
||||||
ffmpeg_proc = None
|
ffmpeg_proc = None
|
||||||
metadata_options, cover_path = (
|
metadata_options, cover_path = (
|
||||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||||
|
if self.output_format == "m4b"
|
||||||
|
else ([], None)
|
||||||
)
|
)
|
||||||
# Prepare ffmpeg command for m4b output
|
cmd = build_ffmpeg_command(
|
||||||
cmd = [
|
Path(merged_out_path),
|
||||||
"ffmpeg",
|
self.output_format,
|
||||||
"-y",
|
|
||||||
"-thread_queue_size",
|
|
||||||
"32768",
|
|
||||||
"-f",
|
|
||||||
"f32le",
|
|
||||||
"-ar",
|
|
||||||
"24000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
]
|
|
||||||
if cover_path and os.path.exists(cover_path):
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"-i",
|
|
||||||
cover_path,
|
|
||||||
"-map",
|
|
||||||
"0:a",
|
|
||||||
"-map",
|
|
||||||
"1",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-disposition:v",
|
|
||||||
"attached_pic",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-q:a",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart+use_metadata_tags",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
cmd += metadata_options
|
# Insert thread queue size after ffmpeg header
|
||||||
cmd.append(merged_out_path)
|
cmd.insert(2, "-thread_queue_size")
|
||||||
|
cmd.insert(3, "32768")
|
||||||
|
if self.output_format == "m4b" and cover_path and os.path.exists(cover_path):
|
||||||
|
# Insert cover image input before the output path
|
||||||
|
output_path = cmd.pop()
|
||||||
|
cmd.extend([
|
||||||
|
"-i", cover_path,
|
||||||
|
"-map", "0:a",
|
||||||
|
"-map", "1",
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-disposition:v", "attached_pic",
|
||||||
|
])
|
||||||
|
cmd.extend(metadata_options)
|
||||||
|
cmd.append(output_path)
|
||||||
|
elif self.output_format == "m4b":
|
||||||
|
output_path = cmd.pop()
|
||||||
|
cmd.extend(metadata_options)
|
||||||
|
cmd.append(output_path)
|
||||||
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||||
elif self.output_format == "opus":
|
|
||||||
static_ffmpeg.add_paths()
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-thread_queue_size",
|
|
||||||
"32768",
|
|
||||||
"-f",
|
|
||||||
"f32le",
|
|
||||||
"-ar",
|
|
||||||
"24000",
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
]
|
|
||||||
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
|
|
||||||
cmd.append(merged_out_path)
|
|
||||||
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
|
||||||
merged_out_file = None
|
|
||||||
else:
|
else:
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
(f"Unsupported output format: {self.output_format}", "red")
|
(f"Unsupported output format: {self.output_format}", "red")
|
||||||
@@ -1258,8 +1192,8 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
if "ass" in subtitle_format:
|
if "ass" in subtitle_format:
|
||||||
for start, end, text in new_entries:
|
for start, end, text in new_entries:
|
||||||
start_time = self._ass_time(start)
|
start_time = _format_timestamp(start, ass=True)
|
||||||
end_time = self._ass_time(end)
|
end_time = _format_timestamp(end, ass=True)
|
||||||
# Use karaoke effect for highlighting mode
|
# Use karaoke effect for highlighting mode
|
||||||
effect = (
|
effect = (
|
||||||
"karaoke"
|
"karaoke"
|
||||||
@@ -1274,7 +1208,7 @@ class ConversionThread(QThread):
|
|||||||
for entry in new_entries:
|
for entry in new_entries:
|
||||||
start, end, text = entry
|
start, end, text = entry
|
||||||
merged_subtitle_file.write(
|
merged_subtitle_file.write(
|
||||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
f"{merged_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
|
||||||
)
|
)
|
||||||
merged_srt_index += 1
|
merged_srt_index += 1
|
||||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||||
@@ -1292,8 +1226,8 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
if "ass" in subtitle_format:
|
if "ass" in subtitle_format:
|
||||||
for start, end, text in new_chapter_entries:
|
for start, end, text in new_chapter_entries:
|
||||||
start_time = self._ass_time(start)
|
start_time = _format_timestamp(start, ass=True)
|
||||||
end_time = self._ass_time(end)
|
end_time = _format_timestamp(end, ass=True)
|
||||||
# Use karaoke effect for highlighting mode
|
# Use karaoke effect for highlighting mode
|
||||||
effect = (
|
effect = (
|
||||||
"karaoke"
|
"karaoke"
|
||||||
@@ -1308,7 +1242,7 @@ class ConversionThread(QThread):
|
|||||||
for entry in new_chapter_entries:
|
for entry in new_chapter_entries:
|
||||||
start, end, text = entry
|
start, end, text = entry
|
||||||
chapter_subtitle_file.write(
|
chapter_subtitle_file.write(
|
||||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
f"{chapter_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
|
||||||
)
|
)
|
||||||
chapter_srt_index += 1
|
chapter_srt_index += 1
|
||||||
if merge_chapters_at_end:
|
if merge_chapters_at_end:
|
||||||
@@ -1597,58 +1531,27 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
static_ffmpeg.add_paths()
|
static_ffmpeg.add_paths()
|
||||||
cmd = [
|
cmd = build_ffmpeg_command(
|
||||||
"ffmpeg",
|
Path(merged_out_path),
|
||||||
"-y",
|
self.output_format,
|
||||||
"-thread_queue_size",
|
)
|
||||||
"32768",
|
cmd.insert(2, "-thread_queue_size")
|
||||||
"-f",
|
cmd.insert(3, "32768")
|
||||||
"f32le",
|
|
||||||
"-ar",
|
|
||||||
str(rate),
|
|
||||||
"-ac",
|
|
||||||
"1",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
]
|
|
||||||
if self.output_format == "m4b":
|
if self.output_format == "m4b":
|
||||||
metadata_options, cover_path = (
|
metadata_options, cover_path = (
|
||||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||||
)
|
)
|
||||||
if cover_path and os.path.exists(cover_path):
|
if cover_path and os.path.exists(cover_path):
|
||||||
cmd.extend(
|
output_path = cmd.pop()
|
||||||
[
|
cmd.extend([
|
||||||
"-i",
|
"-i", cover_path,
|
||||||
cover_path,
|
"-map", "0:a",
|
||||||
"-map",
|
"-map", "1",
|
||||||
"0:a",
|
"-c:v", "copy",
|
||||||
"-map",
|
"-disposition:v", "attached_pic",
|
||||||
"1",
|
])
|
||||||
"-c:v",
|
cmd.append(output_path)
|
||||||
"copy",
|
|
||||||
"-disposition:v",
|
|
||||||
"attached_pic",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cmd.extend(
|
|
||||||
[
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-q:a",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart+use_metadata_tags",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
cmd.extend(metadata_options)
|
cmd.extend(metadata_options)
|
||||||
elif self.output_format == "opus":
|
|
||||||
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
|
|
||||||
else:
|
|
||||||
self.log_updated.emit(
|
|
||||||
(f"Unsupported output format: {self.output_format}", "red")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
cmd.append(merged_out_path)
|
|
||||||
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||||
|
|
||||||
# Always generate subtitles for subtitle input files
|
# Always generate subtitles for subtitle input files
|
||||||
@@ -1938,11 +1841,11 @@ class ConversionThread(QThread):
|
|||||||
else processed_text.replace("\n", "\\N")
|
else processed_text.replace("\n", "\\N")
|
||||||
)
|
)
|
||||||
subtitle_file.write(
|
subtitle_file.write(
|
||||||
f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
|
f"Dialogue: 0,{_format_timestamp(start_time, ass=True)},{_format_timestamp(end_time, ass=True)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
subtitle_file.write(
|
subtitle_file.write(
|
||||||
f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n"
|
f"{srt_index}\n{_format_timestamp(start_time)} --> {_format_timestamp(end_time)}\n{processed_text}\n\n"
|
||||||
)
|
)
|
||||||
srt_index += 1
|
srt_index += 1
|
||||||
|
|
||||||
@@ -2104,22 +2007,6 @@ class ConversionThread(QThread):
|
|||||||
# Add these to ffmpeg command
|
# Add these to ffmpeg command
|
||||||
return metadata_options, cover_path
|
return metadata_options, cover_path
|
||||||
|
|
||||||
def _srt_time(self, t):
|
|
||||||
"""Helper function to format time for SRT files"""
|
|
||||||
h = int(t // 3600)
|
|
||||||
m = int((t % 3600) // 60)
|
|
||||||
s = int(t % 60)
|
|
||||||
ms = int((t - int(t)) * 1000)
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
||||||
|
|
||||||
def _ass_time(self, t):
|
|
||||||
"""Helper function to format time for ASS files"""
|
|
||||||
h = int(t // 3600)
|
|
||||||
m = int((t % 3600) // 60)
|
|
||||||
s = int(t % 60)
|
|
||||||
cs = int((t - int(t)) * 100) # Centiseconds for ASS format
|
|
||||||
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
|
||||||
|
|
||||||
def _process_subtitle_tokens(
|
def _process_subtitle_tokens(
|
||||||
self,
|
self,
|
||||||
tokens_with_timestamps,
|
tokens_with_timestamps,
|
||||||
|
|||||||
+3
-8
@@ -7,6 +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 (
|
||||||
@@ -2428,10 +2429,7 @@ class abogen(QWidget):
|
|||||||
|
|
||||||
# Determine device based on GPU availability
|
# Determine device based on GPU availability
|
||||||
if gpu_ok:
|
if gpu_ok:
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
device = _select_device()
|
||||||
device = "mps"
|
|
||||||
else:
|
|
||||||
device = "cuda"
|
|
||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
@@ -2877,10 +2875,7 @@ class abogen(QWidget):
|
|||||||
|
|
||||||
# Determine device based on GPU availability
|
# Determine device based on GPU availability
|
||||||
if self.gpu_ok:
|
if self.gpu_ok:
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
device = _select_device()
|
||||||
device = "mps"
|
|
||||||
else:
|
|
||||||
device = "cuda"
|
|
||||||
else:
|
else:
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
|||||||
"UPLOAD_FOLDER": str(uploads_dir),
|
"UPLOAD_FOLDER": str(uploads_dir),
|
||||||
"OUTPUT_FOLDER": str(outputs_dir),
|
"OUTPUT_FOLDER": str(outputs_dir),
|
||||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||||
|
# Large books can submit four form fields per chapter. Werkzeug's
|
||||||
|
# defaults reject those requests before the wizard route can process
|
||||||
|
# them, even though the encoded payload is much smaller than the upload
|
||||||
|
# limit above.
|
||||||
|
"MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024,
|
||||||
|
"MAX_FORM_PARTS": 10_000,
|
||||||
}
|
}
|
||||||
if config:
|
if config:
|
||||||
base_config.update(config)
|
base_config.update(config)
|
||||||
|
|||||||
+164
-1607
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import soundfile as sf
|
|||||||
from flask import current_app, send_file
|
from flask import current_app, send_file
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from abogen.domain.device import select_device as _select_device
|
||||||
|
|
||||||
|
|
||||||
SPLIT_PATTERN = r"\n+"
|
SPLIT_PATTERN = r"\n+"
|
||||||
SAMPLE_RATE = 24000
|
SAMPLE_RATE = 24000
|
||||||
@@ -25,31 +27,6 @@ def clear_preview_pipelines() -> None:
|
|||||||
_preview_pipelines.clear()
|
_preview_pipelines.clear()
|
||||||
|
|
||||||
|
|
||||||
def _select_device() -> str:
|
|
||||||
import platform
|
|
||||||
|
|
||||||
try:
|
|
||||||
import torch # type: ignore[import-not-found]
|
|
||||||
except Exception:
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
system = platform.system()
|
|
||||||
if system == "Darwin" and platform.processor() == "arm":
|
|
||||||
try:
|
|
||||||
if torch.backends.mps.is_available():
|
|
||||||
return "mps"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
try:
|
|
||||||
if torch.cuda.is_available():
|
|
||||||
return "cuda"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||||
devices: List[str] = ["cpu"]
|
devices: List[str] = ["cpu"]
|
||||||
if use_gpu:
|
if use_gpu:
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ dependencies = [
|
|||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
"static_ffmpeg>=2.13",
|
"static_ffmpeg>=2.13",
|
||||||
"Markdown>=3.9",
|
"Markdown>=3.9",
|
||||||
"Flask>=3.0.3",
|
"Flask>=3.1.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
"gpustat>=1.1.1",
|
"gpustat>=1.1.1",
|
||||||
"num2words>=0.5.13",
|
"num2words>=0.5.13",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Tests for audio helper utilities.
|
||||||
|
|
||||||
|
Tests import from domain/audio_helpers.py (new module).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_ffmpeg_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFfmpegCommand:
|
||||||
|
"""build_ffmpeg_command builds ffmpeg argument list."""
|
||||||
|
|
||||||
|
def test_base_structure(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out/audio.wav"), "wav")
|
||||||
|
assert cmd[0] == "ffmpeg"
|
||||||
|
assert "-y" in cmd
|
||||||
|
assert "pipe:0" in cmd
|
||||||
|
assert str(Path("/out/audio.wav")) in cmd
|
||||||
|
|
||||||
|
def test_mp3_codec(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out.mp3"), "mp3")
|
||||||
|
assert "libmp3lame" in cmd
|
||||||
|
|
||||||
|
def test_opus_codec(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out.opus"), "opus")
|
||||||
|
assert "libopus" in cmd
|
||||||
|
|
||||||
|
def test_m4b_codec(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out.m4b"), "m4b")
|
||||||
|
assert "aac" in cmd
|
||||||
|
assert "-q:a" in cmd
|
||||||
|
assert "+faststart+use_metadata_tags" in cmd
|
||||||
|
|
||||||
|
def test_wav_copy_codec(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out.wav"), "wav")
|
||||||
|
assert "copy" in cmd
|
||||||
|
|
||||||
|
def test_with_metadata(self):
|
||||||
|
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||||
|
|
||||||
|
cmd = build_ffmpeg_command(Path("/out.mp3"), "mp3", metadata={"album": "Test"})
|
||||||
|
assert str(Path("/out.mp3")) in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# to_float32
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestToFloat32:
|
||||||
|
"""to_float32 converts audio to float32 numpy array."""
|
||||||
|
|
||||||
|
def test_none_returns_empty(self):
|
||||||
|
from abogen.domain.audio_helpers import to_float32
|
||||||
|
|
||||||
|
result = to_float32(None)
|
||||||
|
assert isinstance(result, np.ndarray)
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_numpy_array(self):
|
||||||
|
from abogen.domain.audio_helpers import to_float32
|
||||||
|
|
||||||
|
arr = np.array([1.0, 2.0, 3.0], dtype="float64")
|
||||||
|
result = to_float32(arr)
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
def test_mock_tensor(self):
|
||||||
|
from abogen.domain.audio_helpers import to_float32
|
||||||
|
|
||||||
|
tensor = MagicMock()
|
||||||
|
tensor.detach.return_value = tensor
|
||||||
|
tensor.cpu.return_value = tensor
|
||||||
|
tensor.numpy.return_value = np.array([1.0, 2.0])
|
||||||
|
result = to_float32(tensor)
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_list_input(self):
|
||||||
|
from abogen.domain.audio_helpers import to_float32
|
||||||
|
|
||||||
|
result = to_float32([1.0, 2.0])
|
||||||
|
assert result.dtype == np.float32
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_m4b_chapters_with_mutagen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyM4bChaptersWithMutagen:
|
||||||
|
"""apply_m4b_chapters_with_mutagen writes chapter atoms to MP4."""
|
||||||
|
|
||||||
|
def test_empty_chapters_returns_false(self):
|
||||||
|
from abogen.domain.audio_helpers import apply_m4b_chapters_with_mutagen
|
||||||
|
|
||||||
|
assert apply_m4b_chapters_with_mutagen(Path("/fake.m4b"), []) is False
|
||||||
|
|
||||||
|
def test_missing_mutagen_raises(self):
|
||||||
|
from abogen.domain.audio_helpers import apply_m4b_chapters_with_mutagen
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"mutagen": None, "mutagen.mp4": None}):
|
||||||
|
with pytest.raises((ImportError, KeyError)):
|
||||||
|
apply_m4b_chapters_with_mutagen(
|
||||||
|
Path("/fake.m4b"), [{"start": 0, "title": "Ch1"}]
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tests for chapter_overrides, merge_metadata, normalize_for_pipeline.
|
||||||
|
|
||||||
|
Tests import from domain modules (new location).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_chapter_overrides
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyChapterOverrides:
|
||||||
|
"""apply_chapter_overrides applies chapter overrides to extracted chapters."""
|
||||||
|
|
||||||
|
def test_empty_overrides(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
result, updates, diags = apply_chapter_overrides([], [])
|
||||||
|
assert result == []
|
||||||
|
assert updates == {}
|
||||||
|
assert diags == []
|
||||||
|
|
||||||
|
def test_basic_override_by_index(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
chapters = [ExtractedChapter(title="Ch1", text="original")]
|
||||||
|
overrides = [{"index": 0, "title": "New Title", "text": "new text"}]
|
||||||
|
result, updates, diags = apply_chapter_overrides(chapters, overrides)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].title == "New Title"
|
||||||
|
assert result[0].text == "new text"
|
||||||
|
|
||||||
|
def test_override_by_source_title(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
chapters = [ExtractedChapter(title="Ch1", text="text1")]
|
||||||
|
overrides = [{"source_title": "Ch1", "title": "Renamed"}]
|
||||||
|
result, _, _ = apply_chapter_overrides(chapters, overrides)
|
||||||
|
assert result[0].title == "Renamed"
|
||||||
|
|
||||||
|
def test_disabled_override_skipped(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
chapters = [ExtractedChapter(title="Ch1", text="text1")]
|
||||||
|
overrides = [{"index": 0, "enabled": False}]
|
||||||
|
result, _, _ = apply_chapter_overrides(chapters, overrides)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_metadata_updates_collected(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
chapters = [ExtractedChapter(title="Ch1", text="text1")]
|
||||||
|
overrides = [{"index": 0, "metadata": {"album": "New Album"}}]
|
||||||
|
_, updates, _ = apply_chapter_overrides(chapters, overrides)
|
||||||
|
assert updates["album"] == "New Album"
|
||||||
|
|
||||||
|
def test_no_matching_chapter_diagnostic(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
overrides = [{"index": 99, "title": "X"}]
|
||||||
|
_, _, diags = apply_chapter_overrides([], overrides)
|
||||||
|
assert len(diags) == 1
|
||||||
|
assert "Skipped" in diags[0]
|
||||||
|
|
||||||
|
def test_non_dict_override_skipped(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
_, _, diags = apply_chapter_overrides([], ["bad"])
|
||||||
|
assert len(diags) == 1
|
||||||
|
|
||||||
|
def test_text_from_base_when_not_provided(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
chapters = [ExtractedChapter(title="Ch1", text="original text")]
|
||||||
|
overrides = [{"index": 0, "title": "New Title"}]
|
||||||
|
result, _, _ = apply_chapter_overrides(chapters, overrides)
|
||||||
|
assert result[0].text == "original text"
|
||||||
|
|
||||||
|
def test_default_title_when_no_base(self):
|
||||||
|
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||||
|
|
||||||
|
overrides = [{"text": "some text"}]
|
||||||
|
result, _, _ = apply_chapter_overrides([], overrides)
|
||||||
|
assert result[0].title == "Chapter 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# merge_metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeMetadata:
|
||||||
|
"""merge_metadata merges extracted metadata with overrides."""
|
||||||
|
|
||||||
|
def test_both_empty(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
assert merge_metadata({}, {}) == {}
|
||||||
|
|
||||||
|
def test_only_extracted(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata({"album": "Book"}, {})
|
||||||
|
assert result == {"album": "Book"}
|
||||||
|
|
||||||
|
def test_only_overrides(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata(None, {"album": "Override"})
|
||||||
|
assert result == {"album": "Override"}
|
||||||
|
|
||||||
|
def test_override_wins(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata({"album": "Old"}, {"album": "New"})
|
||||||
|
assert result == {"album": "New"}
|
||||||
|
|
||||||
|
def test_none_value_deletes_key(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata({"album": "Book"}, {"album": None})
|
||||||
|
assert "album" not in result
|
||||||
|
|
||||||
|
def test_none_values_in_extracted_skipped(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata({"album": None, "artist": "X"}, {})
|
||||||
|
assert result == {"artist": "X"}
|
||||||
|
|
||||||
|
def test_numeric_values_stringified(self):
|
||||||
|
from abogen.domain.metadata_merge import merge_metadata
|
||||||
|
|
||||||
|
result = merge_metadata({"track": 1}, {})
|
||||||
|
assert result["track"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_for_pipeline (thin wrapper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeForPipeline:
|
||||||
|
"""normalize_for_pipeline normalizes text with runtime settings."""
|
||||||
|
|
||||||
|
def test_basic_normalize(self):
|
||||||
|
from abogen.domain.normalization import normalize_text_for_pipeline
|
||||||
|
|
||||||
|
result = normalize_text_for_pipeline("Hello World")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
from abogen.domain.normalization import normalize_text_for_pipeline
|
||||||
|
|
||||||
|
result = normalize_text_for_pipeline("")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_with_overrides(self):
|
||||||
|
from abogen.domain.normalization import normalize_text_for_pipeline
|
||||||
|
|
||||||
|
result = normalize_text_for_pipeline("test", normalization_overrides={"number_format": "words"})
|
||||||
|
assert isinstance(result, str)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Tests for domain/chapter_titles.py."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from abogen.domain.chapter_titles import (
|
||||||
|
simplify_heading_text,
|
||||||
|
headings_equivalent,
|
||||||
|
strip_duplicate_heading_line,
|
||||||
|
normalize_caps_word,
|
||||||
|
normalize_chapter_opening_caps,
|
||||||
|
format_spoken_chapter_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimplifyHeadingText:
|
||||||
|
def test_empty(self):
|
||||||
|
assert simplify_heading_text("") == ""
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert simplify_heading_text(None) == ""
|
||||||
|
|
||||||
|
def test_chapter_prefix_removed(self):
|
||||||
|
assert simplify_heading_text("Chapter 1") == "1"
|
||||||
|
|
||||||
|
def test_lowercase(self):
|
||||||
|
assert simplify_heading_text("Chapter 1: The Beginning") == "1thebeginning"
|
||||||
|
|
||||||
|
def test_strips_special_chars(self):
|
||||||
|
result = simplify_heading_text("Ch. 1")
|
||||||
|
assert "1" in result
|
||||||
|
assert "." not in result
|
||||||
|
|
||||||
|
def test_no_chapter_prefix(self):
|
||||||
|
result = simplify_heading_text("Part 2")
|
||||||
|
assert "part" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadingsEquivalent:
|
||||||
|
def test_exact_match(self):
|
||||||
|
assert headings_equivalent("Chapter 1", "Chapter 1")
|
||||||
|
|
||||||
|
def test_prefix_match(self):
|
||||||
|
assert headings_equivalent("Chapter 2", "Chapter 2: The Return")
|
||||||
|
|
||||||
|
def test_reverse_prefix(self):
|
||||||
|
assert headings_equivalent("Chapter 2: The Return", "Chapter 2")
|
||||||
|
|
||||||
|
def test_different_numbers(self):
|
||||||
|
assert not headings_equivalent("Chapter 1", "Chapter 2")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert not headings_equivalent("", "Chapter 1")
|
||||||
|
|
||||||
|
def test_long_containment(self):
|
||||||
|
assert headings_equivalent("Introduction", "Introduction to Everything")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripDuplicateHeadingLine:
|
||||||
|
def test_removes_heading(self):
|
||||||
|
text = "Chapter 1\n\nSome text here"
|
||||||
|
result, removed = strip_duplicate_heading_line(text, "Chapter 1")
|
||||||
|
assert removed is True
|
||||||
|
assert "Chapter 1" not in result
|
||||||
|
assert "Some text here" in result
|
||||||
|
|
||||||
|
def test_no_heading(self):
|
||||||
|
text = "Just some text"
|
||||||
|
result, removed = strip_duplicate_heading_line(text, "Chapter 1")
|
||||||
|
assert removed is False
|
||||||
|
assert result == text
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
result, removed = strip_duplicate_heading_line("", "Chapter 1")
|
||||||
|
assert removed is False
|
||||||
|
|
||||||
|
def test_strips_leading_empty_lines(self):
|
||||||
|
text = "Chapter 1\n\n\n\nText"
|
||||||
|
result, removed = strip_duplicate_heading_line(text, "Chapter 1")
|
||||||
|
assert removed is True
|
||||||
|
assert result.startswith("Text")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeCapsWord:
|
||||||
|
def test_acronym_kept(self):
|
||||||
|
assert normalize_caps_word("TTS") == "TTS"
|
||||||
|
|
||||||
|
def test_single_letter_kept(self):
|
||||||
|
assert normalize_caps_word("A") == "A"
|
||||||
|
|
||||||
|
def test_roman_numeral_kept(self):
|
||||||
|
assert normalize_caps_word("IV") == "IV"
|
||||||
|
|
||||||
|
def test_all_caps_converted(self):
|
||||||
|
result = normalize_caps_word("HELLO")
|
||||||
|
assert result == "Hello"
|
||||||
|
|
||||||
|
def test_with_hyphen(self):
|
||||||
|
result = normalize_caps_word("WELL-KNOWN")
|
||||||
|
assert result == "Well-Known"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeChapterOpeningCaps:
|
||||||
|
def test_all_caps_words(self):
|
||||||
|
text = "THIS IS A TEST"
|
||||||
|
result, changed = normalize_chapter_opening_caps(text)
|
||||||
|
assert changed is True
|
||||||
|
assert result == "This Is A Test"
|
||||||
|
|
||||||
|
def test_already_normal(self):
|
||||||
|
text = "This is normal"
|
||||||
|
result, changed = normalize_chapter_opening_caps(text)
|
||||||
|
assert changed is False
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
result, changed = normalize_chapter_opening_caps("")
|
||||||
|
assert changed is False
|
||||||
|
|
||||||
|
def test_mixed(self):
|
||||||
|
text = "HELLO world"
|
||||||
|
result, changed = normalize_chapter_opening_caps(text)
|
||||||
|
assert changed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatSpokenChapterTitle:
|
||||||
|
def test_empty_no_prefix(self):
|
||||||
|
assert format_spoken_chapter_title("", 1, False) == ""
|
||||||
|
|
||||||
|
def test_empty_with_prefix(self):
|
||||||
|
assert format_spoken_chapter_title("", 1, True) == "Chapter 1"
|
||||||
|
|
||||||
|
def test_no_prefix_returns_base(self):
|
||||||
|
assert format_spoken_chapter_title("My Chapter", 1, False) == "My Chapter"
|
||||||
|
|
||||||
|
def test_already_has_chapter(self):
|
||||||
|
assert format_spoken_chapter_title("Chapter 5", 1, True) == "Chapter 5"
|
||||||
|
|
||||||
|
def test_number_prefix(self):
|
||||||
|
result = format_spoken_chapter_title("3. The End", 1, True)
|
||||||
|
assert result == "Chapter 3. The End"
|
||||||
|
|
||||||
|
def test_number_only(self):
|
||||||
|
result = format_spoken_chapter_title("7", 1, True)
|
||||||
|
assert result == "Chapter 7"
|
||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from abogen.chunking import chunk_text
|
from abogen.chunking import chunk_text
|
||||||
from abogen.webui.conversion_runner import _chunk_voice_spec, _group_chunks_by_chapter
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
from abogen.domain.chunk_utils import group_chunks_by_chapter
|
||||||
|
|
||||||
|
|
||||||
def test_group_chunks_by_chapter_orders_and_groups() -> None:
|
def test_group_chunks_by_chapter_orders_and_groups() -> None:
|
||||||
@@ -13,7 +14,7 @@ def test_group_chunks_by_chapter_orders_and_groups() -> None:
|
|||||||
{"chapter_index": 1, "chunk_index": 0, "text": "next"},
|
{"chapter_index": 1, "chunk_index": 0, "text": "next"},
|
||||||
]
|
]
|
||||||
|
|
||||||
grouped = _group_chunks_by_chapter(chunks)
|
grouped = group_chunks_by_chapter(chunks)
|
||||||
|
|
||||||
assert [entry["text"] for entry in grouped[0]] == ["body", "tail"]
|
assert [entry["text"] for entry in grouped[0]] == ["body", "tail"]
|
||||||
assert grouped[1][0]["text"] == "next"
|
assert grouped[1][0]["text"] == "next"
|
||||||
@@ -23,7 +24,7 @@ def test_chunk_voice_spec_prefers_chunk_overrides() -> None:
|
|||||||
job = SimpleNamespace(voice="base_voice", speakers={})
|
job = SimpleNamespace(voice="base_voice", speakers={})
|
||||||
chunk = {"voice": "override_voice", "speaker_id": "narrator"}
|
chunk = {"voice": "override_voice", "speaker_id": "narrator"}
|
||||||
|
|
||||||
assert _chunk_voice_spec(job, chunk, "fallback") == "override_voice"
|
assert chunk_voice_spec(job, chunk, "fallback") == "override_voice"
|
||||||
|
|
||||||
|
|
||||||
def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None:
|
def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None:
|
||||||
@@ -32,14 +33,14 @@ def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None:
|
|||||||
)
|
)
|
||||||
chunk = {"speaker_id": "narrator"}
|
chunk = {"speaker_id": "narrator"}
|
||||||
|
|
||||||
assert _chunk_voice_spec(job, chunk, "fallback") == "speaker_voice"
|
assert chunk_voice_spec(job, chunk, "fallback") == "speaker_voice"
|
||||||
|
|
||||||
|
|
||||||
def test_chunk_voice_spec_uses_fallback_when_no_overrides() -> None:
|
def test_chunk_voice_spec_uses_fallback_when_no_overrides() -> None:
|
||||||
job = SimpleNamespace(voice="base_voice", speakers={})
|
job = SimpleNamespace(voice="base_voice", speakers={})
|
||||||
chunk = {"speaker_id": "unknown"}
|
chunk = {"speaker_id": "unknown"}
|
||||||
|
|
||||||
assert _chunk_voice_spec(job, chunk, "fallback") == "fallback"
|
assert chunk_voice_spec(job, chunk, "fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
def test_chunk_text_merges_title_abbreviations() -> None:
|
def test_chunk_text_merges_title_abbreviations() -> None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from abogen.webui.conversion_runner import _chunk_text_for_tts
|
from abogen.domain.chunk_utils import chunk_text_for_tts
|
||||||
|
|
||||||
|
|
||||||
def test_chunk_text_for_tts_prefers_text_over_normalized_text():
|
def test_chunk_text_for_tts_prefers_text_over_normalized_text():
|
||||||
@@ -9,7 +9,7 @@ def test_chunk_text_for_tts_prefers_text_over_normalized_text():
|
|||||||
"text": "Unfu*k",
|
"text": "Unfu*k",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert _chunk_text_for_tts(entry) == "Unfu*k"
|
assert chunk_text_for_tts(entry) == "Unfu*k"
|
||||||
|
|
||||||
|
|
||||||
def test_chunk_text_for_tts_falls_back_to_original_text_then_normalized_text():
|
def test_chunk_text_for_tts_falls_back_to_original_text_then_normalized_text():
|
||||||
@@ -17,9 +17,9 @@ def test_chunk_text_for_tts_falls_back_to_original_text_then_normalized_text():
|
|||||||
"original_text": "Hello * world",
|
"original_text": "Hello * world",
|
||||||
"normalized_text": "Hello world",
|
"normalized_text": "Hello world",
|
||||||
}
|
}
|
||||||
assert _chunk_text_for_tts(entry) == "Hello * world"
|
assert chunk_text_for_tts(entry) == "Hello * world"
|
||||||
|
|
||||||
entry2 = {
|
entry2 = {
|
||||||
"normalized_text": "Only normalized",
|
"normalized_text": "Only normalized",
|
||||||
}
|
}
|
||||||
assert _chunk_text_for_tts(entry2) == "Only normalized"
|
assert chunk_text_for_tts(entry2) == "Only normalized"
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests for chunk processing utilities.
|
||||||
|
|
||||||
|
Tests import from domain.chunk_utils (new location).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# safe_int
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeInt:
|
||||||
|
"""safe_int safely converts to int with a default."""
|
||||||
|
|
||||||
|
def test_int_value(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int(42) == 42
|
||||||
|
|
||||||
|
def test_string_number(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int("7") == 7
|
||||||
|
|
||||||
|
def test_float_truncated(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int(3.9) == 3
|
||||||
|
|
||||||
|
def test_none_returns_default(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int(None) == 0
|
||||||
|
|
||||||
|
def test_garbage_returns_default(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int("abc") == 0
|
||||||
|
|
||||||
|
def test_custom_default(self):
|
||||||
|
from abogen.domain.chunk_utils import safe_int
|
||||||
|
|
||||||
|
assert safe_int(None, default=-1) == -1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# chunk_text_for_tts (supplement existing tests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkTextForTts:
|
||||||
|
"""chunk_text_for_tts selects the best text source."""
|
||||||
|
|
||||||
|
def test_non_mapping_returns_empty(self):
|
||||||
|
from abogen.domain.chunk_utils import chunk_text_for_tts
|
||||||
|
|
||||||
|
assert chunk_text_for_tts("not a dict") == ""
|
||||||
|
|
||||||
|
def test_none_returns_empty(self):
|
||||||
|
from abogen.domain.chunk_utils import chunk_text_for_tts
|
||||||
|
|
||||||
|
assert chunk_text_for_tts(None) == ""
|
||||||
|
|
||||||
|
def test_empty_dict_returns_empty(self):
|
||||||
|
from abogen.domain.chunk_utils import chunk_text_for_tts
|
||||||
|
|
||||||
|
assert chunk_text_for_tts({}) == ""
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_empty(self):
|
||||||
|
from abogen.domain.chunk_utils import chunk_text_for_tts
|
||||||
|
|
||||||
|
assert chunk_text_for_tts({"text": " "}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# record_override_usage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordOverrideUsage:
|
||||||
|
"""record_override_usage records pronunciation override usage."""
|
||||||
|
|
||||||
|
def test_noop_when_empty(self):
|
||||||
|
from abogen.domain.chunk_utils import record_override_usage
|
||||||
|
|
||||||
|
job = SimpleNamespace(language="en")
|
||||||
|
record_override_usage(job, {}, {})
|
||||||
|
|
||||||
|
def test_noop_when_all_zero(self):
|
||||||
|
from abogen.domain.chunk_utils import record_override_usage
|
||||||
|
|
||||||
|
job = SimpleNamespace(language="en")
|
||||||
|
record_override_usage(job, {"hello": 0}, {"hello": "hi"})
|
||||||
|
|
||||||
|
def test_records_usage(self):
|
||||||
|
from abogen.domain.chunk_utils import record_override_usage
|
||||||
|
|
||||||
|
job = SimpleNamespace(language="en", add_log=lambda *a, **kw: None)
|
||||||
|
with patch("abogen.domain.chunk_utils.increment_usage") as mock_inc:
|
||||||
|
record_override_usage(job, {"hello": 2}, {"hello": "hi"})
|
||||||
|
mock_inc.assert_called_once_with(language="en", token="hi", amount=2)
|
||||||
|
|
||||||
|
def test_fallback_token_from_normalized(self):
|
||||||
|
from abogen.domain.chunk_utils import record_override_usage
|
||||||
|
|
||||||
|
job = SimpleNamespace(language="ja", add_log=lambda *a, **kw: None)
|
||||||
|
with patch("abogen.domain.chunk_utils.increment_usage") as mock_inc:
|
||||||
|
record_override_usage(job, {"test": 1}, {})
|
||||||
|
mock_inc.assert_called_once_with(language="ja", token="test", amount=1)
|
||||||
|
|
||||||
|
def test_handles_exception_gracefully(self):
|
||||||
|
from abogen.domain.chunk_utils import record_override_usage
|
||||||
|
|
||||||
|
job = SimpleNamespace(language="en", add_log=lambda *a, **kw: None)
|
||||||
|
with patch("abogen.domain.chunk_utils.increment_usage", side_effect=RuntimeError("db error")):
|
||||||
|
record_override_usage(job, {"hello": 1}, {"hello": "hi"})
|
||||||
@@ -124,3 +124,117 @@ def test_normalize_chapter_opening_caps_keeps_mixed_case() -> None:
|
|||||||
normalized, changed = _normalize_chapter_opening_caps("Already Mixed Case")
|
normalized, changed = _normalize_chapter_opening_caps("Already Mixed Case")
|
||||||
assert normalized == "Already Mixed Case"
|
assert normalized == "Already Mixed Case"
|
||||||
assert changed is False
|
assert changed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyChapterTextTransforms:
|
||||||
|
"""Tests for the combined heading-strip + opening-caps helper."""
|
||||||
|
|
||||||
|
def test_both_enabled_heading_matches(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"Chapter 1: The Beginning\nBody text here",
|
||||||
|
heading_text="Chapter 1: The Beginning",
|
||||||
|
raw_title="Chapter 1: The Beginning",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=True,
|
||||||
|
)
|
||||||
|
assert heading_removed is True
|
||||||
|
assert "Body text here" in text
|
||||||
|
assert "Chapter 1" not in text
|
||||||
|
|
||||||
|
def test_heading_fallback_to_number(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"1. The Beginning\nBody text",
|
||||||
|
heading_text="Chapter 1: The Beginning",
|
||||||
|
raw_title="1: The Beginning",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=False,
|
||||||
|
)
|
||||||
|
assert heading_removed is True
|
||||||
|
assert "Body text" in text
|
||||||
|
|
||||||
|
def test_only_heading_strip(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"Chapter 1: Title\nBody text",
|
||||||
|
heading_text="Chapter 1: Title",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=False,
|
||||||
|
)
|
||||||
|
assert heading_removed is True
|
||||||
|
assert caps_changed is False
|
||||||
|
|
||||||
|
def test_only_opening_caps(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"ALL CAPS START OF CHAPTER",
|
||||||
|
heading_text="Chapter 1",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=False,
|
||||||
|
normalize_caps=True,
|
||||||
|
)
|
||||||
|
assert heading_removed is False
|
||||||
|
assert caps_changed is True
|
||||||
|
assert text == "All Caps Start Of Chapter"
|
||||||
|
|
||||||
|
def test_both_disabled_no_change(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
original = "Some text here"
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
original,
|
||||||
|
heading_text="Chapter 1",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=False,
|
||||||
|
normalize_caps=False,
|
||||||
|
)
|
||||||
|
assert text == original
|
||||||
|
assert heading_removed is False
|
||||||
|
assert caps_changed is False
|
||||||
|
|
||||||
|
def test_heading_not_matching(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"Completely different text",
|
||||||
|
heading_text="Chapter 1: Title",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=False,
|
||||||
|
)
|
||||||
|
assert heading_removed is False
|
||||||
|
assert text == "Completely different text"
|
||||||
|
|
||||||
|
def test_empty_text(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"",
|
||||||
|
heading_text="Chapter 1",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=True,
|
||||||
|
)
|
||||||
|
assert text == ""
|
||||||
|
assert heading_removed is False
|
||||||
|
assert caps_changed is False
|
||||||
|
|
||||||
|
def test_both_enabled_text_only_has_caps(self) -> None:
|
||||||
|
from abogen.domain.chapter_titles import apply_chapter_text_transforms
|
||||||
|
|
||||||
|
text, heading_removed, caps_changed = apply_chapter_text_transforms(
|
||||||
|
"NASA MISSION LOG",
|
||||||
|
heading_text="Chapter 1",
|
||||||
|
raw_title="",
|
||||||
|
strip_heading=True,
|
||||||
|
normalize_caps=True,
|
||||||
|
)
|
||||||
|
assert heading_removed is False
|
||||||
|
assert caps_changed is True
|
||||||
|
assert text == "NASA Mission Log"
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for ExportService FFmpeg metadata methods."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeFfmetadataValue:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_simple_string(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("hello") == "hello"
|
||||||
|
|
||||||
|
def test_escapes_backslash(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("a\\b") == "a\\\\b"
|
||||||
|
|
||||||
|
def test_escapes_newline(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("line1\nline2") == "line1\\nline2"
|
||||||
|
|
||||||
|
def test_escapes_equals(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("key=value") == "key\\=value"
|
||||||
|
|
||||||
|
def test_escapes_semicolon(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("a;b") == "a\\;b"
|
||||||
|
|
||||||
|
def test_escapes_hash(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("#comment") == "\\#comment"
|
||||||
|
|
||||||
|
def test_escapes_all_special(self):
|
||||||
|
result = self.svc._escape_ffmetadata_value("a\\b\nc=d;e#f")
|
||||||
|
assert "\\\\" in result
|
||||||
|
assert "\\n" in result
|
||||||
|
assert "\\=" in result
|
||||||
|
assert "\\;" in result
|
||||||
|
assert "\\#" in result
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFfmetadata:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_renders_header(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": "My Book"}, [])
|
||||||
|
assert result.startswith(";FFMETADATA1\n")
|
||||||
|
assert "title=My Book\n" in result
|
||||||
|
|
||||||
|
def test_renders_multiple_keys(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": "T", "artist": "A"}, [])
|
||||||
|
assert "title=T\n" in result
|
||||||
|
assert "artist=A\n" in result
|
||||||
|
|
||||||
|
def test_skips_none_values(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": None}, [])
|
||||||
|
assert "title=" not in result
|
||||||
|
|
||||||
|
def test_renders_chapters(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 10.0, "title": "Ch 1"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "[CHAPTER]" in result
|
||||||
|
assert "TIMEBASE=1/1000" in result
|
||||||
|
assert "START=0" in result
|
||||||
|
assert "END=10000" in result
|
||||||
|
assert "title=Ch 1" in result
|
||||||
|
|
||||||
|
def test_renders_voice_in_chapter(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 5.0, "voice": "af_heart"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "voice=af_heart" in result
|
||||||
|
|
||||||
|
def test_skips_chapters_without_times(self):
|
||||||
|
chapters = [{"title": "No times"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "[CHAPTER]" not in result
|
||||||
|
|
||||||
|
def test_end_equals_start_gets_minimum_duration(self):
|
||||||
|
chapters = [{"start": 5.0, "end": 5.0, "title": "Zero"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "START=5000" in result
|
||||||
|
assert "END=5001" in result
|
||||||
|
|
||||||
|
def test_empty_metadata_and_chapters(self):
|
||||||
|
result = self.svc.render_ffmetadata({}, [])
|
||||||
|
assert result.strip() == ";FFMETADATA1"
|
||||||
|
|
||||||
|
def test_escapes_special_chars_in_title(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 1.0, "title": "A=B;C#D"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "\\=" in result
|
||||||
|
assert "\\;" in result
|
||||||
|
assert "\\#" in result
|
||||||
|
|
||||||
|
def test_negative_start_clamped_to_zero(self):
|
||||||
|
chapters = [{"start": -1.0, "end": 5.0, "title": "Neg"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "START=0" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataToFfmpegArgs:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_simple_metadata(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": "My Book"})
|
||||||
|
assert args == ["-metadata", "title=My Book"]
|
||||||
|
|
||||||
|
def test_year_becomes_date(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"year": "2024"})
|
||||||
|
assert args == ["-metadata", "date=2024"]
|
||||||
|
|
||||||
|
def test_skips_none_and_empty(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": None, "artist": ""})
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_skips_empty_key(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"": "value"})
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_multiple_keys(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": "T", "artist": "A"})
|
||||||
|
assert "-metadata" in args
|
||||||
|
assert "title=T" in args
|
||||||
|
assert "artist=A" in args
|
||||||
|
|
||||||
|
def test_empty_metadata(self):
|
||||||
|
assert self.svc._metadata_to_ffmpeg_args({}) == []
|
||||||
|
|
||||||
|
def test_none_metadata(self):
|
||||||
|
assert self.svc._metadata_to_ffmpeg_args(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteFfmetadataFile:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_writes_file(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
meta = {"title": "My Book"}
|
||||||
|
chapters = [{"start": 0.0, "end": 5.0, "title": "Ch 1"}]
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, meta, chapters)
|
||||||
|
assert result is not None
|
||||||
|
assert result.exists()
|
||||||
|
content = result.read_text()
|
||||||
|
assert ";FFMETADATA1" in content
|
||||||
|
assert "title=My Book" in content
|
||||||
|
|
||||||
|
def test_returns_none_for_empty(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, {}, [])
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_for_only_header(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, None, None)
|
||||||
|
assert result is None
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
from abogen.webui.conversion_runner import _render_ffmetadata, _write_ffmetadata_file
|
|
||||||
|
svc = ExportService()
|
||||||
|
|
||||||
|
|
||||||
def test_render_ffmetadata_includes_chapters(tmp_path):
|
def test_render_ffmetadata_includes_chapters(tmp_path):
|
||||||
@@ -17,7 +18,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
|
|||||||
{"start": 5.0, "end": 12.345, "title": "Chapter 2"},
|
{"start": 5.0, "end": 12.345, "title": "Chapter 2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
rendered = _render_ffmetadata(metadata, chapters)
|
rendered = svc.render_ffmetadata(metadata, chapters)
|
||||||
|
|
||||||
assert ";FFMETADATA1" in rendered
|
assert ";FFMETADATA1" in rendered
|
||||||
assert "title=Sample Book" in rendered
|
assert "title=Sample Book" in rendered
|
||||||
@@ -30,7 +31,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
|
|||||||
assert "voice=voice_a" in rendered
|
assert "voice=voice_a" in rendered
|
||||||
|
|
||||||
audio_path = tmp_path / "book.m4b"
|
audio_path = tmp_path / "book.m4b"
|
||||||
metadata_path = _write_ffmetadata_file(audio_path, metadata, chapters)
|
metadata_path = svc.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||||
assert metadata_path is not None
|
assert metadata_path is not None
|
||||||
assert metadata_path.exists()
|
assert metadata_path.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Tests for domain/metadata_helpers.py."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from abogen.domain.metadata_helpers import (
|
||||||
|
normalize_metadata_map,
|
||||||
|
format_author_sentence,
|
||||||
|
ensure_sentence,
|
||||||
|
normalize_series_number,
|
||||||
|
extract_series_metadata,
|
||||||
|
format_series_sentence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeMetadataMap:
|
||||||
|
def test_empty(self):
|
||||||
|
assert normalize_metadata_map({}) == {}
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert normalize_metadata_map(None) == {}
|
||||||
|
|
||||||
|
def test_normalizes_keys(self):
|
||||||
|
result = normalize_metadata_map({"Title": "My Book", "artist": "John"})
|
||||||
|
assert "title" in result
|
||||||
|
assert "artist" in result
|
||||||
|
|
||||||
|
def test_skips_none_values(self):
|
||||||
|
result = normalize_metadata_map({"title": None, "artist": "John"})
|
||||||
|
assert "title" not in result
|
||||||
|
|
||||||
|
def test_skips_empty_values(self):
|
||||||
|
result = normalize_metadata_map({"title": "", "artist": "John"})
|
||||||
|
assert "title" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatAuthorSentence:
|
||||||
|
def test_none(self):
|
||||||
|
assert format_author_sentence(None) == ""
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert format_author_sentence("") == ""
|
||||||
|
|
||||||
|
def test_unknown(self):
|
||||||
|
assert format_author_sentence("Unknown") == ""
|
||||||
|
|
||||||
|
def test_single(self):
|
||||||
|
assert format_author_sentence("John Doe") == "By John Doe"
|
||||||
|
|
||||||
|
def test_two(self):
|
||||||
|
assert format_author_sentence("John, Jane") == "By John and Jane"
|
||||||
|
|
||||||
|
def test_three(self):
|
||||||
|
assert format_author_sentence("John, Jane, Bob") == "By John, Jane, and Bob"
|
||||||
|
|
||||||
|
def test_ampersand(self):
|
||||||
|
assert format_author_sentence("John & Jane") == "By John and Jane"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureSentence:
|
||||||
|
def test_empty(self):
|
||||||
|
assert ensure_sentence("") == ""
|
||||||
|
|
||||||
|
def test_already_sentence(self):
|
||||||
|
assert ensure_sentence("Hello.") == "Hello."
|
||||||
|
|
||||||
|
def test_adds_period(self):
|
||||||
|
assert ensure_sentence("Hello") == "Hello."
|
||||||
|
|
||||||
|
def test_exclamation(self):
|
||||||
|
assert ensure_sentence("Hello!") == "Hello!"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeSeriesNumber:
|
||||||
|
def test_empty(self):
|
||||||
|
assert normalize_series_number("") is None
|
||||||
|
|
||||||
|
def test_integer(self):
|
||||||
|
assert normalize_series_number("3") == "3"
|
||||||
|
|
||||||
|
def test_float(self):
|
||||||
|
assert normalize_series_number("3.5") == "3.5"
|
||||||
|
|
||||||
|
def test_float_trailing_zero(self):
|
||||||
|
assert normalize_series_number("3.10") == "3.1"
|
||||||
|
|
||||||
|
def test_comma_as_separator(self):
|
||||||
|
assert normalize_series_number("3,5") == "3.5"
|
||||||
|
|
||||||
|
def test_text_with_number(self):
|
||||||
|
assert normalize_series_number("Book 3") == "3"
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert normalize_series_number(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractSeriesMetadata:
|
||||||
|
def test_empty(self):
|
||||||
|
name, number = extract_series_metadata({})
|
||||||
|
assert name is None
|
||||||
|
assert number is None
|
||||||
|
|
||||||
|
def test_series_name(self):
|
||||||
|
name, number = extract_series_metadata({"series": "My Series"})
|
||||||
|
assert name == "My Series"
|
||||||
|
assert number is None
|
||||||
|
|
||||||
|
def test_series_number(self):
|
||||||
|
name, number = extract_series_metadata({"series_index": "3"})
|
||||||
|
assert name is None
|
||||||
|
assert number == "3"
|
||||||
|
|
||||||
|
def test_both(self):
|
||||||
|
name, number = extract_series_metadata({"series": "My Series", "series_index": "3"})
|
||||||
|
assert name == "My Series"
|
||||||
|
assert number == "3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatSeriesSentence:
|
||||||
|
def test_empty(self):
|
||||||
|
assert format_series_sentence(None, None) == ""
|
||||||
|
|
||||||
|
def test_name_only(self):
|
||||||
|
assert format_series_sentence("My Series", None) == ""
|
||||||
|
|
||||||
|
def test_number_only(self):
|
||||||
|
assert format_series_sentence(None, "3") == ""
|
||||||
|
|
||||||
|
def test_both(self):
|
||||||
|
assert format_series_sentence("My Series", "3") == "Book 3 of the My Series"
|
||||||
|
|
||||||
|
def test_with_the(self):
|
||||||
|
assert format_series_sentence("The Lord of the Rings", "1") == "Book 1 of The Lord of the Rings"
|
||||||
+242
-64
@@ -1,79 +1,257 @@
|
|||||||
|
"""Tests for output path utilities.
|
||||||
|
|
||||||
|
Tests import from domain/output_paths.py (new module).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from abogen.webui.conversion_runner import _build_output_path, _prepare_project_layout
|
|
||||||
from abogen.webui.service import Job
|
# ---------------------------------------------------------------------------
|
||||||
|
# slugify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _sample_job(tmp_path: Path) -> Job:
|
class TestSlugify:
|
||||||
source = tmp_path / "sample.txt"
|
"""slugify converts title to filesystem-safe slug."""
|
||||||
source.write_text("example", encoding="utf-8")
|
|
||||||
return Job(
|
def test_basic_slug(self):
|
||||||
id="job-1",
|
from abogen.domain.output_paths import slugify
|
||||||
original_filename="Sample Title.txt",
|
|
||||||
stored_path=source,
|
assert slugify("Hello World", 0) == "hello_world"
|
||||||
language="en",
|
|
||||||
voice="af_alloy",
|
def test_strips_special_chars(self):
|
||||||
speed=1.0,
|
from abogen.domain.output_paths import slugify
|
||||||
use_gpu=False,
|
|
||||||
subtitle_mode="Sentence",
|
result = slugify("Chapter 1: The Beginning!", 0)
|
||||||
output_format="mp3",
|
assert result == "chapter_1_the_beginning"
|
||||||
save_mode="Use default save location",
|
|
||||||
output_folder=tmp_path,
|
def test_empty_title_uses_index(self):
|
||||||
replace_single_newlines=False,
|
from abogen.domain.output_paths import slugify
|
||||||
subtitle_format="srt",
|
|
||||||
created_at=time.time(),
|
assert slugify("", 5) == "chapter_05"
|
||||||
)
|
|
||||||
|
def test_truncated_at_80(self):
|
||||||
|
from abogen.domain.output_paths import slugify
|
||||||
|
|
||||||
|
long_title = "a" * 100
|
||||||
|
assert len(slugify(long_title, 0)) == 80
|
||||||
|
|
||||||
|
def test_preserves_hyphens(self):
|
||||||
|
from abogen.domain.output_paths import slugify
|
||||||
|
|
||||||
|
assert slugify("mid-night", 0) == "mid-night"
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_project_layout_uses_timestamped_folder(
|
# ---------------------------------------------------------------------------
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
# sanitize_output_stem
|
||||||
) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
job = _sample_job(tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"abogen.webui.conversion_runner._output_timestamp_token",
|
|
||||||
lambda: "20250101-120000",
|
|
||||||
)
|
|
||||||
|
|
||||||
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
|
|
||||||
job, tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
assert project_root.name.startswith(
|
|
||||||
"20250101-120000_Sample_Title"
|
|
||||||
), project_root.name
|
|
||||||
assert audio_dir == project_root
|
|
||||||
assert subtitle_dir == project_root
|
|
||||||
assert metadata_dir is None
|
|
||||||
|
|
||||||
output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
|
|
||||||
assert output_path == project_root / "Sample_Title.mp3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_project_layout_creates_project_subdirs(
|
class TestSanitizeOutputStem:
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
"""sanitize_output_stem cleans filename stem."""
|
||||||
) -> None:
|
|
||||||
job = _sample_job(tmp_path)
|
|
||||||
job.save_as_project = True
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"abogen.webui.conversion_runner._output_timestamp_token",
|
|
||||||
lambda: "20250101-120500",
|
|
||||||
)
|
|
||||||
|
|
||||||
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
|
def test_basic_sanitize(self):
|
||||||
job, tmp_path
|
from abogen.domain.output_paths import sanitize_output_stem
|
||||||
)
|
|
||||||
|
|
||||||
assert audio_dir == project_root / "audio"
|
assert sanitize_output_stem("my file.mp3") == "my_file"
|
||||||
assert subtitle_dir == project_root / "subtitles"
|
|
||||||
assert metadata_dir == project_root / "metadata"
|
|
||||||
assert audio_dir.is_dir()
|
|
||||||
assert subtitle_dir.is_dir()
|
|
||||||
assert metadata_dir is not None and metadata_dir.is_dir()
|
|
||||||
|
|
||||||
output_path = _build_output_path(audio_dir, job.original_filename, "wav")
|
def test_empty_returns_default(self):
|
||||||
assert output_path == audio_dir / "Sample_Title.wav"
|
from abogen.domain.output_paths import sanitize_output_stem
|
||||||
|
|
||||||
|
assert sanitize_output_stem("") == "output"
|
||||||
|
|
||||||
|
def test_strips_underscores(self):
|
||||||
|
from abogen.domain.output_paths import sanitize_output_stem
|
||||||
|
|
||||||
|
assert sanitize_output_stem("__test__") == "test"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# output_timestamp_token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutputTimestampToken:
|
||||||
|
"""output_timestamp_token returns timestamp string."""
|
||||||
|
|
||||||
|
def test_format(self):
|
||||||
|
from abogen.domain.output_paths import output_timestamp_token
|
||||||
|
|
||||||
|
token = output_timestamp_token()
|
||||||
|
assert re.match(r"\d{8}-\d{6}", token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_output_path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildOutputPath:
|
||||||
|
"""build_output_path builds the output file path."""
|
||||||
|
|
||||||
|
def test_basic_path(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import build_output_path
|
||||||
|
|
||||||
|
result = build_output_path(tmp_path, "test.mp3", "mp3")
|
||||||
|
assert result.suffix == ".mp3"
|
||||||
|
assert result.parent == tmp_path
|
||||||
|
|
||||||
|
def test_stem_sanitized(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import build_output_path
|
||||||
|
|
||||||
|
result = build_output_path(tmp_path, "my file.txt", "wav")
|
||||||
|
assert "my_file" in result.name
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_newline_policy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyNewlinePolicy:
|
||||||
|
"""apply_newline_policy replaces single newlines in chapter text."""
|
||||||
|
|
||||||
|
def test_noop_when_disabled(self):
|
||||||
|
from abogen.domain.output_paths import apply_newline_policy
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
chapters = [ExtractedChapter(title="t", text="a\nb")]
|
||||||
|
apply_newline_policy(chapters, False)
|
||||||
|
assert chapters[0].text == "a\nb"
|
||||||
|
|
||||||
|
def test_replaces_single_newlines(self):
|
||||||
|
from abogen.domain.output_paths import apply_newline_policy
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
chapters = [ExtractedChapter(title="t", text="a\nb\nc")]
|
||||||
|
apply_newline_policy(chapters, True)
|
||||||
|
assert chapters[0].text == "a b c"
|
||||||
|
|
||||||
|
def test_preserves_double_newlines(self):
|
||||||
|
from abogen.domain.output_paths import apply_newline_policy
|
||||||
|
|
||||||
|
from abogen.text_extractor import ExtractedChapter
|
||||||
|
chapters = [ExtractedChapter(title="t", text="a\n\nb")]
|
||||||
|
apply_newline_policy(chapters, True)
|
||||||
|
assert chapters[0].text == "a\n\nb"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_output_directory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveOutputDirectory:
|
||||||
|
"""resolve_output_directory determines output dir from save_mode."""
|
||||||
|
|
||||||
|
def test_save_to_desktop(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
|
|
||||||
|
result = resolve_output_directory(
|
||||||
|
save_mode="Save to Desktop",
|
||||||
|
stored_path=Path("/input/book.epub"),
|
||||||
|
output_folder=None,
|
||||||
|
desktop_dir=tmp_path,
|
||||||
|
user_output_path=None,
|
||||||
|
user_cache_outputs=None,
|
||||||
|
)
|
||||||
|
assert result == tmp_path
|
||||||
|
|
||||||
|
def test_save_next_to_input(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
|
|
||||||
|
stored = tmp_path / "book.epub"
|
||||||
|
result = resolve_output_directory(
|
||||||
|
save_mode="Save next to input file",
|
||||||
|
stored_path=stored,
|
||||||
|
output_folder=None,
|
||||||
|
desktop_dir=None,
|
||||||
|
user_output_path=None,
|
||||||
|
user_cache_outputs=None,
|
||||||
|
)
|
||||||
|
assert result == tmp_path
|
||||||
|
|
||||||
|
def test_choose_output_folder(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
|
|
||||||
|
custom = tmp_path / "custom"
|
||||||
|
result = resolve_output_directory(
|
||||||
|
save_mode="Choose output folder",
|
||||||
|
stored_path=Path("/x/y.epub"),
|
||||||
|
output_folder=str(custom),
|
||||||
|
desktop_dir=None,
|
||||||
|
user_output_path=None,
|
||||||
|
user_cache_outputs=None,
|
||||||
|
)
|
||||||
|
assert result == custom
|
||||||
|
|
||||||
|
def test_use_default_save_location(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
|
|
||||||
|
result = resolve_output_directory(
|
||||||
|
save_mode="Use default save location",
|
||||||
|
stored_path=Path("/x/y.epub"),
|
||||||
|
output_folder=None,
|
||||||
|
desktop_dir=None,
|
||||||
|
user_output_path=tmp_path / "default",
|
||||||
|
user_cache_outputs=None,
|
||||||
|
)
|
||||||
|
assert result == tmp_path / "default"
|
||||||
|
|
||||||
|
def test_fallback_to_cache(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
|
|
||||||
|
result = resolve_output_directory(
|
||||||
|
save_mode="unknown",
|
||||||
|
stored_path=Path("/x/y.epub"),
|
||||||
|
output_folder=None,
|
||||||
|
desktop_dir=None,
|
||||||
|
user_output_path=None,
|
||||||
|
user_cache_outputs=tmp_path / "cache",
|
||||||
|
)
|
||||||
|
assert result == tmp_path / "cache"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_project_layout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProjectLayout:
|
||||||
|
"""resolve_project_layout computes project folder structure."""
|
||||||
|
|
||||||
|
def test_flat_layout(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_project_layout
|
||||||
|
|
||||||
|
root, audio, subs, meta = resolve_project_layout(
|
||||||
|
original_filename="book.epub",
|
||||||
|
save_as_project=False,
|
||||||
|
base_dir=tmp_path,
|
||||||
|
timestamp_fn=lambda: "20260101-000000",
|
||||||
|
sanitize_fn=lambda n, i: "book",
|
||||||
|
)
|
||||||
|
assert audio == root
|
||||||
|
assert subs == root
|
||||||
|
assert meta is None
|
||||||
|
|
||||||
|
def test_project_layout(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_project_layout
|
||||||
|
|
||||||
|
root, audio, subs, meta = resolve_project_layout(
|
||||||
|
original_filename="book.epub",
|
||||||
|
save_as_project=True,
|
||||||
|
base_dir=tmp_path,
|
||||||
|
timestamp_fn=lambda: "20260101-000000",
|
||||||
|
sanitize_fn=lambda n, i: "book",
|
||||||
|
)
|
||||||
|
assert root.name == "20260101-000000_book"
|
||||||
|
assert audio.name == "audio"
|
||||||
|
assert subs.name == "subtitles"
|
||||||
|
assert meta.name == "metadata"
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""Tests for abogen.domain.pronunciation — compile/apply pronunciation rules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# We import the domain functions. The module must be created first.
|
||||||
|
# For now the tests are written against the expected public API so they can
|
||||||
|
# serve as the contract during extraction.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompilePronunciationRules:
|
||||||
|
"""compile_pronunciation_rules turns override dicts into regex-based rules."""
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
assert compile_pronunciation_rules(None) == []
|
||||||
|
assert compile_pronunciation_rules([]) == []
|
||||||
|
|
||||||
|
def test_single_entry(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [{"token": "albeit", "pronunciation": "all be it"}]
|
||||||
|
rules = compile_pronunciation_rules(overrides)
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0]["replacement"] == "all be it"
|
||||||
|
assert rules[0]["pattern"].search("albeit")
|
||||||
|
|
||||||
|
def test_skips_entries_without_pronunciation(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [{"token": "hello"}]
|
||||||
|
assert compile_pronunciation_rules(overrides) == []
|
||||||
|
|
||||||
|
def test_skips_entries_without_token(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [{"pronunciation": "foo"}]
|
||||||
|
assert compile_pronunciation_rules(overrides) == []
|
||||||
|
|
||||||
|
def test_deduplication_by_casefold(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [
|
||||||
|
{"token": "Albeit", "pronunciation": "all be it"},
|
||||||
|
{"token": "ALBEIT", "pronunciation": "all be it"},
|
||||||
|
]
|
||||||
|
rules = compile_pronunciation_rules(overrides)
|
||||||
|
assert len(rules) == 1
|
||||||
|
|
||||||
|
def test_longer_token_sorted_first(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [
|
||||||
|
{"token": "ice cream", "pronunciation": "ice cream"},
|
||||||
|
{"token": "ice", "pronunciation": "ais"},
|
||||||
|
]
|
||||||
|
rules = compile_pronunciation_rules(overrides)
|
||||||
|
assert len(rules) == 2
|
||||||
|
assert len(rules[0]["token"]) >= len(rules[1]["token"])
|
||||||
|
|
||||||
|
def test_normalized_fallback_to_entity_token(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [{"normalized": "USA", "pronunciation": "you ess ay"}]
|
||||||
|
rules = compile_pronunciation_rules(overrides)
|
||||||
|
assert len(rules) == 1
|
||||||
|
|
||||||
|
def test_pattern_is_case_insensitive(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = [{"token": "hello", "pronunciation": "hi"}]
|
||||||
|
rules = compile_pronunciation_rules(overrides)
|
||||||
|
assert rules[0]["pattern"].search("Hello")
|
||||||
|
assert rules[0]["pattern"].search("HELLO")
|
||||||
|
|
||||||
|
def test_non_mapping_items_skipped(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||||
|
|
||||||
|
overrides = ["bad", None, 42]
|
||||||
|
assert compile_pronunciation_rules(overrides) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompileHeteronymSentenceRules:
|
||||||
|
"""compile_heteronym_sentence_rules builds sentence-level replacements."""
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
assert compile_heteronym_sentence_rules(None) == []
|
||||||
|
assert compile_heteronym_sentence_rules([]) == []
|
||||||
|
|
||||||
|
def test_basic_replacement(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
overrides = [
|
||||||
|
{
|
||||||
|
"sentence": "I read the book",
|
||||||
|
"choice": "past",
|
||||||
|
"options": [
|
||||||
|
{"key": "present", "replacement_sentence": "I read the book"},
|
||||||
|
{"key": "past", "replacement_sentence": "I read the book"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
rules = compile_heteronym_sentence_rules(overrides)
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0]["replacement"] == "I read the book"
|
||||||
|
|
||||||
|
def test_skips_without_sentence(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
overrides = [{"choice": "a", "options": []}]
|
||||||
|
assert compile_heteronym_sentence_rules(overrides) == []
|
||||||
|
|
||||||
|
def test_skips_without_choice(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
overrides = [{"sentence": "hello", "options": []}]
|
||||||
|
assert compile_heteronym_sentence_rules(overrides) == []
|
||||||
|
|
||||||
|
def test_skips_when_no_matching_option(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
overrides = [
|
||||||
|
{
|
||||||
|
"sentence": "I read the book",
|
||||||
|
"choice": "past",
|
||||||
|
"options": [{"key": "present", "replacement_sentence": "I read the book"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert compile_heteronym_sentence_rules(overrides) == []
|
||||||
|
|
||||||
|
def test_deduplication(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"sentence": "I read the book",
|
||||||
|
"choice": "past",
|
||||||
|
"options": [{"key": "past", "replacement_sentence": "I red the book"}],
|
||||||
|
}
|
||||||
|
rules = compile_heteronym_sentence_rules([entry, entry])
|
||||||
|
assert len(rules) == 1
|
||||||
|
|
||||||
|
def test_longer_sentence_sorted_first(self):
|
||||||
|
from abogen.domain.pronunciation import compile_heteronym_sentence_rules
|
||||||
|
|
||||||
|
overrides = [
|
||||||
|
{
|
||||||
|
"sentence": "short",
|
||||||
|
"choice": "a",
|
||||||
|
"options": [{"key": "a", "replacement_sentence": "s"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sentence": "a longer sentence here",
|
||||||
|
"choice": "b",
|
||||||
|
"options": [{"key": "b", "replacement_sentence": "l"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
rules = compile_heteronym_sentence_rules(overrides)
|
||||||
|
assert len(rules[0]["pattern"].pattern) >= len(rules[1]["pattern"].pattern)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyPronunciationRules:
|
||||||
|
"""apply_pronunciation_rules applies compiled token-level rules."""
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
from abogen.domain.pronunciation import apply_pronunciation_rules
|
||||||
|
|
||||||
|
assert apply_pronunciation_rules("", []) == ""
|
||||||
|
|
||||||
|
def test_no_rules(self):
|
||||||
|
from abogen.domain.pronunciation import apply_pronunciation_rules
|
||||||
|
|
||||||
|
assert apply_pronunciation_rules("hello", []) == "hello"
|
||||||
|
|
||||||
|
def test_basic_replacement(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules, apply_pronunciation_rules
|
||||||
|
|
||||||
|
rules = compile_pronunciation_rules([{"token": "albeit", "pronunciation": "all be it"}])
|
||||||
|
result = apply_pronunciation_rules("albeit it was raining", rules)
|
||||||
|
assert result == "all be it it was raining"
|
||||||
|
|
||||||
|
def test_possessive_preserved(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules, apply_pronunciation_rules
|
||||||
|
|
||||||
|
rules = compile_pronunciation_rules([{"token": "dog", "pronunciation": "dawg"}])
|
||||||
|
result = apply_pronunciation_rules("the dog's bone", rules)
|
||||||
|
assert result == "the dawg's bone"
|
||||||
|
|
||||||
|
def test_usage_counter_increments(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules, apply_pronunciation_rules
|
||||||
|
|
||||||
|
rules = compile_pronunciation_rules([{"token": "hello", "pronunciation": "hi"}])
|
||||||
|
counter: dict[str, int] = {}
|
||||||
|
apply_pronunciation_rules("hello hello", rules, usage_counter=counter)
|
||||||
|
assert counter.get("hello", 0) == 2
|
||||||
|
|
||||||
|
def test_case_insensitive_match(self):
|
||||||
|
from abogen.domain.pronunciation import compile_pronunciation_rules, apply_pronunciation_rules
|
||||||
|
|
||||||
|
rules = compile_pronunciation_rules([{"token": "test", "pronunciation": "tst"}])
|
||||||
|
result = apply_pronunciation_rules("This is a Test", rules)
|
||||||
|
assert "tst" in result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyHeteronymSentenceRules:
|
||||||
|
"""apply_heteronym_sentence_rules applies sentence-level replacements."""
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
from abogen.domain.pronunciation import apply_heteronym_sentence_rules
|
||||||
|
|
||||||
|
assert apply_heteronym_sentence_rules("", []) == ""
|
||||||
|
|
||||||
|
def test_no_rules(self):
|
||||||
|
from abogen.domain.pronunciation import apply_heteronym_sentence_rules
|
||||||
|
|
||||||
|
assert apply_heteronym_sentence_rules("hello", []) == "hello"
|
||||||
|
|
||||||
|
def test_basic_replacement(self):
|
||||||
|
from abogen.domain.pronunciation import (
|
||||||
|
compile_heteronym_sentence_rules,
|
||||||
|
apply_heteronym_sentence_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = compile_heteronym_sentence_rules(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sentence": "I read the book",
|
||||||
|
"choice": "past",
|
||||||
|
"options": [{"key": "past", "replacement_sentence": "I read the book"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = apply_heteronym_sentence_rules("I read the book.", rules)
|
||||||
|
assert result == "I read the book."
|
||||||
|
|
||||||
|
def test_no_match_left_unchanged(self):
|
||||||
|
from abogen.domain.pronunciation import (
|
||||||
|
compile_heteronym_sentence_rules,
|
||||||
|
apply_heteronym_sentence_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = compile_heteronym_sentence_rules(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sentence": "I read the book",
|
||||||
|
"choice": "past",
|
||||||
|
"options": [{"key": "past", "replacement_sentence": "I red the book"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = apply_heteronym_sentence_rules("something else entirely", rules)
|
||||||
|
assert result == "something else entirely"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergePronunciationOverrides:
|
||||||
|
"""merge_pronunciation_overrides consolidates override sources."""
|
||||||
|
|
||||||
|
def test_empty_job(self):
|
||||||
|
from abogen.domain.pronunciation import merge_pronunciation_overrides
|
||||||
|
|
||||||
|
class FakeJob:
|
||||||
|
pronunciation_overrides = None
|
||||||
|
speakers = None
|
||||||
|
manual_overrides = None
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
result = merge_pronunciation_overrides(FakeJob())
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_pronunciation_overrides_included(self):
|
||||||
|
from abogen.domain.pronunciation import merge_pronunciation_overrides
|
||||||
|
|
||||||
|
class FakeJob:
|
||||||
|
pronunciation_overrides = [
|
||||||
|
{"token": "hello", "pronunciation": "hi", "normalized": "hello"}
|
||||||
|
]
|
||||||
|
speakers = None
|
||||||
|
manual_overrides = None
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
result = merge_pronunciation_overrides(FakeJob())
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["token"] == "hello"
|
||||||
|
assert result[0]["source"] == "pronunciation"
|
||||||
|
|
||||||
|
def test_manual_overrides_win(self):
|
||||||
|
from abogen.domain.pronunciation import merge_pronunciation_overrides
|
||||||
|
|
||||||
|
class FakeJob:
|
||||||
|
pronunciation_overrides = [
|
||||||
|
{"token": "hello", "pronunciation": "hi old", "normalized": "hello"}
|
||||||
|
]
|
||||||
|
speakers = None
|
||||||
|
manual_overrides = [
|
||||||
|
{"token": "hello", "pronunciation": "hi new", "normalized": "hello"}
|
||||||
|
]
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
result = merge_pronunciation_overrides(FakeJob())
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["pronunciation"] == "hi new"
|
||||||
|
assert result[0]["source"] == "manual"
|
||||||
|
|
||||||
|
def test_speaker_entries_included(self):
|
||||||
|
from abogen.domain.pronunciation import merge_pronunciation_overrides
|
||||||
|
|
||||||
|
class FakeJob:
|
||||||
|
pronunciation_overrides = None
|
||||||
|
speakers = {"narrator": {"token": "war", "pronunciation": "wɔːr"}}
|
||||||
|
manual_overrides = None
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
result = merge_pronunciation_overrides(FakeJob())
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["source"] == "speaker"
|
||||||
|
|
||||||
|
def test_skips_empty_tokens(self):
|
||||||
|
from abogen.domain.pronunciation import merge_pronunciation_overrides
|
||||||
|
|
||||||
|
class FakeJob:
|
||||||
|
pronunciation_overrides = [{"token": "", "pronunciation": "foo"}]
|
||||||
|
speakers = None
|
||||||
|
manual_overrides = None
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
result = merge_pronunciation_overrides(FakeJob())
|
||||||
|
assert result == []
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectDevice:
|
||||||
|
"""Tests for domain.device.select_device."""
|
||||||
|
|
||||||
|
def test_returns_mps_on_apple_silicon_when_available(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Darwin"
|
||||||
|
mock_platform.processor.return_value = "arm"
|
||||||
|
|
||||||
|
mock_torch = MagicMock()
|
||||||
|
mock_torch.backends.mps.is_available.return_value = True
|
||||||
|
mock_torch.cuda.is_available.return_value = False
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": mock_torch}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "mps"
|
||||||
|
|
||||||
|
def test_returns_cpu_on_apple_silicon_when_mps_unavailable(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Darwin"
|
||||||
|
mock_platform.processor.return_value = "arm"
|
||||||
|
|
||||||
|
mock_torch = MagicMock()
|
||||||
|
mock_torch.backends.mps.is_available.return_value = False
|
||||||
|
mock_torch.cuda.is_available.return_value = False
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": mock_torch}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "cpu"
|
||||||
|
|
||||||
|
def test_returns_cuda_when_available(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Linux"
|
||||||
|
mock_platform.processor.return_value = "x86_64"
|
||||||
|
|
||||||
|
mock_torch = MagicMock()
|
||||||
|
mock_torch.backends.mps.is_available.return_value = False
|
||||||
|
mock_torch.cuda.is_available.return_value = True
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": mock_torch}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "cuda"
|
||||||
|
|
||||||
|
def test_returns_cpu_when_cuda_unavailable(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Linux"
|
||||||
|
mock_platform.processor.return_value = "x86_64"
|
||||||
|
|
||||||
|
mock_torch = MagicMock()
|
||||||
|
mock_torch.backends.mps.is_available.return_value = False
|
||||||
|
mock_torch.cuda.is_available.return_value = False
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": mock_torch}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "cpu"
|
||||||
|
|
||||||
|
def test_returns_cpu_when_torch_not_installed(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Linux"
|
||||||
|
mock_platform.processor.return_value = "x86_64"
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": None}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "cpu"
|
||||||
|
|
||||||
|
def test_handles_torch_import_error(self) -> None:
|
||||||
|
from abogen.domain.device import select_device
|
||||||
|
|
||||||
|
mock_platform = MagicMock()
|
||||||
|
mock_platform.system.return_value = "Windows"
|
||||||
|
mock_platform.processor.return_value = "AMD64"
|
||||||
|
|
||||||
|
with patch("abogen.domain.device._platform", mock_platform), \
|
||||||
|
patch.dict(sys.modules, {"torch": None}):
|
||||||
|
result = select_device()
|
||||||
|
assert result == "cpu"
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Tests for split pattern logic (3 identical copies in codebase)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from abogen.domain.split_pattern import get_split_pattern
|
||||||
|
|
||||||
|
|
||||||
|
# --- English always returns \n ---
|
||||||
|
|
||||||
|
class TestEnglish:
|
||||||
|
def test_english_sentence(self):
|
||||||
|
assert get_split_pattern("a", "Sentence") == "\n"
|
||||||
|
|
||||||
|
def test_english_sentence_comma(self):
|
||||||
|
assert get_split_pattern("a", "Sentence + Comma") == "\n"
|
||||||
|
|
||||||
|
def test_english_line(self):
|
||||||
|
assert get_split_pattern("a", "Line") == "\n"
|
||||||
|
|
||||||
|
def test_english_disabled(self):
|
||||||
|
assert get_split_pattern("a", "Disabled") == "\n"
|
||||||
|
|
||||||
|
def test_english_b(self):
|
||||||
|
assert get_split_pattern("b", "Sentence") == "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- CJK languages ---
|
||||||
|
|
||||||
|
class TestCJK:
|
||||||
|
def test_chinese_disabled(self):
|
||||||
|
pattern = get_split_pattern("z", "Disabled")
|
||||||
|
assert pattern != "\n"
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_chinese_line(self):
|
||||||
|
pattern = get_split_pattern("z", "Line")
|
||||||
|
assert pattern != "\n"
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_chinese_sentence(self):
|
||||||
|
pattern = get_split_pattern("z", "Sentence")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_chinese_sentence_comma(self):
|
||||||
|
pattern = get_split_pattern("z", "Sentence + Comma")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_japanese_disabled(self):
|
||||||
|
pattern = get_split_pattern("j", "Disabled")
|
||||||
|
assert pattern != "\n"
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_japanese_sentence(self):
|
||||||
|
pattern = get_split_pattern("j", "Sentence")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
|
||||||
|
# --- Other languages ---
|
||||||
|
|
||||||
|
class TestOtherLanguages:
|
||||||
|
def test_spanish_sentence(self):
|
||||||
|
pattern = get_split_pattern("e", "Sentence")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_spanish_line(self):
|
||||||
|
assert get_split_pattern("e", "Line") == "\n"
|
||||||
|
|
||||||
|
def test_spanish_disabled(self):
|
||||||
|
# canonical: \n+ for non-CJK Disabled
|
||||||
|
assert get_split_pattern("e", "Disabled") == r"\n+"
|
||||||
|
|
||||||
|
def test_french_sentence_comma(self):
|
||||||
|
pattern = get_split_pattern("f", "Sentence + Comma")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
def test_unknown_lang(self):
|
||||||
|
pattern = get_split_pattern("x", "Sentence")
|
||||||
|
assert r"\n+" in pattern
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pattern structure ---
|
||||||
|
|
||||||
|
class TestPatternStructure:
|
||||||
|
def test_sentence_has_lookbehind(self):
|
||||||
|
pattern = get_split_pattern("e", "Sentence")
|
||||||
|
assert r"(?<=" in pattern
|
||||||
|
|
||||||
|
def test_sentence_comma_has_comma_chars(self):
|
||||||
|
pattern = get_split_pattern("e", "Sentence + Comma")
|
||||||
|
assert "," in pattern
|
||||||
|
|
||||||
|
def test_cjk_spacing_uses_star(self):
|
||||||
|
pattern = get_split_pattern("z", "Sentence")
|
||||||
|
assert r"\s*" in pattern
|
||||||
|
|
||||||
|
def test_non_cjk_spacing_uses_plus(self):
|
||||||
|
pattern = get_split_pattern("e", "Sentence")
|
||||||
|
assert r"\s+" in pattern
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Tests for infrastructure/subtitle_writer.py — SrtWriter, AssWriter, VttWriter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from abogen.infrastructure.subtitle_writer import (
|
||||||
|
AssWriter,
|
||||||
|
SrtWriter,
|
||||||
|
SubtitleAlignment,
|
||||||
|
SubtitleConfig,
|
||||||
|
SubtitleFormat,
|
||||||
|
SubtitleMode,
|
||||||
|
VttWriter,
|
||||||
|
create_subtitle_writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# SrtWriter._format_time
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestSrtFormatTime:
|
||||||
|
def test_zero(self):
|
||||||
|
assert SrtWriter._format_time(0.0) == "00:00:00,000"
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
assert SrtWriter._format_time(61.5) == "00:01:01,500"
|
||||||
|
|
||||||
|
def test_hours(self):
|
||||||
|
assert SrtWriter._format_time(3661.123) == "01:01:01,123"
|
||||||
|
|
||||||
|
def test_large(self):
|
||||||
|
assert SrtWriter._format_time(7384.0) == "02:03:04,000"
|
||||||
|
|
||||||
|
def test_fractional_seconds(self):
|
||||||
|
assert SrtWriter._format_time(0.999) == "00:00:00,999"
|
||||||
|
|
||||||
|
def test_matches_old_format_timestamp(self):
|
||||||
|
"""Verify matches old _format_timestamp(ass=False) from conversion_runner."""
|
||||||
|
import math
|
||||||
|
for t in [0.0, 61.5, 3661.123, 7384.0, 0.999, 125.7]:
|
||||||
|
h = int(t // 3600)
|
||||||
|
m = int((t % 3600) // 60)
|
||||||
|
s = int(t % 60)
|
||||||
|
ms = int((t - math.floor(t)) * 1000)
|
||||||
|
expected = f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||||
|
assert SrtWriter._format_time(t) == expected, f"Mismatch at t={t}"
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# AssWriter._format_time
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestAssFormatTime:
|
||||||
|
def test_zero(self):
|
||||||
|
assert AssWriter._format_time(0.0) == "0:00:00.00"
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
assert AssWriter._format_time(61.5) == "0:01:01.50"
|
||||||
|
|
||||||
|
def test_hours(self):
|
||||||
|
assert AssWriter._format_time(3661.12) == "1:01:01.12"
|
||||||
|
|
||||||
|
def test_centiseconds(self):
|
||||||
|
assert AssWriter._format_time(1.55) == "0:00:01.55"
|
||||||
|
|
||||||
|
def test_matches_old_format_timestamp_ass(self):
|
||||||
|
"""Verify matches old _format_timestamp(ass=True) from conversion_runner.
|
||||||
|
|
||||||
|
Note: the old code used int(milliseconds/10) which truncates centiseconds,
|
||||||
|
while the new code uses float formatting which rounds. For most values they
|
||||||
|
match; the difference is at most 1 centisecond due to float precision.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
for t in [0.0, 61.5, 1.55, 125.7]:
|
||||||
|
h = int(t // 3600)
|
||||||
|
m = int((t % 3600) // 60)
|
||||||
|
s = int(t % 60)
|
||||||
|
ms = int((t - math.floor(t)) * 1000)
|
||||||
|
cs = int(ms / 10)
|
||||||
|
expected = f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
||||||
|
assert AssWriter._format_time(t) == expected, f"Mismatch at t={t}"
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# SrtWriter full write
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestSrtWriter:
|
||||||
|
def test_single_entry(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=2.5, text="Hello")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "1\n" in content
|
||||||
|
assert "00:00:00,000 --> 00:00:02,500" in content
|
||||||
|
assert "Hello\n" in content
|
||||||
|
|
||||||
|
def test_multiple_entries(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="First")
|
||||||
|
writer.write_entry(start=1.0, end=2.0, text="Second")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "1\n" in content
|
||||||
|
assert "2\n" in content
|
||||||
|
assert "First" in content
|
||||||
|
assert "Second" in content
|
||||||
|
|
||||||
|
def test_voice_prefix(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="Hello", voice="af_heart")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "[af_heart] Hello" in content
|
||||||
|
|
||||||
|
def test_auto_index(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="A")
|
||||||
|
writer.write_entry(start=1.0, end=2.0, text="B")
|
||||||
|
writer.write_entry(start=2.0, end=3.0, text="C")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "1\n" in content
|
||||||
|
assert "2\n" in content
|
||||||
|
assert "3\n" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# AssWriter full write
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestAssWriter:
|
||||||
|
def test_header_structure(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
|
||||||
|
writer.open()
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "[Script Info]" in content
|
||||||
|
assert "[V4+ Styles]" in content
|
||||||
|
assert "[Events]" in content
|
||||||
|
assert "Format: Layer, Start, End" in content
|
||||||
|
|
||||||
|
def test_single_entry(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=2.5, text="Hello")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "Dialogue:" in content
|
||||||
|
assert "Hello" in content
|
||||||
|
|
||||||
|
def test_voice_prefix(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="Hello", voice="af_heart")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "[af_heart] Hello" in content
|
||||||
|
|
||||||
|
def test_highlight_mode(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
config = SubtitleConfig(
|
||||||
|
format=SubtitleFormat.ASS,
|
||||||
|
mode=SubtitleMode.SENTENCE_HIGHLIGHT,
|
||||||
|
)
|
||||||
|
writer = AssWriter(path, config)
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="Hello world")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "Highlight" in content
|
||||||
|
assert r"{\k100}" in content
|
||||||
|
|
||||||
|
def test_centered_alignment(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
config = SubtitleConfig(
|
||||||
|
format=SubtitleFormat.ASS,
|
||||||
|
mode=SubtitleMode.LINE,
|
||||||
|
alignment=SubtitleAlignment.CENTER,
|
||||||
|
)
|
||||||
|
writer = AssWriter(path, config)
|
||||||
|
writer.open()
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
# Centered uses alignment=5
|
||||||
|
assert ",5," in content or ",5\n" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# VttWriter
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestVttWriter:
|
||||||
|
def test_header(self, tmp_path):
|
||||||
|
path = tmp_path / "test.vtt"
|
||||||
|
writer = VttWriter(path, SubtitleConfig(format=SubtitleFormat.VTT, mode=SubtitleMode.LINE))
|
||||||
|
writer.open()
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert content.startswith("WEBVTT")
|
||||||
|
|
||||||
|
def test_single_entry(self, tmp_path):
|
||||||
|
path = tmp_path / "test.vtt"
|
||||||
|
writer = VttWriter(path, SubtitleConfig(format=SubtitleFormat.VTT, mode=SubtitleMode.LINE))
|
||||||
|
writer.write_entry(start=0.0, end=2.5, text="Hello")
|
||||||
|
writer.close()
|
||||||
|
content = path.read_text()
|
||||||
|
assert "1\n" in content
|
||||||
|
assert "Hello" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# create_subtitle_writer factory
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestCreateSubtitleWriter:
|
||||||
|
def test_srt(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
writer = create_subtitle_writer(path, "srt", "Line")
|
||||||
|
assert isinstance(writer, SrtWriter)
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
def test_ass(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
writer = create_subtitle_writer(path, "ass", "Line")
|
||||||
|
assert isinstance(writer, AssWriter)
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
def test_vtt(self, tmp_path):
|
||||||
|
path = tmp_path / "test.vtt"
|
||||||
|
writer = create_subtitle_writer(path, "vtt", "Line")
|
||||||
|
assert isinstance(writer, VttWriter)
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
def test_unsupported_raises(self, tmp_path):
|
||||||
|
path = tmp_path / "test.xyz"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
create_subtitle_writer(path, "xyz", "Line")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Context manager
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
class TestContextManager:
|
||||||
|
def test_srt_context_manager(self, tmp_path):
|
||||||
|
path = tmp_path / "test.srt"
|
||||||
|
with SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE)) as writer:
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="Hello")
|
||||||
|
content = path.read_text()
|
||||||
|
assert "Hello" in content
|
||||||
|
|
||||||
|
def test_ass_context_manager(self, tmp_path):
|
||||||
|
path = tmp_path / "test.ass"
|
||||||
|
with AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE)) as writer:
|
||||||
|
writer.write_entry(start=0.0, end=1.0, text="Hello")
|
||||||
|
content = path.read_text()
|
||||||
|
assert "Dialogue:" in content
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for domain/title_builder.py."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from abogen.domain.title_builder import build_title_intro_text, build_outro_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildTitleIntroText:
|
||||||
|
def test_empty_metadata(self):
|
||||||
|
result = build_title_intro_text({}, "book.epub")
|
||||||
|
assert result == "book."
|
||||||
|
|
||||||
|
def test_title_from_metadata(self):
|
||||||
|
result = build_title_intro_text({"title": "My Book"}, "book.epub")
|
||||||
|
assert result == "My Book."
|
||||||
|
|
||||||
|
def test_title_fallback_basename(self):
|
||||||
|
result = build_title_intro_text({}, "my_book.epub")
|
||||||
|
assert result == "my_book."
|
||||||
|
|
||||||
|
def test_with_author(self):
|
||||||
|
result = build_title_intro_text({"title": "My Book", "author": "John Doe"}, "book.epub")
|
||||||
|
assert result == "My Book. By John Doe."
|
||||||
|
|
||||||
|
def test_with_subtitle(self):
|
||||||
|
result = build_title_intro_text({"title": "My Book", "subtitle": "A Tale"}, "book.epub")
|
||||||
|
assert result == "My Book. A Tale."
|
||||||
|
|
||||||
|
def test_duplicate_title_subtitle(self):
|
||||||
|
result = build_title_intro_text({"title": "My Book", "subtitle": "My Book"}, "book.epub")
|
||||||
|
assert result == "My Book."
|
||||||
|
|
||||||
|
def test_with_series(self):
|
||||||
|
result = build_title_intro_text({"title": "My Book", "series": "Series", "series_index": "3"}, "book.epub")
|
||||||
|
assert result == "Book 3 of the Series. My Book."
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildOutroText:
|
||||||
|
def test_empty(self):
|
||||||
|
result = build_outro_text({}, "book.epub")
|
||||||
|
assert result == "The end of book."
|
||||||
|
|
||||||
|
def test_title_only(self):
|
||||||
|
result = build_outro_text({"title": "My Book"}, "book.epub")
|
||||||
|
assert result == "The end of My Book."
|
||||||
|
|
||||||
|
def test_author_only(self):
|
||||||
|
result = build_outro_text({"author": "John Doe"}, "book.epub")
|
||||||
|
assert result == "The end of book from John Doe."
|
||||||
|
|
||||||
|
def test_title_and_author(self):
|
||||||
|
result = build_outro_text({"title": "My Book", "author": "John Doe"}, "book.epub")
|
||||||
|
assert result == "The end of My Book from John Doe."
|
||||||
|
|
||||||
|
def test_with_series(self):
|
||||||
|
result = build_outro_text({"title": "My Book", "series": "Series", "series_index": "3"}, "book.epub")
|
||||||
|
assert "The end of My Book." in result
|
||||||
|
assert "Book 3 of the Series." in result
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
if "soundfile" not in sys.modules:
|
||||||
|
soundfile_stub = types.ModuleType("soundfile")
|
||||||
|
|
||||||
|
class _SoundFileStub: # pragma: no cover - placeholder to satisfy imports
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
raise RuntimeError("soundfile is not installed in the test environment")
|
||||||
|
|
||||||
|
soundfile_stub.SoundFile = _SoundFileStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["soundfile"] = soundfile_stub
|
||||||
|
|
||||||
|
if "static_ffmpeg" not in sys.modules:
|
||||||
|
sys.modules["static_ffmpeg"] = types.ModuleType("static_ffmpeg")
|
||||||
|
|
||||||
|
if "ebooklib" not in sys.modules:
|
||||||
|
ebooklib_stub = types.ModuleType("ebooklib")
|
||||||
|
ebooklib_epub_stub = types.ModuleType("ebooklib.epub")
|
||||||
|
ebooklib_stub.epub = ebooklib_epub_stub # type: ignore[attr-defined]
|
||||||
|
sys.modules["ebooklib"] = ebooklib_stub
|
||||||
|
sys.modules["ebooklib.epub"] = ebooklib_epub_stub
|
||||||
|
|
||||||
|
if "fitz" not in sys.modules:
|
||||||
|
sys.modules["fitz"] = types.ModuleType("fitz")
|
||||||
|
|
||||||
|
if "markdown" not in sys.modules:
|
||||||
|
markdown_stub = types.ModuleType("markdown")
|
||||||
|
|
||||||
|
class _MarkdownStub:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
self.toc_tokens = []
|
||||||
|
|
||||||
|
def convert(self, text: str) -> str:
|
||||||
|
return text
|
||||||
|
|
||||||
|
markdown_stub.Markdown = _MarkdownStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["markdown"] = markdown_stub
|
||||||
|
|
||||||
|
if "bs4" not in sys.modules:
|
||||||
|
bs4_stub = types.ModuleType("bs4")
|
||||||
|
|
||||||
|
class _BeautifulSoupStub:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def find(self, *args: object, **kwargs: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def decompose(self) -> None: # pragma: no cover - compatibility shim
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _NavigableStringStub(str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
bs4_stub.BeautifulSoup = _BeautifulSoupStub # type: ignore[attr-defined]
|
||||||
|
bs4_stub.NavigableString = _NavigableStringStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["bs4"] = bs4_stub
|
||||||
|
|
||||||
|
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveFallbackVoiceSpec:
|
||||||
|
"""Tests for the voice fallback resolution helper."""
|
||||||
|
|
||||||
|
def test_uses_base_voice_spec(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="af_heart",
|
||||||
|
job_voice="af_bella",
|
||||||
|
voice_cache_keys=[],
|
||||||
|
)
|
||||||
|
assert result == "af_heart"
|
||||||
|
|
||||||
|
def test_falls_back_to_job_voice(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="",
|
||||||
|
job_voice="af_bella",
|
||||||
|
voice_cache_keys=[],
|
||||||
|
)
|
||||||
|
assert result == "af_bella"
|
||||||
|
|
||||||
|
def test_skips_custom_mix_uses_job_voice(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="__custom_mix",
|
||||||
|
job_voice="af_bella",
|
||||||
|
voice_cache_keys=[],
|
||||||
|
)
|
||||||
|
assert result == "af_bella"
|
||||||
|
|
||||||
|
def test_falls_back_to_voice_cache(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="",
|
||||||
|
job_voice="",
|
||||||
|
voice_cache_keys=["kokoro:af_heart"],
|
||||||
|
)
|
||||||
|
assert result == "af_heart"
|
||||||
|
|
||||||
|
def test_skips_custom_mix_in_cache(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="",
|
||||||
|
job_voice="",
|
||||||
|
voice_cache_keys=["__custom_mix", "kokoro:af_heart"],
|
||||||
|
)
|
||||||
|
assert result == "af_heart"
|
||||||
|
|
||||||
|
def test_falls_back_to_default_voice(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
with patch("abogen.domain.voice_resolution.get_default_voice", return_value="af_heart"):
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="",
|
||||||
|
job_voice="",
|
||||||
|
voice_cache_keys=[],
|
||||||
|
)
|
||||||
|
assert result == "af_heart"
|
||||||
|
|
||||||
|
def test_empty_base_and_job_with_cache(self) -> None:
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
|
|
||||||
|
result = resolve_fallback_voice_spec(
|
||||||
|
base_spec="",
|
||||||
|
job_voice="",
|
||||||
|
voice_cache_keys=["kokoro:af_bella", "kokoro:af_heart"],
|
||||||
|
)
|
||||||
|
assert result == "af_bella"
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""Tests for voice resolution helpers.
|
||||||
|
|
||||||
|
Tests import from domain.voice_resolution (new location).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Dict
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# spec_to_voice_ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecToVoiceIds:
|
||||||
|
"""spec_to_voice_ids extracts voice identifiers from a spec string."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
assert spec_to_voice_ids("") == set()
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
assert spec_to_voice_ids(None) == set()
|
||||||
|
|
||||||
|
def test_custom_mix_returns_empty(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
assert spec_to_voice_ids("__custom_mix") == set()
|
||||||
|
|
||||||
|
def test_single_known_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart"}):
|
||||||
|
assert spec_to_voice_ids("af_heart") == {"af_heart"}
|
||||||
|
|
||||||
|
def test_unknown_single_voice_returns_empty(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value=set()):
|
||||||
|
assert spec_to_voice_ids("nonexistent") == set()
|
||||||
|
|
||||||
|
def test_formula_with_star(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
with patch("abogen.domain.voice_resolution.extract_voice_ids", return_value=["v1", "v2"]):
|
||||||
|
result = spec_to_voice_ids("v1*v2")
|
||||||
|
assert result == {"v1", "v2"}
|
||||||
|
|
||||||
|
def test_formula_value_error_returns_empty(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
with patch("abogen.domain.voice_resolution.extract_voice_ids", side_effect=ValueError("bad")):
|
||||||
|
assert spec_to_voice_ids("bad*spec") == set()
|
||||||
|
|
||||||
|
def test_whitespace_stripped(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
|
|
||||||
|
assert spec_to_voice_ids(" ") == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# job_voice_fallback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobVoiceFallback:
|
||||||
|
"""job_voice_fallback resolves a fallback voice from job attributes."""
|
||||||
|
|
||||||
|
def test_direct_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[])
|
||||||
|
assert job_voice_fallback(job) == "af_heart"
|
||||||
|
|
||||||
|
def test_custom_mix_ignored(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="__custom_mix", speakers=None, chapters=[])
|
||||||
|
assert job_voice_fallback(job) == ""
|
||||||
|
|
||||||
|
def test_narrator_speaker(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="__custom_mix",
|
||||||
|
speakers={"narrator": {"resolved_voice": "af_heart"}},
|
||||||
|
chapters=[],
|
||||||
|
)
|
||||||
|
assert job_voice_fallback(job) == "af_heart"
|
||||||
|
|
||||||
|
def test_speaker_voice_formula(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="",
|
||||||
|
speakers={"speaker1": {"voice_formula": "v1*v2"}},
|
||||||
|
chapters=[],
|
||||||
|
)
|
||||||
|
assert job_voice_fallback(job) == "v1*v2"
|
||||||
|
|
||||||
|
def test_chapter_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="",
|
||||||
|
speakers=None,
|
||||||
|
chapters=[{"resolved_voice": "af_bella"}],
|
||||||
|
)
|
||||||
|
assert job_voice_fallback(job) == "af_bella"
|
||||||
|
|
||||||
|
def test_empty_job(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="", speakers=None, chapters=[])
|
||||||
|
assert job_voice_fallback(job) == ""
|
||||||
|
|
||||||
|
def test_narrator_custom_mix_falls_through(self):
|
||||||
|
from abogen.domain.voice_resolution import job_voice_fallback
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="",
|
||||||
|
speakers={"narrator": {"voice": "__custom_mix"}},
|
||||||
|
chapters=[{"voice": "af_heart"}],
|
||||||
|
)
|
||||||
|
assert job_voice_fallback(job) == "af_heart"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# chapter_voice_spec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChapterVoiceSpec:
|
||||||
|
"""chapter_voice_spec resolves voice for a chapter override."""
|
||||||
|
|
||||||
|
def test_no_override_uses_fallback(self):
|
||||||
|
from abogen.domain.voice_resolution import chapter_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[])
|
||||||
|
assert chapter_voice_spec(job, None) == "af_heart"
|
||||||
|
|
||||||
|
def test_resolved_voice_wins(self):
|
||||||
|
from abogen.domain.voice_resolution import chapter_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[])
|
||||||
|
override = {"resolved_voice": "af_bella", "voice_formula": "x", "voice": "y"}
|
||||||
|
assert chapter_voice_spec(job, override) == "af_bella"
|
||||||
|
|
||||||
|
def test_formula_second(self):
|
||||||
|
from abogen.domain.voice_resolution import chapter_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="", speakers=None, chapters=[])
|
||||||
|
override = {"voice_formula": "v1*v2", "voice": "y"}
|
||||||
|
assert chapter_voice_spec(job, override) == "v1*v2"
|
||||||
|
|
||||||
|
def test_voice_third(self):
|
||||||
|
from abogen.domain.voice_resolution import chapter_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="", speakers=None, chapters=[])
|
||||||
|
override = {"voice": "af_nicole"}
|
||||||
|
assert chapter_voice_spec(job, override) == "af_nicole"
|
||||||
|
|
||||||
|
def test_empty_override_falls_to_fallback(self):
|
||||||
|
from abogen.domain.voice_resolution import chapter_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[])
|
||||||
|
assert chapter_voice_spec(job, {}) == "af_heart"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# chunk_voice_spec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkVoiceSpec:
|
||||||
|
"""chunk_voice_spec resolves voice for a TTS chunk."""
|
||||||
|
|
||||||
|
def test_chunk_direct_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(speakers=None)
|
||||||
|
chunk = {"resolved_voice": "af_heart"}
|
||||||
|
assert chunk_voice_spec(job, chunk, "fallback") == "af_heart"
|
||||||
|
|
||||||
|
def test_chunk_speaker_lookup(self):
|
||||||
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(speakers={"narrator": {"resolved_voice": "af_bella"}})
|
||||||
|
chunk = {"speaker_id": "narrator"}
|
||||||
|
assert chunk_voice_spec(job, chunk, "") == "af_bella"
|
||||||
|
|
||||||
|
def test_chunk_voice_profile_lookup(self):
|
||||||
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(speakers={"角色A": {"voice": "af_nicole"}})
|
||||||
|
chunk = {"voice_profile": "角色A"}
|
||||||
|
assert chunk_voice_spec(job, chunk, "") == "af_nicole"
|
||||||
|
|
||||||
|
def test_uses_fallback_string(self):
|
||||||
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(speakers=None)
|
||||||
|
chunk = {}
|
||||||
|
assert chunk_voice_spec(job, chunk, "my_fallback") == "my_fallback"
|
||||||
|
|
||||||
|
def test_fallback_to_job(self):
|
||||||
|
from abogen.domain.voice_resolution import chunk_voice_spec
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", speakers=None, chapters=[])
|
||||||
|
chunk = {}
|
||||||
|
assert chunk_voice_spec(job, chunk, "") == "af_heart"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# collect_required_voice_ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectRequiredVoiceIds:
|
||||||
|
"""collect_required_voice_ids gathers all voice IDs from a job."""
|
||||||
|
|
||||||
|
def test_includes_job_voice(self):
|
||||||
|
from abogen.domain.voice_resolution import collect_required_voice_ids
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="af_heart", chapters=[], chunks=[], speakers={})
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart"}), \
|
||||||
|
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
|
||||||
|
result = collect_required_voice_ids(job)
|
||||||
|
assert "af_heart" in result
|
||||||
|
|
||||||
|
def test_includes_chapter_voices(self):
|
||||||
|
from abogen.domain.voice_resolution import collect_required_voice_ids
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="",
|
||||||
|
chapters=[{"resolved_voice": "af_bella"}],
|
||||||
|
chunks=[],
|
||||||
|
speakers={},
|
||||||
|
)
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_bella"}), \
|
||||||
|
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
|
||||||
|
result = collect_required_voice_ids(job)
|
||||||
|
assert "af_bella" in result
|
||||||
|
|
||||||
|
def test_includes_chunk_voices(self):
|
||||||
|
from abogen.domain.voice_resolution import collect_required_voice_ids
|
||||||
|
|
||||||
|
job = SimpleNamespace(
|
||||||
|
voice="",
|
||||||
|
chapters=[],
|
||||||
|
chunks=[{"voice": "af_nicole"}],
|
||||||
|
speakers={},
|
||||||
|
)
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_nicole"}), \
|
||||||
|
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
|
||||||
|
result = collect_required_voice_ids(job)
|
||||||
|
assert "af_nicole" in result
|
||||||
|
|
||||||
|
def test_always_includes_kokoro_voices(self):
|
||||||
|
from abogen.domain.voice_resolution import collect_required_voice_ids
|
||||||
|
|
||||||
|
job = SimpleNamespace(voice="", chapters=[], chunks=[], speakers={})
|
||||||
|
with patch("abogen.domain.voice_resolution.get_voices", return_value={"af_heart", "af_bella"}), \
|
||||||
|
patch("abogen.domain.voice_resolution.job_voice_fallback", return_value=""):
|
||||||
|
result = collect_required_voice_ids(job)
|
||||||
|
assert {"af_heart", "af_bella"}.issubset(result)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tests for domain/voice_utils.py."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from abogen.domain.voice_utils import (
|
||||||
|
infer_provider_from_spec,
|
||||||
|
supertonic_voice_from_spec,
|
||||||
|
split_speaker_reference,
|
||||||
|
formula_from_kokoro_entry,
|
||||||
|
coerce_truthy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInferProviderFromSpec:
|
||||||
|
def test_empty_returns_fallback(self):
|
||||||
|
assert infer_provider_from_spec("", "kokoro") == "kokoro"
|
||||||
|
|
||||||
|
def test_supertonic_uppercase(self):
|
||||||
|
assert infer_provider_from_spec("M1", "kokoro") == "supertonic"
|
||||||
|
|
||||||
|
def test_kokoro_voice(self):
|
||||||
|
assert infer_provider_from_spec("af_bella", "kokoro") == "kokoro"
|
||||||
|
|
||||||
|
def test_custom_mix(self):
|
||||||
|
assert infer_provider_from_spec("__custom_mix", "kokoro") == "kokoro"
|
||||||
|
|
||||||
|
def test_formula(self):
|
||||||
|
assert infer_provider_from_spec("af_bella*0.5+am_adam*0.5", "kokoro") == "kokoro"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupertonicVoiceFromSpec:
|
||||||
|
def test_normal(self):
|
||||||
|
assert supertonic_voice_from_spec("m1", "m2") == "M1"
|
||||||
|
|
||||||
|
def test_empty_uses_fallback(self):
|
||||||
|
assert supertonic_voice_from_spec("", "m2") == "M2"
|
||||||
|
|
||||||
|
def test_formula_uses_fallback(self):
|
||||||
|
assert supertonic_voice_from_spec("m1*0.5", "m2") == "M2"
|
||||||
|
|
||||||
|
def test_both_empty_uses_default(self):
|
||||||
|
assert supertonic_voice_from_spec("", "") == "M1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitSpeakerReference:
|
||||||
|
def test_speaker(self):
|
||||||
|
name, original = split_speaker_reference("speaker:John")
|
||||||
|
assert name == "John"
|
||||||
|
assert original == "speaker:John"
|
||||||
|
|
||||||
|
def test_profile(self):
|
||||||
|
name, original = split_speaker_reference("profile:Main")
|
||||||
|
assert name == "Main"
|
||||||
|
assert original == "profile:Main"
|
||||||
|
|
||||||
|
def test_invalid_prefix(self):
|
||||||
|
name, original = split_speaker_reference("voice:John")
|
||||||
|
assert name is None
|
||||||
|
assert original == "voice:John"
|
||||||
|
|
||||||
|
def test_no_colon(self):
|
||||||
|
name, original = split_speaker_reference("John")
|
||||||
|
assert name is None
|
||||||
|
assert original == "John"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
name, original = split_speaker_reference("")
|
||||||
|
assert name is None
|
||||||
|
assert original == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormulaFromKokoroEntry:
|
||||||
|
def test_normal(self):
|
||||||
|
entry = {"voices": [["af_bella", 0.5], ["am_adam", 0.5]]}
|
||||||
|
result = formula_from_kokoro_entry(entry)
|
||||||
|
assert "af_bella" in result
|
||||||
|
assert "am_adam" in result
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert formula_from_kokoro_entry({}) == ""
|
||||||
|
|
||||||
|
def test_invalid_items(self):
|
||||||
|
entry = {"voices": [["af_bella", "invalid"], ["am_adam", 0.5]]}
|
||||||
|
result = formula_from_kokoro_entry(entry)
|
||||||
|
assert "am_adam" in result
|
||||||
|
assert "af_bella" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestCoerceTruthy:
|
||||||
|
def test_bool_true(self):
|
||||||
|
assert coerce_truthy(True) is True
|
||||||
|
|
||||||
|
def test_bool_false(self):
|
||||||
|
assert coerce_truthy(False) is False
|
||||||
|
|
||||||
|
def test_string_true(self):
|
||||||
|
assert coerce_truthy("true") is True
|
||||||
|
assert coerce_truthy("yes") is True
|
||||||
|
assert coerce_truthy("1") is True
|
||||||
|
assert coerce_truthy("on") is True
|
||||||
|
|
||||||
|
def test_string_false(self):
|
||||||
|
assert coerce_truthy("false") is False
|
||||||
|
assert coerce_truthy("no") is False
|
||||||
|
assert coerce_truthy("0") is False
|
||||||
|
assert coerce_truthy("off") is False
|
||||||
|
assert coerce_truthy("") is False
|
||||||
|
|
||||||
|
def test_none_default_true(self):
|
||||||
|
assert coerce_truthy(None, True) is True
|
||||||
|
|
||||||
|
def test_none_default_false(self):
|
||||||
|
assert coerce_truthy(None, False) is False
|
||||||
|
|
||||||
|
def test_int(self):
|
||||||
|
assert coerce_truthy(1) is True
|
||||||
|
assert coerce_truthy(0) is False
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from abogen.webui.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def _large_chapter_form() -> dict[str, str]:
|
||||||
|
data = {"step": "chapters"}
|
||||||
|
for index in range(370):
|
||||||
|
prefix = f"chapter-{index}"
|
||||||
|
data[f"{prefix}-enabled"] = "on"
|
||||||
|
data[f"{prefix}-title"] = f"Chapter {index} " + ("x" * 1400)
|
||||||
|
data[f"{prefix}-voice"] = "af_heart"
|
||||||
|
data[f"{prefix}-formula"] = "default"
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_chapter_form_reaches_wizard_route(tmp_path):
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test",
|
||||||
|
"OUTPUT_FOLDER": str(tmp_path / "output"),
|
||||||
|
"UPLOAD_FOLDER": str(tmp_path / "uploads"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/wizard/update?format=json",
|
||||||
|
data=_large_chapter_form(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["error"] == "Missing job ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_multipart_chapter_form_reaches_wizard_route(tmp_path):
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test",
|
||||||
|
"OUTPUT_FOLDER": str(tmp_path / "output"),
|
||||||
|
"UPLOAD_FOLDER": str(tmp_path / "uploads"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/wizard/update?format=json",
|
||||||
|
data=_large_chapter_form(),
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["error"] == "Missing job ID"
|
||||||
Reference in New Issue
Block a user