aboutsummaryrefslogtreecommitdiff
path: root/app/converter
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
parentdc6e7cd43029da62dabe2513fb5aa8a34df1bd6d (diff)
downloadtts-audiobook-generator-6cfcd564c0684c52618235e6366f4a81c02b9a5b.tar.gz
slop refactor/dedup
Diffstat (limited to 'app/converter')
-rw-r--r--app/converter/audio.py8
-rw-r--r--app/converter/clients/__init__.py8
-rw-r--r--app/converter/clients/audiocpp.py442
-rw-r--r--app/converter/clients/base.py9
-rw-r--r--app/converter/clients/faster.py10
-rw-r--r--app/converter/clients/qwen.py4
-rw-r--r--app/converter/converter.py67
-rw-r--r--app/converter/cover.py8
-rw-r--r--app/converter/extractors.py117
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))