Skip to Content
Server integrationAvatar only (your own audio)

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.

  1. Your server creates the session for a face, asking for avatar only.
  2. The page joins it as usual, without a microphone.
  3. 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 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 off

What to know:

  • Audio: pcm_s16le, 24 kHz, mono — the values come back from audio-ingress as audio. Send at least as fast as real time; faster is fine, it is queued. The avatar calls RPC lk.playback_finished on your server when it has spoken (or dropped) what was queued, which is when say returns; 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}/interrupt drops 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 of audio-ingress, the session fails with sender_not_joined.
  • What you don’t get: no message events (we neither hear nor see the words), no tool calls and no transcript — those belong to your pipeline. video_play_started and session_ended work as usual.
  • Billing: by session length, from when the avatar takes the call.
  • Errors from POST /v1/auth/session-token: invalid_request with param sessionOptions.audioPassthrough when you send a face but not audioPassthrough: true (so a forgotten characterId is not a silent avatar), or characterId / characterConfig when you send a character as well; concurrency_limit and engine_unavailable as for any session.