From 5ca77f86b70718b4ef1a07299efbd6431268d546 Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 20 Aug 2026 18:06:22 -0400 Subject: fix: do not chunk with audio.cpp backend (double chunking) --- converter/config.py | 12 ++++------- converter/converter.py | 40 +++++++++++++++++++++++++++++++---- converter/tts.py | 57 +++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 85 insertions(+), 24 deletions(-) (limited to 'converter') diff --git a/converter/config.py b/converter/config.py index ed49f12..83778ab 100644 --- a/converter/config.py +++ b/converter/config.py @@ -7,14 +7,10 @@ API_TIMEOUT = 600 # Timeout per chunk request in seconds MAX_RETRIES = 3 # Attempts per chunk request HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N seconds -# Words per TTS generation request. -# Note that qwen-tts-demo does no chunking at all, but faster-qwen3-tts and -# audio.cpp may do chunking as well, so you may be needlessly double-chunking. -# This is the only size limit: there is no hard ceiling. However, servers -# silently truncate audio when a single generation runs too long (roughly -# ~2.5 min on the faster backend's static KV cache, ~11 min on the Gradio -# demo) without reporting an error, so raising this is at your own risk. -# The client-side truncation check still catches and retries gross cases. +# Words per TTS generation request (client-side chunking). +# The gradio and faster backends always chunk with this size +# The audio.cpp backend chunks long text itself, so this is ignored +# by default with that backend. Force chunking with --chunk CHUNK_SIZE = 250 # Default TTS backend. diff --git a/converter/converter.py b/converter/converter.py index 6994a31..f1064f1 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -134,7 +134,8 @@ class AudiobookConverter: voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT, language: Optional[str] = None, backend: str = BACKEND_GRADIO, - voice: Optional[str] = None, debug: bool = False): + voice: Optional[str] = None, debug: bool = False, + chunk: bool = False): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") if output_format not in AUDIO_FORMATS: @@ -154,6 +155,12 @@ class AudiobookConverter: self.backend = backend self.voice = voice self.debug = bool(debug) + # Client-side chunking: the gradio and faster backends always chunk + # (their servers do one generation per request and silently truncate + # long text). The audio.cpp server chunks long text itself, so it + # defaults to one request per chapter; --chunk forces client-side + # chunking on top (possible needless double-chunking). + self.client_chunks = bool(chunk) or backend != BACKEND_AUDIOCPP self._validate_configuration() if backend == BACKEND_FASTER: # The faster backend always voice-clones using a reference voice @@ -162,7 +169,8 @@ class AudiobookConverter: elif backend == BACKEND_AUDIOCPP: # Speaker mode (no voice) uses a built-in CustomVoice speaker; # an explicit voice selects a server-side preset (cloning). - self.tts = AudioCppTTSClient(voice=voice, language=self.language) + self.tts = AudioCppTTSClient(voice=voice, language=self.language, + chunk_text=self.client_chunks) else: self.tts = QwenTTSClient( voice_mode=voice_mode, @@ -426,6 +434,18 @@ class AudiobookConverter: logger.info("Chunk processing completed: %d/%d chunks", successful_chunks, total_chunks) return results + def _chapter_chunks(self, text: str) -> List[str]: + """Split chapter text into TTS requests. + + Client-side chunking splits into CHUNK_SIZE-word chunks (gradio and + faster always; audio.cpp only with --chunk). Otherwise (audio.cpp + default) the whole text is one request and the server does its own + long-form chunking. + """ + if self.client_chunks: + return chunking.split_into_chunks(text) + return [text] if text.strip() else [] + def _convert_text(self, text: str, output_path: Path, start_time: float, speed: Optional[float] = None, output_format: Optional[str] = None, @@ -452,7 +472,7 @@ class AudiobookConverter: logger.info("Extracted %d characters (%d words)", len(text), len(text.split())) - chunks = chunking.split_into_chunks(text) + chunks = self._chapter_chunks(text) total_chunks = len(chunks) if total_chunks == 0: logger.error("No chunks created") @@ -460,7 +480,13 @@ class AudiobookConverter: chunk_sizes = [len(chunk.split()) for chunk in chunks] avg_chunk_size = sum(chunk_sizes) / len(chunk_sizes) - logger.info("Split into %d chunks (avg %.0f words per chunk)", total_chunks, avg_chunk_size) + if len(chunks) == 1: + logger.info("Sending the whole text as one request (%d words; " + "the server chunks long text itself)", + chunk_sizes[0]) + else: + logger.info("Split into %d chunks (avg %.0f words per chunk)", + total_chunks, avg_chunk_size) backend_labels = { BACKEND_FASTER: "faster TTS API", BACKEND_AUDIOCPP: "audio.cpp server", @@ -526,6 +552,12 @@ class AudiobookConverter: else: print("Backend: audio.cpp (custom voice, built-in speaker)") print(f"Speaker: {config.SPEAKER}") + if self.client_chunks: + print("Chunking: client-side (--chunk; the server also chunks " + "long text itself, so this may double-chunk)") + else: + print("Chunking: server-side (one request per chapter; " + "--chunk forces client-side chunking)") print(f"Language: {self.language}") else: api_url = (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE diff --git a/converter/tts.py b/converter/tts.py index 1a5b1a0..afb0655 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -724,22 +724,35 @@ class AudioCppTTSClient(_BaseTTSClient): server works for --voice runs, while speaker mode on such a server fails with a hint to pass --voice. + Chunking: the server does its own long-form text chunking (its + ``text_chunk_size`` option, 8192 chars by default for qwen3_tts), so by + default each chapter is sent as a single request and the audio comes + back already stitched. With ``chunk_text=True`` (the --chunk CLI flag), + text is instead split client-side into CHUNK_SIZE-word sub-requests, + which may needlessly double-chunk — the warning is printed by the CLI. + Each response is a complete WAV file, so sub-request audio is concatenated with the same lossless path used for the Gradio client. """ def __init__(self, voice: Optional[str] = None, language: Optional[str] = None, - api_url: Optional[str] = None): + api_url: Optional[str] = None, chunk_text: bool = False): self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/") self.model_id = config.AUDIOCPP_MODEL_ID # Validate before connecting so bad values fail fast without a server. self.language = normalize_language( language if language is not None else config.LANGUAGE) - # Same seed convention as the Gradio client: one value per run, - # reused for every request (see _resolve_request_seed). + # One seed value per run, reused for every request (see + # _resolve_request_seed). Unlike the Gradio demo, audio.cpp has no + # negative "randomize" seed, so a negative value means "send no seed + # at all" (see _request_wav) and the server randomizes. self._seed = _resolve_request_seed() self.preset_mode = bool(voice) self.voice = voice or speaker_display_name() + # When False (default), each chapter is sent as one request and the + # server does its own long-form chunking (text_chunk_size); when True, + # text is split client-side into CHUNK_SIZE-word sub-requests first. + self.chunk_text = bool(chunk_text) self._check_health() model_ids = self._list_model_ids() if self.preset_mode: @@ -887,15 +900,23 @@ class AudioCppTTSClient(_BaseTTSClient): # ------------------------------------------------------------------ def _request_wav(self, text: str) -> bytes: - """POST one sub-chunk and return the raw WAV bytes.""" + """POST one sub-chunk and return the raw WAV bytes. + + The request timeout scales with the text length when a whole + chapter is sent in one request (no client-side chunking), since a + long chapter means many minutes of audio generated in one go. + """ url = f"{self.api_url}/v1/audio/speech" payload: Dict[str, Any] = { "model": self.model_id, "input": text, "voice": self.voice, "language": self.language, - "seed": self._seed, } + if self._seed >= 0: + # audio.cpp has no negative "randomize" seed; a negative seed + # means "let the server randomize", so the field is omitted. + payload["seed"] = self._seed if not self.preset_mode and config.INSTRUCT: # Style instruction for the CustomVoice speakers; ignored by # the Base (cloning) model. @@ -903,8 +924,14 @@ class AudioCppTTSClient(_BaseTTSClient): request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST") + timeout = config.API_TIMEOUT + if not self.chunk_text: + # Estimated audio duration at 150 wpm, doubled plus a minute of + # slack, bounded below by the configured per-request timeout. + estimated_seconds = 60.0 * len(text.split()) / _ESTIMATED_WORDS_PER_MINUTE + timeout = max(timeout, int(estimated_seconds * 2) + 60) try: - with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response: + with urllib.request.urlopen(request, timeout=timeout) as response: wav = response.read() except urllib.error.HTTPError as exc: detail = "" @@ -940,14 +967,20 @@ class AudioCppTTSClient(_BaseTTSClient): def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: """Generate one audio chunk; returns its path in the chunks folder. - The text is split into sub-requests of at most - ``config.CHUNK_SIZE`` words each (defense in depth against - pathological input, matching the Gradio client), each sub-request - returns a complete WAV file, and the parts are concatenated into - one chunk file. + By default the whole text goes out as a single request and the + server does its own long-form chunking (see the class docstring). + With ``chunk_text=True`` (--chunk), the text is split into + sub-requests of at most ``config.CHUNK_SIZE`` words each; each + sub-request returns a complete WAV file and the parts are + concatenated into one chunk file. """ try: - sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE) + if self.chunk_text: + sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE) + elif text.strip(): + sub_texts = [text] + else: + sub_texts = [] if not sub_texts: raise RuntimeError("No text to synthesize") -- cgit v1.2.3