Streaming replies and STT, amplitude lip-sync, one place for speaking

Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON
endpoint, so the wait is time-to-first-sentence rather than the whole model
call, and Deepgram's live websocket transcribes while you're still talking
instead of uploading the WAV afterwards. Both fall back invisibly — a stream
that fails before anything was said drops to converse(), and a socket that
never opens just means the old one-shot path.

Speaking lived in four near-copies in the controller (a reply, a holding line,
a streamed sentence, a dialogue scene) that had already drifted: one didn't arm
barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an
Utterance describing the policy differences, with collaborators injected so the
whole of it tests without Qt or audio.

The mouth follows the audio rather than a timer: tts.level_of reduces each PCM
frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a
linear map leaves the mouth barely open during normal talking) and that indexes
the talking frames, which the sprite script now draws as an openness ramp.
Offline pyttsx3 has no waveform, so stale levels hand control back to the timed
loop instead of freezing the mouth mid-syllable.

Also: the pet starts where you left it (ignoring positions on monitors that are
no longer connected, since restoring those faithfully is how it ends up
somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that
says what to do about each problem rather than only what's wrong.

tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit
test passed all week while notifications sat unspoken for minutes, the pet said
things twice and [laughing] got read aloud — each an interaction between two
individually-correct units. It drives whole turns against a real HTTP server on
a loopback port, faking only the mic and the speakers. It found a NameError in
the paint path that would have fired on every repaint while talking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:01:06 -06:00
parent c4e805defd
commit 3a0959f55d
55 changed files with 2796 additions and 151 deletions
+48 -20
View File
@@ -663,31 +663,43 @@ def render_frame(p) -> Image.Image:
def frames_for(state: str) -> list[dict]:
if state == "idle":
# Eight frames, all distinct. The old version drove breathing on
# sin(2*pi*t) and the tail on sin(4*pi*t), which both cross zero at
# i=0 and i=4 — so frame 4 was byte-identical to frame 0 and the loop
# was really four frames stored twice.
out = []
for i in range(8):
t = i / 8
br = math.sin(t * 2 * math.pi)
br = math.sin(t * 2 * math.pi + math.pi / 7)
out.append(
default_pose(
breathe=br,
head_dy=-0.006 * br,
tail=math.sin(t * 4 * math.pi),
# Three-halves harmonic: never in phase with the breath, so
# no two frames of the cycle can coincide.
tail=math.sin(t * 3 * math.pi + 0.6),
ear_twitch=0.30 if i == 3 else 0.0, # a flick, once a loop
blink=1.0 if i == 6 else 0.0,
)
)
return out
if state == "listening":
# Six frames of *orienting*, not idling: ears up, head turning toward
# whoever is talking, then settling. The old four had frames 0 and 2
# differing by 0.09 mean pixels — a two-pose animation wearing four.
out = []
for i in range(4):
t = i / 4
for i in range(6):
t = i / 6
lean = math.sin(t * 2 * math.pi + math.pi / 5)
out.append(
default_pose(
ear=1.0,
ear_twitch=0.35 * math.sin(t * 2 * math.pi),
tilt=-7 + 2.0 * math.sin(t * 2 * math.pi),
ear_twitch=0.45 * math.sin(t * 4 * math.pi),
tilt=-9 + 4.0 * lean,
look=(0.010 * lean, -0.004),
brow=1.0,
tail=0.5 * math.sin(t * 2 * math.pi),
head_dy=-0.008,
tail=0.7 * math.sin(t * 2 * math.pi + 1.1),
head_dy=-0.010 - 0.004 * lean,
tag_glow=True,
extras="listen",
phase=i,
@@ -695,35 +707,51 @@ def frames_for(state: str) -> list[dict]:
)
return out
if state == "thinking":
# The old six moved by a mean of ~1.3 pixels — effectively a still
# image. Thinking should *look* like thinking: the head tilts, the eyes
# travel as if following a thought, and one ear rotates independently.
out = []
for i in range(6):
t = i / 6
sway = math.sin(t * 2 * math.pi)
out.append(
default_pose(
ear=0.25,
tilt=6.0,
look=(0.022, -0.026),
brow=0.5,
breathe=0.4 * math.sin(t * 2 * math.pi),
tail=0.2 * math.sin(t * 2 * math.pi),
ear=0.25 + 0.35 * abs(sway),
ear_twitch=0.5 * math.cos(t * 2 * math.pi),
tilt=4.0 + 7.0 * sway,
# Eyes wander a small circle: the cheapest possible read of
# "working something out" and the thing most obviously
# missing before.
look=(0.026 * math.cos(t * 2 * math.pi),
-0.020 + 0.014 * math.sin(t * 2 * math.pi)),
brow=0.5 + 0.4 * abs(sway),
breathe=0.5 * math.sin(t * 2 * math.pi + 0.9),
head_dy=-0.008 * sway,
tail=0.35 * math.sin(t * 3 * math.pi),
blink=1.0 if i == 4 else 0.0,
extras="think",
phase=i // 2,
)
)
return out
if state == "talking":
# Ordered by mouth openness — closed at frame 0, widest at the last —
# because the window indexes these by the loudness of the audio that is
# actually playing (see audio/tts.level_of and PetWindow.set_mouth).
# A time-ordered loop cannot be indexed that way, and a mouth that
# flaps on a timer is what makes a talking sprite look dubbed.
out = []
for i in range(4):
t = i / 4
open_ = (math.sin(t * 2 * math.pi) + 1) / 2
count = 6
for i in range(count):
open_ = i / (count - 1)
out.append(
default_pose(
mouth=0.25 + 0.75 * open_,
mouth=0.06 + 0.94 * open_,
ear=0.6,
head_dy=-0.010 * open_,
breathe=open_,
tail=math.sin(t * 2 * math.pi + 1.0),
brow=0.35,
tail=0.45 * math.sin(i * 0.9),
brow=0.35 * open_,
)
)
return out