← Whispering

fix: accumulate all TTS text until stream ends before synthesizing

97518c115f6c59cb36782752da0df9d9814d41c1 · Claude · Feb 10, 2026 19:26

The choppy playback was caused by each text segment triggering its own
separate synthesis+playback cycle. With Chatterbox taking minutes per
synthesis call, the gaps between clips made audio unlistenable.

Now the playback loop accumulates ALL text segments until the bridge
signals end-of-stream via flush_playback(). Only then does it synthesize
the combined text in a single pass and play it back smoothly.

- Add _FLUSH sentinel and flush_playback() to TTSController
- Playback loop waits for flush (with 30s safety timeout)
- Bridge calls flush_playback() from _finalize_tts_session()

https://claude.ai/code/session_01965m1v3f1cK6gDV1JHioA8

+55 −33 · 2 files changed

  • src/tts_controller.py +50 −33
  • src/whispering_ui/bridge.py +5 −0
diff --git a/src/tts_controller.py b/src/tts_controller.py
index 3d9ee4c..4cddd98 100644
--- a/src/tts_controller.py
+++ b/src/tts_controller.py
@@ -70,11 +70,14 @@ class TTSController:
         self.on_error: Optional[Callable[[str], None]] = None
 
         # Playback queue for real-time TTS
-        self._playback_queue: queue.Queue[Optional[str]] = queue.Queue()
+        self._playback_queue: queue.Queue = queue.Queue()
         self._playback_thread: Optional[threading.Thread] = None
         self._playback_stop = threading.Event()
         self._is_playing = False
 
+    # Sentinel pushed by flush_playback() to tell the loop "no more text"
+    _FLUSH = object()
+
     @property
     def is_playing(self) -> bool:
         return self._is_playing
@@ -302,8 +305,10 @@ class TTSController:
     def synthesize_and_play(self, text: str, also_save: bool = False,
                             file_format: str = "wav"):
         """
-        Synthesize text and play it through the speakers.
-        Runs in background - queues the text for sequential playback.
+        Queue text for synthesis + playback.
+
+        Text is accumulated until :meth:`flush_playback` is called (or a
+        safety timeout expires), then synthesized and played in one pass.
 
         Args:
             text: Text to synthesize and play
@@ -323,65 +328,77 @@ class TTSController:
             )
             self._playback_thread.start()
 
-    # How long (seconds) to wait for more text before starting synthesis.
-    # Longer = fewer synthesis calls (smoother) but more latency.
-    ACCUMULATION_DELAY = 0.5
+    def flush_playback(self):
+        """Signal that no more text is coming for the current stream.
+
+        The playback loop will stop waiting and immediately synthesize
+        whatever text it has accumulated so far.
+        """
+        self._playback_queue.put(self._FLUSH)
+
+    # Maximum seconds to wait for a flush before synthesizing anyway.
+    _ACCUMULATION_TIMEOUT = 30.0
 
     def _playback_loop(self):
         """Background thread that processes the playback queue.
 
-        Waits briefly after the first item to accumulate more text,
-        then synthesizes and plays in one pass.  This eliminates the
-        choppy gaps that occur when many short segments arrive in
-        rapid succession from the AI processor.
+        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.
         """
         import sounddevice as sd
-        import time as _time
 
         while not self._playback_stop.is_set():
+            # ── Wait for the first text segment ──
             try:
                 item = self._playback_queue.get(timeout=1.0)
             except queue.Empty:
-                # No more items - exit thread
-                break
+                break  # thread idle → exit
 
-            if item is None:
+            if item is None or item is self._FLUSH:
                 break
 
             text, also_save, file_format = item
 
-            # Wait a short time for more segments to arrive, then drain.
-            # This gives the AI processor time to push several segments
-            # before we commit to a synthesis call.
-            deadline = _time.monotonic() + self.ACCUMULATION_DELAY
-            while _time.monotonic() < deadline and not self._playback_stop.is_set():
+            # ── Accumulate until flush / timeout / stop ──
+            import time as _time
+            deadline = _time.monotonic() + self._ACCUMULATION_TIMEOUT
+            flushed = False
+
+            while not self._playback_stop.is_set():
                 remaining = deadline - _time.monotonic()
                 if remaining <= 0:
                     break
                 try:
-                    extra = self._playback_queue.get(timeout=remaining)
+                    extra = self._playback_queue.get(timeout=min(remaining, 1.0))
                 except queue.Empty:
-                    break
+                    continue  # keep waiting
                 if extra is None:
+                    flushed = True
                     break
-                extra_text, extra_save, extra_fmt = extra
-                text = text.rstrip() + " " + extra_text.lstrip()
-                also_save = also_save or extra_save
-                file_format = extra_fmt
-
-            # Also drain anything that arrived right at the deadline
-            while True:
-                try:
-                    extra = self._playback_queue.get_nowait()
-                except queue.Empty:
-                    break
-                if extra is None:
+                if extra is self._FLUSH:
+                    flushed = True
                     break
                 extra_text, extra_save, extra_fmt = extra
                 text = text.rstrip() + " " + extra_text.lstrip()
                 also_save = also_save or extra_save
                 file_format = extra_fmt
 
+            if not flushed:
+                # Drain anything that arrived right at the deadline
+                while True:
+                    try:
+                        extra = self._playback_queue.get_nowait()
+                    except queue.Empty:
+                        break
+                    if extra is None or extra is self._FLUSH:
+                        break
+                    extra_text, extra_save, extra_fmt = extra
+                    text = text.rstrip() + " " + extra_text.lstrip()
+                    also_save = also_save or extra_save
+                    file_format = extra_fmt
+
+            # ── Synthesize and play ──
             try:
                 self._is_playing = True
 
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index fe52c0b..26144b7 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -493,6 +493,11 @@ class ProcessingBridge:
             self.tts_session_text = ""
             return
 
+        # Tell the playback loop that no more text is coming so it can
+        # synthesize all accumulated segments in one pass.
+        if self.state.tts_auto_play:
+            self.tts_controller.flush_playback()
+
         # If auto-play was on, segments were already played (and optionally saved)
         # individually. Only save a combined session file if save is on
         # and auto-play was off (meaning segments weren't saved yet).