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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions