Files
themajesticmagician 3a0959f55d 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>
2026-08-02 19:01:06 -06:00
..

Sprite assets

Art: Bolt himself — a cream shepherd pup with a slate cap, a lightning blaze on his forehead and a bolt tag on his collar. The frames are generated, not hand-drawn: scripts/generate_bolt_sprites.py draws every one of them with Pillow and writes this folder.

python scripts/generate_bolt_sprites.py                  # rewrite this folder
python scripts/generate_bolt_sprites.py --out /tmp/prev  # preview elsewhere first
python scripts/generate_bolt_sprites.py --states idle    # just one state

That means tweaking the art is editing code, not 24 PNGs: the palette is a block of constants at the top of the script, the body/head/ear/tail shapes are one function each in normalised 0..1 coordinates, and each state's animation is a list of pose dicts in frames_for(). Everything is super-sampled 4x and downscaled on save, because PIL's draw primitives have no antialiasing.

Regenerate after editing — the PNGs here are committed, so a change to the script alone doesn't move the pet.

Convention the loader (bolt_pet/ui/sprite.py) expects:

assets/sprites/
  idle/        frame_00..07.png   breathing, tail wag, blink on frame 06
  listening/   frame_00..03.png   ears perked, head tilted in, collar tag lit, sound arcs
  thinking/    frame_00..05.png   eyes up, head cocked, cycling dots
  talking/     frame_00..03.png   mouth open/close with tongue, ears bouncing
  error/       frame_00..01.png   X eyes, ears drooped, red spark
  walk/        frame_00..07.png   side-view walk cycle (see below)
  • One subfolder per pet state (matches bolt_pet.state.PetState), plus walk/, which is not a state — see below.
  • Any *.png filenames work — they're played back in alphabetical-sort order, looping, at IDLE_ANIMATION_FPS (see .env). At the default 6fps the 8-frame idle loop runs about 1.3s.
  • Frames are square (320px, 2x the default PET_SIZE of 160) so they downscale cleanly; the loader scales to fit PET_SIZE keeping aspect ratio and centres them in the square pet window.
  • A state directory with no frames in it falls back to a small procedurally-drawn placeholder blob (see _placeholder_frames in sprite.py).

The walk cycle

walk/ is the one animation that isn't a PetState. Walking is a property of movement — orthogonal to whether he's idle, listening or talking — so it stays out of the state machine and is keyed by name instead (sprite.EXTRA_ANIMATIONS). PetWindow uses it whenever the pet is actually travelling and falls back to the state animation the moment it stops.

Three things about it are load-bearing if you redraw it:

  • It's a side view, drawn facing right. The other poses are a front-facing sit, which is fine standing still but slides like a chess piece when moving. PetWindow._oriented() mirrors the frames (cached) when he walks left, so only the right-facing version exists on disk.
  • The cycle is advanced by distance travelled, not by the animation timer (_WALK_PIXELS_PER_FRAME, one frame per ~13px). That's what keeps a planted paw tracking backwards at exactly the speed the window moves forwards. Drive it off the clock and the feet skate whenever PET_WANDER_SPEED doesn't happen to match IDLE_ANIMATION_FPS. If you change the number of frames or the stride length in paw_position(), retune that constant to match or he'll moonwalk.
  • The frames carry their own vertical bob, so the window's own bob is switched off while they're in use. Only the no-walk-art fallback still bobs in code.

Delete walk/ and everything still runs — he reverts to sliding with a small coded bob, which is what the pet did before the cycle existed.

Swapping in different art

Replace any state's PNGs (same alphabetical-order-loops convention) to change its look — no code changes needed, and nothing forces you to keep using the generator. If your source is a single grid spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame, use scripts/slice_spritesheet.py to cut it into this folder-of-frames convention:

python scripts/slice_spritesheet.py path/to/idle_sheet.png assets/sprites/idle \
    --cols 6 --rows 1

If your format is something else entirely (a single animated GIF/APNG, a Spine/DragonBones skeletal export, an Aseprite .json atlas, etc.) — tell me the format and I'll adapt sprite.py's loader rather than making you convert by hand.