LiveKit → experience.speech
Pipe a LiveKit remote audio track into Liforma with @liforma/client/livekit.
Idea in one sentence
Keep LiveKit (Agents, rooms, or any remote participant) as your voice source, and use connectLiveKitAgent from @liforma/client/livekit to drive the Liforma
avatar from the remote MediaStreamTrack.
This is not Liforma’s future transport: livekit adapter. Here LiveKit
is your external voice stack; the Experience session still mints normally (http adapter today) with externalSpeechAudio.
Copy into your product: the runnable example’s helloByo.ts / helloByo.js (startByoSpeech) is a thin
wrapper over connectLiveKitAgent — DemoApp / page UI is scaffolding only.
Runnable example
See examples.liforma.ai (examples/livekit-embed in the examples repo) · live demo.
Install
npm install @liforma/client livekit-client Checklist
- Mount a Liforma Experience with
externalSpeechAudio(oftenmode="presenter",speechInputMode="off"). - Wait until the player has started (audio unlocked inside the iframe).
- Mint a LiveKit participant token on your server.
- Call
connectLiveKitAgent(experience, { url, token })— the helper joins the room, bridges agent audio viacreateUtterance({ track }), forwardslk.transcriptiontext streams intosetTranscriptfor force-align (disable withenableTranscript: false), and interrupts only while a bridge is outstanding. - Do not also play that track through a LiveKit
<audio>element — you would hear the voice twice. - Call
bridge.end()when the conversation finishes.
Example
// After Experience is started (audio unlocked):
// Or copy helloByo.ts from examples/livekit-embed → startByoSpeech(experience, { url, token })
import { connectLiveKitAgent } from '@liforma/client/livekit';
// Mint a LiveKit participant token on your server:
// const { url, token } = await fetch('/api/livekit-token', { method: 'POST' }).then((r) => r.json());
const bridge = await connectLiveKitAgent(experience, {
url, // wss://…
token // from your mint route
// enableTranscript: true, // default — lk.transcription → setTranscript
// transcriptionTopic: 'lk.transcription',
// shouldBridgeParticipant: (p) => p.identity.startsWith('agent')
});
// … later
await bridge.end(); Advanced: own the LiveKit Room yourself
Only if you are not using connectLiveKitAgent.
// Advanced: own the LiveKit Room yourself.
// Prefer connectLiveKitAgent from @liforma/client/livekit unless you need full control.
import { Room, RoomEvent, Track } from 'livekit-client';
// Host owns the LiveKit room; Liforma owns avatar playback + lipsync.
// Do not call track.attach() / render an <audio> element for the agent — that doubles the voice.
const room = new Room();
let activeUtterance: ReturnType<typeof experience.speech.createUtterance> | null = null;
room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
if (track.kind !== Track.Kind.Audio) return;
// Optional: only bridge the agent participant
// if (!participant.identity.startsWith('agent')) return;
const mediaTrack = track.mediaStreamTrack;
activeUtterance = experience.speech.createUtterance({
track: mediaTrack,
sampleRate: 48_000,
queue: 'replace-active'
});
// Optional: room.registerTextStreamHandler('lk.transcription', …)
// → activeUtterance.setTranscript(text) for force-align lipsync
});
room.on(RoomEvent.TrackUnsubscribed, async (track) => {
if (track.kind !== Track.Kind.Audio) return;
activeUtterance = null;
await experience.speech.interrupt({ scope: 'active' });
});
await room.connect(LIVEKIT_URL, USER_TOKEN); Turns vs continuous tracks
LiveKit often gives one long-lived remote audio track rather than discrete PCM turn chunks. The
helper uses createUtterance({ track }) with replace-active and upgrades lipsync when agent text arrives on the lk.transcription stream. If
your agent emits framed PCM over a data channel instead, use the PCM helpers from the BYO hub.
Session capability
Mint with externalSpeechAudio (and usually speechAnimation). Capture runs
in the host SDK; the player only receives PCM.