Realtime Voice & Avatars

The cascade pipeline — VAD → STT → LLM → TTS — bottoms out around 1.3–1.6s voice-to-voice: every stage adds serial latency, and end-of-turn has to be reconstructed from transcripts with heuristics. A speech-to-speech model collapses the cascade — audio in, one model, audio out — with turn-taking decided by the model listening. Production S2S APIs run 500–800ms.

Three packages layer on top of each other:

PackageAdds
glove-voice-s2sRun a built Glove agent on a realtime S2S model
glove-voice-avatarA lip-synced face over the agent's audio
glove-voice-livekitLiveKit as the room transport, plus LiveKit-native avatars

The whole progression is preserved as runnable examples in the repo: examples/layered-voiceserver-voice s2s-roomsavatar-rooms livekit-rooms.

Speech-to-speech

terminalbash
pnpm add glove-voice-s2s

The pieces

PieceWhat it is
S2SAdapterThe provider contract: one live session — audio in/out, tool calls as events, a text side-channel
OpenAIRealtimeAdapterdevice mode (WebRTC, browser-only): owns the mic and plays the reply itself
OpenAIRealtimeSocketAdaptertransport mode (WebSocket, Node + browser): 24 kHz PCM both ways
GeminiLiveAdaptertransport mode: 16 kHz in, 24 kHz out — the mode a server-hosted room needs
RealtimeAgentRuns a built Glove on an S2S model: its prompt and tools configure the session
createS2SAdapterProvider/model/credential factory — args first, S2S_* env second
s2sDrivenModelThe Glove model slot for S2S-driven agents, optionally carrying the full realtime config
runConformanceThe behavioural suite every adapter must pass

Every adapter declares mode: "device" | "transport" so a host can refuse a mismatch loudly at startup instead of discovering silence on the first call. Device opens the microphone and plays the reply itself — least code, browser only. Transport moves PCM and nothing else: the only mode a server room or phone bridge can use, because there is no microphone in the process.

Running a Glove agent on an S2S model

Author the agent exactly as you always do — tools, prompt, store — and hand it to RealtimeAgent. One definition, two runtimes: the same tools serve text turns through the normal loop and voice turns through the provider's.

voice-agent.tstypescript
import { RealtimeAgent, s2sDrivenModel } from "glove-voice-s2s";

// The cleanest form: the model slot carries the realtime config, and
// RealtimeAgent derives the provider session from the agent itself.
const agent = new Glove({
  model: s2sDrivenModel({
    label: "s2s-front",
    provider: "openai",                                        // or S2S_PROVIDER
    voice: "marin",                                            // or S2S_VOICE
    turnDetection: { type: "semantic_vad", eagerness: "low" }, // typed knobs
  }),
  systemPrompt, store, displayManager, compaction_config,
}).fold(myTool).build();

const rt = new RealtimeAgent({ agent });
await rt.start();

Or pass an explicit adapter — it always wins over the model-slot config:

voice-agent.tstypescript
const rt = new RealtimeAgent({
  agent,                                    // a built Glove (IGloveRunnable)
  adapter: createS2SAdapter({ provider: "gemini" }),
  instructions: SPOKEN_PERSONA,             // re-voice the text prompt for speech
  excludeTools: ["render_chart"],           // withhold tools that don't belong in a call
});

rt.on("user_said", (t) => log("caller:", t));
rt.on("agent_said", (t) => log("agent:", t));
await rt.start();

// transport mode: wire audio yourself
micStream.on("pcm", (pcm) => rt.sendAudio(pcm));
rt.adapter.on("audio", (pcm, format) => speaker.play(pcm, format.sampleRate));

// push an async result into the live call — the model relays it out loud
rt.inject("the lookup finished: covered until 2031", { respond: true });

