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) --- README.md | 1 + audiobook.py | 20 ++++++++++++ converter/config.py | 12 +++---- converter/converter.py | 40 +++++++++++++++++++++--- converter/tts.py | 57 ++++++++++++++++++++++++++------- tests/test_tts.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 188 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index dd3aece..b9d3057 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ You will also need to install one of the following backends (see below for insta | `--language ` | Output language for the synthesized speech. Can add an accent even if the text is English. | | `--backend {gradio,faster,audiocpp}` | TTS server to use (default `gradio`). `faster` and `audiocpp` require their server running first — see the backend sections above. | | `--voice ` | Voice to request from a server-side voice configuration (`--backend faster` or `audiocpp` only). | +| `--chunk` | Force client-side chunking into `CHUNK_SIZE`-word requests. Only matters for `--backend audiocpp`, which otherwise sends each chapter as one request and lets the server chunk long text itself (may double-chunk); the `gradio` and `faster` backends always chunk. | | `--debug` | Troubleshooting: dump each chunk's raw audio and sent text to `debug/` and log every request. | Other options including backend server URLs/ports are configured in `converter/config.py` diff --git a/audiobook.py b/audiobook.py index c2639ce..b881a12 100755 --- a/audiobook.py +++ b/audiobook.py @@ -145,11 +145,30 @@ Examples: "and log every TTS request and response to the console and log file.") ) + parser.add_argument( + "--chunk", + action="store_true", + help=("Force client-side chunking into CHUNK_SIZE-word requests (see " + "converter/config.py). Only matters for --backend audiocpp, which " + "otherwise sends each chapter as one request and lets the server " + "chunk long text itself; the gradio and faster backends always " + "chunk.") + ) + args = parser.parse_args() if args.speed <= 0: parser.error(f"--speed must be a positive number (got {args.speed:g})") + if args.chunk: + if args.backend == BACKEND_AUDIOCPP: + print("[WARNING] --chunk: the audio.cpp server already splits long text " + "internally (its text_chunk_size); forcing client-side chunking " + "may cause needless double-chunking") + else: + print(f"[INFO] --chunk has no effect with --backend {args.backend}: " + "that backend always chunks") + if args.backend == BACKEND_FASTER: if args.clone: print("[WARNING] --clone is ignored with --backend faster: that backend " @@ -221,6 +240,7 @@ Examples: backend=args.backend, voice=args.voice, debug=args.debug, + chunk=args.chunk, ) ok = converter.run() except KeyboardInterrupt: 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") diff --git a/tests/test_tts.py b/tests/test_tts.py index 9ca6f0d..ec357c1 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -703,7 +703,8 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self._tmp.cleanup() @staticmethod - def _make_client(preset_mode=False, voice="Vivian", language="English", seed=-1): + def _make_client(preset_mode=False, voice="Vivian", language="English", seed=-1, + chunk_text=True): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" client.model_id = config.AUDIOCPP_MODEL_ID @@ -711,6 +712,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.voice = voice client.language = language client._seed = seed + client.chunk_text = chunk_text return client @staticmethod @@ -746,6 +748,45 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(payload["seed"], 1234) self.assertNotIn("instructions", payload) + def test_negative_seed_omitted_from_payload(self): + client = self._make_client(preset_mode=True, voice="narrator", seed=-1) + with patch("converter.tts.urllib.request.urlopen", + return_value=self._post_response(self._wav_bytes())) as mock_urlopen: + client._request_wav("Hello world.") + payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) + self.assertNotIn("seed", payload) + + def test_whole_text_sent_as_one_request_without_client_chunking(self): + client = self._make_client(chunk_text=False) + # 9 words with CHUNK_SIZE=5 would split in two if client chunking + # were on; kept under the 10-word truncation-check threshold. + text = " ".join(f"word{i}" for i in range(9)) + with patch.object(config, "CHUNK_SIZE", 5), \ + patch.object(client, "_request_wav", + return_value=self._wav_bytes()) as mock_request: + result = client.generate_chunk(text, 1) + self.assertIsNotNone(result) + self.assertEqual(mock_request.call_count, 1) + self.assertEqual(mock_request.call_args[0][0], text) + + def test_single_request_timeout_scales_with_text_length(self): + client = self._make_client(chunk_text=False) + long_text = " ".join(f"word{i}" for i in range(1500)) # ~10 min of audio + with patch("converter.tts.urllib.request.urlopen", + return_value=self._post_response(self._wav_bytes())) as mock_urlopen: + client._request_wav(long_text) + timeout = mock_urlopen.call_args[1]["timeout"] + self.assertGreater(timeout, config.API_TIMEOUT) + + def test_client_chunking_keeps_configured_timeout(self): + client = self._make_client(chunk_text=True) + long_text = " ".join(f"word{i}" for i in range(1500)) + with patch("converter.tts.urllib.request.urlopen", + return_value=self._post_response(self._wav_bytes())) as mock_urlopen: + client._request_wav(long_text) + timeout = mock_urlopen.call_args[1]["timeout"] + self.assertEqual(timeout, config.API_TIMEOUT) + def test_speaker_mode_sends_instruct(self): client = self._make_client(preset_mode=False) with patch("converter.tts.urllib.request.urlopen", @@ -857,6 +898,7 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase): client.voice = "narrator" client.language = "English" client._seed = -1 + client.chunk_text = True return client @staticmethod @@ -909,7 +951,8 @@ class BackendWiringTests(unittest.TestCase): AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, backend=tts.BACKEND_AUDIOCPP, voice="narrator", language="ja") - mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese") + mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese", + chunk_text=False) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -917,7 +960,18 @@ class BackendWiringTests(unittest.TestCase): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM, backend=tts.BACKEND_AUDIOCPP) - mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE) + mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE, + chunk_text=False) + + def test_audiocpp_backend_chunk_flag_forces_client_chunking(self): + with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: + converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, + backend=tts.BACKEND_AUDIOCPP, + voice="narrator", chunk=True) + mock_audiocpp.assert_called_once_with(voice="narrator", + language=config.LANGUAGE, + chunk_text=True) + self.assertTrue(converter.client_chunks) def test_gradio_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -942,6 +996,31 @@ class BackendWiringTests(unittest.TestCase): voice="narrator") self.assertIsNone(converter.voice_clone_ref_audio) + def test_chapter_chunks_audiocpp_default_is_one_request(self): + converter = self._audiocpp_converter(voice="narrator") + text = " ".join(f"word{i}" for i in range(50)) + with patch.object(config, "CHUNK_SIZE", 10): + self.assertEqual(converter._chapter_chunks(text), [text]) + + def test_chapter_chunks_audiocpp_chunk_flag_splits(self): + with patch("converter.converter.AudioCppTTSClient"): + converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, + backend=tts.BACKEND_AUDIOCPP, + voice="narrator", chunk=True) + text = " ".join(f"word{i}" for i in range(50)) + with patch.object(config, "CHUNK_SIZE", 10): + chunks = converter._chapter_chunks(text) + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks)) + + def test_chapter_chunks_gradio_always_splits(self): + with patch("converter.converter.QwenTTSClient"): + converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM) + text = " ".join(f"word{i}" for i in range(50)) + with patch.object(config, "CHUNK_SIZE", 10): + chunks = converter._chapter_chunks(text) + self.assertGreater(len(chunks), 1) + def test_faster_backend_still_validates_other_settings(self): with patch("converter.converter.FasterTTSClient"): with self.assertRaises(ValueError): -- cgit v1.2.3