Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions src/myai/tts/chunking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +102 to +103

Copilot AI Apr 4, 2026

Copy link

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 both first_only=True and the default “last boundary” behavior (including commas/abbrev/decimal cases) would help prevent future regressions without requiring any external TTS/audio dependencies.

Suggested change
fast-start chunking). Otherwise the last valid boundary is returned
(maximises chunk length for better prosody).
fast-start chunking). Otherwise the last valid boundary is returned
(maximises chunk length for better prosody).
The examples below act as regression coverage for the boundary-selection
behavior and common edge cases:
>>> 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 should stay connected rather than treating the first
period as a sentence boundary:
>>> find_sentence_boundary("Meet J. T. Smith.")
16
>>> find_sentence_boundary("Meet J. T. Smith.", first_only=True)
16
When commas are enabled as chunk characters, weak commas are ignored and
stronger commas still participate in first-vs-last selection:
>>> comma_text = "Alpha, beta gamma delta epsilon zeta, final stop."
>>> find_sentence_boundary(comma_text, chunk_chars=",.!?")
50
>>> find_sentence_boundary(comma_text, chunk_chars=",.!?", first_only=True)
37
>>> find_sentence_boundary("Count 1, 2, 3", chunk_chars=",")
-1

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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=True and default (last boundary), plus edge cases for decimals, initials/abbreviations, and weak commas. All 8 doctests pass via python -m doctest.


Generated by Claude Code


>>> 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

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new doctest examples in this docstring won’t be executed by the current CI/test configuration (CI runs python -m pytest -q and pyproject.toml doesn’t enable --doctest-modules). That means the first_only behavior and edge cases can regress without any automated signal. Consider either enabling doctest collection in pytest (e.g., add --doctest-modules in [tool.pytest.ini_options].addopts) or moving these cases into an explicit tests/test_chunking.py unit test.

Suggested change
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

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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

"""
last_valid_boundary = -1

for i, char in enumerate(text):
if char not in chunk_chars:
continue

valid = False
if char == ".":
if is_sentence_boundary(text, i):
last_valid_boundary = i
valid = True
elif char == ",":
if not is_weak_comma(text, i):
last_valid_boundary = i
valid = True
elif char == "—":
if i > 0 and (i + 1 >= len(text) or text[i + 1] != "—"):
last_valid_boundary = i
valid = True
else:
if i > 0 and text[i - 1] in "!?":
continue
valid = True

if valid:
if first_only:
return i
last_valid_boundary = i

return last_valid_boundary
259 changes: 204 additions & 55 deletions src/myai/tts/text_to_speech.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions a _get_audio_duration helper, but the implementation introduces _estimate_audio_duration. If _get_audio_duration is referenced elsewhere (docs, callers, or future work), this naming mismatch can cause confusion. Consider aligning the helper name with the PR description or updating the description to match the code.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — the PR description was written before the rename. The method was originally _get_audio_duration but was renamed to _estimate_audio_duration in a later commit to reflect that it now only does file-size estimation (no pygame). This is a description-vs-code mismatch, not a code issue. I'll update the PR description to match.


Generated by Claude Code

"""
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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_remaining_playback_time() recomputes sum(state["queued_durations"]) each time it is called. Since this runs in the hot token-processing loop, it can become expensive when the queue grows. Consider tracking a single queued_total_duration float in state and updating it on append/pop so remaining time is computed in O(1).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced the queued_durations list with a single queued_total float that is incremented on append and decremented on pop, making remaining-time computation O(1).


Generated by Claude Code


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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_update_avg is described as a rolling average, but it appends to an unbounded list and recomputes sum(...) on every update. Over long streams this becomes O(n^2) time and unbounded memory growth. Prefer an incremental mean (keep count + running sum) and/or a fixed-size window (e.g., collections.deque(maxlen=...)) to keep the update O(1) and memory bounded.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced the unbounded list with an incremental mean using synth_count + synth_sum stored in the shared state dict. O(1) time and constant memory per update.


Generated by Claude Code

Comment on lines +534 to +539

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avg_synthesis_time (and synthesis_count/synthesis_sum) are updated in synthesis_worker but read in the main token-processing thread without any synchronization. This is a cross-thread data race that can yield inconsistent thresholds. Store/read these values under a lock (e.g., reuse state_lock or a dedicated lock) or use a thread-safe primitive (e.g., queue/atomic pattern) for publishing the latest average.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — moved synth_count, synth_sum, and avg_synth into the shared state dict so all reads and writes happen under state_lock, eliminating the cross-thread data race.


Generated by Claude Code

# ---- worker threads ----
def synthesis_worker():
"""Worker thread that synthesizes speech."""
while True:
Expand All @@ -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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary MP3 files can be leaked when synthesis fails. A temp file is created (delete=False) before synthesize_to_file, but if audio_file is falsy (quota block / error) or an exception is raised, there’s no cleanup of temp_filename, and playback never removes it. Add a cleanup path to delete the temp file when synthesis doesn’t produce a playable file.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added cleanup of temp_filename in both the else branch (when synthesize_to_file returns falsy, e.g. quota block) and the except branch (on unexpected errors), preventing temp file leaks.


