UNPKG

@audin.ai/operator-sdk

Version:

Headless browser SDK for the Audin operator softphone — make and receive calls over the Audin operator WebSockets.

1,320 lines (1,305 loc) 39.1 kB
class v { constructor() { this.listeners = /* @__PURE__ */ new Map(); } /** Subscribe to `event`. Returns an unsubscribe function. */ on(e, t) { let s = this.listeners.get(e); return s || (s = /* @__PURE__ */ new Set(), this.listeners.set(e, s)), s.add(t), () => this.off(e, t); } /** Subscribe once: the listener is removed after the first emit. */ once(e, t) { const s = this.on(e, (n) => { s(), t(n); }); return s; } /** Unsubscribe a previously-registered listener. Idempotent. */ off(e, t) { const s = this.listeners.get(e); s && (s.delete(t), s.size === 0 && this.listeners.delete(e)); } /** Emit `event` to all current listeners. Listener errors are swallowed. */ emit(e, t) { const s = this.listeners.get(e); if (s) for (const n of [...s]) try { n(t); } catch (i) { console.error("[audin-operator-sdk] listener threw:", i); } } /** Remove every listener (used on teardown). */ removeAllListeners() { this.listeners.clear(); } } class b { constructor(e) { this.ws = null, this.heartbeatTimer = null, this.reconnectTimer = null, this.reconnectAttempt = 0, this.desiredOnline = !1, this.lastAvailability = null, this.opts = e; } /** Open the presence socket and keep it open (auto-reconnecting). */ async connect() { this.desiredOnline = !0, this.reconnectAttempt = 0, await this.openOnce(); } /** * Close the socket and stop reconnecting. Best-effort `set_unavailable` * first so the server drops presence immediately rather than waiting for the * reaper. */ disconnect() { if (this.desiredOnline = !1, this.lastAvailability = null, this.clearTimers(), this.ws && this.ws.readyState === WebSocket.OPEN && this.sendRaw({ type: "set_unavailable" }), this.ws) { try { this.ws.close(1e3, "client_offline"); } catch { } this.ws = null; } } /** Announce availability on `phoneNumberIds`; remembered for reconnects. */ setAvailable(e) { this.lastAvailability = [...e], this.sendRaw({ type: "set_available", phoneNumberIds: e }); } /** Drop availability (stays connected). */ setUnavailable() { this.lastAvailability = null, this.sendRaw({ type: "set_unavailable" }); } /** Answer an inbound offer. */ accept(e) { this.sendRaw({ type: "accept", callSid: e }); } /** Decline an inbound offer. */ reject(e) { this.sendRaw({ type: "reject", callSid: e }); } /** Request an outbound call. The server replies with `outbound_started`. */ startOutbound(e, t) { this.sendRaw({ type: "start_outbound", to: e, callerId: t }); } /** Whether the socket is currently open. */ get isOpen() { return this.ws?.readyState === WebSocket.OPEN; } // ────────────────────────────────────────────────────────────────────── async openOnce() { let e; try { e = await this.opts.ensureToken(); } catch (n) { const i = n?.code ?? "TOKEN_FETCH_FAILED"; this.opts.onError(i, "session token unavailable", n), this.scheduleReconnect(); return; } const t = `${this.opts.presenceWsUrl}?token=${encodeURIComponent( e )}`, s = new WebSocket(t); this.ws = s, s.onopen = () => { this.opts.logger.debug("[presence] WS open"), this.reconnectAttempt = 0, this.startHeartbeat(), this.opts.onOpen(), this.lastAvailability && this.lastAvailability.length > 0 && this.sendRaw({ type: "set_available", phoneNumberIds: this.lastAvailability }); }, s.onmessage = (n) => { let i; try { i = JSON.parse( typeof n.data == "string" ? n.data : String(n.data) ); } catch { this.opts.logger.warn("[presence] non-JSON message ignored"); return; } this.opts.onMessage(i); }, s.onerror = (n) => { this.opts.logger.warn("[presence] WS error", n), this.opts.onError("WS_ERROR", "presence socket error"); }, s.onclose = (n) => { this.opts.logger.debug( `[presence] WS closed code=${n.code} reason=${n.reason}` ), this.stopHeartbeat(), this.ws = null, _(n.code) && this.opts.invalidateToken(), this.desiredOnline && (this.opts.onReconnecting(), this.scheduleReconnect()); }; } scheduleReconnect() { if (!this.desiredOnline || this.reconnectTimer) return; const e = this.opts.reconnectBackoffMs, t = Math.min(this.reconnectAttempt, e.length - 1), s = e[t]; this.reconnectAttempt += 1, this.opts.logger.debug( `[presence] reconnect in ${s}ms (attempt ${this.reconnectAttempt})` ), this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null, this.desiredOnline && this.openOnce(); }, s); } startHeartbeat() { this.stopHeartbeat(), this.heartbeatTimer = setInterval(() => { this.sendRaw({ type: "ping" }); }, this.opts.heartbeatIntervalMs); } stopHeartbeat() { this.heartbeatTimer && (clearInterval(this.heartbeatTimer), this.heartbeatTimer = null); } clearTimers() { this.stopHeartbeat(), this.reconnectTimer && (clearTimeout(this.reconnectTimer), this.reconnectTimer = null); } sendRaw(e) { const t = this.ws; if (!t || t.readyState !== WebSocket.OPEN) { this.opts.logger.warn( `[presence] dropped ${String(e.type)} — socket not open` ); return; } try { t.send(JSON.stringify(e)); } catch (s) { this.opts.logger.warn("[presence] send failed:", s); } } } function _(r) { return r === 1008 || r === 4001 || r === 4003; } const A = String.raw` // ---- G.711 μ-law (mirrors src/codec/mulaw.ts; standard Sun convention) ---- var MULAW_BIAS = 0x84; var MULAW_CLIP = 32635; var MULAW_EXP_LUT = new Int8Array([ 0,0,1,1,2,2,2,2,3,3,3,3,3,3,3,3, 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, 5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5, 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6, 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7 ]); function encodeMuLawSample(sample) { var pcm = sample; if (pcm > 32767) pcm = 32767; else if (pcm < -32768) pcm = -32768; var sign = (pcm >> 8) & 0x80; if (sign !== 0) pcm = -pcm; if (pcm > MULAW_CLIP) pcm = MULAW_CLIP; pcm += MULAW_BIAS; var exponent = MULAW_EXP_LUT[(pcm >> 7) & 0xff]; var mantissa = (pcm >> (exponent + 3)) & 0x0f; return (~(sign | (exponent << 4) | mantissa)) & 0xff; } function decodeMuLawSample(byte) { var u = ~byte & 0xff; var sign = u & 0x80; var exponent = (u >> 4) & 0x07; var mantissa = u & 0x0f; var sample = ((mantissa << 3) + MULAW_BIAS) << exponent; sample -= MULAW_BIAS; return (sign !== 0 ? -sample : sample) || 0; } var TARGET_RATE = 8000; // Cap the playback jitter buffer so a stall can't grow memory unbounded // (~2s of 8kHz audio decoded to context rate). var MAX_PLAYBACK_SAMPLES = 96000; class OperatorAudioProcessor extends AudioWorkletProcessor { constructor() { super(); // Capture accumulator (context-rate Float32) and its fractional read head // for resampling to 8kHz across process() boundaries. this._capBuf = new Float32Array(0); this._capPos = 0; // fractional position into _capBuf for the next 8k sample // Playback queue (context-rate Float32 already upsampled) + read head. this._playBuf = new Float32Array(0); this._playPos = 0; this._muted = false; this.port.onmessage = (e) => { var d = e.data; if (!d) return; if (d.type === "mulaw") { this._enqueuePlayback(d.payload); } else if (d.type === "mute") { this._muted = !!d.on; } else if (d.type === "flush") { this._playBuf = new Float32Array(0); this._playPos = 0; } }; } // Decode μ-law (8kHz) -> upsample to context rate -> append to playback queue. _enqueuePlayback(mulawBytes) { var n = mulawBytes.length; if (n === 0) return; var ctxRate = sampleRate; // global in AudioWorkletGlobalScope var ratio = ctxRate / TARGET_RATE; // e.g. 6 for 48k var outLen = Math.round(n * ratio); var upsampled = new Float32Array(outLen); var last = n - 1; for (var i = 0; i < outLen; i++) { var srcPos = i / ratio; var i0 = Math.floor(srcPos); var frac = srcPos - i0; var a = decodeMuLawSample(mulawBytes[i0 > last ? last : i0]) / 32768; var bIdx = i0 + 1 > last ? last : i0 + 1; var b = decodeMuLawSample(mulawBytes[bIdx]) / 32768; upsampled[i] = a + (b - a) * frac; } // Compact any already-consumed head, then append, bounded by the cap. var remaining = this._playBuf.length - this._playPos; var combinedLen = remaining + upsampled.length; if (combinedLen > MAX_PLAYBACK_SAMPLES) { // Drop oldest audio to stay bounded (better latency than OOM on a stall). var keep = MAX_PLAYBACK_SAMPLES - upsampled.length; if (keep < 0) keep = 0; var trimmed = new Float32Array(keep + upsampled.length); if (keep > 0) { trimmed.set( this._playBuf.subarray(this._playBuf.length - keep, this._playBuf.length), 0 ); } trimmed.set(upsampled, keep); this._playBuf = trimmed; this._playPos = 0; return; } var merged = new Float32Array(combinedLen); merged.set(this._playBuf.subarray(this._playPos), 0); merged.set(upsampled, remaining); this._playBuf = merged; this._playPos = 0; } // CAPTURE: append context-rate input, then emit as many 8k μ-law samples as // the accumulated buffer allows. Keeps a fractional read head so resampling // is continuous across blocks. _captureAndEmit(input) { var prevLen = this._capBuf.length; var headStart = Math.floor(this._capPos); // The resample read head can overshoot past the end of the previous buffer // (the loop below advances pos by up to one full step beyond the last // produced sample). Clamp how much of the old buffer we drop so grown.set never gets // a negative offset (which would throw and freeze the worklet after block 1). var dropped = headStart < prevLen ? headStart : prevLen; var tailLen = prevLen - dropped; // >= 0 var grown = new Float32Array(tailLen + input.length); if (tailLen > 0) grown.set(this._capBuf.subarray(dropped), 0); grown.set(input, tailLen); this._capBuf = grown; this._capPos -= dropped; // read head relative to the new buffer start var ctxRate = sampleRate; var step = ctxRate / TARGET_RATE; // input samples per 8k output sample var last = this._capBuf.length - 1; var out = []; var pos = this._capPos; while (pos + 1 <= last) { var i0 = Math.floor(pos); var frac = pos - i0; var a = this._capBuf[i0]; var b = this._capBuf[i0 + 1]; var s = a + (b - a) * frac; // float [-1,1] var pcm = s < 0 ? s * 0x8000 : s * 0x7fff; out.push(encodeMuLawSample(pcm | 0)); pos += step; } this._capPos = pos; if (out.length > 0 && !this._muted) { var bytes = new Uint8Array(out); // Transfer the buffer to avoid a copy on the way to the main thread. this.port.postMessage({ type: "mulaw", payload: bytes }, [bytes.buffer]); } } process(inputs, outputs) { // --- capture --- var input = inputs[0]; if (input && input.length > 0 && input[0] && input[0].length > 0) { this._captureAndEmit(input[0]); } // --- playback --- var output = outputs[0]; if (output && output.length > 0) { var chan0 = output[0]; var frames = chan0.length; var avail = this._playBuf.length - this._playPos; var toCopy = avail < frames ? avail : frames; for (var i = 0; i < toCopy; i++) { var v = this._playBuf[this._playPos + i]; for (var c = 0; c < output.length; c++) output[c][i] = v; } for (var j = toCopy; j < frames; j++) { for (var c2 = 0; c2 < output.length; c2++) output[c2][j] = 0; } this._playPos += toCopy; // Reclaim memory once the buffer is fully drained. if (this._playPos >= this._playBuf.length) { this._playBuf = new Float32Array(0); this._playPos = 0; } } return true; // keep processor alive } } registerProcessor("__PROCESSOR_NAME__", OperatorAudioProcessor); `, m = "audin-operator-audio-processor", E = A.replace( "__PROCESSOR_NAME__", m ); function S() { const r = new Blob([E], { type: "application/javascript" }); return URL.createObjectURL(r); } class k { constructor(e) { this.ws = null, this.audioContext = null, this.micStream = null, this.sourceNode = null, this.workletNode = null, this.blobUrl = null, this.closed = !1, this.muted = !1, this.opts = e; } /** * Open the WS, capture the mic, load the worklet and wire the graph. Resolves * once audio is flowing both ways (or throws on a fatal setup error, having * already torn down whatever was partially created). */ async start() { try { await this.openSocket(), await this.setupAudioGraph(); } catch (e) { throw this.opts.logger.error("[audio] start failed:", e), this.close("mic_error"), e; } } /** Mute / unmute the operator microphone toward the far end. */ setMuted(e) { this.muted = e, this.workletNode?.port.postMessage({ type: "mute", on: e }), this.sendControl({ type: "mute", on: e }); } get isMuted() { return this.muted; } /** * Send a DTMF digit as a control message. * * Note: not yet honored end-to-end — the server does not currently forward * the tones onto the telephone network, so this is a functional no-op for * the far end today (the control message itself stays valid for the future). */ sendDtmf(e) { this.sendControl({ type: "dtmf", digit: e }); } /** Tell the server to hang up, then tear down locally. */ hangup() { this.sendControl({ type: "hangup" }), this.close("local_hangup"); } /** * Tear down everything this bridge created. Idempotent. Fires `onClosed` * exactly once with the FIRST reason it was closed with. */ close(e) { if (!this.closed) { if (this.closed = !0, this.micStream) { for (const t of this.micStream.getTracks()) try { t.stop(); } catch { } this.micStream = null; } try { this.workletNode?.port.postMessage({ type: "flush" }); } catch { } try { this.workletNode?.disconnect(); } catch { } try { this.sourceNode?.disconnect(); } catch { } if (this.workletNode = null, this.sourceNode = null, this.audioContext && (this.audioContext.close().catch(() => { }), this.audioContext = null), this.ws) { try { this.ws.close(1e3, e); } catch { } this.ws = null; } this.blobUrl && (URL.revokeObjectURL(this.blobUrl), this.blobUrl = null), this.opts.onClosed(e); } } // ────────────────────────────────────────────────────────────────────── openSocket() { return new Promise((e, t) => { let s = !1; const n = new WebSocket(this.opts.audioWsUrl); n.binaryType = "arraybuffer", this.ws = n, n.onopen = () => { s = !0, this.opts.logger.debug("[audio] WS open"), e(); }, n.onmessage = (i) => this.onWsMessage(i), n.onerror = (i) => { this.opts.logger.warn("[audio] WS error", i), s || (s = !0, t(new Error("audio WebSocket failed to open"))); }, n.onclose = (i) => { this.opts.logger.debug( `[audio] WS closed code=${i.code} reason=${i.reason}` ), this.closed || this.close("remote_close"); }; }); } onWsMessage(e) { const t = e.data; if (t instanceof ArrayBuffer) { const n = new Uint8Array(t).slice(); this.workletNode?.port.postMessage({ type: "mulaw", payload: n }, [ n.buffer ]); return; } typeof t == "string" && this.opts.logger.debug("[audio] control:", t); } async setupAudioGraph() { const e = window.AudioContext || window.webkitAudioContext; if (!e) throw new Error("Web Audio API not available in this environment"); const t = new e(); this.audioContext = t, t.state === "suspended" && await t.resume().catch(() => { }), this.blobUrl = S(), await t.audioWorklet.addModule(this.blobUrl); const s = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices); if (!s) throw new Error("getUserMedia not available in this environment"); let n; try { n = await s({ audio: this.opts.audioConstraints }); } catch (o) { const l = new Error("microphone permission denied or unavailable"); throw l.code = "MIC_PERMISSION_DENIED", l.cause = o, l; } this.micStream = n; const i = t.createMediaStreamSource(n), a = new AudioWorkletNode( t, m, { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] } ); this.sourceNode = i, this.workletNode = a, a.onprocessorerror = (o) => this.opts.logger.error("[audio] worklet processor error", o), a.port.onmessage = (o) => { const l = o.data; l?.type === "mulaw" && l.payload && this.sendAudio(l.payload); }, i.connect(a), a.connect(t.destination), this.muted && a.port.postMessage({ type: "mute", on: !0 }); } sendAudio(e) { const t = this.ws; if (!(!t || t.readyState !== WebSocket.OPEN)) try { t.send(e); } catch (s) { this.opts.logger.warn("[audio] send audio failed:", s); } } sendControl(e) { const t = this.ws; if (!(!t || t.readyState !== WebSocket.OPEN)) try { t.send(JSON.stringify(e)); } catch (s) { this.opts.logger.warn("[audio] send control failed:", s); } } } const C = /^[0-9*#]$/; class T { constructor(e, t) { this._muted = !1, this.callSid = e.callSid, this.direction = e.direction, this.from = e.from, this.to = e.to, this._state = e.state, this.deps = t; } get state() { return this._state; } get endReason() { return this._endReason; } get muted() { return this._muted; } // ── public API ────────────────────────────────────────────────────────── accept() { this._state === "ringing" && (this._state = "connecting", this.deps.onAccept(this)); } reject() { this._state === "ringing" && (this.markEnded("rejected"), this.deps.onReject(this)); } mute(e) { this._state !== "ended" && (this._muted = e, this.deps.onMute(this, e)); } sendDtmf(e) { if (this._state !== "ended") { if (!C.test(e)) throw new Error(`invalid DTMF digit: ${JSON.stringify(e)}`); this.deps.onDtmf(this, e); } } hangup() { this._state !== "ended" && (this.markEnded("hangup"), this.deps.onHangup(this)); } // ── internal state transitions (called by AudinOperator) ───────────────── /** Move to `connecting` (audio bridge opening). */ setConnecting() { this._state !== "ended" && (this._state = "connecting"); } /** Move to `active` (audio flowing). */ setActive() { this._state !== "ended" && (this._state = "active"); } /** Terminal transition. Idempotent — keeps the first end reason. */ markEnded(e) { this._state !== "ended" && (this._state = "ended", this._endReason = e); } } const O = 3e4, M = 6e4; class R { constructor(e) { this.cached = null, this.inFlight = null, this.getToken = e; } /** * Resolve a valid session token, reusing the cached one when possible. * Concurrent callers share a single underlying `getToken()` invocation. */ async ensureToken() { const e = Date.now(); return this.cached && this.cached.expiresAtMs > e ? this.cached.token : this.inFlight ? this.inFlight : (this.inFlight = this.fetchAndCache().finally(() => { this.inFlight = null; }), this.inFlight); } /** * Drop the cached token so the next {@link ensureToken} re-mints. Call this * on an auth failure (401 / auth-related WS close). */ invalidate() { this.cached = null; } async fetchAndCache() { const e = await this.getToken(); if (!e?.token) throw new P( "TOKEN_INVALID", "getToken() returned no token" ); return this.cached = { token: e.token, expiresAtMs: B(e, Date.now()) }, e.token; } } class P extends Error { constructor(e, t) { super(t), this.name = "TokenError", this.code = e; } } function B(r, e) { if (r.expiresAt) { const t = Date.parse(r.expiresAt); if (!Number.isNaN(t)) return Math.max(t - O, e); } return e + M; } const L = 25e3, U = [1e3, 2e3, 5e3, 1e4, 3e4], x = { echoCancellation: !0, noiseSuppression: !0, autoGainControl: !0 }, I = 15e3; class j extends v { constructor(e) { super(), this.presenceState = "offline", this.activeCall = null, this.activeBridge = null, this.pendingOutbound = null, this.tokens = new R(e.getToken), this.logger = e.logger ?? console, this.audioConstraints = e.audioConstraints ?? x, this.coreWsHost = N(e.coreUrl), this.coreHttpBase = W(e.coreUrl), this.presence = new b({ presenceWsUrl: `${this.coreWsHost}/operator-presence/ws`, ensureToken: () => this.tokens.ensureToken(), invalidateToken: () => this.tokens.invalidate(), heartbeatIntervalMs: e.heartbeatIntervalMs ?? L, reconnectBackoffMs: e.reconnectBackoffMs ?? U, logger: this.logger, onMessage: (t) => this.handlePresenceMessage(t), onOpen: () => this.setPresenceState("online"), onReconnecting: () => this.setPresenceState("reconnecting"), onError: (t, s, n) => this.emitError(t, s, n) }); } // ── typed event surface (re-export emitter methods publicly) ───────────── on(e, t) { return super.on(e, t); } off(e, t) { super.off(e, t); } once(e, t) { return super.once(e, t); } // ── numbers ──────────────────────────────────────────────────────────── /** * List the phone numbers this account owns and may go online on / dial from. * * Fetches over REST using the SAME short-lived session token the WebSockets * use (obtained through `getToken`) — the Account API Key never enters the * browser. On a 401 the cached token is invalidated and the request is * retried ONCE with a fresh token; a second 401 throws an * {@link OperatorRequestError} with code `UNAUTHORIZED`. Other non-OK * responses throw with code `REQUEST_FAILED`. * * Use a returned number's `id` for {@link goOnline} and its `phoneNumber` * (E.164) as the `callerId` for {@link dial}. */ async listPhoneNumbers() { return this.fetchPhoneNumbers(!0); } async fetchPhoneNumbers(e) { const t = await this.tokens.ensureToken(), s = `${this.coreHttpBase}/operator/phone-numbers`; let n; try { n = await fetch(s, { headers: { Authorization: `Bearer ${t}` } }); } catch (a) { throw new c( "REQUEST_FAILED", "failed to reach the operator service", a ); } if (n.status === 401) { if (this.tokens.invalidate(), e) return this.fetchPhoneNumbers(!1); throw new c( "UNAUTHORIZED", "session token rejected fetching phone numbers (401)" ); } if (!n.ok) throw new c( "REQUEST_FAILED", `phone-numbers request failed with status ${n.status}` ); let i; try { i = await n.json(); } catch (a) { throw new c( "REQUEST_FAILED", "phone-numbers response was not valid JSON", a ); } return Array.isArray(i.phoneNumbers) ? i.phoneNumbers : []; } // ── lifecycle ──────────────────────────────────────────────────────────── /** * Go online and start accepting inbound calls on `phoneNumberIds`. Connects * the presence channel (fetching a token via `getToken`) and announces * availability. Safe to call again to change the number set. */ async goOnline(e) { if (!Array.isArray(e) || e.length === 0) throw new Error("goOnline requires a non-empty phoneNumberIds array"); this.setPresenceState("connecting"), this.presence.isOpen || await this.presence.connect(), this.presence.setAvailable(e); } /** * Go offline: drop availability, tear down any active call, and close the * presence channel (no auto-reconnect until `goOnline` again). */ async goOffline() { this.activeCall && this.teardownActiveCall("offline"), this.presence.disconnect(), this.setPresenceState("offline"); } /** Current presence channel state. */ get state() { return this.presenceState; } /** The current active/ringing call, if any. */ get currentCall() { return this.activeCall; } /** * Switch the microphone constraints used to capture the operator's audio — * typically to pick a specific input device, e.g. * `setAudioConstraints({ deviceId: { exact: id }, echoCancellation: true })`. * * Takes effect from the NEXT call: a call already in progress keeps the * device its {@link AudioBridge} opened with (this does not re-open the * microphone mid-call). The new constraints are read when the next * `dial`/accepted-inbound opens its audio leg. */ setAudioConstraints(e) { this.audioConstraints = e; } // ── outbound ───────────────────────────────────────────────────────────── /** * Place an outbound call to `to` (E.164), presenting `options.callerId` * (which must be an active number your account owns). Resolves once the call * is accepted by the platform and the audio bridge is opening; rejects on * validation failure, a busy operator, or if the platform doesn't ack in * time. */ dial(e, t) { return this.presence.isOpen ? this.activeCall ? Promise.reject( new Error("an active call is already in progress (MVP: one at a time)") ) : this.pendingOutbound ? Promise.reject(new Error("an outbound dial is already pending")) : !e || typeof e != "string" ? Promise.reject(new Error("dial requires a destination number")) : t?.callerId ? new Promise((s, n) => { const i = setTimeout(() => { this.pendingOutbound = null, n(new Error("outbound call was not acknowledged in time")); }, I); this.pendingOutbound = { to: e, callerId: t.callerId, resolve: s, reject: n, timer: i }, this.presence.startOutbound(e, t.callerId); }) : Promise.reject(new Error("dial requires options.callerId")) : Promise.reject( new Error("not online — call goOnline() before dialing") ); } // ── presence message handling ──────────────────────────────────────────── handlePresenceMessage(e) { switch (e.type) { case "connected": break; case "pong": break; case "available_set": { const t = p(e.accepted), s = p(e.rejected); this.emitEvent("availabilityChanged", { accepted: t, rejected: s }); break; } case "unavailable_set": break; case "incoming_call": this.handleIncomingCall(e); break; case "call_taken": this.handleCallTaken(e); break; case "call_assigned": this.handleCallAssigned(e); break; case "outbound_started": this.handleOutboundStarted(e); break; case "call_ended": this.handleCallEnded(e); break; case "error": { const t = typeof e.code == "string" ? e.code : "SERVER_ERROR", s = typeof e.message == "string" ? e.message : "server error"; this.emitError(t, s, e), this.failPendingOutbound(new Error(`${t}: ${s}`)); break; } default: this.logger.debug("[operator] unhandled presence message:", e.type); } } handleIncomingCall(e) { const t = typeof e.callSid == "string" ? e.callSid : ""; if (!t) return; const s = typeof e.from == "string" ? e.from : void 0, n = typeof e.to == "string" ? e.to : void 0; if (this.activeCall) { this.logger.debug( `[operator] auto-rejecting incoming ${t} — already on a call` ), this.presence.reject(t); return; } const i = this.makeCall({ callSid: t, direction: "inbound", from: s, to: n, state: "ringing" }); this.activeCall = i, this.emitEvent("incomingCall", i); } handleCallTaken(e) { const t = typeof e.callSid == "string" ? e.callSid : "", s = this.activeCall; !s || s.callSid !== t || (s.markEnded("taken_by_other"), this.activeCall = null, this.emitEvent("callEnded", s)); } handleCallAssigned(e) { const t = typeof e.callSid == "string" ? e.callSid : "", s = this.activeCall; if (!s || s.callSid !== t) { this.logger.warn( `[operator] call_assigned for unknown callSid=${t} — ignoring` ); return; } s.setConnecting(), this.openAudioBridge(s); } handleOutboundStarted(e) { const t = typeof e.callSid == "string" ? e.callSid : "", s = this.pendingOutbound; if (!s) { this.logger.warn( `[operator] outbound_started without a pending dial (callSid=${t})` ); return; } if (clearTimeout(s.timer), this.pendingOutbound = null, !t) { s.reject(new Error("outbound_started missing callSid")); return; } const n = this.makeCall({ callSid: t, direction: "outbound", to: s.to, state: "connecting" }); this.activeCall = n, s.resolve(n), this.openAudioBridge(n); } /** * Explicit end-of-call notification from the platform (presence channel). * * Authoritative: the platform sends `call_ended` when the call terminates * for ANY reason — remote hangup, no answer, busy, failure — including the * cases where the audio leg never opened at all (e.g. an outbound the far * end never answered), so the audio-WS close alone could never signal them. */ handleCallEnded(e) { const t = typeof e.callSid == "string" ? e.callSid : "", s = this.activeCall; if (!s || s.callSid !== t) return; const n = typeof e.reason == "string" ? e.reason : "", i = n.includes("no-answer") ? "no_answer" : n.includes("busy") || n.includes("failed") || n.includes("canceled") ? "failed" : "remote_hangup", a = this.activeBridge; this.activeCall = null, this.activeBridge = null, s.markEnded(i), a?.close("teardown"), this.emitEvent("callEnded", s); } // ── audio bridge wiring ────────────────────────────────────────────────── async openAudioBridge(e) { let t; try { t = await this.tokens.ensureToken(); } catch (i) { const a = i?.code ?? "TOKEN_FETCH_FAILED"; this.emitError(a, "session token unavailable", i), this.endCallWithReason(e, "failed"); return; } const s = `${this.coreWsHost}/operator-audio/ws/${encodeURIComponent( e.callSid )}?token=${encodeURIComponent(t)}`, n = new k({ audioWsUrl: s, audioConstraints: this.audioConstraints, logger: this.logger, onClosed: (i) => this.onBridgeClosed(e, i) }); this.activeBridge = n; try { await n.start(); } catch (i) { const a = i?.code ?? "AUDIO_START_FAILED"; this.emitError(a, i.message ?? "audio start failed", i), this.endCallWithReason(e, "failed"); return; } e.muted && n.setMuted(!0), e.setActive(), this.emitEvent("callStarted", e); } onBridgeClosed(e, t) { if (this.activeCall !== e) return; this.activeBridge = null; const s = t === "local_hangup" ? "hangup" : t === "ws_error" || t === "mic_error" ? "failed" : "remote_hangup"; e.markEnded(s), this.activeCall = null, this.emitEvent("callEnded", e); } // ── CallHandle side-effect deps ────────────────────────────────────────── makeCall(e) { return new T(e, { onAccept: (t) => { this.presence.accept(t.callSid); }, onReject: (t) => { this.presence.reject(t.callSid), this.activeCall === t && (this.activeCall = null, this.emitEvent("callEnded", t)); }, onMute: (t, s) => { this.activeBridge?.setMuted(s); }, onDtmf: (t, s) => { this.activeBridge?.sendDtmf(s); }, onHangup: (t) => { if (this.activeBridge) { this.activeBridge.hangup(); return; } this.presence.reject(t.callSid), this.activeCall === t && (this.activeCall = null, this.activeBridge = null, this.emitEvent("callEnded", t)); } }); } // ── helpers ────────────────────────────────────────────────────────────── endCallWithReason(e, t) { const s = this.activeBridge; this.activeBridge = null, this.activeCall === e && (this.activeCall = null), e.markEnded(t), s?.close("teardown"), this.emitEvent("callEnded", e); } teardownActiveCall(e) { const t = this.activeCall; if (!t) return; const s = this.activeBridge; this.activeBridge = null, this.activeCall = null, t.markEnded(e), s?.close("teardown"), this.emitEvent("callEnded", t); } failPendingOutbound(e) { const t = this.pendingOutbound; t && (clearTimeout(t.timer), this.pendingOutbound = null, t.reject(e)); } setPresenceState(e) { this.presenceState !== e && (this.presenceState = e, this.emitEvent("presenceStateChanged", e)); } emitError(e, t, s) { this.emitEvent("error", { code: e, message: t, cause: s }); } /** Public-facing emit wrapper (the base `emit` is protected). */ emitEvent(e, t) { this.emit(e, t); } } function N(r) { let e; try { e = new URL(r); } catch { throw new Error(`invalid coreUrl: ${JSON.stringify(r)}`); } return `${e.protocol === "https:" || e.protocol === "wss:" ? "wss:" : "ws:"}//${e.host}`; } function W(r) { let e; try { e = new URL(r); } catch { throw new Error(`invalid coreUrl: ${JSON.stringify(r)}`); } return `${e.protocol === "https:" || e.protocol === "wss:" ? "https:" : "http:"}//${e.host}`; } class c extends Error { constructor(e, t, s) { super(t), this.name = "OperatorRequestError", this.code = e, this.cause = s; } } function p(r) { return Array.isArray(r) ? r.filter((e) => typeof e == "string") : []; } const d = 132, f = 32635, F = new Int8Array([ 0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7 ]); function D(r) { let e = r; e > 32767 ? e = 32767 : e < -32768 && (e = -32768); const t = e >> 8 & 128; t !== 0 && (e = -e), e > f && (e = f), e += d; const s = F[e >> 7 & 255], n = e >> s + 3 & 15; return ~(t | s << 4 | n) & 255; } function $(r) { const e = ~r & 255, t = e & 128, s = e >> 4 & 7; let i = ((e & 15) << 3) + d << s; return i -= d, (t !== 0 ? -i : i) || 0; } function H(r) { const e = new Uint8Array(r.length); for (let t = 0; t < r.length; t++) e[t] = D(r[t]); return e; } function K(r) { const e = new Int16Array(r.length); for (let t = 0; t < r.length; t++) e[t] = $(r[t]); return e; } function g(r, e, t) { if (e <= 0 || t <= 0) throw new Error("resampleLinear: sample rates must be positive"); if (e === t || r.length === 0) return r.slice(); const s = e / t, n = Math.round(r.length / s), i = new Float32Array(n), a = r.length - 1; for (let o = 0; o < n; o++) { const l = o * s, h = Math.floor(l), w = l - h, u = r[Math.min(h, a)], y = r[Math.min(h + 1, a)]; i[o] = u + (y - u) * w; } return i; } function q(r, e) { return g(r, e, 8e3); } function G(r, e) { return g(r, 8e3, e); } function J(r) { const e = new Int16Array(r.length); for (let t = 0; t < r.length; t++) { let s = r[t]; s > 1 ? s = 1 : s < -1 && (s = -1), e[t] = s < 0 ? s * 32768 : s * 32767; } return e; } function X(r) { const e = new Float32Array(r.length); for (let t = 0; t < r.length; t++) { const s = r[t]; e[t] = s < 0 ? s / 32768 : s / 32767; } return e; } export { j as AudinOperator, c as OperatorRequestError, K as decodeMuLaw, $ as decodeMuLawSample, q as downsampleTo8k, H as encodeMuLaw, D as encodeMuLawSample, J as floatToInt16, X as int16ToFloat, g as resampleLinear, G as upsampleFrom8k }; //# sourceMappingURL=audin-operator-sdk.js.map