diff options
| author | historia <historiavg@proton.me> | 2026-09-01 14:32:05 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-01 14:32:05 -0400 |
| commit | 6cfcd564c0684c52618235e6366f4a81c02b9a5b (patch) | |
| tree | 55321760a8103bc6b5d79489fac4135a60e6e3ba /app/converter | |
| parent | dc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (diff) | |
| download | tts-audiobook-generator-6cfcd564c0684c52618235e6366f4a81c02b9a5b.tar.gz | |
slop refactor/dedup
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/audio.py | 8 | ||||
| -rw-r--r-- | app/converter/clients/__init__.py | 8 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 442 | ||||
| -rw-r--r-- | app/converter/clients/base.py | 9 | ||||
| -rw-r--r-- | app/converter/clients/faster.py | 10 | ||||
| -rw-r--r-- | app/converter/clients/qwen.py | 4 | ||||
| -rw-r--r-- | app/converter/converter.py | 67 | ||||
| -rw-r--r-- | app/converter/cover.py | 8 | ||||
| -rw-r--r-- | app/converter/extractors.py | 117 |
9 files changed, 255 insertions, 418 deletions
diff --git a/app/converter/audio.py b/app/converter/audio.py index 58cd5a5..9df7aff 100644 --- a/app/converter/audio.py +++ b/app/converter/audio.py @@ -46,14 +46,6 @@ def atempo_filters(speed: float) -> str: return ",".join(chain) -def speed_export_params(speed: float) -> List[str]: - """Return ffmpeg filter args for pitch-preserving speed adjustment.""" - filters = atempo_filters(speed) - if not filters: - return [] - return ["-filter:a", filters] - - def _concat_escape(path: str) -> str: """Escape a path for use inside single quotes in an ffmpeg concat list.""" return path.replace("'", "'\\''") diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index a107d29..cdb7912 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -51,8 +51,8 @@ from .audiocpp import ( audiocpp_family_voice_policy, audiocpp_request_error, audiocpp_script_input, + audiocpp_voice_for_run, allocation_log_note, - build_trimmed_voice_reference, nvidia_device_memory_report, ) @@ -83,12 +83,10 @@ __all__ = [ "AUDIOCPP_VOICE_OPTIONAL", "AUDIOCPP_VOICE_NONE", "AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE", "AUDIOCPP_FAMILY_PROFILES", "audiocpp_entry_voice_capability", - "audiocpp_entry_supports_design", + "audiocpp_entry_supports_design", "audiocpp_voice_for_run", "audiocpp_family_narrates", "audiocpp_family_spec_tasks", "audiocpp_family_voice_policy", "audiocpp_request_error", "audiocpp_script_input", - "allocation_log_note", "build_trimmed_voice_reference", - "nvidia_device_memory_report", "spec_request_option_names", + "allocation_log_note", "nvidia_device_memory_report", "AUDIOCPP_VOICE_REQUIRED_FAMILIES", "AUDIOCPP_ALLOCATION_FRAGMENTS", - "AUDIOCPP_REFERENCE_TRIM_SECONDS", ] diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index 3637a50..98d64ba 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -1,20 +1,15 @@ """Client for the audio.cpp audiocpp_server (native ggml TTS families).""" -import array -import base64 import json import logging import shutil -import struct import subprocess -import sys import tempfile import urllib.error import urllib.parse import urllib.request -import wave from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set from .. import config from ..audio import concat_audio_files @@ -22,7 +17,8 @@ from ..chunking import split_into_chunks from .base import (BaseTTSClient, ConversionCancelled, NonRetryableTTSError, resolve_request_seed) from .languages import LANGUAGE_ISO_CODES, normalize_language -from .speakers import is_builtin_speaker, speaker_display_name_for +from .speakers import QWEN3_TTS_SPEAKERS, is_builtin_speaker, \ + speaker_display_name_for logger = logging.getLogger(__name__) @@ -129,13 +125,10 @@ def _ALLOCATION_HINT_TEXT() -> str: "The server ran out of device memory while building a compute " "graph: check what else is using the GPU, and read the server's " "log (app/logs/audiocpp-server.log), which records the exact " - "allocation size it attempted. Cloning families that encode the " - "whole reference with attention over its length (MOSS-TTS-Local) " - "retry automatically with a shorter reference when the voice's " - "wav is readable locally. For DramaBox, adding \"session_options\": " - "{\"dramabox.mem_saver\": \"true\"} to its server.json model entry " - "trades speed for a much lower memory peak (restart the server " - "after editing).") + "allocation size it attempted. For DramaBox, adding " + "\"session_options\": {\"dramabox.mem_saver\": \"true\"} to its " + "server.json model entry trades speed for a much lower memory " + "peak (restart the server after editing).") # Deterministic failures whose one-line server message is not actionable @@ -224,18 +217,10 @@ AUDIOCPP_VOICE_REQUIRED = "required" # clone-only: a reference voice is mandato AUDIOCPP_VOICE_OPTIONAL = "optional" # tts + clone: blank voice means plain TTS AUDIOCPP_VOICE_NONE = "none" # pure TTS: no cloning, no voice at all -# The allocation-failure fragments that trigger the server-log detail and -# the trimmed-reference retry (see AUDIOCPP_NON_RETRYABLE_ERRORS). +# The allocation-failure fragments that trigger the server-log detail +# (see AUDIOCPP_NON_RETRYABLE_ERRORS and allocation_log_note). AUDIOCPP_ALLOCATION_FRAGMENTS = ("failed to allocate", "allocation failed") -# A cloned reference long enough to blow up reference-attention encoders -# (MOSS-TTS-Local's codec encoder: memory grows with the reference's -# square) is retried as this many seconds of the same voice, read from -# the voice's local wav and sent as a base64 voice_ref (bounded by the -# server's 5 MiB inline-reference limit). -AUDIOCPP_REFERENCE_TRIM_SECONDS = 30.0 -_AUDIOCPP_VOICE_REF_MAX_BYTES = 5 * 1024 * 1024 - # Warn about a nearly-full local GPU before the first request: with # another process holding the memory, even small graph allocations fail. _LOW_FREE_DEVICE_MIB = 4096 @@ -288,27 +273,6 @@ def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: return {str(task) for task in spec["tasks"]} -def spec_request_option_names(family: str) -> Set[str]: - """FAMILY's accepted request-option names from its local model spec. - - Used to decide whether an option may be attached to a request (e.g. - the reference_text carried alongside an inline voice_ref): families - whose runtime validates request options against the spec would reject - an unknown key outright. An unknown family (no local spec) yields an - empty set — the caller then omits the option rather than risking a - rejection. - """ - spec = _family_spec(family) - if not spec: - return set() - options = spec.get("options") - request = options.get("request") if isinstance(options, dict) else None - if not isinstance(request, list): - return set() - return {str(item["name"]) for item in request - if isinstance(item, dict) and item.get("name")} - - def audiocpp_entry_supports_design(family: str, task: str, model_id: str) -> bool: """Whether a server model entry can design a voice from a description. @@ -431,34 +395,51 @@ def _reference_text_error(voice: Optional[str], server_message: str) -> str: ) +def _http_error_body(exc: urllib.error.HTTPError) -> str: + """The FULL decoded HTTP error body (classified before truncation). + + Deterministic-error detection matches fragments that can sit deep in a + long server message, so the body must be read whole; only the final + user-facing text is capped (see audiocpp_request_error). + """ + try: + return exc.read().decode("utf-8", errors="replace") + except Exception: + return "" + + def audiocpp_request_error(status: int, detail: str, voice: Optional[str] = None, log_note: Optional[str] = None) -> Exception: """The exception for a failed audio.cpp speech request. - Deterministic request-configuration errors (a fragment in - AUDIOCPP_NON_RETRYABLE_ERRORS) become NonRetryableTTSError so the - chunk retry loop skips attempts that cannot succeed; clone-only - hosting errors (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host - hint, hinted errors (AUDIOCPP_HINTED_ERRORS) their per-fragment - guidance; everything else returns the plain RuntimeError the retry - loop has always retried. LOG_NOTE, when given for an allocation - failure, appends the server log's own record of the failed - allocation (the exact size it attempted, from the managed server's - log file) to the non-retryable message. + DETAIL is the full HTTP error body. Deterministic request- + configuration errors (a fragment in AUDIOCPP_NON_RETRYABLE_ERRORS) + become NonRetryableTTSError so the chunk retry loop skips attempts + that cannot succeed; clone-only hosting errors + (AUDIOCPP_CLONE_ONLY_ERRORS) also carry the re-host hint, hinted + errors (AUDIOCPP_HINTED_ERRORS) their per-fragment guidance; + everything else returns the plain RuntimeError the retry loop has + always retried. Classification runs on the FULL body — only the + message quoted in the final text is truncated to keep it readable. + LOG_NOTE, when given for an allocation failure, appends the server + log's own record of the failed allocation (the exact size it + attempted, from the managed server's log file) to the non-retryable + message. """ message = _server_error_message(detail) lowered = message.lower() + shown = message[:200] allocation_failure = any( fragment in lowered for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS) error: Exception if _REFERENCE_TEXT_FRAGMENT in lowered: error = NonRetryableTTSError( - _reference_text_error(voice, message)) + _reference_text_error(voice, shown)) elif any(fragment in lowered for fragment in AUDIOCPP_CLONE_ONLY_ERRORS): error = NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " - f"{message}. This model family only synthesizes by cloning a " + f"{shown}. This model family only synthesizes by cloning a " "reference voice, so its server entry must be hosted with task " '"clon" — re-run Configure Backends → audio.cpp (or edit ' "server.json) and restart the server.") @@ -469,15 +450,15 @@ def audiocpp_request_error(status: int, detail: str, if hinted is not None: error = NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " - f"{message}. {hinted}") + f"{shown}. {hinted}") elif any(fragment in lowered for fragment in AUDIOCPP_NON_RETRYABLE_ERRORS): error = NonRetryableTTSError( f"audio.cpp server returned HTTP {status} (not retryable): " - f"{message}") + f"{shown}") else: error = RuntimeError( - f"audio.cpp server returned HTTP {status}: {detail}") + f"audio.cpp server returned HTTP {status}: {shown}") if allocation_failure and log_note \ and isinstance(error, NonRetryableTTSError): error = NonRetryableTTSError(f"{error}{log_note}") @@ -540,125 +521,18 @@ def nvidia_device_memory_report() -> Optional[str]: return result.stdout.strip() -def _pcm16_mono_samples(path: Path, max_seconds: float - ) -> Optional[Tuple[int, List[int]]]: - """The first MAX_SECONDS of a wav as (sample rate, mono PCM16 samples). - - Reads with the stdlib wave module (PCM u8/s16/s24/s32), mixes channels - by averaging, and stops at MAX_SECONDS so a long reference costs only - the frames actually sent. Returns None for files the wave module - cannot parse (float-format wavs, non-WAV files) or that carry no - samples — the trimmed-reference retry then does not fire. - """ - max_frames = 0 - if max_seconds > 0: - try: - with wave.open(str(path), "rb") as probe: - rate = probe.getframerate() - max_frames = int(max_seconds * rate) + 1 - except (OSError, EOFError, wave.Error): - return None - try: - with wave.open(str(path), "rb") as handle: - rate = handle.getframerate() - channels = handle.getnchannels() - width = handle.getsampwidth() - frames = handle.getnframes() - raw = handle.readframes(min(frames, max_frames) if max_frames - else frames) - except (OSError, EOFError, wave.Error): - return None - if rate <= 0 or channels <= 0 or width not in (1, 2, 3, 4) or not raw: - return None - frame_bytes = width * channels - frame_count = len(raw) // frame_bytes - if frame_count <= 0: - return None - if width == 2: - values = array.array("h") - values.frombytes(raw[:frame_count * frame_bytes]) - if sys.byteorder == "big": - values.byteswap() - elif width == 1: - # u8 -> s16, scaled to the full 16-bit range. - values = array.array("h", ((byte - 128) << 8 - for byte in raw[:frame_count])) - else: - # s24/s32 -> s16 by dropping the low bits (keeps every value inside - # int16 so the mixdown and the PCM16 container need no clipping). - values = array.array( - "i", (int.from_bytes(raw[i * width:i * width + width], - "little", signed=True) - for i in range(frame_count))) - shift = 8 if width == 3 else 16 - values = array.array("h", (value >> shift for value in values)) - if channels == 1: - return rate, values.tolist() - mixed: List[int] = [] - for index in range(frame_count): - start = index * channels - mixed.append(sum(values[start:start + channels]) // channels) - return rate, mixed - - -def pcm16_wav_bytes(sample_rate: int, samples: List[int]) -> bytes: - """Wrap mono PCM16 samples in a minimal RIFF/WAVE container.""" - data = array.array("h", samples) - if sys.byteorder == "big": - data.byteswap() - payload = data.tobytes() - return (b"RIFF" - + struct.pack("<I", 36 + len(payload)) - + b"WAVEfmt " + struct.pack("<IHHIIHH", 16, 1, 1, - sample_rate, sample_rate * 2, 2, 16) - + b"data" + struct.pack("<I", len(payload)) - + payload) - - -def build_trimmed_voice_reference( - path: Optional[Path], max_seconds: float = AUDIOCPP_REFERENCE_TRIM_SECONDS, - max_bytes: int = _AUDIOCPP_VOICE_REF_MAX_BYTES - ) -> Optional[Tuple[str, float, str]]: - """A base64 voice_ref of the first MAX_SECONDS of a reference wav. - - Returns (base64 wav, seconds used, file name), or None when PATH is - missing or unreadable. The payload keeps the file's native sample rate - and is mixed to mono; it is further capped so the decoded bytes stay - within the server's 5 MiB inline-reference limit (16-bit mono means - ~2.6 MB per 30 s at 44.1 kHz, comfortably inside). - """ - if path is None: - return None - decoded = _pcm16_mono_samples(path, max_seconds) - if decoded is None: - return None - rate, samples = decoded - if not samples: - return None - byte_cap_seconds = max_bytes / (2 * rate) - frames = int(min(max_seconds, byte_cap_seconds) * rate) - samples = samples[:frames] - if not samples: - return None - wav = pcm16_wav_bytes(rate, samples) - return base64.b64encode(wav).decode("ascii"), len(samples) / rate, path.name - - class AudioCppFamilyProfile: """Request conventions of one audio.cpp model family. - Language style, whether the family reads a style/instruction prompt, - and how the request text is formatted; these are family-level (every - entry of a family shares them). Whether a *specific entry* has - built-in speakers is an entry-level concern, decided by - audiocpp_entry_voice_capability, not this profile. + Language style and how the request text is formatted; these are + family-level (every entry of a family shares them). Whether a + *specific entry* has built-in speakers is an entry-level concern, + decided by audiocpp_entry_voice_capability, not this profile. """ def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT, - sends_instructions: bool = False, script_prefix: Optional[str] = None): self.language_style = language_style - self.sends_instructions = sends_instructions # SCRIPT_PREFIX, when set, formats every request's text as one # "<prefix>: text" script line (audiocpp_script_input): the # family's server implementation parses the prompt as a @@ -667,17 +541,15 @@ class AudioCppFamilyProfile: # Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES: -# clone-only, no style instructions, and no language field (the model -# detects the language itself). Describes higgs_audio_tts, voxcpm2, -# fish_audio, dots_tts, dramabox, omnivoice, outetts, glm_tts, miotts, -# moss_tts_*, pocket_tts, ... as well as families added to -# audio.cpp after this table was written. +# clone-only and no language field (the model detects the language +# itself). Describes higgs_audio_tts, voxcpm2, fish_audio, dots_tts, +# dramabox, omnivoice, outetts, glm_tts, miotts, moss_tts_*, pocket_tts, +# ... as well as families added to audio.cpp after this table was written. AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile() AUDIOCPP_FAMILY_PROFILES = { AUDIOCPP_FAMILY_QWEN3_TTS: AudioCppFamilyProfile( language_style=AUDIOCPP_LANG_DISPLAY, - sends_instructions=True, ), # Families whose language option takes a code (e.g. "en") instead of # a Qwen display name; otherwise clone-only like the default profile. @@ -727,13 +599,37 @@ def audiocpp_entry_voice_capability(family: str, task: str, return AUDIOCPP_VOICE_CLONE -class AudioCppTTSClient(BaseTTSClient): - # Class-level defaults so a partially-constructed instance behaves like - # a fresh run (tests build clients via __new__; see BaseTTSClient). - _voice_ref_b64: Optional[str] = None - _voice_ref_reference_text: Optional[str] = None - _reference_trim_attempted = False +def audiocpp_voice_for_run(family: str, task: str, model_id: str, + picked: Optional[str], + entry_voices: List[str]) -> Optional[str]: + """The voice one conversion of this entry sends, given a shared pick. + + The single source of the per-model voice resolution the Generate + form's "All (multiple generation)" pick uses: the picked voice wins + wherever the model accepts it (a built-in speaker on the CustomVoice + entry, a server-side preset on a clone-capable one); models the pick + cannot serve fall back to their own default — the first built-in + speaker, or the first server voice — or to no voice at all (design + entries take the Instructions text instead; pure-TTS families take + no voice). Pure, so the form can call it per configured entry. + """ + capability = audiocpp_entry_voice_capability(family, task, model_id) + if capability == AUDIOCPP_VOICE_DESIGN: + return None + if capability == AUDIOCPP_VOICE_SPEAKER: + if picked and picked in QWEN3_TTS_SPEAKERS: + return picked + return QWEN3_TTS_SPEAKERS[0] + # Clone capability; the family policy decides whether a voice + # exists at all. + if audiocpp_family_voice_policy(family) == AUDIOCPP_VOICE_NONE: + return None + if picked and picked in entry_voices: + return picked + return entry_voices[0] if entry_voices else None + +class AudioCppTTSClient(BaseTTSClient): """Generates audio chunks through an audio.cpp audiocpp_server. Talks to the OpenAI-style HTTP API of audiocpp_server, which hosts TTS @@ -801,8 +697,9 @@ class AudioCppTTSClient(BaseTTSClient): instructions: Optional[str] = None, request_options: Optional[Dict[str, str]] = None, quiet: bool = False, - unload_models: Optional[bool] = None): - super().__init__(chunks_dir, quiet=quiet) + unload_models: Optional[bool] = None, + cancel=None): + super().__init__(chunks_dir, quiet=quiet, cancel=cancel) self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/") # Per-run model selection: the --model CLI flag (or the Generate # form's Model pick). An empty value is resolved at connect time @@ -850,15 +747,6 @@ class AudioCppTTSClient(BaseTTSClient): self.design_mode = False self.instruction_voice = False self.plain_mode = False - # Trimmed-reference retry state (see _switch_to_trimmed_reference): - # an inline base64 voice_ref that replaces the voice name after an - # allocation failure, the transcript carried alongside it, and the - # one-attempt guard. The class-level defaults above keep partially - # constructed instances (tests via __new__) behaving like a fresh - # run; the assignments here shadow them for this instance. - self._voice_ref_b64 = None - self._voice_ref_reference_text = None - self._reference_trim_attempted = False # Family and task of the selected model entry and the family's request # profile; all are resolved from GET /v1/models during _connect. self.family = "" @@ -1058,13 +946,10 @@ class AudioCppTTSClient(BaseTTSClient): with urllib.request.urlopen(url, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: - detail = "" - try: - detail = exc.read().decode("utf-8", errors="replace")[:200] - except Exception: - pass + detail = _http_error_body(exc) raise RuntimeError( - f"audio.cpp server returned HTTP {exc.code} for {path}: {detail}") from exc + f"audio.cpp server returned HTTP {exc.code} for {path}: " + f"{detail[:200]}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"audio.cpp request failed for {path}: {exc.reason}") from exc @@ -1232,7 +1117,7 @@ class AudioCppTTSClient(BaseTTSClient): ) # ------------------------------------------------------------------ - # Reference trimming and device diagnostics + # Device diagnostics # ------------------------------------------------------------------ def _warn_low_device_memory(self) -> None: @@ -1268,131 +1153,6 @@ class AudioCppTTSClient(BaseTTSClient): "condition usually mean another process is using " "the GPU.") - def _voice_wav_path(self) -> Optional[Path]: - """The selected voice's reference wav on this machine, when readable. - - Resolved from the local checkout's server.json exactly like the - server resolves the request's voice name: the entry's - ``voice_presets[name].voice_ref`` first, then ``voice_dir/<name>.wav``. - Only preset-mode runs with a locally readable file return a path — - remote-only servers (or voice names that only exist server-side) - yield None and the trimmed-reference retry does not fire. - """ - if not self.voice: - return None - name = self.voice - if not name or name in (".", "..") or "/" in name or "\\" in name: - return None - try: - # Imported lazily: backends.audiocpp imports this package, so a - # module-level import would cycle. - from backends.audiocpp.build import find_local_checkout - checkout = find_local_checkout() - except Exception: # noqa: BLE001 - best effort - return None - if checkout is None: - return None - server_json = checkout / "server.json" - try: - data = json.loads(server_json.read_text(encoding="utf-8")) - except (OSError, ValueError): - return None - if not isinstance(data, dict): - return None - for entry in data.get("models") or []: - if not isinstance(entry, dict) or entry.get("id") != self.model_id: - continue - presets = entry.get("voice_presets") - if isinstance(presets, dict): - preset = presets.get(name) - if isinstance(preset, dict): - ref = preset.get("voice_ref") - if isinstance(ref, str) and ref: - path = Path(ref) - if not path.is_absolute(): - path = server_json.parent / ref - if path.is_file(): - return path - voice_dir = data.get("voice_dir") - if isinstance(voice_dir, str) and voice_dir: - candidate = Path(voice_dir) / f"{name}.wav" - if candidate.is_file(): - return candidate - return None - - def _voice_transcript(self) -> Optional[str]: - """The voice library transcript for the selected voice, or None. - - Read from the voice directory's prompt_text mapping (the same file - the server consults when it resolves a voice NAME); only carried - alongside an inline voice_ref, where the server's own injection is - bypassed. - """ - if not self.voice: - return None - try: - # Imported lazily: backends.audiocpp imports this package, so a - # module-level import would cycle. - from backends.common import PROMPT_TEXT_FILENAME, read_prompt_text - from backends.audiocpp.build import find_local_checkout - checkout = find_local_checkout() - except Exception: # noqa: BLE001 - best effort - return None - if checkout is None: - return None - try: - data = json.loads((checkout / "server.json") - .read_text(encoding="utf-8")) - except (OSError, ValueError): - return None - voice_dir = data.get("voice_dir") if isinstance(data, dict) else None - if not isinstance(voice_dir, str) or not voice_dir: - return None - try: - return (read_prompt_text(Path(voice_dir) / PROMPT_TEXT_FILENAME) - .get(self.voice) or None) - except OSError: - return None - - def _switch_to_trimmed_reference(self, server_message: str) -> bool: - """Switch the cloning reference to a trimmed local wav, once. - - Some families encode the whole reference with attention over its - length (MOSS-TTS-Local's codec encoder: the required memory grows - with the reference's square), so a long voice reference fails the - graph allocation regardless of how much VRAM the device has. When - the selected voice resolves to a locally readable wav, replace the - voice name with an inline base64 voice_ref cut to - AUDIOCPP_REFERENCE_TRIM_SECONDS and retry the request once; the - trimmed reference then applies to the rest of the run. Only fires - on allocation-failure messages for preset-mode runs; everything - else keeps the original behavior. - """ - lowered = server_message.lower() - if self._reference_trim_attempted \ - or not any(fragment in lowered - for fragment in AUDIOCPP_ALLOCATION_FRAGMENTS) \ - or self.design_mode or self.instruction_voice \ - or self.plain_mode or not self.voice: - return False - self._reference_trim_attempted = True - trimmed = build_trimmed_voice_reference(self._voice_wav_path()) - if trimmed is None: - return False - b64, seconds, source = trimmed - self._voice_ref_b64 = b64 - transcript = self._voice_transcript() - if transcript and "reference_text" in spec_request_option_names( - self.family): - self._voice_ref_reference_text = transcript - self._report( - f"[INFO] {source}'s family encodes the whole reference with " - f"attention over its length; retrying with the first " - f"{seconds:.0f}s of voice '{self.voice}' as the cloning " - "reference (the trimmed reference applies to the rest of this " - "run).") - return True - # ------------------------------------------------------------------ # HTTP requests # ------------------------------------------------------------------ @@ -1413,13 +1173,8 @@ class AudioCppTTSClient(BaseTTSClient): # Design models take no voice field (the voice comes from the # instruction); instruction-voice runs on families without built-in # speakers omit it too, since no speaker or preset was requested; - # plain-TTS runs (no reference voice needed) omit it likewise. A - # trimmed-reference retry replaces the voice name with an inline - # base64 voice_ref (see _switch_to_trimmed_reference). - if self._voice_ref_b64 is not None: - payload["voice_ref"] = {"type": "base64", - "data": self._voice_ref_b64} - elif not self.design_mode and not self.instruction_voice \ + # plain-TTS runs (no reference voice needed) omit it likewise. + if not self.design_mode and not self.instruction_voice \ and not self.plain_mode: payload["voice"] = self.voice if self.profile.language_style == AUDIOCPP_LANG_DISPLAY: @@ -1440,17 +1195,10 @@ class AudioCppTTSClient(BaseTTSClient): # Explicit voice-design or style instruction (required for task # "vdes" entries; a Ctrl/style control on families that read it). payload["instructions"] = self.instructions - options = dict(self.request_options) - if self._voice_ref_reference_text \ - and "reference_text" not in options: - # The server only injects the voice library's transcript when it - # resolves the voice NAME; an inline voice_ref bypasses that, so - # carry the transcript explicitly for families that accept it. - options["reference_text"] = self._voice_ref_reference_text - if options: + if self.request_options: # Generic per-model controls (--option KEY=VALUE): forwarded # verbatim; the model ignores keys it does not know. - payload["options"] = options + payload["options"] = dict(self.request_options) request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST") @@ -1459,16 +1207,8 @@ class AudioCppTTSClient(BaseTTSClient): with urllib.request.urlopen(request, timeout=timeout) as response: wav = response.read() except urllib.error.HTTPError as exc: - detail = "" - try: - detail = exc.read().decode("utf-8", errors="replace")[:200] - except Exception: - pass + detail = _http_error_body(exc) message = _server_error_message(detail) - if self._switch_to_trimmed_reference(message): - # One retry with the trimmed reference; if that fails too the - # error below carries the server log's allocation detail. - return self._request_wav(text) raise audiocpp_request_error(exc.code, detail, voice=self.voice, log_note=allocation_log_note( diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py index 8a0b4e4..9cf6979 100644 --- a/app/converter/clients/base.py +++ b/app/converter/clients/base.py @@ -68,14 +68,17 @@ class BaseTTSClient: cancel = None quiet = False - def __init__(self, chunks_dir: Path, quiet: bool = False): + def __init__(self, chunks_dir: Path, quiet: bool = False, + cancel: Optional[threading.Event] = None): self.chunks_dir = Path(chunks_dir) # Quiet silences console prints (the run view owns the screen). self.quiet = bool(quiet) # Set by the converter when the run is cancellable (the TUI run # view): a threading.Event that, once set, aborts the run between - # requests (and interrupts retry back-off sleeps). - self.cancel = None + # requests (and interrupts retry back-off sleeps). Assigned here — + # before the subclass's connect logic runs — so a cancel pressed + # while the client is still connecting is not lost. + self.cancel = cancel def _report(self, message: str) -> None: """Print a console line unless quiet (the run view owns the screen).""" diff --git a/app/converter/clients/faster.py b/app/converter/clients/faster.py index 48c26ab..eeecee9 100644 --- a/app/converter/clients/faster.py +++ b/app/converter/clients/faster.py @@ -31,8 +31,8 @@ class FasterTTSClient(BaseTTSClient): def __init__(self, chunks_dir: Path, voice: Optional[str] = None, api_url: Optional[str] = None, - quiet: bool = False): - super().__init__(chunks_dir, quiet=quiet) + quiet: bool = False, cancel=None): + super().__init__(chunks_dir, quiet=quiet, cancel=cancel) # The voice is per-run (--voice / the Generate form's Voice pick); # there is no configured default. self.voice = (voice or "").strip() @@ -85,10 +85,12 @@ class FasterTTSClient(BaseTTSClient): except urllib.error.HTTPError as exc: detail = "" try: - detail = exc.read().decode("utf-8", errors="replace")[:200] + detail = exc.read().decode("utf-8", errors="replace") except Exception: pass - raise RuntimeError(f"Faster TTS server returned HTTP {exc.code}: {detail}") from exc + raise RuntimeError( + f"Faster TTS server returned HTTP {exc.code}: {detail[:200]}" + ) from exc except urllib.error.URLError as exc: raise RuntimeError(f"Faster TTS request failed: {exc.reason}") from exc if not pcm: diff --git a/app/converter/clients/qwen.py b/app/converter/clients/qwen.py index 17f14c5..dd1c8ba 100644 --- a/app/converter/clients/qwen.py +++ b/app/converter/clients/qwen.py @@ -33,8 +33,8 @@ class QwenTTSClient(BaseTTSClient): voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, language: Optional[str] = None, api_url: Optional[str] = None, instructions: Optional[str] = None, quiet: bool = False, - voice: Optional[str] = None): - super().__init__(chunks_dir, quiet=quiet) + voice: Optional[str] = None, cancel=None): + super().__init__(chunks_dir, quiet=quiet, cancel=cancel) if voice_mode not in VOICE_MODES: raise ValueError( f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})" 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}") diff --git a/app/converter/cover.py b/app/converter/cover.py index b2d3cb5..9bf9237 100644 --- a/app/converter/cover.py +++ b/app/converter/cover.py @@ -122,6 +122,10 @@ _FONT = { _GLYPH_WIDTH = 5 _GLYPH_HEIGHT = 7 +# Any character outside the embedded font (accented letters, CJK, +# Cyrillic, ...) renders as an empty box: silently dropping it would +# blank those titles while still counting their width for centering. +_UNKNOWN_GLYPH = [0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F] _TEXT_SCALE = 6 # render each font pixel as a 6x6 block _TEXT_MARGIN = 60 # horizontal padding when wrapping _TEXT_COLOR = (255, 255, 255) @@ -194,9 +198,7 @@ def _render_line(pixels: List[List[Tuple[int, int, int]]], text: str, x0: int, y stays tinted by the gradient behind it). """ for char_index, char in enumerate(text): - glyph = _FONT.get(char) - if glyph is None: - continue + glyph = _FONT.get(char, _UNKNOWN_GLYPH) x_off = x0 + char_index * (_GLYPH_WIDTH + 1) * _TEXT_SCALE for gy, bits in enumerate(glyph): for gx in range(_GLYPH_WIDTH): diff --git a/app/converter/extractors.py b/app/converter/extractors.py index f05d451..8b01372 100644 --- a/app/converter/extractors.py +++ b/app/converter/extractors.py @@ -6,7 +6,7 @@ import re import zipfile from html import unescape from pathlib import Path -from typing import List, NamedTuple +from typing import List, NamedTuple, Optional try: from bs4 import BeautifulSoup @@ -39,8 +39,6 @@ def extract_text(file_path: Path) -> str: return _extract_txt(file_path) if extension == ".pdf": return _extract_pdf(file_path) - if extension == ".epub": - return extract_epub(file_path) raise ValueError(f"Unsupported file format: {extension}") @@ -186,19 +184,91 @@ def _read_epub_ebooklib(file_path: Path): def _read_epub_zipfile(file_path: Path): - """Read EPUB HTML members as (title, html) pairs, ordered by filename.""" + """Read EPUB HTML members as (title, html) pairs, in spine order. + + Fallback for EPUBs ebooklib cannot read. The package's OPF describes + the reading order (its ``<spine>`` itemrefs reference manifest items + by id), so documents are emitted in that order; the manifest's + ``properties="nav"`` item (the table of contents) and any document + outside the spine are skipped so the TOC is never narrated as a + chapter. EPUBs without a parsable OPF fall back to natural filename + order over every HTML member. + """ items = [] with zipfile.ZipFile(file_path, "r") as epub_zip: - for file_name in sorted(epub_zip.namelist(), key=_natural_key): - if file_name.lower().endswith((".html", ".xhtml", ".htm")): - try: - content = epub_zip.read(file_name).decode("utf-8", errors="ignore") - items.append((Path(file_name).stem, content)) - except Exception as exc: - logger.debug("Skipping EPUB member %r: %s", file_name, exc) + names = epub_zip.namelist() + html_names = [name for name in names + if name.lower().endswith((".html", ".xhtml", ".htm"))] + order = _epub_spine_order(epub_zip, html_names) + if order is None: + order = sorted(html_names, key=_natural_key) + for file_name in order: + try: + content = epub_zip.read(file_name).decode("utf-8", errors="ignore") + items.append((Path(file_name).stem, content)) + except Exception as exc: + logger.debug("Skipping EPUB member %r: %s", file_name, exc) return items +def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]): + """The EPUB's HTML members in spine order, or None when unparsable. + + Parses the package OPF (located via META-INF/container.xml, else the + only *.opf member): manifest item id -> href, then the spine's + idrefs. Returns member paths limited to HTML_NAMES; nav documents + (``properties`` containing "nav") and non-HTML items are excluded. + """ + container = "META-INF/container.xml" + opf_name = None + try: + rootfile = epub_zip.read(container).decode("utf-8", errors="ignore") + match = re.search(r"full-path\s*=\s*[\"']([^\"']+)[\"']", rootfile) + if match and match.group(1) in epub_zip.namelist(): + opf_name = match.group(1) + except (KeyError, OSError): + pass + if opf_name is None: + opf_candidates = [name for name in epub_zip.namelist() + if name.lower().endswith(".opf")] + if len(opf_candidates) != 1: + return None + opf_name = opf_candidates[0] + try: + opf = epub_zip.read(opf_name).decode("utf-8", errors="ignore") + except (KeyError, OSError): + return None + + def attr(tag: str, name: str) -> Optional[str]: + match = re.search(rf"\b{name}\s*=\s*[\"']([^\"']*)[\"']", tag) + return match.group(1) if match else None + + base = "/".join(opf_name.split("/")[:-1]) + item_tags = re.findall(r"<item\b[^>]*>", opf) + + def is_nav(item_tag: str) -> bool: + properties = attr(item_tag, "properties") or "" + return "nav" in properties.split() + + order: List[str] = [] + for ref_tag in re.findall(r"<itemref\b[^>]*>", opf): + idref = attr(ref_tag, "idref") + if not idref: + continue + match = next((item_tag for item_tag in item_tags + if attr(item_tag, "id") == idref), None) + if match is None or is_nav(match): + continue + href = attr(match, "href") + if not href: + continue + path = (f"{base}/{href}" if base else href) + path = re.sub(r"#.*$", "", path) + if path in html_names and path not in order: + order.append(path) + return order or None + + def _read_epub_manual(file_path: Path): """Last-resort read of any markup-looking EPUB member.""" skipped_extensions = (".jpg", ".jpeg", ".png", ".gif", ".css", ".js") @@ -219,15 +289,17 @@ def _read_epub_manual(file_path: Path): def clean_text(text: str) -> str: """Normalize whitespace and strip standalone page numbers. - Page numbers are removed only when they appear as a short number alone on - its own line (before whitespace collapsing), so inline numbers like - "42 years", "1,000" or "3.5" are preserved. + Page numbers are removed only when a short number (up to three digits) + appears alone on its own line (before whitespace collapsing), so inline + numbers like "42 years", "1,000" or "3.5" are preserved, as are + four-digit standalone lines, which are usually years ("1984") or + chapter numbers rather than page numbers. """ if not text: return "" # Standalone page numbers (digits alone on a line) must go BEFORE the # newline-collapsing step below. - text = re.sub(r"(?m)^\s*\d{1,4}\s*$", " ", text) + text = re.sub(r"(?m)^\s*\d{1,3}\s*$", " ", text) text = re.sub(r"\s+", " ", text) return text.strip() @@ -256,14 +328,6 @@ def clean_html(html_content: str) -> str: return html_content.strip() -def extract_epub(file_path: Path) -> str: - """Extract the book's text from EPUB, trying several methods in order.""" - chapters = _extract_epub_chapters(file_path) - if not chapters: - raise RuntimeError("All EPUB extraction methods failed") - return "\n\n".join(section.text for section in chapters) - - def _natural_key(name: str): """Sort key that orders numeric runs numerically (chapter2 before chapter10).""" return [int(part) if part.isdigit() else part.lower() @@ -286,10 +350,15 @@ def _extract_txt(file_path: Path) -> str: return clean_text(data.decode("utf-8-sig")) # No BOM: UTF-16 without BOM is common on Windows; detect via NUL bytes. + # A real UTF-16 file of ASCII-range text has a NUL at every other byte + # position, so require a substantial NUL share before committing to + # UTF-16: a lone stray NUL in a UTF-8/cp1252 file must not flip the + # whole book into mojibake (the decode ladder below handles that). sample = data[:4096] even_nuls = sum(1 for i, b in enumerate(sample) if b == 0 and i % 2 == 0) odd_nuls = sum(1 for i, b in enumerate(sample) if b == 0 and i % 2 == 1) - if even_nuls or odd_nuls: + threshold = max(len(sample) // 4, 1) + if even_nuls >= threshold or odd_nuls >= threshold: encoding = "utf-16-be" if even_nuls > odd_nuls else "utf-16-le" return clean_text(data.decode(encoding)) |
