-
Notifications
You must be signed in to change notification settings - Fork 0
Improve TTS streaming chunking for better prosody and gapless playback #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
3f88ff7
fc73c27
469f70a
406b95f
bce7061
a4ed68b
afeb431
6894cfe
8b89b98
5e0eaa1
f275fe6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -95,26 +95,62 @@ def is_weak_comma(text: str, position: int) -> bool: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def find_sentence_boundary(text: str, chunk_chars: str = ".!?") -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Find the last valid boundary index in text, or -1 if none found.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def find_sentence_boundary(text: str, chunk_chars: str = ".!?", first_only: bool = False) -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Find a valid boundary index in text, or -1 if none found. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When *first_only* is True the first valid boundary is returned (useful for | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fast-start chunking). Otherwise the last valid boundary is returned | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (maximises chunk length for better prosody). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> text = "Hello world. Another sentence!" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary(text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 29 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary(text, first_only=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 11 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Decimals should not be split at the period: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary("The value is 3.14 and rising.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 28 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary("Version 2.0") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Initials / abbreviations stay connected: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary("Meet J. T. Smith.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 16 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary("Meet J. T. Smith.", first_only=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 16 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| When commas are enabled, weak commas are skipped: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >>> find_sentence_boundary("Count 1, 2, 3", chunk_chars=",") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+102
to
+128
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fast-start chunking). Otherwise the last valid boundary is returned | |
| (maximises chunk length for better prosody). | |
| >>> text = "Hello world. Another sentence!" | |
| >>> find_sentence_boundary(text) | |
| 29 | |
| >>> find_sentence_boundary(text, first_only=True) | |
| 11 | |
| Decimals should not be split at the period: | |
| >>> find_sentence_boundary("The value is 3.14 and rising.") | |
| 28 | |
| >>> find_sentence_boundary("Version 2.0") | |
| -1 | |
| Initials / abbreviations stay connected: | |
| >>> find_sentence_boundary("Meet J. T. Smith.") | |
| 16 | |
| >>> find_sentence_boundary("Meet J. T. Smith.", first_only=True) | |
| 16 | |
| When commas are enabled, weak commas are skipped: | |
| >>> find_sentence_boundary("Count 1, 2, 3", chunk_chars=",") | |
| -1 | |
| fast-start chunking). Otherwise the last valid boundary is returned | |
| (maximises chunk length for better prosody). | |
| Example: | |
| text = "Hello world. Another sentence!" | |
| find_sentence_boundary(text) -> 29 | |
| find_sentence_boundary(text, first_only=True) -> 11 | |
| Decimals should not be split at the period: | |
| find_sentence_boundary("The value is 3.14 and rising.") -> 28 | |
| find_sentence_boundary("Version 2.0") -> -1 | |
| Initials / abbreviations stay connected: | |
| find_sentence_boundary("Meet J. T. Smith.") -> 16 | |
| find_sentence_boundary("Meet J. T. Smith.", first_only=True) -> 16 | |
| When commas are enabled, weak commas are skipped: | |
| find_sentence_boundary("Count 1, 2, 3", chunk_chars=",") -> -1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Created tests/tts/test_chunking.py with 11 pytest test cases covering first_only, last boundary, decimals, initials/abbreviations, weak commas, and edge cases. These run in CI via the standard pytest invocation — no need to enable --doctest-modules globally. The doctests remain in the docstring as inline documentation.
Generated by Claude Code
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -461,22 +461,79 @@ def speech_worker(): | |||||||||||||||||||||||||||||||||
| speech_queue.put(None) # Stop the worker | ||||||||||||||||||||||||||||||||||
| speech_thread.join() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def speak_streaming_async(self, text_generator, chunk_on: str = ".", print_text: bool = True, | ||||||||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||||||||
| def _estimate_audio_duration(file_path: str) -> float: | ||||||||||||||||||||||||||||||||||
| """Estimate the duration of an MP3 file from its size. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Uses ~32 kbps (4 KB/s), which is typical for Google Cloud TTS MP3 | ||||||||||||||||||||||||||||||||||
| output. This avoids calling any pygame API so it is safe to use | ||||||||||||||||||||||||||||||||||
| from any thread. | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+464
to
+470
|
||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| return os.path.getsize(file_path) / 4000.0 | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| return 0.0 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def speak_streaming_async(self, text_generator, chunk_on: str = ",.!?", print_text: bool = True, | ||||||||||||||||||||||||||||||||||
| min_chunk_size: int = 15): | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| Speak text as it's being generated with truly parallel processing. | ||||||||||||||||||||||||||||||||||
| Multiple sentences can be synthesized and queued while others are playing. | ||||||||||||||||||||||||||||||||||
| This provides the fastest response time. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Chunking strategy | ||||||||||||||||||||||||||||||||||
| ----------------- | ||||||||||||||||||||||||||||||||||
| * **Chunk 0 (fast start):** fire synthesis at the first punctuation | ||||||||||||||||||||||||||||||||||
| boundary (including commas) to minimise time-to-first-sound. | ||||||||||||||||||||||||||||||||||
| * **Chunk 1+ (quality chunks):** accumulate tokens and, once the | ||||||||||||||||||||||||||||||||||
| remaining playback time of previously queued audio drops to within | ||||||||||||||||||||||||||||||||||
| ``avg_synthesis_time + 200 ms``, trigger synthesis at the last | ||||||||||||||||||||||||||||||||||
| available sentence-ending boundary (``.!?``). This maximises input | ||||||||||||||||||||||||||||||||||
| length for better prosody while keeping playback gapless. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| :param text_generator: Generator that yields text tokens | ||||||||||||||||||||||||||||||||||
| :param chunk_on: Character to chunk on (default: "." for sentences) | ||||||||||||||||||||||||||||||||||
| :param chunk_on: Characters used to detect chunk 0 boundaries only (default: ``",.!?"``) | ||||||||||||||||||||||||||||||||||
| :param print_text: If True, print the text as it's being spoken | ||||||||||||||||||||||||||||||||||
| :param min_chunk_size: Minimum characters before considering a chunk (prevents tiny fragments, default 15) | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
| buffer = "" | ||||||||||||||||||||||||||||||||||
| synthesis_queue = queue.Queue() | ||||||||||||||||||||||||||||||||||
| playback_queue = queue.Queue() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # ---- per-call timing state (reset each invocation) ---- | ||||||||||||||||||||||||||||||||||
| chunk_index = 0 | ||||||||||||||||||||||||||||||||||
| pending_boundary = -1 # cached sentence boundary index for chunk 1+ | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Shared state – accessed from main + worker threads under state_lock | ||||||||||||||||||||||||||||||||||
| state_lock = threading.Lock() | ||||||||||||||||||||||||||||||||||
| state = { | ||||||||||||||||||||||||||||||||||
| "playback_active": False, | ||||||||||||||||||||||||||||||||||
| "play_start": 0.0, # time.monotonic() when current file started | ||||||||||||||||||||||||||||||||||
| "play_duration": 0.0, # duration in seconds of current file | ||||||||||||||||||||||||||||||||||
| "queued_total": 0.0, # total duration of files waiting in playback_queue | ||||||||||||||||||||||||||||||||||
| "has_audio": False, # True once first synthesis result is queued | ||||||||||||||||||||||||||||||||||
| "synth_count": 0, # number of completed syntheses | ||||||||||||||||||||||||||||||||||
| "synth_sum": 0.0, # cumulative synthesis duration | ||||||||||||||||||||||||||||||||||
| "avg_synth": 0.4, # rolling average, seeded at 400 ms | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _remaining_playback_time() -> float: | ||||||||||||||||||||||||||||||||||
| """Total seconds of audio still to play (current + queued).""" | ||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| remaining = 0.0 | ||||||||||||||||||||||||||||||||||
| if state["playback_active"]: | ||||||||||||||||||||||||||||||||||
| elapsed = time.monotonic() - state["play_start"] | ||||||||||||||||||||||||||||||||||
| remaining = max(0.0, state["play_duration"] - elapsed) | ||||||||||||||||||||||||||||||||||
| remaining += state["queued_total"] | ||||||||||||||||||||||||||||||||||
| return remaining | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+520
to
+532
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _update_avg(duration: float): | ||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| state["synth_count"] += 1 | ||||||||||||||||||||||||||||||||||
| state["synth_sum"] += duration | ||||||||||||||||||||||||||||||||||
| state["avg_synth"] = state["synth_sum"] / state["synth_count"] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
Comment on lines
+534
to
+539
|
||||||||||||||||||||||||||||||||||
| # ---- worker threads ---- | ||||||||||||||||||||||||||||||||||
| def synthesis_worker(): | ||||||||||||||||||||||||||||||||||
| """Worker thread that synthesizes speech.""" | ||||||||||||||||||||||||||||||||||
| while True: | ||||||||||||||||||||||||||||||||||
|
|
@@ -485,98 +542,190 @@ def synthesis_worker(): | |||||||||||||||||||||||||||||||||
| synthesis_queue.task_done() | ||||||||||||||||||||||||||||||||||
| playback_queue.put(None) # Signal playback worker | ||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| text_to_speak = item | ||||||||||||||||||||||||||||||||||
| temp_filename = None | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| # Create a temporary file for the audio | ||||||||||||||||||||||||||||||||||
| with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file: | ||||||||||||||||||||||||||||||||||
| temp_filename = temp_file.name | ||||||||||||||||||||||||||||||||||
| # Synthesize speech to the temp file | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| t0 = time.monotonic() | ||||||||||||||||||||||||||||||||||
| audio_file = self.synthesize_to_file(text_to_speak, temp_filename) | ||||||||||||||||||||||||||||||||||
| synth_dur = time.monotonic() - t0 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if audio_file: | ||||||||||||||||||||||||||||||||||
| playback_queue.put(audio_file) | ||||||||||||||||||||||||||||||||||
| audio_dur = self._estimate_audio_duration(audio_file) | ||||||||||||||||||||||||||||||||||
| _update_avg(synth_dur) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
Comment on lines
553
to
+563
|
||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| state["queued_total"] += audio_dur | ||||||||||||||||||||||||||||||||||
| state["has_audio"] = True | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| playback_queue.put((audio_file, audio_dur)) | ||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||
| # Synthesis returned nothing (e.g. quota block) – clean up | ||||||||||||||||||||||||||||||||||
| if temp_filename: | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| os.remove(temp_filename) | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||
| print(f"\n❌ Synthesis error: {e}") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if temp_filename: | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| os.remove(temp_filename) | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| synthesis_queue.task_done() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def playback_worker(): | ||||||||||||||||||||||||||||||||||
| """Worker thread that plays synthesized audio.""" | ||||||||||||||||||||||||||||||||||
| last_end: float = 0.0 # monotonic time previous chunk finished | ||||||||||||||||||||||||||||||||||
| while True: | ||||||||||||||||||||||||||||||||||
| audio_file = playback_queue.get() | ||||||||||||||||||||||||||||||||||
| if audio_file is None: | ||||||||||||||||||||||||||||||||||
| item = playback_queue.get() | ||||||||||||||||||||||||||||||||||
| if item is None: | ||||||||||||||||||||||||||||||||||
| playback_queue.task_done() | ||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| audio_file, estimated_dur = item | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| # Detect stalls (playback queue went empty) | ||||||||||||||||||||||||||||||||||
| now = time.monotonic() | ||||||||||||||||||||||||||||||||||
| if last_end > 0 and (now - last_end) > 0.15: | ||||||||||||||||||||||||||||||||||
| print(f"\n⚠️ Playback stall: waited {now - last_end:.1f}s for next chunk") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Get accurate duration via pygame (all pygame calls | ||||||||||||||||||||||||||||||||||
| # happen on this single thread to avoid cross-thread | ||||||||||||||||||||||||||||||||||
| # SDL_mixer issues). | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| snd = pygame.mixer.Sound(audio_file) | ||||||||||||||||||||||||||||||||||
| actual_dur = snd.get_length() | ||||||||||||||||||||||||||||||||||
| del snd | ||||||||||||||||||||||||||||||||||
| if actual_dur <= 0: | ||||||||||||||||||||||||||||||||||
| actual_dur = estimated_dur | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| actual_dur = estimated_dur | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| # Correct queued_total: remove the estimate, the | ||||||||||||||||||||||||||||||||||
| # play_duration field now uses the real value. | ||||||||||||||||||||||||||||||||||
| state["queued_total"] = max(0.0, state["queued_total"] - estimated_dur) | ||||||||||||||||||||||||||||||||||
| state["play_duration"] = actual_dur | ||||||||||||||||||||||||||||||||||
| state["playback_active"] = True | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+625
to
+628
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| pygame.mixer.music.load(audio_file) | ||||||||||||||||||||||||||||||||||
| pygame.mixer.music.play() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Wait for playback to finish | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Set play_start after .play() so elapsed time reflects | ||||||||||||||||||||||||||||||||||
| # actual playback, not load latency. | ||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| state["play_start"] = time.monotonic() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| while pygame.mixer.music.get_busy(): | ||||||||||||||||||||||||||||||||||
| time.sleep(0.1) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Clean up the temporary file | ||||||||||||||||||||||||||||||||||
| time.sleep(0.05) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| last_end = time.monotonic() | ||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||
| print(f"\n❌ Playback error: {e}") | ||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||
| with state_lock: | ||||||||||||||||||||||||||||||||||
| state["playback_active"] = False | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| os.remove(audio_file) | ||||||||||||||||||||||||||||||||||
| except: | ||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||
| print(f"\n❌ Playback error: {e}") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
Comment on lines
622
to
+644
|
||||||||||||||||||||||||||||||||||
| playback_queue.task_done() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Start worker threads | ||||||||||||||||||||||||||||||||||
| synthesis_thread = threading.Thread(target=synthesis_worker, daemon=True) | ||||||||||||||||||||||||||||||||||
| playback_thread = threading.Thread(target=playback_worker, daemon=True) | ||||||||||||||||||||||||||||||||||
| synthesis_thread.start() | ||||||||||||||||||||||||||||||||||
| playback_thread.start() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| # Process tokens from the LLM | ||||||||||||||||||||||||||||||||||
| for token in text_generator: | ||||||||||||||||||||||||||||||||||
| if print_text: | ||||||||||||||||||||||||||||||||||
| print(token.content, end="", flush=True) | ||||||||||||||||||||||||||||||||||
| buffer += token.content | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Check if we have potential sentence boundaries | ||||||||||||||||||||||||||||||||||
| if any(c in buffer for c in chunk_on): | ||||||||||||||||||||||||||||||||||
| # Find the last valid sentence boundary | ||||||||||||||||||||||||||||||||||
| last_chunk_idx = self._find_sentence_boundary(buffer, chunk_on) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if last_chunk_idx >= 0: | ||||||||||||||||||||||||||||||||||
| # Extract the complete sentence(s) | ||||||||||||||||||||||||||||||||||
| to_speak = buffer[:last_chunk_idx + 1].strip() | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Smart chunking: use different min sizes based on delimiter type | ||||||||||||||||||||||||||||||||||
| # Complete sentences (.!?) can be shorter, commas need more context | ||||||||||||||||||||||||||||||||||
| delimiter = buffer[last_chunk_idx] if last_chunk_idx < len(buffer) else '' | ||||||||||||||||||||||||||||||||||
| effective_min = 5 if delimiter in '.!?' else min_chunk_size | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Only chunk if we have substantial content (prevents tiny fragments) | ||||||||||||||||||||||||||||||||||
| # This ensures we don't speak very short incomplete phrases | ||||||||||||||||||||||||||||||||||
| if len(to_speak) >= effective_min: | ||||||||||||||||||||||||||||||||||
| # Keep the remainder for the next iteration | ||||||||||||||||||||||||||||||||||
| buffer = buffer[last_chunk_idx + 1:] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Queue for synthesis (non-blocking) | ||||||||||||||||||||||||||||||||||
| synthesis_queue.put(to_speak) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| # Process any remaining text | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if chunk_index == 0: | ||||||||||||||||||||||||||||||||||
| # --- Chunk 0: fast start – fire at earliest acceptable boundary --- | ||||||||||||||||||||||||||||||||||
| if any(c in buffer for c in chunk_on): | ||||||||||||||||||||||||||||||||||
| search_start = 0 | ||||||||||||||||||||||||||||||||||
| while search_start < len(buffer): | ||||||||||||||||||||||||||||||||||
| rel_idx = find_sentence_boundary( | ||||||||||||||||||||||||||||||||||
| buffer[search_start:], chunk_on, first_only=True, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| if rel_idx < 0: | ||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||
| idx = search_start + rel_idx | ||||||||||||||||||||||||||||||||||
| candidate = buffer[:idx + 1].strip() | ||||||||||||||||||||||||||||||||||
| delimiter = buffer[idx] | ||||||||||||||||||||||||||||||||||
| effective_min = 5 if delimiter in '.!?' else min_chunk_size | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+659
to
+672
|
||||||||||||||||||||||||||||||||||
| if len(candidate) >= effective_min: | ||||||||||||||||||||||||||||||||||
| buffer = buffer[idx + 1:] | ||||||||||||||||||||||||||||||||||
| synthesis_queue.put(candidate) | ||||||||||||||||||||||||||||||||||
| chunk_index += 1 | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| chunk_index += 1 | |
| chunk_index += 1 | |
| # The remainder may already contain one or more | |
| # sentence-ending delimiters from the same token. | |
| # Cache the next boundary now so chunk 1+ does not | |
| # wait for a future token containing punctuation. | |
| pending_boundary = self._find_sentence_boundary(buffer, ".!?") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — immediately after chunk 0 fires and the buffer is sliced, we now scan the remainder with self._find_sentence_boundary(buffer, ".!?") and seed pending_boundary. This ensures chunk 1+ doesn't stall waiting for a future token if the remainder already contains a sentence boundary.
Generated by Claude Code
Copilot
AI
Apr 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the chunk 1+ path, _find_sentence_boundary(buffer, ".!?") is called on every token even when the newly appended token contains no boundary punctuation. Because find_sentence_boundary scans the entire buffer, this turns long no-punctuation stretches into O(n^2) work as the buffer grows. Consider short-circuiting based on the incoming token (e.g., only scan when the new token contains ., !, or ?) or tracking the last scanned index to avoid rescanning the whole buffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — added a short-circuit check: the chunk 1+ path only calls _find_sentence_boundary when token.content contains ., !, or ?. Tokens without sentence-ending punctuation skip the scan entirely.
Generated by Claude Code
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The chunk 1+ short-circuit (continue when the incoming token has no .?!) can prevent synthesis from ever triggering on an already-present sentence boundary. Example: after a . arrives you may skip enqueuing because remaining > threshold; subsequent tokens without punctuation will continue and never re-check remaining for that existing boundary, which can delay synthesis until the next punctuation and increase stall risk. Consider tracking a pending boundary index and re-evaluating the remaining <= threshold condition on subsequent tokens (even if the token has no punctuation), or only skipping the buffer scan while still checking a previously found boundary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Replaced the hard continue with a pending_boundary cache. The buffer scan only runs when the new token contains .!?, but the timing condition (remaining <= threshold) is re-evaluated on every token against the cached boundary. Once consumed, pending_boundary resets to -1.
Generated by Claude Code
Copilot
AI
Apr 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When chunk 0 has been queued for synthesis but no audio has been produced/queued yet, _remaining_playback_time() will return 0.0, which makes remaining <= threshold true and can cause chunk 1+ to be enqueued as soon as a sentence boundary appears—before there is any playback window to compare against. If the intent is to wait until there is actual queued/playing audio to keep chunks larger, gate the chunk 1+ logic on state["playback_active"] or state["queued_durations"] being non-empty (or seed an initial estimated playback window).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Added a has_audio flag to the shared state that is set to True once the first synthesis result is queued. The chunk 1+ path now skips until has_audio is true, preventing premature firing when remaining==0 just because no audio exists yet.
Generated by Claude Code
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The “Edge case” guard is currently unreachable: it’s nested under if remaining <= threshold: but then checks remaining > threshold * 2, which can never be true (threshold is positive). As a result, the intended behavior of holding back very short chunks when there’s still plenty of playback window won’t ever happen. Consider revising the condition/placement so the short-chunk suppression can actually trigger (or remove the dead code if it’s not needed).
| if remaining <= threshold: | |
| to_speak = buffer[:pending_boundary + 1].strip() | |
| # Edge case: chunk 0 still playing with time to spare | |
| # and buffer text is very short – hold for more tokens | |
| if remaining > threshold * 2 and len(to_speak) < min_chunk_size: | |
| continue | |
| to_speak = buffer[:pending_boundary + 1].strip() | |
| # Edge case: chunk 0 still playing with time to spare | |
| # and buffer text is very short – hold for more tokens | |
| if remaining > threshold * 2 and len(to_speak) < min_chunk_size: | |
| continue | |
| if remaining <= threshold: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already fixed in f275fe6 — moved the short-chunk suppression guard before the if remaining <= threshold: block, exactly as suggested.
Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
find_sentence_boundary(..., first_only=True)changes boundary-selection behavior in a way that’s easy to regress (and affects time-to-first-audio). Since this function is pure/deterministic, adding unit tests covering bothfirst_only=Trueand the default “last boundary” behavior (including commas/abbrev/decimal cases) would help prevent future regressions without requiring any external TTS/audio dependencies.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added doctests covering both
first_only=Trueand default (last boundary), plus edge cases for decimals, initials/abbreviations, and weak commas. All 8 doctests pass viapython -m doctest.Generated by Claude Code