Skip to content

wait_done() can return before playback starts — the documented voice audition exits silently, and the "brain unreachable" spoken warning can be lost #32

Description

@AbesMaster

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions