UNPKG

@assistant-ui/react

Version:

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

389 lines (373 loc) • 13.6 kB
import { createVoiceSession, type RealtimeVoiceAdapter, } from "@assistant-ui/core"; /** Options for a browser WebRTC connection to OpenAI Realtime. */ export type OpenAIRealtimeVoiceAdapterOptions = { /** Exchange the browser's SDP offer for an answer through your authenticated server. Keep the API key and session configuration on the server. */ exchangeSdp: ( sdp: string, options: { signal: AbortSignal }, ) => Promise<string>; }; 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 OpenAI Realtime over WebRTC. * Configure audio output, input transcription, and automatic turn detection on * the server. Each connection starts a new conversation; existing thread history * and assistant-ui tools are not sent to the voice model. */ export class OpenAIRealtimeVoiceAdapter implements RealtimeVoiceAdapter { private readonly options: OpenAIRealtimeVoiceAdapterOptions; constructor(options: OpenAIRealtimeVoiceAdapterOptions) { this.options = options; } connect(options: { abortSignal?: AbortSignal; }): RealtimeVoiceAdapter.Session { return createVoiceSession(options, async (session) => { if ( typeof RTCPeerConnection === "undefined" || typeof document === "undefined" || typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia ) { throw new Error( "OpenAI Realtime voice requires a browser with WebRTC and microphone access.", ); } const controller = new AbortController(); const peer = new RTCPeerConnection(); const channel = peer.createDataChannel("oai-events"); const audio = document.createElement("audio"); audio.autoplay = true; let microphone: MediaStream | undefined; let muted = false; let closed = false; let generating = false; let speaking = false; let transcribing = false; let volumeTimer: ReturnType<typeof setInterval> | undefined; let connectionTimer: ReturnType<typeof setTimeout> | undefined; type Transcript = { ready: boolean; value: RealtimeVoiceAdapter.TranscriptItem | undefined; }; let assistantTranscript: (Transcript & { id: string }) | undefined; const transcriptQueue: Transcript[] = []; const pendingTranscripts = new Map<string, Transcript>(); const flushTranscripts = () => { while (transcriptQueue[0]?.ready) { const item = transcriptQueue[0]; item.ready = false; if (item.value?.isFinal !== false) transcriptQueue.shift(); if (item.value) session.emitTranscript(item.value); } }; const finishUserTranscript = ( id: unknown, value?: RealtimeVoiceAdapter.TranscriptItem, ) => { const item = typeof id === "string" ? pendingTranscripts.get(id) : undefined; if (item) { item.ready = true; item.value = value; pendingTranscripts.delete(id as string); } else if (value) transcriptQueue.push({ ready: true, value }); flushTranscripts(); }; const pending = new Map< string, { resolve: () => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout>; } >(); const disconnect = () => { if (closed) return; closed = true; controller.abort(); clearInterval(volumeTimer); clearTimeout(connectionTimer); for (const id of pending.keys()) settle( id, new Error( "OpenAI Realtime session disconnected before accepting the message.", ), ); pendingTranscripts.clear(); transcriptQueue.length = 0; channel.onopen = channel.onmessage = channel.onclose = channel.onerror = null; peer.ontrack = peer.onconnectionstatechange = null; for (const track of microphone?.getTracks() ?? []) track.stop(); audio.pause(); audio.srcObject = null; channel.close(); peer.close(); }; const end = (reason: "finished" | "error", error?: unknown) => { session.end(reason, error); disconnect(); }; const send = (event: Record<string, unknown>) => { if (closed || channel.readyState !== "open") throw new Error("OpenAI Realtime session is not connected."); channel.send(JSON.stringify(event)); }; const request = (id: string, event: Record<string, unknown>) => new Promise<void>((resolve, reject) => { const timer = setTimeout(() => { reject( new Error("OpenAI Realtime did not acknowledge the message."), ); }, 10_000); pending.set(id, { resolve, reject, timer }); send({ ...event, event_id: id }); }).finally(() => settle(id)); const settle = (id: unknown, error?: Error) => { if (typeof id !== "string") return false; const message = pending.get(id); if (!message) return false; clearTimeout(message.timer); pending.delete(id); if (error) message.reject(error); else message.resolve(); return true; }; const setSpeaking = (value: boolean) => { speaking = value; session.emitMode(value ? "speaking" : "listening"); }; channel.onmessage = ({ data }) => { if (closed) return; let event: Record<string, unknown> | undefined; try { event = asRecord(JSON.parse(data)); } catch { return; } if (!event) return; switch (event.type) { case "session.created": case "session.updated": { const audio = asRecord(asRecord(event.session)?.audio); const input = asRecord(audio?.input); transcribing = asRecord(input?.transcription) !== undefined; break; } case "input_audio_buffer.committed": if ( transcribing && typeof event.item_id === "string" && !pendingTranscripts.has(event.item_id) ) { const item = { ready: false, value: undefined }; pendingTranscripts.set(event.item_id, item); transcriptQueue.push(item); } break; case "conversation.item.input_audio_transcription.completed": if (typeof event.transcript === "string") finishUserTranscript(event.item_id, { role: "user", text: event.transcript, isFinal: true, }); break; case "response.output_audio_transcript.delta": case "response.output_audio_transcript.done": { if (typeof event.item_id !== "string") break; const final = event.type === "response.output_audio_transcript.done"; const text = final ? event.transcript : event.delta; if (typeof text !== "string") break; if (assistantTranscript?.id !== event.item_id) { assistantTranscript = { id: event.item_id, ready: false, value: undefined, }; transcriptQueue.push(assistantTranscript); } assistantTranscript.ready = true; assistantTranscript.value = { role: "assistant", text: final ? text : (assistantTranscript.value?.text ?? "") + text, isFinal: final, }; flushTranscripts(); break; } case "response.created": generating = true; settle( asRecord(asRecord(event.response)?.metadata)?.aui_request_id, ); break; case "response.done": { generating = false; const response = asRecord(event.response); if (assistantTranscript?.value?.isFinal === false) { assistantTranscript.value = { ...assistantTranscript.value, isFinal: true, }; assistantTranscript.ready = true; flushTranscripts(); } if (response?.status === "failed") console.warn( "OpenAI Realtime response failed.", response.status_details, ); break; } case "output_audio_buffer.started": setSpeaking(true); break; case "output_audio_buffer.stopped": case "output_audio_buffer.cleared": setSpeaking(false); break; case "conversation.item.added": settle(asRecord(event.item)?.id); break; case "conversation.item.input_audio_transcription.failed": finishUserTranscript(event.item_id); console.warn("OpenAI Realtime transcription failed.", event.error); break; case "error": { const error = asRecord(event.error); if (error?.code === "response_cancel_not_active") break; if ( !settle( error?.event_id, new Error( typeof error?.message === "string" ? error.message : "OpenAI Realtime rejected the message.", ), ) ) console.warn("OpenAI Realtime error.", event.error); break; } } }; channel.onopen = () => { if (closed) return; clearTimeout(connectionTimer); session.setStatus({ type: "running" }); if (closed) return; session.emitMode("listening"); if (closed) return; volumeTimer = setInterval(() => { void peer .getStats() .then((report) => { if (closed) return; let volume = 0; report.forEach((stat) => { if ( (stat.type === "inbound-rtp" || (!muted && stat.type === "media-source")) && typeof stat.audioLevel === "number" ) volume = Math.max(volume, stat.audioLevel); }); session.emitVolume(Math.min(1, volume)); }) .catch(() => {}); }, 100); }; channel.onclose = () => end("finished"); channel.onerror = () => end("error", new Error("OpenAI Realtime data channel failed.")); peer.onconnectionstatechange = () => { if (peer.connectionState === "failed") { end("error", new Error("OpenAI Realtime connection failed.")); } else if (peer.connectionState === "closed") { end("finished"); } }; peer.ontrack = ({ track }) => { if (closed || track.kind !== "audio") return; audio.srcObject = new MediaStream([track]); void audio.play().catch((error: unknown) => end("error", error)); }; const start = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); if (closed || session.isDisposed()) { for (const track of stream.getTracks()) track.stop(); return; } microphone = stream; for (const track of stream.getAudioTracks()) { track.enabled = !muted; peer.addTrack(track, stream); } connectionTimer = setTimeout( () => end("error", new Error("OpenAI Realtime connection timed out.")), 30_000, ); const offer = await peer.createOffer(); if (closed) return; await peer.setLocalDescription(offer); if (closed) return; const answer = await this.options.exchangeSdp(offer.sdp!, { signal: controller.signal, }); if (closed) return; await peer.setRemoteDescription({ type: "answer", sdp: answer }); }; void start().catch((error: unknown) => end("error", error)); return { disconnect, mute: () => { muted = true; for (const track of microphone?.getAudioTracks() ?? []) track.enabled = false; }, unmute: () => { muted = false; for (const track of microphone?.getAudioTracks() ?? []) track.enabled = true; }, sendText: async (text) => { if (generating) send({ type: "response.cancel" }); if (speaking) send({ type: "output_audio_buffer.clear" }); const id = crypto.randomUUID().replaceAll("-", ""); await request(id, { type: "conversation.item.create", item: { id, type: "message", role: "user", content: [{ type: "input_text", text }], }, }); const responseId = crypto.randomUUID().replaceAll("-", ""); await request(responseId, { type: "response.create", response: { metadata: { aui_request_id: responseId } }, }); }, }; }); } }