fix: accumulate all TTS text until stream ends before synthesizing
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
@@ -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 @@ -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).