aboutsummaryrefslogtreecommitdiff
path: root/app/converter/converter.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-01 14:32:05 -0400
committerhistoria <historiavg@proton.me>2026-09-01 14:32:05 -0400
commit6cfcd564c0684c52618235e6366f4a81c02b9a5b (patch)
tree55321760a8103bc6b5d79489fac4135a60e6e3ba /app/converter/converter.py
parentdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (diff)
downloadtts-audiobook-generator-6cfcd564c0684c52618235e6366f4a81c02b9a5b.tar.gz
slop refactor/dedup
Diffstat (limited to 'app/converter/converter.py')
-rw-r--r--app/converter/converter.py67
1 files changed, 49 insertions, 18 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py
index a10a57d..32cd342 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -66,6 +66,13 @@ DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
+# Device names Windows cannot use as a file name (with or without an
+# extension); sanitized output names matching these get a prefix.
+_WINDOWS_RESERVED_NAMES = frozenset(
+ {"CON", "PRN", "AUX", "NUL"}
+ | {f"COM{i}" for i in range(1, 10)}
+ | {f"LPT{i}" for i in range(1, 10)})
+
def _console_log_filter(record: logging.LogRecord) -> bool:
"""Keep httpx/httpcore request logs and file-only traceback dumps out
@@ -258,7 +265,7 @@ class AudiobookConverter:
# configured on the server, so no local reference audio is needed.
self.tts = FasterTTSClient(chunks_dir=CHUNKS_FOLDER,
voice=voice, api_url=api_url,
- quiet=quiet)
+ quiet=quiet, cancel=cancel)
elif backend == BACKEND_AUDIOCPP:
# --voice picks the voice: a built-in speaker name on the
# CustomVoice entry, or a server-side preset (cloning)
@@ -273,7 +280,8 @@ class AudiobookConverter:
instructions=instructions,
request_options=self.request_options,
api_url=api_url, quiet=quiet,
- unload_models=unload_models)
+ unload_models=unload_models,
+ cancel=cancel)
else:
# Qwen: the voice mode picks the request shape (built-in
# speaker, clone from a reference .wav, or a designed voice);
@@ -290,9 +298,12 @@ class AudiobookConverter:
api_url=api_url,
quiet=quiet,
voice=voice,
+ cancel=cancel,
)
self._progress = progress
- self.tts.cancel = cancel
+ # The converter's own handle on the run's cancel event (also passed
+ # to the client, so a cancel during connect-time work is honored).
+ self._cancel = cancel
def _emit(self, event: dict) -> None:
"""Send one progress event (a no-op without a progress callback)."""
@@ -306,7 +317,11 @@ class AudiobookConverter:
def _check_cancelled(self) -> None:
"""Raise ConversionCancelled when the run's cancel event is set."""
- cancel = getattr(getattr(self, "tts", None), "cancel", None)
+ cancel = getattr(self, "_cancel", None)
+ if not isinstance(cancel, threading.Event):
+ # Converters built without __init__ (tests): fall back to the
+ # client's event, the pre-constructor-arg wiring.
+ cancel = getattr(getattr(self, "tts", None), "cancel", None)
if isinstance(cancel, threading.Event) and cancel.is_set():
raise ConversionCancelled("Cancelled by user")
@@ -344,10 +359,17 @@ class AudiobookConverter:
@staticmethod
def _sanitize_filename(name: str, fallback: str = "chapter") -> str:
- """Make a chapter title safe to use as part of a file name."""
+ """Make a chapter title safe to use as part of a file name.
+
+ Reserved Windows device names (CON, NUL, COM1, ...) are suffixed
+ so the resulting name is writable on every platform.
+ """
cleaned = re.sub(r'[\\/:*?"<>|]', " ", name)
cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".")
- return cleaned[:80] or fallback
+ cleaned = cleaned[:80] or fallback
+ if cleaned.upper() in _WINDOWS_RESERVED_NAMES:
+ return f"{fallback}_{cleaned}"
+ return cleaned
def _narrator_tag(self) -> str:
"""Narrator name used in output file names (see compute_narrator_tag)."""
@@ -480,15 +502,20 @@ class AudiobookConverter:
stem = output_name or f"{file_path.stem}_{self._narrator_tag()}"
# The output files this book will produce (single final file,
- # or one per chapter). Reported on the book_done/book_failed
- # events so the run view can list them in its summary.
+ # or one per chapter — each with its speed-adjusted copy when
+ # SPEED != 1.0, see audio.combine_chunks). Reported on the
+ # book_done/book_failed events so the run view can list them
+ # in its summary.
+ speed_tag = ("" if abs(self.speed - 1.0) < audio.SPEED_EPSILON
+ else f"_{self.speed:g}")
if self.output_format == "m4b" or self.single_file \
or len(sections) == 1:
- self.current_outputs = [f"{stem}.{self.output_format}"]
+ self.current_outputs = [f"{stem}{speed_tag}."
+ f"{self.output_format}"]
else:
self.current_outputs = [
f"{stem}_{index:02d}_"
- f"{self._sanitize_filename(section.title)}."
+ f"{self._sanitize_filename(section.title)}{speed_tag}."
f"{self.output_format}"
for index, section in enumerate(sections, 1)]
@@ -930,7 +957,11 @@ class AudiobookConverter:
self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
- results = {}
+ # Per-book outcome ({book file name: ok}), published on the
+ # instance so multi-book orchestrators (the "All" run) can count
+ # partial success after run() returns False (a failed book aborts
+ # the rest, but earlier books still count).
+ self.results = {}
cancelled = False
for index, (book_file, output_name) in enumerate(planned, 1):
self._check_cancelled()
@@ -938,7 +969,7 @@ class AudiobookConverter:
"name": book_file.name})
try:
success = self.convert_book(book_file, output_name=output_name)
- results[book_file.name] = success
+ self.results[book_file.name] = success
self._emit({"kind": "book_done", "name": book_file.name,
"ok": bool(success),
"files": list(getattr(self, "current_outputs", []))})
@@ -949,21 +980,21 @@ class AudiobookConverter:
break
except KeyboardInterrupt:
self._say("\n[WARNING] Conversion interrupted by user")
- results[book_file.name] = False
+ self.results[book_file.name] = False
break
except Exception as exc:
logger.error("Unexpected error: %s", exc)
- results[book_file.name] = False
+ self.results[book_file.name] = False
self._emit({"kind": "book_failed", "name": book_file.name,
"error": str(exc),
"files": list(getattr(self, "current_outputs", []))})
- if not results.get(book_file.name):
+ if not self.results.get(book_file.name):
logger.error("Conversion of %s failed; aborting the remaining books",
book_file.name)
break
- successful = sum(results.values())
- total = len(results)
+ successful = sum(self.results.values())
+ total = len(self.results)
# A cancelled run is not a successful run on either path (the TUI
# event consumer and the console summary report it consistently).
ok = not cancelled and total > 0 and successful == total
@@ -979,7 +1010,7 @@ class AudiobookConverter:
print(f"Total: {total} | Success: {successful} | Failed: {total - successful}")
print("=" * 70)
- for filename, success in results.items():
+ for filename, success in self.results.items():
status = "[OK]" if success else "[FAIL]"
print(f"{status} {filename}")