← Whispering

fix: stream TTS audio chunk-by-chunk to eliminate choppy playback

c662eaef89f558964f8583ff1e1d8917fd46a0b6 · Claude · Feb 10, 2026 19:50

Root cause: the playback loop synthesized ALL text chunks before
playing anything. With Chatterbox taking ~5 min per chunk, the user
waited for the entire synthesis to finish before hearing any audio.

Changes:
- Replace sd.play()+sd.wait() with sd.OutputStream for buffered
  streaming — each chunk plays immediately as it's synthesized
- Add _synthesize_chunks_iter() generator that yields audio chunks
  one at a time instead of concatenating everything
- Reduce accumulation timeout from 30s to 5s so playback starts
  sooner after text is queued
- Explicit float32 dtype, blocksize=2048, latency="low" on the
  output stream to prevent buffer underruns
- Proper stream cleanup on error (abort+close)

https://claude.ai/code/session_01965m1v3f1cK6gDV1JHioA8

+72 −27 · 1 file changed

  • src/tts_controller.py +72 −27
diff --git a/src/tts_controller.py b/src/tts_controller.py
index 4cddd98..b0f848b 100644
--- a/src/tts_controller.py
+++ b/src/tts_controller.py
@@ -337,14 +337,34 @@ class TTSController:
         self._playback_queue.put(self._FLUSH)
 
     # Maximum seconds to wait for a flush before synthesizing anyway.
-    _ACCUMULATION_TIMEOUT = 30.0
+    _ACCUMULATION_TIMEOUT = 5.0
+
+    def _synthesize_chunks_iter(self, text: str):
+        """Yield (audio_chunk, sample_rate) for each text chunk as it finishes."""
+        chunks = self.chunk_text(text)
+        total = len(chunks)
+        for i, chunk in enumerate(chunks):
+            if self._playback_stop.is_set():
+                return
+            if self.on_progress:
+                self.on_progress(f"Synthesizing chunk {i + 1}/{total}...")
+            audio, sr = self.provider.synthesize(
+                text=chunk,
+                reference_audio_path=self.reference_voice_path,
+                language=self.language,
+                exaggeration=self.exaggeration,
+                cfg=self.cfg,
+                speaker=self.speaker,
+            )
+            yield audio, sr
 
     def _playback_loop(self):
         """Background thread that processes the playback queue.
 
-        Accumulates all queued text segments until a _FLUSH sentinel
-        arrives (or the safety timeout expires), then synthesizes and
-        plays the combined text in one pass.
+        Accumulates queued text segments until a _FLUSH sentinel arrives
+        (or the safety timeout expires), then synthesizes and streams
+        audio chunk-by-chunk — each chunk plays as soon as it's ready
+        instead of waiting for the entire text to be synthesized.
         """
         import sounddevice as sd
 
@@ -370,7 +390,7 @@ class TTSController:
                 if remaining <= 0:
                     break
                 try:
-                    extra = self._playback_queue.get(timeout=min(remaining, 1.0))
+                    extra = self._playback_queue.get(timeout=min(remaining, 0.5))
                 except queue.Empty:
                     continue  # keep waiting
                 if extra is None:
@@ -398,40 +418,58 @@ class TTSController:
                     also_save = also_save or extra_save
                     file_format = extra_fmt
 
-            # ── Synthesize and play ──
+            # ── Synthesize chunk-by-chunk and stream audio ──
             try:
                 self._is_playing = True
+                stream = None
+                all_segments = []  # collected for file save
 
-                if self.on_progress:
-                    self.on_progress("Synthesizing...")
-
-                result = self.synthesize_to_array(text)
-                if result is None:
-                    continue
-
-                audio, sample_rate = result
-
-                if self._playback_stop.is_set():
-                    break
+                for audio_chunk, sample_rate in self._synthesize_chunks_iter(text):
+                    if self._playback_stop.is_set():
+                        break
 
-                # Save to file if requested
-                if also_save:
+                    # Ensure float32, mono, column-vector for sounddevice
+                    audio_chunk = np.asarray(audio_chunk, dtype=np.float32)
+                    if audio_chunk.ndim > 1:
+                        audio_chunk = audio_chunk.flatten()
+
+                    if also_save:
+                        all_segments.append(audio_chunk)
+
+                    # Open the output stream on first chunk (now we know the sample rate)
+                    if stream is None:
+                        stream = sd.OutputStream(
+                            samplerate=sample_rate,
+                            channels=1,
+                            dtype="float32",
+                            blocksize=2048,
+                            latency="low",
+                        )
+                        stream.start()
+                        if self.on_progress:
+                            self.on_progress("Playing...")
+
+                    # write() blocks until the device has consumed the data,
+                    # so the next chunk synthesizes while this one finishes playing.
+                    stream.write(audio_chunk.reshape(-1, 1))
+
+                # Wait for the remaining audio in the buffer to finish
+                if stream is not None:
+                    stream.stop()
+                    stream.close()
+
+                # Save combined audio to file if requested
+                if also_save and all_segments:
                     import time
                     timestamp = time.strftime("%Y%m%d_%H%M%S")
                     filename = f"tts_{timestamp}"
                     output_path = self.output_dir / f"{filename}.{file_format}"
-                    sf.write(str(output_path), audio, sample_rate,
+                    full_audio = np.concatenate(all_segments) if len(all_segments) > 1 else all_segments[0]
+                    sf.write(str(output_path), full_audio, sample_rate,
                              format=file_format.upper())
                     if self.on_complete:
                         self.on_complete(str(output_path))
 
-                # Play audio
-                if self.on_progress:
-                    self.on_progress("Playing...")
-
-                sd.play(audio, sample_rate)
-                sd.wait()
-
                 if self.on_progress:
                     self.on_progress("")
 
@@ -441,6 +479,13 @@ class TTSController:
                     self.on_error(error_msg)
                 else:
                     print(error_msg)
+                # Clean up stream on error
+                if stream is not None:
+                    try:
+                        stream.abort()
+                        stream.close()
+                    except Exception:
+                        pass
             finally:
                 self._is_playing = False