Mouth.wait_done() decides speech is finished by sampling two pieces of state
that are briefly both clear while a sentence is being handed to the worker. A
caller that samples in that window is told the speech is over before a single
sample has been rendered.
Observed on: 84b3a6c, macOS 15.6 (Darwin 25.6.0), Python 3.12, Kokoro voice
bm_lewis. Nothing platform-specific about it.
The race
mouth.py:389
while (not self._q.empty()) or self._speaking.is_set():
_run (mouth.py:394) pops the item and only then marks itself speaking:
item = self._q.get() # queue is now EMPTY
...
self._stop.clear()
self._speaking.set() # ...but nothing is marked speaking until here
Between those two statements _q.empty() is True and _speaking.is_set() is
False, so the while condition is False and wait_done returns
immediately. The window is small but it is entered on every single utterance,
and say() returning before the worker is scheduled makes it easy to land in.
What it breaks
1. The voice audition in the setup guide plays nothing.
backtalk.md tells the installing agent to audition voices with this command,
in two separate places (the Kokoro step and the ElevenLabs voice-picking step):
python -m backtalk.mouth "Hello there. This is what I sound like."
__main__ (mouth.py:521-524) is say() then wait_done(timeout=60). When
wait_done returns early, the interpreter falls off the end and exits — and the
worker is a daemon thread, so it is killed mid-render. The command prints
[mouth] voice ready, exits cleanly, and makes no sound.
There is no error, so it reads as "the voice engine is broken" rather than "the
process left early". I lost real time to this while debugging an unrelated
silence, because the audition is the natural first thing to reach for and it
lies to you.
2. The spoken "I couldn't reach my brain" warning can be swallowed.
main.py:702-708:
mouth.say("Bad news. The voice and the face are fine, but I "
"couldn't reach my brain, the Claude Code session. "
...
"Claude Code isn't signed in, the internet is down, "
"or the plan is out of usage.")
mouth.wait_done(timeout=30)
raise SystemExit(1)
This is the one message that tells a user why their assistant is mute, and
it's followed immediately by process exit. If wait_done returns early the
SystemExit kills the daemon worker and the warning is never spoken — the
failure mode it exists to explain becomes silent too.
The signoff at main.py:904 (wait_done(timeout=15) then return False) can
be truncated the same way.
Reproduction
import time
from backtalk.mouth import Mouth, synth_stream
import numpy as np
TEXT = "One two three four five six seven eight."
n = 0
for r, pcm in synth_stream(TEXT):
rate = r
n += len(np.frombuffer(pcm, dtype="int16"))
print(f"audio duration: {n/rate:.2f}s")
m = Mouth(); t0 = time.time()
m.say(TEXT); m.wait_done(timeout=90)
print(f"wait_done returned after: {time.time()-t0:.2f}s")
Measured here — audio duration: 3.55s, and wait_done returning well short of
that. After the fix below it returns at 4.60s (synthesis plus playback), i.e.
it actually waits.
Fix
Track work accepted but not yet finished playing, and wait on that instead of on
"queue empty", so there is no window where the state says idle.
# __init__
self._pending = 0
self._pending_lock = threading.Lock()
def _took_on(self, n=1):
with self._pending_lock:
self._pending += n
def _finished(self, n=1):
with self._pending_lock:
self._pending = max(0, self._pending - n)
say() / say_chunk() call _took_on() before self._q.put(...)
_run calls _finished() in its finally (and on the if not sentence
early-continue)
shut_up() counts what it drains and calls _finished(dropped)
wait_done becomes while self._pending > 0 or self._speaking.is_set():
This is the same mechanism PR #4 introduced in passing ("a pending counter
replaces 'queue empty' for the speaking flag and wait_done()"), though for a
different reason — lookahead rendering. That PR was closed unmerged on
2026-08-29, so the race is still on main today. It seems worth fixing on its
own merit, independently of whether the lookahead work lands.
Happy to open a PR if useful.
Mouth.wait_done()decides speech is finished by sampling two pieces of statethat are briefly both clear while a sentence is being handed to the worker. A
caller that samples in that window is told the speech is over before a single
sample has been rendered.
Observed on: 84b3a6c, macOS 15.6 (Darwin 25.6.0), Python 3.12, Kokoro voice
bm_lewis. Nothing platform-specific about it.The race
mouth.py:389_run(mouth.py:394) pops the item and only then marks itself speaking:Between those two statements
_q.empty()isTrueand_speaking.is_set()isFalse, so thewhilecondition isFalseandwait_donereturnsimmediately. The window is small but it is entered on every single utterance,
and
say()returning before the worker is scheduled makes it easy to land in.What it breaks
1. The voice audition in the setup guide plays nothing.
backtalk.mdtells the installing agent to audition voices with this command,in two separate places (the Kokoro step and the ElevenLabs voice-picking step):
__main__(mouth.py:521-524) issay()thenwait_done(timeout=60). Whenwait_donereturns early, the interpreter falls off the end and exits — and theworker is a daemon thread, so it is killed mid-render. The command prints
[mouth] voice ready, exits cleanly, and makes no sound.There is no error, so it reads as "the voice engine is broken" rather than "the
process left early". I lost real time to this while debugging an unrelated
silence, because the audition is the natural first thing to reach for and it
lies to you.
2. The spoken "I couldn't reach my brain" warning can be swallowed.
main.py:702-708:This is the one message that tells a user why their assistant is mute, and
it's followed immediately by process exit. If
wait_donereturns early theSystemExitkills the daemon worker and the warning is never spoken — thefailure mode it exists to explain becomes silent too.
The signoff at
main.py:904(wait_done(timeout=15)thenreturn False) canbe truncated the same way.
Reproduction
Measured here —
audio duration: 3.55s, andwait_donereturning well short ofthat. After the fix below it returns at
4.60s(synthesis plus playback), i.e.it actually waits.
Fix
Track work accepted but not yet finished playing, and wait on that instead of on
"queue empty", so there is no window where the state says idle.
say()/say_chunk()call_took_on()beforeself._q.put(...)_runcalls_finished()in itsfinally(and on theif not sentenceearly-continue)
shut_up()counts what it drains and calls_finished(dropped)wait_donebecomeswhile self._pending > 0 or self._speaking.is_set():This is the same mechanism PR #4 introduced in passing ("a pending counter
replaces 'queue empty' for the
speakingflag andwait_done()"), though for adifferent reason — lookahead rendering. That PR was closed unmerged on
2026-08-29, so the race is still on
maintoday. It seems worth fixing on itsown merit, independently of whether the lookahead work lands.
Happy to open a PR if useful.