UNPKG

@assistant-ui/react

Version:

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

296 lines (295 loc) • 10.3 kB
import { createVoiceSession } from "@assistant-ui/core"; // src/adapters/OpenAIRealtimeVoiceAdapter.ts const asRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0; /** * 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. */ var OpenAIRealtimeVoiceAdapter = class { options; constructor(options) { this.options = options; } connect(options) { 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; let muted = false; let closed = false; let generating = false; let speaking = false; let transcribing = false; let volumeTimer; let connectionTimer; let assistantTranscript; const transcriptQueue = []; const pendingTranscripts = /* @__PURE__ */ new Map(); 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, value) => { const item = typeof id === "string" ? pendingTranscripts.get(id) : void 0; if (item) { item.ready = true; item.value = value; pendingTranscripts.delete(id); } else if (value) transcriptQueue.push({ ready: true, value }); flushTranscripts(); }; const pending = /* @__PURE__ */ new Map(); const disconnect = () => { if (closed) return; closed = true; controller.abort(); clearInterval(volumeTimer); clearTimeout(connectionTimer); for (const id of pending.keys()) settle(id, /* @__PURE__ */ 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, error) => { session.end(reason, error); disconnect(); }; const send = (event) => { if (closed || channel.readyState !== "open") throw new Error("OpenAI Realtime session is not connected."); channel.send(JSON.stringify(event)); }; const request = (id, event) => new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(/* @__PURE__ */ new Error("OpenAI Realtime did not acknowledge the message.")); }, 1e4); pending.set(id, { resolve, reject, timer }); send({ ...event, event_id: id }); }).finally(() => settle(id)); const settle = (id, 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) => { speaking = value; session.emitMode(value ? "speaking" : "listening"); }; channel.onmessage = ({ data }) => { if (closed) return; let event; 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) !== void 0; break; } case "input_audio_buffer.committed": if (transcribing && typeof event.item_id === "string" && !pendingTranscripts.has(event.item_id)) { const item = { ready: false, value: void 0 }; 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: void 0 }; 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(() => { 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", /* @__PURE__ */ new Error("OpenAI Realtime data channel failed.")); peer.onconnectionstatechange = () => { if (peer.connectionState === "failed") end("error", /* @__PURE__ */ 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]); audio.play().catch((error) => 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", /* @__PURE__ */ new Error("OpenAI Realtime connection timed out.")), 3e4); 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 }); }; start().catch((error) => 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 } } }); } }; }); } }; export { OpenAIRealtimeVoiceAdapter }; //# sourceMappingURL=OpenAIRealtimeVoiceAdapter.js.map