Skip to content

Animate the mascot's mouth during realtime voice calls #5545

Description

@YellowSnnowmann

Summary

Animate the mascot's mouth while the realtime ElevenLabs voice agent is speaking, so the Human tab's realtime path has lip-sync the way the classic tap-and-speak path already does.

Problem

The mascot sits with a frozen mouth for the entire realtime call. Tap-and-speak animates it; realtime does not, so the surface we are moving users onto is the one that looks broken — the mascot appears asleep while the agent talks.

The two paths get their audio differently, which is why the existing lip-sync does not simply carry over:

  • Tap-and-speak synthesises through our own TTS proxy, so we own the audio element and hold a viseme timeline — frames of {viseme, ms}. useHumanMascot samples that timeline every animation frame against playbackRef.current.currentMs() (findActiveFrame).
  • Realtime hands playback to the ElevenLabs SDK. There is no audio element of ours to read, so there is no currentMs() to sample and no timeline to sample against — the two inputs the existing lip-sync needs are both absent.

What the SDK does expose is the output signal itself (getOutputVolume()), plus character-level timings via the onAudioAlignment callback. useRealtimeVoiceSession already forces connectionType: 'websocket' specifically "so the per-audio-event character alignment is available for mascot lip-sync" — the intent was designed in and never wired.

Solution

Two stages, because they differ sharply in cost and risk.

Stage 1 — amplitude-driven (this issue). Drive the mouth from getOutputVolume() on an animation-frame loop. Less accurate than visemes — it opens and closes with the envelope rather than forming phonemes, so no M/F closures — but in sync by construction, because it is the audio being played rather than a prediction of it. A frozen mouth reads as broken; an approximate one reads as alive.

Stage 2 — alignment-driven visemes (follow-up). Build a viseme timeline from onAudioAlignment and reconstruct a clock from wall time anchored at the alignment event, matching tap-and-speak quality. Riskier: wall-clock drift when the SDK buffers or the user interrupts. Most of the mapping already exists — visemeMap.ts maps ElevenLabs/Oculus viseme codes to mouth shapes, and ttsClient.ts already derives rough visemes from char-level alignment when the backend omits them. Stage 1 stays as its fallback for when alignment is absent or the timeline runs dry, mirroring the hedge the TTS path already makes.

Scope: app only. No core, backend, or API change.

Acceptance criteria

  • Mouth animates during realtime speech — the mascot's mouth moves while the ElevenLabs agent speaks on the Human tab, and rests when it stops.
  • No frozen-open mouth on any exit — turn ends, session ends mid-speech, component unmounts, or the SDK analyser throws mid-frame: the mouth returns to rest rather than sticking on its last shape.
  • No per-frame re-render of the page — the 60fps signal does not travel through React state; state commits only when the visible mouth shape changes. The chat panel is memoised precisely to keep per-frame mascot work off the chat tree (Cannot switch tabs from Human tab while agent is speaking #5357), and this must not undo that.
  • The classic path is untouched — tap-and-speak lip-sync behaves exactly as before, and the two sources never drive the mouth in the same frame.
  • Diff coverage ≥ 80% — the implementing PR meets the changed-lines coverage gate (Vitest + cargo-llvm-cov, enforced by .github/workflows/ci-lite.yml).

Related

Activity

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions