| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { extractResponseTranscript } from "../ws/codec.js"; |
| import { OrbVisualiser, VIS_FFT_SIZE } from "../ws/orb-visualizer.js"; |
|
|
| |
| const DATA_CHANNEL_LABEL = "oai-events"; |
| |
| |
| |
| const DC_OPEN_TIMEOUT_MS = 20_000; |
| |
| |
| const ICE_GATHERING_TIMEOUT_MS = 3_000; |
| |
| |
| const SPEAKING_OPEN_DB = -50; |
| const SPEAKING_HANG_MS = 250; |
| const LEVEL_POLL_MS = 50; |
|
|
| |
| |
| function _codedError(message, code) { |
| const err = (new Error(message)); |
| err.code = code; |
| return err; |
| } |
|
|
| export class S2sRtcRealtimeClient extends EventTarget { |
| |
| constructor(options) { |
| super(); |
| |
| this.options = options; |
| |
| this._tools = options.tools ?? []; |
| |
| this._acquireMic = options.acquireMic ?? null; |
| |
| this._closed = false; |
| |
| this._pc = null; |
| |
| this._dc = null; |
| |
| this._ctx = null; |
| |
| this._micSrc = null; |
| |
| this._remoteSrc = null; |
| |
| |
| |
| this._quirkAudio = null; |
| |
| this._micAnalyser = null; |
| |
| this._outAnalyser = null; |
| |
| this._visualiser = null; |
| |
| this._levelTimer = 0; |
| |
| this._levelBuf = new Uint8Array(VIS_FFT_SIZE); |
| |
| this._lastAudibleAt = 0; |
| |
| this._status = "idle"; |
| this._aiSpeaking = false; |
| |
| |
| this._activeResponseId = ""; |
| |
| |
| |
| |
| this._audibleResponses = new Set(); |
| |
| |
| this._asstTranscriptByResp = new Map(); |
| |
| this._asstFullByResp = new Map(); |
| this._muted = false; |
| |
| |
| |
| |
| |
| this._openResponses = 0; |
| this._createInFlight = false; |
| |
| this._createQueue = []; |
| this._sessionConfigured = false; |
| this._startupGreeting = options.startupGreeting?.trim() ?? ""; |
| this._startupGreetingSent = false; |
| this._debug = (() => { try { return localStorage.getItem("s2s.debug") === "1"; } catch { return false; } })(); |
| } |
|
|
| get status() { |
| return this._status; |
| } |
|
|
| |
| _setStatus(status) { |
| if (this._status === status) return; |
| this._status = status; |
| this.dispatchEvent(new CustomEvent("status", { detail: { status } })); |
| } |
|
|
| |
| |
| _asstDisplay(rid) { |
| const full = this._asstFullByResp.get(rid) || ""; |
| const seg = this._asstTranscriptByResp.get(rid) || ""; |
| if (!seg) return full; |
| return full ? `${full} ${seg}` : seg; |
| } |
|
|
| _markAudible() { |
| if (this._status === "ai-speaking") return; |
| if (this._status === "closed" || this._status === "error") return; |
| this._setStatus("ai-speaking"); |
| } |
|
|
| |
| |
| |
| |
| |
| async connect() { |
| if (this._pc) throw new Error("Already connected"); |
| this._setStatus("connecting"); |
|
|
| if (!this.options.micStream && this._acquireMic) { |
| this.options.micStream = await this._acquireMic(); |
| } |
| if (this._closed) throw _codedError("connect aborted", "aborted"); |
|
|
| this._setupAudioGraph(); |
|
|
| const pc = new RTCPeerConnection( |
| this.options.iceServers?.length ? { iceServers: this.options.iceServers } : {}, |
| ); |
| this._pc = pc; |
|
|
| pc.addEventListener("track", (e) => this._onRemoteTrack(e)); |
| pc.addEventListener("connectionstatechange", () => this._onConnectionState()); |
|
|
| const micTrack = this.options.micStream?.getAudioTracks()[0]; |
| if (!micTrack) throw new Error("No microphone track available"); |
| micTrack.enabled = !this._muted; |
| pc.addTrack(micTrack, (this.options.micStream)); |
|
|
| |
| |
| const dc = pc.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true }); |
| this._dc = dc; |
| dc.addEventListener("message", (e) => this._onDcMessage(e.data)); |
| dc.addEventListener("close", () => this._onDcClose()); |
|
|
| await pc.setLocalDescription(await pc.createOffer()); |
| |
| |
| |
| await this._waitIceGathering(pc); |
| if (this._closed) throw _codedError("connect aborted", "aborted"); |
|
|
| const answerSdp = await this._postOffer(pc.localDescription?.sdp ?? ""); |
| if (this._closed) throw _codedError("connect aborted", "aborted"); |
| await pc.setRemoteDescription({ type: "answer", sdp: answerSdp }); |
|
|
| await this._waitDataChannelOpen(dc); |
| this._startLevelLoop(); |
| |
| |
| } |
|
|
| |
| _waitIceGathering(pc) { |
| if (pc.iceGatheringState === "complete") return Promise.resolve(); |
| return new Promise((resolve) => { |
| const done = () => { |
| pc.removeEventListener("icegatheringstatechange", check); |
| clearTimeout(timer); |
| resolve(undefined); |
| }; |
| const check = () => { |
| if (pc.iceGatheringState === "complete") done(); |
| }; |
| const timer = setTimeout(() => { |
| console.warn("[rtc] ICE gathering timed out; sending offer with partial candidates"); |
| done(); |
| }, ICE_GATHERING_TIMEOUT_MS); |
| pc.addEventListener("icegatheringstatechange", check); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| async _postOffer(offerSdp) { |
| console.log("[rtc] POST", this.options.callsUrl); |
| const response = await fetch(this.options.callsUrl, { |
| method: "POST", |
| headers: { "Content-Type": "application/sdp" }, |
| body: offerSdp, |
| }); |
| if (!response.ok) { |
| const text = await response.text().catch(() => ""); |
| if (response.status === 503) { |
| |
| try { |
| const j = JSON.parse(text); |
| const msg = j?.error?.message; |
| if (msg) throw _codedError(msg, "busy"); |
| } catch (err) { |
| if (err instanceof Error && (err).code === "busy") throw err; |
| } |
| throw _codedError("The speech service is busy — try again shortly.", "busy"); |
| } |
| throw new Error(`WebRTC handshake failed (${response.status}): ${text.slice(0, 200)}`); |
| } |
| return response.text(); |
| } |
|
|
| |
| _waitDataChannelOpen(dc) { |
| if (dc.readyState === "open") return Promise.resolve(); |
| return new Promise((resolve, reject) => { |
| const cleanup = () => { |
| clearTimeout(timer); |
| dc.removeEventListener("open", onOpen); |
| dc.removeEventListener("close", onClose); |
| dc.removeEventListener("error", onClose); |
| }; |
| const onOpen = () => { |
| cleanup(); |
| resolve(undefined); |
| }; |
| const onClose = () => { |
| cleanup(); |
| reject(new Error("WebRTC data channel closed before opening")); |
| }; |
| const timer = setTimeout(() => { |
| cleanup(); |
| reject(new Error( |
| "WebRTC connection timed out. If the server is remote, it may need a STUN/TURN server (RTC_ICE_SERVERS).", |
| )); |
| }, DC_OPEN_TIMEOUT_MS); |
| dc.addEventListener("open", onOpen); |
| dc.addEventListener("close", onClose); |
| dc.addEventListener("error", onClose); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| _setupAudioGraph() { |
| const ctx = this.options.audioContext ?? new AudioContext({ latencyHint: "interactive" }); |
| this._ctx = ctx; |
| if (ctx.state === "suspended") { |
| |
| void ctx.resume().catch((err) => console.warn("[rtc] AudioContext resume failed:", err)); |
| } |
|
|
| const micAnalyser = ctx.createAnalyser(); |
| micAnalyser.fftSize = VIS_FFT_SIZE; |
| micAnalyser.smoothingTimeConstant = 0; |
| const micSrc = ctx.createMediaStreamSource( (this.options.micStream)); |
| micSrc.connect(micAnalyser); |
| this._micSrc = micSrc; |
| this._micAnalyser = micAnalyser; |
|
|
| const outAnalyser = ctx.createAnalyser(); |
| outAnalyser.fftSize = VIS_FFT_SIZE; |
| outAnalyser.smoothingTimeConstant = 0.3; |
| outAnalyser.connect(ctx.destination); |
| this._outAnalyser = outAnalyser; |
|
|
| void this.setAudioOutputDevice(this.options.audioOutputId || ""); |
|
|
| this._visualiser = new OrbVisualiser(micAnalyser, outAnalyser, () => this._aiSpeaking); |
| this._visualiser.start(); |
| } |
|
|
| |
| |
| |
| |
| |
| async setAudioOutputDevice(deviceId = "") { |
| const ctx = this._ctx; |
| if (!ctx || typeof (ctx).setSinkId !== "function") return false; |
| try { |
| await (ctx).setSinkId(deviceId || ""); |
| return true; |
| } catch (err) { |
| console.warn("[rtc] setSinkId failed:", err); |
| return false; |
| } |
| } |
|
|
| |
| _onRemoteTrack(e) { |
| if (e.track.kind !== "audio" || !this._ctx || !this._outAnalyser) return; |
| const stream = e.streams[0] ?? new MediaStream([e.track]); |
| |
| |
| const quirk = new Audio(); |
| quirk.muted = true; |
| quirk.srcObject = stream; |
| this._quirkAudio = quirk; |
| this._remoteSrc?.disconnect(); |
| this._remoteSrc = this._ctx.createMediaStreamSource(stream); |
| this._remoteSrc.connect(this._outAnalyser); |
| if (this._debug) console.debug("[rtc] remote audio track attached"); |
| } |
|
|
| |
|
|
| _startLevelLoop() { |
| if (this._levelTimer) return; |
| this._levelTimer = window.setInterval(() => this._pollLevels(), LEVEL_POLL_MS); |
| } |
|
|
| |
| |
| _rmsOf(analyser) { |
| analyser.getByteTimeDomainData(this._levelBuf); |
| let sum = 0; |
| for (let i = 0; i < this._levelBuf.length; i++) { |
| const s = (this._levelBuf[i] - 128) / 128; |
| sum += s * s; |
| } |
| return Math.sqrt(sum / this._levelBuf.length); |
| } |
|
|
| _pollLevels() { |
| if (!this._micAnalyser || !this._outAnalyser) return; |
|
|
| |
| this.dispatchEvent( |
| new CustomEvent("input-level", { detail: { rms: this._rmsOf(this._micAnalyser) } }), |
| ); |
|
|
| |
| |
| const rms = this._rmsOf(this._outAnalyser); |
| const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; |
| const now = performance.now(); |
| if (db > SPEAKING_OPEN_DB) this._lastAudibleAt = now; |
| const audible = now - this._lastAudibleAt < SPEAKING_HANG_MS; |
| if (audible && !this._aiSpeaking) { |
| this._aiSpeaking = true; |
| if (this._activeResponseId) this._audibleResponses.add(this._activeResponseId); |
| this._markAudible(); |
| } |
| } |
|
|
| |
|
|
| |
| _onDcMessage(raw) { |
| if (typeof raw !== "string") return; |
| let event; |
| try { |
| event = JSON.parse(raw); |
| } catch { |
| return; |
| } |
|
|
| const type = event?.type; |
| if (typeof type !== "string") return; |
| if (this._debug) { |
| const extra = type.startsWith("conversation.item.input_audio_transcription") |
| ? ` item=${event.item_id} ${event.delta ?? event.transcript ?? ""}` |
| : type.startsWith("response.") |
| ? ` resp=${event.response_id ?? event.response?.id ?? ""} status=${event.response?.status ?? ""}` |
| : ""; |
| console.debug(`[rtc] ${type}${extra}`); |
| } |
|
|
| switch (type) { |
| case "session.created": |
| |
| |
| this._sendSessionUpdate(); |
| this._sessionConfigured = true; |
| |
| |
| this._sendStartupGreeting(); |
| if (this._status === "connecting") this._setStatus("connected"); |
| break; |
|
|
| case "session.updated": |
| break; |
|
|
| case "input_audio_buffer.speech_started": |
| |
| |
| this._aiSpeaking = false; |
| this._lastAudibleAt = 0; |
| this.dispatchEvent(new CustomEvent("user-turn-started", { |
| detail: { |
| itemId: typeof event.item_id === "string" ? event.item_id : "", |
| }, |
| })); |
| this._setStatus("user-speaking"); |
| break; |
|
|
| case "input_audio_buffer.speech_stopped": |
| this.dispatchEvent(new CustomEvent("user-turn-stopped", { |
| detail: { |
| itemId: typeof event.item_id === "string" ? event.item_id : "", |
| }, |
| })); |
| if (this._status === "user-speaking") this._setStatus("processing"); |
| break; |
|
|
| case "response.created": |
| this._openResponses++; |
| this._createInFlight = false; |
| this._activeResponseId = event.response?.id ?? ""; |
| if (this._status === "connected" || this._status === "user-speaking") { |
| this._setStatus("processing"); |
| } |
| break; |
|
|
| case "response.output_item.added": |
| if (this._status === "connected" || this._status === "user-speaking") { |
| this._setStatus("processing"); |
| } |
| break; |
|
|
| case "response.done": { |
| this._aiSpeaking = false; |
| this._lastAudibleAt = 0; |
| this._openResponses = Math.max(0, this._openResponses - 1); |
| if (this._status === "ai-speaking" || this._status === "processing") { |
| this._setStatus("connected"); |
| } |
| |
| |
| const status = event.response?.status ?? "completed"; |
| const responseId = event.response?.id ?? ""; |
| if (this._activeResponseId === responseId) this._activeResponseId = ""; |
| const audible = responseId ? this._audibleResponses.has(responseId) : false; |
| this._audibleResponses.delete(responseId); |
| const transcript = |
| extractResponseTranscript(event.response) || |
| this._asstDisplay(responseId) || |
| ""; |
| this._asstTranscriptByResp.delete(responseId); |
| this._asstFullByResp.delete(responseId); |
| this.dispatchEvent(new CustomEvent("response-finished", { |
| detail: { responseId, status, audible, transcript }, |
| })); |
| this._flushQueuedCreate(); |
| break; |
| } |
|
|
| case "response.function_call_arguments.done": { |
| const name = typeof event.name === "string" ? event.name : ""; |
| const args = typeof event.arguments === "string" ? event.arguments : "{}"; |
| const callId = typeof event.call_id === "string" ? event.call_id : ""; |
| if (name) { |
| this.dispatchEvent(new CustomEvent("toolcall", { |
| detail: { name, arguments: args, callId }, |
| })); |
| } else { |
| console.warn(`[rtc] function_call_arguments.done with no name (call_id=${callId}); cannot run tool — turn may stall`); |
| } |
| break; |
| } |
|
|
| case "conversation.item.input_audio_transcription.delta": { |
| const delta = typeof event.delta === "string" ? event.delta : ""; |
| if (delta) { |
| |
| |
| this.dispatchEvent( |
| new CustomEvent("transcript", { |
| detail: { |
| role: "user", |
| text: delta, |
| partial: true, |
| itemId: typeof event.item_id === "string" ? event.item_id : "", |
| }, |
| }), |
| ); |
| } |
| break; |
| } |
|
|
| case "conversation.item.input_audio_transcription.completed": { |
| const transcript = typeof event.transcript === "string" ? event.transcript : ""; |
| if (transcript) { |
| this.dispatchEvent( |
| new CustomEvent("transcript", { |
| detail: { |
| role: "user", |
| text: transcript, |
| partial: false, |
| itemId: typeof event.item_id === "string" ? event.item_id : "", |
| }, |
| }), |
| ); |
| } |
| break; |
| } |
|
|
| case "response.audio_transcript.delta": |
| case "response.output_audio_transcript.delta": { |
| this._markAudible(); |
| const rid = typeof event.response_id === "string" ? event.response_id : ""; |
| const delta = typeof event.delta === "string" ? event.delta : ""; |
| if (delta) { |
| this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta); |
| this.dispatchEvent( |
| new CustomEvent("transcript", { |
| detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid }, |
| }), |
| ); |
| } |
| break; |
| } |
|
|
| case "response.audio_transcript.done": |
| case "response.output_audio_transcript.done": { |
| const rid = typeof event.response_id === "string" ? event.response_id : ""; |
| |
| |
| const segment = |
| (typeof event.transcript === "string" && event.transcript) || |
| this._asstTranscriptByResp.get(rid) || |
| ""; |
| this._asstTranscriptByResp.delete(rid); |
| if (segment) { |
| const prev = this._asstFullByResp.get(rid) || ""; |
| this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment); |
| } |
| const full = this._asstFullByResp.get(rid) || ""; |
| if (full) { |
| this.dispatchEvent( |
| new CustomEvent("transcript", { |
| detail: { role: "assistant", text: full, partial: false, responseId: rid }, |
| }), |
| ); |
| } |
| break; |
| } |
|
|
| case "error": { |
| const err = event.error; |
| console.error("[rtc] server error:", err); |
| |
| |
| |
| if (err?.type === "conversation_already_has_active_response" || |
| err?.code === "conversation_already_has_active_response") { |
| if (this._createInFlight) { |
| this._createInFlight = false; |
| this._createQueue.push({}); |
| } |
| break; |
| } |
| |
| |
| this.dispatchEvent( |
| new CustomEvent("server-error", { detail: { error: new Error(err?.message ?? "Server error") } }), |
| ); |
| break; |
| } |
| } |
| } |
|
|
| _onDcClose() { |
| if (this._closed) return; |
| if (this._status === "closed" || this._status === "error") return; |
| console.log("[rtc] data channel closed by server"); |
| this.dispatchEvent( |
| new CustomEvent("error", { detail: { error: new Error("Connection closed by the server") } }), |
| ); |
| this._setStatus("error"); |
| } |
|
|
| _onConnectionState() { |
| const state = this._pc?.connectionState; |
| if (this._debug) console.debug(`[rtc] connection state: ${state}`); |
| if (this._closed) return; |
| if (state === "disconnected") { |
| |
| |
| |
| console.warn("[rtc] connection disconnected — waiting for recovery or failure"); |
| return; |
| } |
| if (state === "failed") { |
| if (this._status === "closed" || this._status === "error") return; |
| this.dispatchEvent( |
| new CustomEvent("error", { detail: { error: new Error(`WebRTC connection ${state}`) } }), |
| ); |
| this._setStatus("error"); |
| } |
| } |
|
|
| _sendSessionUpdate() { |
| |
| |
| |
| const session = { |
| type: "realtime", |
| instructions: this.options.instructions, |
| audio: { |
| output: { voice: this.options.voice }, |
| }, |
| }; |
| if (this._tools.length) { |
| session.tools = this._tools; |
| session.tool_choice = "auto"; |
| } |
| this._send({ type: "session.update", session }); |
| } |
|
|
| |
| |
| updateSession(patch) { |
| |
| const session = { type: "realtime" }; |
| if (patch.instructions) session.instructions = patch.instructions; |
| if (patch.voice) session.audio = { output: { voice: patch.voice } }; |
| if (Object.keys(session).length > 1) { |
| this._send({ type: "session.update", session }); |
| } |
| } |
|
|
| |
| |
| setTools(tools) { |
| this._tools = tools; |
| this._send({ |
| type: "session.update", |
| session: { type: "realtime", tools, tool_choice: tools.length ? "auto" : "none" }, |
| }); |
| } |
|
|
| |
| sendToolOutput(callId, output) { |
| if (!callId) return; |
| this._send({ |
| type: "conversation.item.create", |
| item: { type: "function_call_output", call_id: callId, output }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| sendUserImage(dataUrl) { |
| this._send({ |
| type: "conversation.item.create", |
| item: { |
| type: "message", |
| role: "user", |
| content: [{ type: "input_image", image_url: dataUrl }], |
| }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| _sendStartupGreeting() { |
| if (!this._startupGreeting || this._startupGreetingSent) return; |
| this._startupGreetingSent = true; |
| this._send({ |
| type: "conversation.item.create", |
| item: { |
| type: "message", |
| role: "user", |
| content: [{ type: "input_text", text: this._startupGreeting }], |
| }, |
| }); |
| this.requestResponse(); |
| if (this._debug) console.debug("[rtc] startup greeting queued"); |
| } |
|
|
| |
| |
| |
| requestResponse(opts = {}) { |
| if (this._responseActive()) { |
| this._createQueue.push(opts); |
| if (this._debug) console.debug(`[rtc] response.create queued; pending=${this._createQueue.length}`); |
| return; |
| } |
| this._createResponseNow(opts); |
| } |
|
|
| _responseActive() { |
| return this._openResponses > 0 || this._createInFlight; |
| } |
|
|
| |
| _createResponseNow(opts = {}) { |
| if (!this._dc || this._dc.readyState !== "open") return; |
| if (opts.image) this.sendUserImage(opts.image); |
| this._createInFlight = true; |
| this._send({ type: "response.create" }); |
| } |
|
|
| _flushQueuedCreate() { |
| if (this._createQueue.length > 0 && !this._responseActive()) { |
| const opts = this._createQueue.shift(); |
| if (this._debug) console.debug(`[rtc] replaying queued response.create; remaining=${this._createQueue.length}`); |
| this._createResponseNow(opts); |
| } |
| } |
|
|
| |
| setMuted(muted) { |
| this._muted = muted; |
| |
| |
| |
| for (const track of this.options.micStream?.getAudioTracks() ?? []) { |
| track.enabled = !muted; |
| } |
| } |
|
|
| |
| |
| |
| setNoiseGate(_gate) {} |
|
|
| |
| |
| join() {} |
|
|
| |
| _send(event) { |
| if (!this._dc || this._dc.readyState !== "open") return; |
| try { |
| this._dc.send(JSON.stringify(event)); |
| } catch (err) { |
| |
| |
| console.error("[rtc] data channel send failed:", err); |
| } |
| } |
|
|
| async close() { |
| this._closed = true; |
| if (this._levelTimer) { |
| clearInterval(this._levelTimer); |
| this._levelTimer = 0; |
| } |
| this._visualiser?.stop(); |
| this._visualiser = null; |
| try { |
| this._dc?.close(); |
| } catch { |
| |
| } |
| this._dc = null; |
| try { |
| this._pc?.close(); |
| } catch { |
| |
| } |
| this._pc = null; |
| if (this._quirkAudio) { |
| this._quirkAudio.srcObject = null; |
| this._quirkAudio = null; |
| } |
| try { |
| this._remoteSrc?.disconnect(); |
| } catch { |
| |
| } |
| try { |
| this._micSrc?.disconnect(); |
| } catch { |
| |
| } |
| try { |
| this._micAnalyser?.disconnect(); |
| } catch { |
| |
| } |
| try { |
| this._outAnalyser?.disconnect(); |
| } catch { |
| |
| } |
| try { |
| await this._ctx?.close(); |
| } catch { |
| |
| } |
| this._ctx = null; |
| this._remoteSrc = null; |
| this._micSrc = null; |
| this._micAnalyser = null; |
| this._outAnalyser = null; |
| this._setStatus("closed"); |
| } |
| } |
|
|