@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
455 lines (431 loc) • 14.7 kB
text/typescript
import {
createVoiceSession,
type RealtimeVoiceAdapter,
} from "@assistant-ui/core";
import {
createPcmPlayer,
openMicrophone,
type MicrophoneCapture,
type PcmPlayer,
} from "./gemini-live/audio";
/** Options for a browser WebSocket connection to the Gemini Live API. */
export type GeminiLiveVoiceAdapterOptions = {
/** Return a short-lived ephemeral token minted by your authenticated server. Keep the API key and session configuration on the server. */
getToken: (options: { signal: AbortSignal }) => Promise<string>;
/** Live model to connect to, such as `gemini-3.8-live`; it must match the model your token was minted for. */
model: string;
};
const ENDPOINT =
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained";
const INPUT_RATE = 16_000;
const OUTPUT_RATE = 24_000;
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
/**
* Connects browser microphone audio directly to the Gemini Live API over a
* WebSocket. Configure the model, voice, and transcription on the server
* through the ephemeral token's connection constraints. Each connection starts
* a new conversation; existing thread history and assistant-ui tools are not
* sent to the voice model, and sessions end when Google's session limit is
* reached.
*/
export class GeminiLiveVoiceAdapter implements RealtimeVoiceAdapter {
private readonly options: GeminiLiveVoiceAdapterOptions;
constructor(options: GeminiLiveVoiceAdapterOptions) {
this.options = options;
}
connect(options: {
abortSignal?: AbortSignal;
}): RealtimeVoiceAdapter.Session {
return createVoiceSession(options, async (session) => {
if (
typeof WebSocket === "undefined" ||
typeof AudioContext === "undefined" ||
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia
) {
throw new Error(
"Gemini Live voice requires a browser with WebSockets, Web Audio, and microphone access.",
);
}
const controller = new AbortController();
let socket: WebSocket | undefined;
let microphone: MicrophoneCapture | undefined;
let player: PcmPlayer | undefined;
let muted = false;
let closed = false;
let ending = false;
let ready = false;
let generating = false;
let cancelled = false;
let goingAway = false;
let holdingAssistant = false;
let assistantText = "";
let userText = "";
let userFinished = false;
let nextUserText = "";
let nextUserFinals: string[] = [];
let deferred: Record<string, unknown>[] | undefined;
let volumeTimer: ReturnType<typeof setInterval> | undefined;
let connectionTimer: ReturnType<typeof setTimeout> | undefined;
let pendingText:
| {
awaitingInterrupt: boolean;
resolve: () => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
| undefined;
const settleText = (error?: Error) => {
if (!pendingText) return;
clearTimeout(pendingText.timer);
const { resolve, reject } = pendingText;
pendingText = undefined;
if (error) reject(error);
else resolve();
};
const disconnect = () => {
if (closed) return;
closed = true;
controller.abort();
clearInterval(volumeTimer);
clearTimeout(connectionTimer);
deferred = undefined;
settleText(
new Error(
"Gemini Live session disconnected before accepting the message.",
),
);
if (socket) {
socket.onopen = socket.onmessage = socket.onclose = null;
socket.onerror = null;
if (
socket.readyState === WebSocket.CONNECTING ||
socket.readyState === WebSocket.OPEN
)
socket.close();
}
void microphone?.close().catch(() => {});
void player?.close().catch(() => {});
};
const end = (reason: "finished" | "error", error?: unknown) => {
if (ending) return;
ending = true;
if (ready && !closed) {
// The deferral timer skips disconnected sessions, so route queued frames before finishing the turn.
try {
while (deferred !== undefined) {
const queue = deferred;
deferred = undefined;
for (const item of queue) routeContent(item);
}
} catch (drainError) {
reason = "error";
error = drainError;
}
finishTurn();
flushUser();
}
session.end(reason, error);
disconnect();
};
const send = (message: Record<string, unknown>) => {
if (closed || ending || !ready || socket?.readyState !== WebSocket.OPEN)
throw new Error("Gemini Live session is not connected.");
socket.send(JSON.stringify(message));
};
const flushUser = () => {
userFinished = true;
if (!userText) return;
const text = userText;
userText = "";
session.emitTranscript({ role: "user", text, isFinal: true });
};
const emitAssistant = (isFinal: boolean) => {
if (!assistantText || holdingAssistant) return;
session.emitTranscript({
role: "assistant",
text: assistantText,
isFinal,
});
if (isFinal) assistantText = "";
};
const stopPlayback = () => {
if (!player?.isPlaying()) return;
player.clear();
session.emitMode("listening");
};
const releaseAssistant = () => {
if (!holdingAssistant) return;
flushUser();
holdingAssistant = false;
emitAssistant(false);
};
const startReply = (typed: boolean) => {
generating = true;
cancelled = false;
holdingAssistant = !typed && !userFinished;
};
const finishTurn = () => {
generating = false;
holdingAssistant = false;
flushUser();
emitAssistant(true);
for (const text of nextUserFinals)
session.emitTranscript({ role: "user", text, isFinal: true });
userFinished = nextUserFinals.length > 0 && !nextUserText;
userText = nextUserText;
nextUserText = "";
nextUserFinals = [];
if (pendingText) pendingText.awaitingInterrupt = false;
};
const handleContent = (content: Record<string, unknown>) => {
const parts = asRecord(content.modelTurn)?.parts;
const output = asRecord(content.outputTranscription)?.text;
const input = asRecord(content.inputTranscription);
const nextTurn = generating && !holdingAssistant;
if (typeof input?.text === "string" && input.text) {
if (nextTurn) nextUserText += input.text;
else {
if (!generating) userFinished = false;
userText += input.text;
}
}
if (input?.finished === true) {
if (nextTurn) {
if (nextUserText) nextUserFinals.push(nextUserText);
nextUserText = "";
} else if (generating) releaseAssistant();
else flushUser();
}
if (content.interrupted === true) {
stopPlayback();
finishTurn();
cancelled = true;
return;
}
if (Array.isArray(parts) && !generating) startReply(false);
if (generating) {
if (Array.isArray(parts) && !pendingText?.awaitingInterrupt)
for (const part of parts) {
const data = asRecord(asRecord(part)?.inlineData)?.data;
if (typeof data === "string" && player) {
const wasPlaying = player.isPlaying();
player.enqueue(data);
if (!wasPlaying) session.emitMode("speaking");
}
}
if (typeof output === "string" && output) {
assistantText += output;
emitAssistant(false);
}
}
if (content.turnComplete === true) {
if (cancelled) cancelled = false;
else finishTurn();
}
};
const routeContent = (content: Record<string, unknown>) => {
if (deferred) {
deferred.push(content);
return;
}
if (
pendingText &&
!pendingText.awaitingInterrupt &&
content.modelTurn !== undefined
) {
const input = asRecord(content.inputTranscription)?.text;
if (typeof input === "string") userText += input;
flushUser();
settleText();
startReply(true);
const queue = (deferred = [
{ ...content, inputTranscription: undefined },
]);
setTimeout(() => {
deferred = undefined;
if (closed) return;
try {
for (const item of queue) routeContent(item);
} catch (error) {
end("error", error);
}
}, 0);
return;
}
handleContent(content);
};
const handleMessage = (message: Record<string, unknown>) => {
if (closed) return;
if (message.setupComplete !== undefined) {
if (ready) return;
ready = true;
clearTimeout(connectionTimer);
session.setStatus({ type: "running" });
if (closed) return;
session.emitMode("listening");
if (closed) return;
volumeTimer = setInterval(() => {
if (closed) return;
session.emitVolume(
Math.min(
1,
Math.max(
muted ? 0 : (microphone?.level() ?? 0),
player?.level() ?? 0,
),
),
);
}, 100);
return;
}
const content = asRecord(message.serverContent);
if (!content) {
const goAway = asRecord(message.goAway);
if (goAway) {
goingAway = true;
console.warn("Gemini Live session is closing.", goAway.timeLeft);
}
return;
}
routeContent(content);
};
const open = (token: string) => {
socket = new WebSocket(`${ENDPOINT}?access_token=${token}`);
socket.binaryType = "arraybuffer";
const decoder = new TextDecoder();
socket.onopen = () => {
if (closed) return;
socket?.send(
JSON.stringify({
setup: {
model: `models/${this.options.model}`,
generationConfig: { responseModalities: ["AUDIO"] },
inputAudioTranscription: {},
outputAudioTranscription: {},
},
}),
);
};
socket.onmessage = ({ data }) => {
let message: Record<string, unknown> | undefined;
try {
message = asRecord(
JSON.parse(
typeof data === "string" ? data : decoder.decode(data),
),
);
} catch {
return;
}
if (!message) return;
try {
handleMessage(message);
} catch (error) {
end("error", error);
}
};
socket.onclose = ({ code, reason }) => {
if (ready) {
if (code === 1000 || goingAway) end("finished");
else
end(
"error",
new Error(
`Gemini Live connection closed (${code}${reason ? `: ${reason}` : ""}).`,
),
);
} else
end(
"error",
new Error(
`Gemini Live connection closed before setup completed (${code}${reason ? `: ${reason}` : ""}).`,
),
);
};
socket.onerror = () =>
end("error", new Error("Gemini Live connection failed."));
};
const start = async () => {
player = createPcmPlayer({
sampleRate: OUTPUT_RATE,
onIdle: () => {
if (!closed) session.emitMode("listening");
},
});
const capture = await openMicrophone({
sampleRate: INPUT_RATE,
signal: controller.signal,
onChunk: (data) => {
if (closed || !ready || socket?.readyState !== WebSocket.OPEN)
return;
socket.send(
JSON.stringify({
realtimeInput: {
audio: { data, mimeType: `audio/pcm;rate=${INPUT_RATE}` },
},
}),
);
},
});
if (closed || session.isDisposed()) {
void capture.close().catch(() => {});
return;
}
microphone = capture;
capture.setMuted(muted);
connectionTimer = setTimeout(
() => end("error", new Error("Gemini Live connection timed out.")),
30_000,
);
const token = await this.options.getToken({
signal: controller.signal,
});
if (closed) return;
open(token);
};
void start().catch((error: unknown) => end("error", error));
return {
disconnect,
mute: () => {
muted = true;
microphone?.setMuted(true);
if (ready && socket?.readyState === WebSocket.OPEN)
send({ realtimeInput: { audioStreamEnd: true } });
},
unmute: () => {
muted = false;
microphone?.setMuted(false);
},
sendText: (text) =>
new Promise<void>((resolve, reject) => {
settleText(
new Error("Gemini Live replaced the message with a newer one."),
);
send({
clientContent: {
turns: [{ role: "user", parts: [{ text }] }],
turnComplete: true,
},
});
stopPlayback();
pendingText = {
awaitingInterrupt: generating,
resolve,
reject,
timer: setTimeout(() => {
settleText(
new Error(
"Gemini Live did not start a reply to the message.",
),
);
}, 10_000),
};
}),
};
});
}
}