UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

455 lines (431 loc) • 14.7 kB
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), }; }), }; }); } }