aboutsummaryrefslogtreecommitdiff
path: root/app/converter/converter.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/converter.py')
-rw-r--r--app/converter/converter.py148
1 files changed, 132 insertions, 16 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py
index 3b878c8..88e17a9 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -1,7 +1,9 @@
"""Orchestrates book-to-audiobook conversion."""
+import contextlib
import glob
import logging
+import math
import re
import shutil
import sys
@@ -64,6 +66,50 @@ CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
LOGS_FOLDER = logging_kit.LOG_DIR
DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
+
+@contextlib.contextmanager
+def _conversion_lock():
+ """An exclusive cross-process lock over the shared scratch folder.
+
+ Every converter run from this checkout synthesizes into the same
+ ``app/chunks`` directory (fixed chunk_NNNN names, cleaned per book), so
+ two overlapping runs would delete or overwrite each other's audio.
+ The lock makes a second run fail fast instead. It is an advisory
+ fcntl/msvcrt lock that dies with its process, so a crashed run never
+ leaves a stale lock wedging later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ CHUNKS_FOLDER.mkdir(parents=True, exist_ok=True)
+ handle = open(CHUNKS_FOLDER / ".conversion.lock", "w")
+ handle.write("0")
+ handle.flush()
+ try:
+ busy: Optional[OSError] = None
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ try:
+ import msvcrt
+ try:
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ logger.warning("No file locking on this platform; the "
+ "conversion lock is not enforced")
+ if busy is not None:
+ raise RuntimeError(
+ "Another conversion is already running from this "
+ "installation: the shared scratch folder (app/chunks) is "
+ "in use. Wait for the run to finish or stop it first.")
+ yield
+ finally:
+ handle.close() # releases the lock
+
# Output containers and supported input formats.
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
@@ -137,9 +183,10 @@ def voice_mode_for(backend: str, voice: Optional[str] = None,
sglomni resolves from the selected model's capability — a design model
takes instructions, a clone-capable model clones when a reference .wav
is given and otherwise synthesizes its default voice, and a
- speaker-capable model takes a preset name; qwen designs with
- instructions, clones only with a reference .wav, and uses a built-in
- speaker otherwise), so the hub can run the pre-flight overwrite checks
+ speaker-capable model takes a preset name; qwen clones with a
+ reference .wav, takes a built-in speaker otherwise (instructions
+ sent as that model's delivery control), and designs with
+ instructions alone), so the hub can run the pre-flight overwrite checks
against exactly the output names the conversion will produce.
"""
if backend == BACKEND_FASTER:
@@ -157,6 +204,17 @@ def voice_mode_for(backend: str, voice: Optional[str] = None,
return VOICE_MODE_CUSTOM
# Unresolved model (the caller resolves it later): the qwen-style
# heuristic is the closest pre-flight approximation.
+ if backend == BACKEND_QWEN:
+ # Mirrors the CLI routing (audiobook.convert): a reference clone
+ # wins, then a built-in speaker (with instructions as that
+ # model's delivery control), then instructions alone (VoiceDesign).
+ if clone:
+ return VOICE_MODE_CLONE
+ if (voice or "").strip():
+ return VOICE_MODE_CUSTOM
+ if (instructions or "").strip():
+ return VOICE_MODE_DESIGN
+ return VOICE_MODE_CUSTOM
if (instructions or "").strip():
return VOICE_MODE_DESIGN
return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM
@@ -239,11 +297,11 @@ def chunk_clamp_needed(entry) -> bool:
def chunk_clamp_message(entry) -> List[str]:
"""The popup text for a chunk_words-capped ENTRY (short lines)."""
return [
- f"{entry.label} can narrate at most ~{entry.chunk_words} words "
- "per request: the server caps",
- "each request's prompt plus generation at a fixed window. "
- "Longer sub-chunks",
- "may cut off mid-sentence.",
+ f"{entry.label} narrates best at ~{entry.chunk_words} words "
+ "per request on this server:",
+ "each request's prompt plus generation must fit a fixed "
+ "window, so longer",
+ "sub-chunks may cut off mid-sentence.",
]
@@ -293,6 +351,9 @@ class AudiobookConverter:
# Class-level default so a partially-constructed instance (tests build
# these with __new__) behaves like a plain console run.
_progress = None
+ # Same for the per-run chunk override: without __init__ there is no
+ # clamp, so chunking follows the config.CHUNK_SIZE setting.
+ chunk_size = None
def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
@@ -307,8 +368,12 @@ class AudiobookConverter:
unload_models: Optional[bool] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
- if speed <= 0:
- raise ValueError(f"Speed must be a positive number, got {speed}")
+ # Non-finite speeds pass a bare "> 0" check (and float("inf") is
+ # "positive"): they would loop forever in atempo filter chaining,
+ # so they are rejected here at the boundary.
+ if not math.isfinite(speed) or speed <= 0:
+ raise ValueError(
+ f"Speed must be a finite positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
raise ValueError(f"Unsupported output format: {output_format}")
if backend is None:
@@ -328,6 +393,12 @@ class AudiobookConverter:
self.backend = backend
self.voice = voice
self.debug = bool(debug)
+ # Per-run chunk-word override (the pre-flight popup's clamp for
+ # models whose engine caps one request below a full sub-chunk,
+ # SGLang-Omni's Higgs): caps the chapter chunks themselves so
+ # progress and debug dumps span one generation request each.
+ # None follows the config.CHUNK_SIZE setting.
+ self.chunk_size = chunk_size
# The run's model selection (audio.cpp: a server entry id, sglomni:
# the resolved catalog key, None elsewhere) — the startup banner
# reports it.
@@ -392,10 +463,9 @@ class AudiobookConverter:
"catalog key for --model (see the backend docs).")
model_id = entry.key
self.model_id = model_id
- # CHUNK_SIZE carries a per-run override (the pre-flight chunk
- # popup's clamp for models whose engine caps one request below
- # a full sub-chunk); None follows the config.CHUNK_SIZE setting.
- self.chunk_size = chunk_size
+ # The catalog/request shape is resolved below; the per-run
+ # chunk override (self.chunk_size) was recorded above and
+ # reaches the client here.
self.tts = SgOmniTTSClient(
chunks_dir=CHUNKS_FOLDER, model=model_id, voice=voice,
ref_audio=voice_clone_ref_audio,
@@ -747,6 +817,9 @@ class AudiobookConverter:
return False
output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ # Same rule as _convert_text: honor a cancel that arrived during
+ # the final chapter's synthesis instead of assembling anyway.
+ self._check_cancelled()
if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path,
chunks_dir=CHUNKS_FOLDER,
speed=self.speed,
@@ -827,8 +900,14 @@ class AudiobookConverter:
return results
def _chapter_chunks(self, text: str) -> List[str]:
- """Split chapter text into CHUNK_SIZE-word TTS requests."""
- return chunking.split_into_chunks(text)
+ """Split chapter text into word-capped TTS request chunks.
+
+ Each chunk is one progress unit; when the pre-flight popup set
+ a per-run clamp (models whose engine caps one request below
+ CHUNK_SIZE), it caps these chunks too so a chunk is never
+ wider than one generation request.
+ """
+ return chunking.split_into_chunks(text, max_words=self.chunk_size)
def _convert_text(self, text: str, output_path: Path, start_time: float,
speed: Optional[float] = None,
@@ -884,6 +963,9 @@ class AudiobookConverter:
successful_chunks, total_chunks)
return False
+ # A cancel that arrived while the last chunk was generating
+ # must still stop the run here, before any output is produced.
+ self._check_cancelled()
success = audio.combine_chunks(total_chunks, output_path,
chunk_results=results,
chunks_dir=CHUNKS_FOLDER,
@@ -1061,6 +1143,27 @@ class AudiobookConverter:
tag = f"{name_tag}_{narrator_tag}" if name_tag else narrator_tag
names.append((book_file, f"{name}_{tag}"))
+ # The stem-collision suffix above can itself collide with another
+ # book's natural stem ("book.txt" and "book_txt.txt" both produce
+ # "book_txt_<tag>"), so make every planned name unique across the
+ # batch: two books sharing an output name would silently overwrite
+ # each other (ffmpeg writes with -y and both are approved here,
+ # before any output exists). Later occurrences get a numeric
+ # suffix; the list is sorted, so the result is deterministic.
+ used: set = set()
+ unique: List[Tuple[Path, str]] = []
+ for book_file, name in names:
+ if name in used:
+ suffix = 2
+ while f"{name}_{suffix}" in used:
+ suffix += 1
+ print(f"[INFO] Output name {name} is already planned for "
+ f"another book; {book_file.name} becomes {name}_{suffix}")
+ name = f"{name}_{suffix}"
+ used.add(name)
+ unique.append((book_file, name))
+ names = unique
+
# Ask every overwrite question up front, before any conversion
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
@@ -1110,6 +1213,19 @@ class AudiobookConverter:
self._emit({"kind": "done", "ok": 0, "total": 0})
return True
+ # The scratch folder is shared by every run from this checkout;
+ # hold the cross-process lock for the whole conversion.
+ with _conversion_lock():
+ return self._convert_planned_books(book_files, planned, run_start)
+
+ def _convert_planned_books(self, book_files, planned,
+ run_start: float) -> bool:
+ """Convert PLANNED books sequentially (the body of ``run``).
+
+ BOOK_FILES/PLANNED come from the pre-flight (outer or caller);
+ RUN_START timestamps the console summary. Returns True when every
+ planned book converted.
+ """
self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
# Per-book outcome ({book file name: ok}), published on the