diff options
Diffstat (limited to 'app/converter/converter.py')
| -rw-r--r-- | app/converter/converter.py | 148 |
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 |