What the voice path deliberately does not do. The provider owns the loop, so the Glove Executor never runs: requiresPermission is not enforced (put gated tools in excludeTools); display.pushAndWait tools get no handOver and will throw (exclude them — voice-first tools should return descriptive data instead); and tool calls and transcripts are not persisted to the store or fired as subscriber events (use RealtimeAgent's own user_said / agent_said / tool_started / tool_finished events to log).

What is shared with the text path: tool definitions and JSON schemas, Zod input validation before run, the system prompt, and the renderData-stays-client-side contract — the bridge strips renderData and summary before anything reaches the provider, exactly like the model adapters do.

Configuration

EnvMeaning
S2S_PROVIDERopenai (WS transport) | openai-webrtc (browser device) | gemini. Unset: whichever key exists, OpenAI first
S2S_MODELModel id; unset uses the provider default
OPENAI_API_KEY / GEMINI_API_KEYThe credential when no getToken/apiKey is passed — server-side only
S2S_TURN_DETECTIONOpenAI: semantic_vad (default) | server_vad (snappier barge-in)

A missing credential fails at construction with the env var name, not at connect() with a 401. Both providers expose their full turn-taking surface as typed config, so a mistyped field fails at compile time instead of being silently ignored:

turn-taking.tstypescript
// OpenAI — the model judges WHETHER you were done
createS2SAdapter({ provider: "openai", turnDetection: {
  type: "semantic_vad",
  eagerness: "low",              // low | medium | high | auto
}});

// …or threshold-driven
createS2SAdapter({ provider: "openai", turnDetection: {
  type: "server_vad",
  threshold: 0.6,                // how loud counts as speech
  silence_duration_ms: 700,      // trailing silence before end-of-turn
  prefix_padding_ms: 300,
  idle_timeout_ms: 10_000,
}});
// turnDetection: null → manual / push-to-talk

// Gemini
createS2SAdapter({ provider: "gemini", realtimeInput: {
  automaticActivityDetection: {
    startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
    endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
    silenceDurationMs: 700,
  },
  activityHandling: "NO_INTERRUPTION",   // default is barge-in
}});

Browser sessions

API keys never reach the browser. Mint an ephemeral token server-side:

app/api/voice/s2s-token/route.tstypescript
import { createOpenAIRealtimeToken } from "glove-voice-s2s/server";

const { token } = await createOpenAIRealtimeToken({
  apiKey: process.env.OPENAI_API_KEY!,
  instructions: SPOKEN_PERSONA,
  voice: "marin",
  tools: [{ name: "delegate_to_worker", description: "", parameters: {} }],
});
app/voice.tsxtypescript
import { OpenAIRealtimeAdapter } from "glove-voice-s2s";

const s2s = new OpenAIRealtimeAdapter({
  getToken: () => fetchToken("/api/voice/s2s-token"),
});

s2s.on("tool_call", async ({ callId, name, arguments: args }) => {
  const result = await runWorker(JSON.parse(args).request);  // your heavy agent
  s2s.sendToolResult(callId, result);                        // relayed out loud
});

await s2s.connect();

Avatars

A realtime avatar provider is a lip-sync renderer over an audio stream: PCM in, a talking face out on a WebRTC surface. That is exactly the shape of the audio events a transport-mode S2SAdapter already emits — so the avatar is a rendering layer, not a replacement for any of the stack. The mic path, tools and delegation are untouched.

the shapetext
mic ──▶ S2S model (brain + voice) ──▶ agent PCM ──▶ AvatarAdapter ──▶ the face
          │ tool calls unchanged                          (provider WebRTC surface)

     worker over the mesh
terminalbash
pnpm add glove-voice-avatar
avatar.tstypescript
import { RealtimeAgent } from "glove-voice-s2s";
import { TavusEchoAdapter, attachAvatar } from "glove-voice-avatar";

const rt = new RealtimeAgent({ agent });   // the voice stack, exactly as before
await rt.start();

const avatar = new TavusEchoAdapter({
  apiKey: process.env.TAVUS_API_KEY!,      // server-side only
  faceId: process.env.TAVUS_FACE_ID!,
  // palId omitted → ensureEchoPal() reuses-or-creates a MINIMAL echo PAL
  // (no greeting, no TTS layer) so the ONLY voice is ever the agent's.
  sendInteraction: (event) => duct.send({ t: "avatar_interaction", event }),
});

const detach = await attachAvatar(rt, avatar);

avatar.view; // { kind: "webrtc-room", url: "https://…" } — hand to the client

attachAvatar is the whole bridge: audio sendAudio, agent_speech_stopped endUtterance, interrupted interrupt. Barge-in therefore follows the voice automatically. AvatarView is a tagged union — a WebRTC room URL (Tavus/Daily) or an SDK session token (Anam) — so a client knows how to attach without knowing the provider.

AdapterMode
TavusEchoAdapterTavus pipeline_mode: "echo" — our PCM as base64 24 kHz events; the caller joins the conversation's Daily room
AnamPassthroughAdapterAnam audio-passthrough (Anam's own LLM/TTS stay out of the loop). The server mints the token, the browser owns the SDK session, so the adapter needs a sendCommand courier

Writing your own? Implement AvatarAdapter connect(), sendAudio(), endUtterance(), interrupt() (always safe, conformance-enforced) — and run runAvatarConformance against a fake transport.

LiveKit

glove-voice-livekit is two halves sharing one room connection. LiveKitTransport is the room leg every LiveKit-backed voice host otherwise hand-rolls: join, publish the agent's voice as a paced WebRTC track, feed remote mic tracks back out as PCM events, carry JSON on the data channel. Barge-in is server-authoritative — clear() flushes the outbound AudioSource queue, so there is no client playback buffer to chase.

terminalbash
pnpm add glove-voice-livekit
room.tstypescript
import { LiveKitTransport, attachRealtime, mintParticipantToken } from "glove-voice-livekit";

const transport = new LiveKitTransport({
  url: process.env.LIVEKIT_URL!,
  token: await mintParticipantToken(
    { apiKey: process.env.LIVEKIT_API_KEY!, apiSecret: process.env.LIVEKIT_API_SECRET! },
    { roomName: "call-42", identity: "agent" },
  ),
});

await transport.connect();
attachRealtime(rt, transport);   // mics → model, model → track, interrupt → flush
await rt.start();

With a face

TavusLiveKitAvatar and AnamLiveKitAvatar implement the same AvatarAdapter contract (and pass its conformance suite), so a face over LiveKit is interchangeable with the Daily-based one. Under the hood they speak LiveKit's published avatar protocol: the provider's worker joins your room as a second participant and publishes synchronized voice and face itself. A Glove agent is indistinguishable from a LiveKit Agents worker as far as the avatar can tell.

room-with-face.tstypescript
import { TavusLiveKitAvatar, mintAvatarToken } from "glove-voice-livekit";
import { attachAvatar } from "glove-voice-avatar";

// The avatar publishes the voice on the agent's behalf — don't double it.
const transport = new LiveKitTransport({ url, token, publishAgentAudio: false });
await transport.connect();
attachRealtime(rt, transport, { agentAudio: false });

const avatar = new TavusLiveKitAvatar({
  apiKey: process.env.TAVUS_API_KEY!,
  faceId: process.env.TAVUS_FACE_ID!,   // minimal echo PAL ensured automatically
  livekitUrl: url,
  avatarToken: await mintAvatarToken(creds, { roomName: "call-42" }),
});

await attachAvatar(rt, avatar);
  • Voice Pipeline — the cascade, push-to-talk, noise robustness and React Native
  • Mesh — the heavy worker a thin voice front agent delegates to
  • Lola — a voice-first app read end to end