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+804+012345678901234567890123456789-46-46-01234567890123456789
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.LIVEbetween 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:
startAvatarFeednow 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.) LIVEmid-turn: the open turn finishes on the element. Its remaining audio and itsturn_endare 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 fromSTARTING.- Anything but
LIVE(ENDED,OFF): the element is unmuted at once and the feed stops. - While
silenceOutputstands: 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 optionalavatarFeed: {acts: AvatarFeedActs, startTee, onAvatarVoiced}.- The tee runs as a fiber under the call's scope while the remote stream stands and the status is
STARTINGorLIVE. 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,
interruptis 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_MSis exported fromvoice-level-meter.tsfor 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.sleepon the renderer's services, so tests drive them withTestClock. - Teardown hands the voice back to the element, because the element outlives the call.
- The tee runs as a fiber under the call's scope while the remote stream stands and the status is
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.statuswhen 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()beforestopSpeaking, and the panel's end-call command calls it beforestopCall, so the avatar does not talk on through the graceful close.
- The element is muted when
- Cleanup:
- #4's local
feed.tsis replaced by #1'sAvatarFeedfrom#shared/messages/avatar.avatarFeedSchemadid not need exporting. - The
start-avatar-feed.tsknip entry is dropped, sinceuse-voice-session.tsimports it now. - The
pcm-tee-worklet.tsknip 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.tsis listed for the same reason.
- #4's local
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; LIVEbetween turns hands over at once;LIVEmid-turn leaves that turn on the element, then hands over;ENDEDmid-turn gives the voice back and sends nothing more;- silence drops the feed;
- an interrupt goes only after fed audio.
- nothing goes out before
live-call.test.ts(it.effectwithTestClock; 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;
LIVEbefore 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
interruptactually flushes the avatar's buffer.
š¤ Generated with Claude Code