Avatar only (your own audio)
Running your own voice pipeline — your own speech recognition, LLM and voice, or a
speech-to-speech model? Use just the avatar: your server sends the audio, the avatar lip-syncs
to it. There is no character and nothing to set up in the console; a face id (GET /v1/avatars)
is all it takes.
- Your server creates the session for a face, asking for avatar only.
- The page joins it as usual, without a microphone.
- Your server gets credentials to send audio, joins the session’s room, waits until the avatar is ready, and sends each utterance as PCM.
Your server, in Node or Python:
Node
Node 18+; npm install @livekit/rtc-node.
import { Room, TrackKind } from "@livekit/rtc-node";
const API = "https://api.avatargo.ai/v1";
async function call(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { authorization: `Bearer ${process.env.AVATARGO_API_KEY}`, ...(body && { "content-type": "application/json" }) },
body: body && JSON.stringify(body),
});
const json = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json;
}
// 1. A session for a face, with your server sending the audio. Give sessionToken to the page.
export function startSession(avatarId) {
return call("POST", "/auth/session-token", { avatarId, sessionOptions: { audioPassthrough: true } });
}
// 3. Join the room as the sender, and wait until the avatar is ready.
export async function joinAsSender(sessionId) {
const ingress = await call("POST", `/sessions/${sessionId}/audio-ingress`);
const room = new Room();
await room.connect(ingress.livekitUrl, ingress.token, { autoSubscribe: false });
// The avatar calls this when it has finished speaking an utterance (or was interrupted).
let finished = () => {};
room.localParticipant.registerRpcMethod("lk.playback_finished", async () => (finished(), ""));
// Joining is not readiness: audio sent before the avatar publishes its video is dropped.
const ready = () => [...(room.remoteParticipants.get(ingress.avatarIdentity)?.trackPublications.values() ?? [])]
.some((t) => t.kind === TrackKind.KIND_VIDEO);
while (!ready()) await new Promise((r) => setTimeout(r, 250));
// One stream per utterance: pcm_s16le, 24 kHz, mono. Closing it ends the utterance.
// Resolves once the avatar has spoken it: sending is faster than speaking.
const { topic, sampleRate, channels } = ingress.audio;
async function say(pcm) {
const spoken = new Promise((resolve) => (finished = resolve));
const writer = await room.localParticipant.streamBytes({
name: `utterance-${Date.now()}`, topic, destinationIdentities: [ingress.avatarIdentity],
attributes: { sample_rate: String(sampleRate), num_channels: String(channels) },
});
for (let i = 0; i < pcm.length; i += 15_000) await writer.write(pcm.subarray(i, i + 15_000));
await writer.close();
await spoken;
}
return { room, say };
}
// Stop what the avatar is saying now; end the session.
export const interrupt = (sessionId) => call("POST", `/sessions/${sessionId}/interrupt`);
export async function end(sessionId, room) {
await call("POST", `/sessions/${sessionId}/stop`);
await room.disconnect();
}The page — the SDK as usual, without a microphone:
const client = createClient(sessionToken);
await client.streamToVideoElement("avatar", { microphone: false });Putting it together on your server (Node shown; Python is the same in snake_case):
const { sessionId, sessionToken } = await startSession("av_…");
// … give sessionToken to the page, which joins as above …
const { room, say } = await joinAsSender(sessionId);
await say(pcm); // a Buffer of pcm_s16le, 24 kHz, mono: from your voice or speech-to-speech model
// … more utterances, as the conversation goes on …
await end(sessionId, room); // after the last one is spoken: ending sooner cuts it offWhat to know:
- Audio:
pcm_s16le, 24 kHz, mono — the values come back fromaudio-ingressasaudio. Send at least as fast as real time; faster is fine, it is queued. The avatar calls RPClk.playback_finishedon your server when it has spoken (or dropped) what was queued, which is whensayreturns; a session ended before that cuts off the rest. - Readiness: the avatar joins after your server does, then publishes its video once it can take audio — a few seconds. Audio sent before that is dropped, so wait as shown.
- Interrupting (your user started talking):
POST /v1/sessions/{id}/interruptdrops what is queued; then send the next utterance. - Ending:
POST /v1/sessions/{id}/stop. If your server leaves the room instead, the session ends about 5 s later (sender_left). If it never joins within 30 s ofaudio-ingress, the session fails withsender_not_joined. - What you don’t get: no
messageevents (we neither hear nor see the words), no tool calls and no transcript — those belong to your pipeline.video_play_startedandsession_endedwork as usual. - Billing: by session length, from when the avatar takes the call.
- Errors from
POST /v1/auth/session-token:invalid_requestwithparamsessionOptions.audioPassthroughwhen you send a face but notaudioPassthrough: true(so a forgottencharacterIdis not a silent avatar), orcharacterId/characterConfigwhen you send a character as well;concurrency_limitandengine_unavailableas for any session.