Séance
- Voice AI and telephony
- AI Experiments
- 2026
- Open source
Next.js, TypeScript, WebRTC, D-ID and 1 more
Séance is a webcam parlor game. You hold up an object, a chipped mug or somebody else’s hairbrush. Madame Esmé Voss, a Victorian medium dead since 1881, channels its haunted history out loud. I built it for the D-ID and ElevenLabs hackathon, and the whole point was to make vision load-bearing. Take the camera away and there is no game left.
Most avatar demos bolt vision on afterward, so the character narrates in general terms and never names the chip on the rim. That is the moment the illusion dies. I wanted the character to react to the object, and then to the face the player makes when she gets it right. That means the seeing and the speaking have to sit in one conversation, not two.
# held up to the camera: a chipped enamel mugEsme Oh. That crack on the rim. It was dropped in anger.Player It was my grandmother's.Esme And she kept it after the argument. That says plenty.Player How could you possibly know that?Esme I am dead, dear. I am not unobservant.Player Please do not tell my mother.prophecy card: "the mug that outlived the quarrel"
6 turnsone call
- Chapter
- Voice AI and telephony
- Category
- AI Experiments
- Year
- Availability
- Open source
- Last commit
- Next.js
- TypeScript
- WebRTC
- D-ID
- ElevenLabs
Why the native bridge
Three ways exist to put an ElevenLabs voice on a D-ID face. I worked through all of them before writing the connection.
| Approach | Verdict |
|---|---|
| Relay ElevenLabs audio per turn | Rejected. speak() takes only a static audio_url, so every turn pays an upload |
| D-ID runs the model, ElevenLabs supplies the voice | Rejected. That uses ElevenLabs as a voice, not as the agent |
| A D-ID agent bound natively to an ElevenLabs Agent | Chosen. Audio bridges server side and vision arrives with it |
The deciding fact is that the SDK exposes only {type:'text'} and {type:'audio', audio_url} script shapes. No streaming audio ingestion exists, so a live ElevenLabs track cannot be piped in from the page.
How she sees
scripts/create-agent.mjs runs once. It stores the ElevenLabs key in D-ID’s secret vault. Then it posts to /v2/agents/integrations/elevenlabs with an expressive presenter, the external agent id and vision: {enabled: true}, and polls until the agent is ready. At runtime src/lib/did.ts wraps @d-id/client-sdk, imported dynamically because it touches window. The browser captures microphone and camera itself, then calls publishMicrophoneStream and publishCameraStream. D-ID forwards the microphone to ElevenLabs and samples camera frames into that same conversation as system updates. The ElevenLabs key never reaches the page. The D-ID client key is domain scoped and served from /api/did-credentials, which keeps it out of the static bundle and leaves it rotatable.
Latency in costume
The phase machine runs dormant, summoning, present, channeling, revealing and prophecy, with a lost state on failure. Most transitions come from onAgentActivityStateChange, which reports IDLE, LOADING, TALKING or TOOL_ACTIVE. Channeling is the exception, because it fires locally. src/lib/motion.ts downsamples the self-view to a 32 by 24 grayscale buffer six times a second and watches the mean frame difference. A burst above 0.06 that then settles below 0.025 for three ticks means an object was presented and held still. The candle flares and the smoke rises before Esmé has said anything. That gap is the round trip to the vision model, wearing a costume.
Decisions and costs
- Let the platform own the conversation. ElevenLabs Agents runs speech to text, the persona, the voice and turn-taking, so the browser holds no conversation state. Tuning any of it then happens in a dashboard rather than in this repository.
- Keep vision ambient rather than structured. D-ID samples frames on its own schedule, and
chat(text)sends a full user turn, so there is no quiet context channel. The game cannot hand her a clean object description without spending the player’s turn. - Make the Gemini route optional.
/api/visionreturns 501 whenGEMINI_API_KEYis unset and the client skips it, so the prophecy card loses its label and nothing else breaks. Oneanalyze()function holds the provider. - Treat manual interrupt as best effort.
interrupt()sits inside a try block because it may be inert on the expressive path, which is the piece I would revisit.
Setup and limits
Setup is npm install, an .env.local carrying DID_API_KEY, ELEVENLABS_API_KEY and ELEVENLABS_AGENT_ID, then npm run setup:agent, which prints DID_AGENT_ID and DID_CLIENT_KEY to paste back. A paid D-ID plan is required, because the native ElevenLabs integration needs one. Deploying also means adding the production domain to the client key’s allowed domains in D-ID Studio. The architecture notes list what still wants checking against a live account, including the /secrets body shape, which is why the setup script tries two variants. The source is MIT licensed, and D-ID, ElevenLabs and Gemini remain under their own terms.