feat(desktop): the voice window feeds Luke's voice to his avatar from the first turn after it goes live (avatar 10/12)

Merged
CH
5d ago
+804-46

Plan item 10. voice window: feed path (video calls with Luke). Builds on #2472 (contracts, 1) and #2474 (PCM tee and turn gate, 4), both already in feature/avatar-video-keyframe.

What it does

  • Handover (avatar-feed/handover.ts): a pure function over the avatar status, the tee's feed, and interrupts, with no clock. It decides whether Luke is heard through the <audio> element or through the avatar, and what goes out.
    • LIVE between turns: the avatar takes over at once, so the element mutes while nothing is playing and the next turn is fed whole.
    • Not before the tee stands: startAvatarFeed now answers whether the tee stood up. A tee still loading its worklet, or one that failed (for example, a context that refuses 24 kHz), leaves Luke on the element. When the tee stands inside Luke's turn, that turn is taken as open and finishes on the element. "Inside" means from the meter hearing him until his track has been quiet for the turn gate's own 800 ms, not the status's 1.5 s hangover. (From review.)
    • LIVE mid-turn: the open turn finishes on the element. Its remaining audio and its turn_end are not sent, and the turn after it goes to the avatar, so no word is heard twice. "A turn is open" is read off the tee's own feed, so the tee runs from STARTING.
    • Anything but LIVE (ENDED, OFF): the element is unmuted at once and the feed stops.
    • While silenceOutput stands: the feed is dropped.
    • Interrupt: sent only when audio has gone to the avatar since the last interrupt. It is not retried, since Keyframe's lossy channel is fine per the spike.
  • LiveCall (voice/live-call.ts, an existing edge) takes an optional avatarFeed: {acts: AvatarFeedActs, startTee, onAvatarVoiced}.
    • The tee runs as a fiber under the call's scope while the remote stream stands and the status is STARTING or LIVE. Closing its scope releases the Web Audio context.
    • New methods: reportAvatarStatus, reportTalkKey, interruptAvatar.
    • Barge-in: with the talk key down and Luke's direct track quiet for ≄ 350 ms, interrupt is sent, with a 1 s cooldown. The meter reports quiet only after its own 220 ms hangover, so the timer armed on that report is 130 ms. VOICE_ACTIVITY_HANGOVER_MS is exported from voice-level-meter.ts for this. The developer's planning-button toggle does not trigger barge-in, because with the mic open for minutes it would cut every turn's tail.
    • Every wait is Effect.sleep on the renderer's services, so tests drive them with TestClock.
    • Teardown hands the voice back to the element, because the element outlives the call.
  • useVoiceSession(remoteAudio, avatarFeed?: AvatarFeedActs) (voice/use-voice-session.ts, an existing edge):
    • The element is muted when outputSilenced || speakerMuted || avatarVoiced. It is never paused: the remote stream stays on a playing element, per #4, or Web Audio gets only zeros.
    • Each new call reads state.voice.avatar.status when it is made, and status changes are forwarded to the call.
    • The talk key's press and release go to the call. The stop key and the panel's stop command call interruptAvatar() before stopSpeaking, and the panel's end-call command calls it before stopCall, so the avatar does not talk on through the graceful close.
  • Cleanup:
    • #4's local feed.ts is replaced by #1's AvatarFeed from #shared/messages/avatar. avatarFeedSchema did not need exporting.
    • The start-avatar-feed.ts knip entry is dropped, since use-voice-session.ts imports it now.
    • The pcm-tee-worklet.ts knip entry stays. The worklet is loaded by URL (audioWorklet.addModule) from its own esbuild entry, so knip cannot trace it and reports it as an unused file without the entry. session-recorder/index.ts is listed for the same reason.

Consumer

#11 binds AvatarFeedActs to the VOICE_AVATAR_FEED act and passes it from voice-host.tsx. Until then VoiceHost passes none, so every call is voice alone whatever the status, and the feed path is dark.

Tests

  • handover.test.ts (pure):
    • nothing goes out before LIVE;
    • LIVE between turns hands over at once;
    • LIVE mid-turn leaves that turn on the element, then hands over;
    • ENDED mid-turn gives the voice back and sends nothing more;
    • silence drops the feed;
    • an interrupt goes only after fed audio.
  • live-call.test.ts (it.effect with TestClock; a fake tee at the Web Audio boundary):
    • handover and unmute over a real call;
    • a call with no feed path never mutes;
    • a tee that refuses to stand leaves the element playing;
    • LIVE before the tee stands waits for it, and a turn the meter hears as the tee stands finishes on the element;
    • a tee standing in a pause inside a turn leaves that turn on the element, and one standing after the gate's quiet (still inside the status's hangover) feeds the next turn;
    • the stop's silence drops the feed, and the stop key interrupts;
    • barge-in fires at 350 ms of real quiet, not 1 ms before, holds off during the 1 s cooldown, and does not fire with the key up;
    • the call's end unmutes and takes the tee down.

./scripts/check.sh exits 0.

What CI could not verify

CI could not verify this; Mac evidence (verify.sh) is pending. CI builds nothing for the Mac.

  • Muting the element while the tee reads the track, and the handover timing on a real GPT Live reply, are untested on a Mac.
  • Untested against a live Keyframe session (none was ever opened):
    • whether the handover is gapless at the turn boundary;
    • whether barge-in's 350 ms and 1 s bounds feel right against the avatar's ~1 s lag;
    • whether an interrupt actually flushes the avatar's buffer.

šŸ¤– Generated with Claude Code