Generated by Claude Code

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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state["play_start"] is set before pygame.mixer.music.load() / .play(). If load/play takes non-trivial time, elapsed playback will be overcounted and _remaining_playback_time() will underestimate remaining audio, causing chunk 1+ to trigger earlier than intended. Set play_start immediately after .play() returns (or at least after load) so timing reflects real playback start.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Moved play_start assignment to immediately after pygame.mixer.music.play() returns, so elapsed-time calculations reflect actual playback start rather than including load latency.


Generated by Claude Code


Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

playback_active is set to True before play_start is updated (it remains 0.0 until the later lock block after .play()). During that window, _remaining_playback_time() can compute elapsed = time.monotonic() - 0.0 and treat the current chunk as already fully elapsed, temporarily driving remaining to 0 and potentially triggering chunk 1+ too early. Consider updating play_start and setting playback_active=True in the same locked state update (after .play()), or have _remaining_playback_time() treat play_start==0.0 as “not started yet” even if playback_active is true.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Moved playback_active = True into the same lock acquisition as play_start, both set after .play() returns. The earlier lock block now only updates queued_total and play_duration, so _remaining_playback_time() never sees playback_active=True with a stale play_start.


Generated by Claude Code

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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If pygame.mixer.music.load/play raises or another playback exception occurs, the temporary audio_file is not removed, which can leak files over time. Consider moving the os.remove(audio_file) cleanup into a finally block (or attempt cleanup in the exception handler as well), while still ensuring state["playback_active"] is cleared.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — moved both state["playback_active"] = False and os.remove(audio_file) into a finally block so they execute regardless of whether an exception occurred during load/play.


Generated by Claude Code

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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Chunk 0 (fast start)” description says synthesis fires at the first punctuation boundary, but the implementation uses _find_sentence_boundary(...) which returns the last valid boundary in the current buffer (see chunking.find_sentence_boundary). This can delay time-to-first-audio if multiple boundaries arrive before the first synthesis is queued. Consider changing chunk 0 to select the first valid boundary, or update the doc/PR description if “last boundary” is intended.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added a first_only parameter to find_sentence_boundary() in chunking.py and chunk 0 now calls it with first_only=True so synthesis fires at the earliest valid boundary, minimising time-to-first-audio.


Generated by Claude Code

if len(candidate) >= effective_min:
buffer = buffer[idx + 1:]
synthesis_queue.put(candidate)
chunk_index += 1

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After chunk 0 is enqueued, buffer may still contain sentence-ending punctuation from the same incoming token (e.g., a token containing multiple sentences). Because chunk 1+ only scans for boundaries when a future token contains .?!, pending_boundary may stay -1 and delay synthesis until the next punctuation or end-of-stream. Consider scanning the post-split remainder immediately when transitioning to chunk 1+ (or loop to consume multiple boundaries within the current token).

Suggested change
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, ".!?")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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

# Remainder may already contain sentence-
# ending punctuation; seed pending_boundary
# so chunk 1+ doesn't wait for a future token.
pending_boundary = self._find_sentence_boundary(
buffer, ".!?",
)
break
search_start = idx + 1
else:
# --- Chunk 1+: quality chunks – sentence boundaries only ---
# Only rescan the buffer when the new token contains
# sentence-ending punctuation; otherwise reuse the cached
# boundary so we still re-evaluate timing each token.
if any(c in token.content for c in ".!?"):
pending_boundary = self._find_sentence_boundary(buffer, ".!?")

if pending_boundary < 0:
continue
Comment on lines +685 to +694

Copilot AI Apr 2, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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

Comment on lines +686 to +694

Copilot AI Apr 3, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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


# Wait until chunk 0 has been synthesised and we have a real
# playback window to compare against; otherwise remaining==0
# would trigger immediately before any audio is ready.
with state_lock:
has_audio = state["has_audio"]
current_avg_synth = state["avg_synth"]
if not has_audio:
continue

remaining = _remaining_playback_time()
threshold = current_avg_synth + 0.2 # 200 ms margin

if remaining <= threshold:
to_speak = buffer[:pending_boundary + 1].strip()

Comment on lines +685 to +709

Copilot AI Apr 2, 2026

Copy link

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).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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

# 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

Comment on lines +707 to +714

Copilot AI Apr 6, 2026

Copy link

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).

Suggested change
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:

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

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

buffer = buffer[pending_boundary + 1:]
synthesis_queue.put(to_speak)
chunk_index += 1
pending_boundary = -1 # reset after consuming

# LLM stream ended – synthesize whatever is buffered
if buffer.strip():
synthesis_queue.put(buffer.strip())

# Signal workers to stop
synthesis_queue.put(None)

# Wait for all work to complete
synthesis_thread.join()
playback_thread.join()

if print_text:
print() # New line at the end

except Exception as e:
print(f"\n❌ Error in async streaming speech: {e}")
synthesis_queue.put(None)
Expand Down
Loading