@audin.ai/operator-sdk
Version:
Headless browser SDK for the Audin operator softphone — make and receive calls over the Audin operator WebSockets.
1 lines • 94.5 kB
Source Map (JSON)
{"version":3,"file":"audin-operator-sdk.umd.cjs","sources":["../src/emitter.ts","../src/presence-client.ts","../src/audio-worklet.ts","../src/audio-client.ts","../src/call.ts","../src/token-manager.ts","../src/AudinOperator.ts","../src/codec/mulaw.ts","../src/codec/resample.ts"],"sourcesContent":["/**\n * Tiny dependency-free typed event emitter.\n *\n * Browser `EventTarget` would force `CustomEvent` boxing and loses payload\n * typing; this keeps strong types on `on`/`off`/`emit` with no runtime cost.\n * Listener exceptions are isolated so one bad subscriber cannot break the\n * emit loop or the SDK's internal state machine.\n */\nexport class TypedEmitter<EventMap> {\n private readonly listeners = new Map<\n keyof EventMap,\n Set<(payload: never) => void>\n >();\n\n /** Subscribe to `event`. Returns an unsubscribe function. */\n on<K extends keyof EventMap>(\n event: K,\n listener: (payload: EventMap[K]) => void\n ): () => void {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(listener as (payload: never) => void);\n return () => this.off(event, listener);\n }\n\n /** Subscribe once: the listener is removed after the first emit. */\n once<K extends keyof EventMap>(\n event: K,\n listener: (payload: EventMap[K]) => void\n ): () => void {\n const off = this.on(event, (payload) => {\n off();\n listener(payload);\n });\n return off;\n }\n\n /** Unsubscribe a previously-registered listener. Idempotent. */\n off<K extends keyof EventMap>(\n event: K,\n listener: (payload: EventMap[K]) => void\n ): void {\n const set = this.listeners.get(event);\n if (!set) return;\n set.delete(listener as (payload: never) => void);\n if (set.size === 0) this.listeners.delete(event);\n }\n\n /** Emit `event` to all current listeners. Listener errors are swallowed. */\n protected emit<K extends keyof EventMap>(\n event: K,\n payload: EventMap[K]\n ): void {\n const set = this.listeners.get(event);\n if (!set) return;\n // Copy so a listener that unsubscribes during emit doesn't mutate the set\n // we're iterating.\n for (const listener of [...set]) {\n try {\n (listener as (p: EventMap[K]) => void)(payload);\n } catch (err) {\n // Never let a subscriber throw into the SDK's internals.\n // eslint-disable-next-line no-console\n console.error(\"[audin-operator-sdk] listener threw:\", err);\n }\n }\n }\n\n /** Remove every listener (used on teardown). */\n protected removeAllListeners(): void {\n this.listeners.clear();\n }\n}\n","/**\n * PresenceClient — the signalling channel.\n *\n * A single WebSocket to the Audin presence endpoint over which the operator:\n * - announces availability on a set of phone numbers (`set_available`),\n * - keeps the connection alive (`ping` heartbeat),\n * - answers / declines inbound offers (`accept` / `reject`),\n * - starts outbound calls (`start_outbound`).\n *\n * and over which the server pushes: `connected`, `available_set`,\n * `incoming_call`, `call_taken`, `call_assigned`, `outbound_started`, `pong`,\n * `error`.\n *\n * Resilience built in here so the higher-level `AudinOperator` doesn't have to:\n * - HEARTBEAT: a `ping` every `heartbeatIntervalMs`; the server reaps idle\n * connections at ~90s.\n * - RECONNECT: on unexpected close, walk a backoff schedule, fetch a FRESH\n * token via `getToken` (the old one may have expired), reconnect, and\n * re-send the last `set_available` so the operator comes back online\n * automatically.\n *\n * This class is transport-only: it parses/dispatches messages and owns the\n * socket lifecycle. It holds NO call state and names NO providers.\n */\n\nimport type { OperatorLogger } from \"./types.js\";\n\n/** A server→client message. Loosely typed; the handler narrows on `type`. */\nexport interface PresenceServerMessage {\n type: string;\n [key: string]: unknown;\n}\n\nexport interface PresenceClientOptions {\n /** Base WS URL WITHOUT query, e.g. `wss://host/operator-presence/ws`. */\n presenceWsUrl: string;\n /**\n * Resolve a valid session token (cached & shared with REST/audio). Returns\n * the raw JWT string. Should re-mint internally once expired.\n */\n ensureToken: () => Promise<string>;\n /**\n * Drop the cached token so the next {@link ensureToken} re-mints. Invoked on\n * an auth-related socket close so a reconnect uses a fresh token.\n */\n invalidateToken: () => void;\n heartbeatIntervalMs: number;\n reconnectBackoffMs: number[];\n logger: OperatorLogger;\n /** Invoked for every parsed server message. */\n onMessage: (msg: PresenceServerMessage) => void;\n /** Connection lifecycle for the higher layer (state, errors). */\n onOpen: () => void;\n onReconnecting: () => void;\n onError: (code: string, message: string, cause?: unknown) => void;\n}\n\nexport class PresenceClient {\n private readonly opts: PresenceClientOptions;\n\n private ws: WebSocket | null = null;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private reconnectAttempt = 0;\n\n /** True between `connect()` and `disconnect()` — gates auto-reconnect. */\n private desiredOnline = false;\n /** Last availability we announced, replayed after a reconnect. */\n private lastAvailability: string[] | null = null;\n\n constructor(opts: PresenceClientOptions) {\n this.opts = opts;\n }\n\n /** Open the presence socket and keep it open (auto-reconnecting). */\n async connect(): Promise<void> {\n this.desiredOnline = true;\n this.reconnectAttempt = 0;\n await this.openOnce();\n }\n\n /**\n * Close the socket and stop reconnecting. Best-effort `set_unavailable`\n * first so the server drops presence immediately rather than waiting for the\n * reaper.\n */\n disconnect(): void {\n this.desiredOnline = false;\n this.lastAvailability = null;\n this.clearTimers();\n if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n this.sendRaw({ type: \"set_unavailable\" });\n }\n if (this.ws) {\n try {\n this.ws.close(1000, \"client_offline\");\n } catch {\n /* ignore */\n }\n this.ws = null;\n }\n }\n\n /** Announce availability on `phoneNumberIds`; remembered for reconnects. */\n setAvailable(phoneNumberIds: string[]): void {\n this.lastAvailability = [...phoneNumberIds];\n this.sendRaw({ type: \"set_available\", phoneNumberIds });\n }\n\n /** Drop availability (stays connected). */\n setUnavailable(): void {\n this.lastAvailability = null;\n this.sendRaw({ type: \"set_unavailable\" });\n }\n\n /** Answer an inbound offer. */\n accept(callSid: string): void {\n this.sendRaw({ type: \"accept\", callSid });\n }\n\n /** Decline an inbound offer. */\n reject(callSid: string): void {\n this.sendRaw({ type: \"reject\", callSid });\n }\n\n /** Request an outbound call. The server replies with `outbound_started`. */\n startOutbound(to: string, callerId: string): void {\n this.sendRaw({ type: \"start_outbound\", to, callerId });\n }\n\n /** Whether the socket is currently open. */\n get isOpen(): boolean {\n return this.ws?.readyState === WebSocket.OPEN;\n }\n\n // ──────────────────────────────────────────────────────────────────────\n\n private async openOnce(): Promise<void> {\n let token: string;\n try {\n token = await this.opts.ensureToken();\n } catch (err) {\n const code =\n (err as { code?: string })?.code ?? \"TOKEN_FETCH_FAILED\";\n this.opts.onError(code, \"session token unavailable\", err);\n this.scheduleReconnect();\n return;\n }\n\n const url = `${this.opts.presenceWsUrl}?token=${encodeURIComponent(\n token\n )}`;\n const ws = new WebSocket(url);\n this.ws = ws;\n\n ws.onopen = () => {\n this.opts.logger.debug(\"[presence] WS open\");\n this.reconnectAttempt = 0;\n this.startHeartbeat();\n this.opts.onOpen();\n // Re-announce availability after a reconnect so we come back online.\n if (this.lastAvailability && this.lastAvailability.length > 0) {\n this.sendRaw({\n type: \"set_available\",\n phoneNumberIds: this.lastAvailability,\n });\n }\n };\n\n ws.onmessage = (ev: MessageEvent) => {\n let parsed: PresenceServerMessage;\n try {\n parsed = JSON.parse(\n typeof ev.data === \"string\" ? ev.data : String(ev.data)\n );\n } catch {\n this.opts.logger.warn(\"[presence] non-JSON message ignored\");\n return;\n }\n this.opts.onMessage(parsed);\n };\n\n ws.onerror = (ev) => {\n this.opts.logger.warn(\"[presence] WS error\", ev);\n this.opts.onError(\"WS_ERROR\", \"presence socket error\");\n };\n\n ws.onclose = (ev: CloseEvent) => {\n this.opts.logger.debug(\n `[presence] WS closed code=${ev.code} reason=${ev.reason}`\n );\n this.stopHeartbeat();\n this.ws = null;\n // An auth-related close means the token was rejected — drop the cache so\n // the reconnect re-mints rather than replaying the same bad token.\n if (isAuthCloseCode(ev.code)) {\n this.opts.invalidateToken();\n }\n if (this.desiredOnline) {\n this.opts.onReconnecting();\n this.scheduleReconnect();\n }\n };\n }\n\n private scheduleReconnect(): void {\n if (!this.desiredOnline) return;\n if (this.reconnectTimer) return; // one in flight already\n const schedule = this.opts.reconnectBackoffMs;\n const idx = Math.min(this.reconnectAttempt, schedule.length - 1);\n const delay = schedule[idx];\n this.reconnectAttempt += 1;\n this.opts.logger.debug(\n `[presence] reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`\n );\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = null;\n if (this.desiredOnline) void this.openOnce();\n }, delay);\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.sendRaw({ type: \"ping\" });\n }, this.opts.heartbeatIntervalMs);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private clearTimers(): void {\n this.stopHeartbeat();\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n }\n\n private sendRaw(message: Record<string, unknown>): void {\n const ws = this.ws;\n if (!ws || ws.readyState !== WebSocket.OPEN) {\n this.opts.logger.warn(\n `[presence] dropped ${String(message.type)} — socket not open`\n );\n return;\n }\n try {\n ws.send(JSON.stringify(message));\n } catch (err) {\n this.opts.logger.warn(\"[presence] send failed:\", err);\n }\n }\n}\n\n/**\n * Whether a WebSocket close code signals an auth/policy rejection (so the\n * cached token should be discarded before reconnecting). 1008 is the standard\n * \"policy violation\"; 4001/4003 are the conventional app-level auth codes.\n */\nfunction isAuthCloseCode(code: number): boolean {\n return code === 1008 || code === 4001 || code === 4003;\n}\n","/**\n * AudioWorklet processor source for the operator softphone.\n *\n * IMPORTANT: an `AudioWorkletProcessor` runs in `AudioWorkletGlobalScope`, a\n * separate realm with NO module system and NO access to the SDK's other\n * modules. So everything it needs — μ-law codec and linear resampling — is\n * inlined here as a single self-contained source STRING. At runtime the SDK\n * turns this string into a `Blob` URL and `audioWorklet.addModule()`s it (see\n * `audio-client.ts`). Keeping it as an exported string (rather than a separate\n * `.js` asset) means the published bundle is a single file with no asset paths\n * to resolve — the right shape for an embeddable SDK.\n *\n * Two responsibilities, one processor instance:\n *\n * - CAPTURE: `process()` receives 128-frame Float32 blocks at the context\n * sample rate (typically 48 kHz). It buffers them, resamples down to\n * 8 kHz, μ-law-encodes, and `postMessage`s the resulting bytes to the main\n * thread, which forwards them on the audio WebSocket.\n * - PLAYBACK: the main thread `postMessage`s μ-law bytes received from the\n * WebSocket; the processor decodes them, upsamples 8 kHz → context rate,\n * and queues PCM that `process()` drains into the output. A small jitter\n * buffer smooths network arrival; underflow emits silence.\n *\n * The μ-law tables/logic here are byte-for-byte the same algorithm as\n * `src/codec/mulaw.ts` (kept in sync; the codec KAT guards the canonical\n * source). The inline copy exists only because the worklet realm can't import.\n */\n\n/**\n * The processor source as a string, ready to be wrapped in a Blob URL.\n * `__PROCESSOR_NAME__` is replaced by {@link OPERATOR_WORKLET_NAME} below so\n * the registered name and the name the main thread references can never drift.\n */\nconst PROCESSOR_SOURCE = String.raw`\n// ---- G.711 μ-law (mirrors src/codec/mulaw.ts; standard Sun convention) ----\nvar MULAW_BIAS = 0x84;\nvar MULAW_CLIP = 32635;\nvar MULAW_EXP_LUT = new Int8Array([\n 0,0,1,1,2,2,2,2,3,3,3,3,3,3,3,3,\n 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,\n 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,\n 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,\n 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,\n 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,\n 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,\n 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,\n 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\n]);\nfunction encodeMuLawSample(sample) {\n var pcm = sample;\n if (pcm > 32767) pcm = 32767; else if (pcm < -32768) pcm = -32768;\n var sign = (pcm >> 8) & 0x80;\n if (sign !== 0) pcm = -pcm;\n if (pcm > MULAW_CLIP) pcm = MULAW_CLIP;\n pcm += MULAW_BIAS;\n var exponent = MULAW_EXP_LUT[(pcm >> 7) & 0xff];\n var mantissa = (pcm >> (exponent + 3)) & 0x0f;\n return (~(sign | (exponent << 4) | mantissa)) & 0xff;\n}\nfunction decodeMuLawSample(byte) {\n var u = ~byte & 0xff;\n var sign = u & 0x80;\n var exponent = (u >> 4) & 0x07;\n var mantissa = u & 0x0f;\n var sample = ((mantissa << 3) + MULAW_BIAS) << exponent;\n sample -= MULAW_BIAS;\n return (sign !== 0 ? -sample : sample) || 0;\n}\n\nvar TARGET_RATE = 8000;\n// Cap the playback jitter buffer so a stall can't grow memory unbounded\n// (~2s of 8kHz audio decoded to context rate).\nvar MAX_PLAYBACK_SAMPLES = 96000;\n\nclass OperatorAudioProcessor extends AudioWorkletProcessor {\n constructor() {\n super();\n // Capture accumulator (context-rate Float32) and its fractional read head\n // for resampling to 8kHz across process() boundaries.\n this._capBuf = new Float32Array(0);\n this._capPos = 0; // fractional position into _capBuf for the next 8k sample\n\n // Playback queue (context-rate Float32 already upsampled) + read head.\n this._playBuf = new Float32Array(0);\n this._playPos = 0;\n\n this._muted = false;\n\n this.port.onmessage = (e) => {\n var d = e.data;\n if (!d) return;\n if (d.type === \"mulaw\") {\n this._enqueuePlayback(d.payload);\n } else if (d.type === \"mute\") {\n this._muted = !!d.on;\n } else if (d.type === \"flush\") {\n this._playBuf = new Float32Array(0);\n this._playPos = 0;\n }\n };\n }\n\n // Decode μ-law (8kHz) -> upsample to context rate -> append to playback queue.\n _enqueuePlayback(mulawBytes) {\n var n = mulawBytes.length;\n if (n === 0) return;\n var ctxRate = sampleRate; // global in AudioWorkletGlobalScope\n var ratio = ctxRate / TARGET_RATE; // e.g. 6 for 48k\n var outLen = Math.round(n * ratio);\n var upsampled = new Float32Array(outLen);\n var last = n - 1;\n for (var i = 0; i < outLen; i++) {\n var srcPos = i / ratio;\n var i0 = Math.floor(srcPos);\n var frac = srcPos - i0;\n var a = decodeMuLawSample(mulawBytes[i0 > last ? last : i0]) / 32768;\n var bIdx = i0 + 1 > last ? last : i0 + 1;\n var b = decodeMuLawSample(mulawBytes[bIdx]) / 32768;\n upsampled[i] = a + (b - a) * frac;\n }\n\n // Compact any already-consumed head, then append, bounded by the cap.\n var remaining = this._playBuf.length - this._playPos;\n var combinedLen = remaining + upsampled.length;\n if (combinedLen > MAX_PLAYBACK_SAMPLES) {\n // Drop oldest audio to stay bounded (better latency than OOM on a stall).\n var keep = MAX_PLAYBACK_SAMPLES - upsampled.length;\n if (keep < 0) keep = 0;\n var trimmed = new Float32Array(keep + upsampled.length);\n if (keep > 0) {\n trimmed.set(\n this._playBuf.subarray(this._playBuf.length - keep, this._playBuf.length),\n 0\n );\n }\n trimmed.set(upsampled, keep);\n this._playBuf = trimmed;\n this._playPos = 0;\n return;\n }\n var merged = new Float32Array(combinedLen);\n merged.set(this._playBuf.subarray(this._playPos), 0);\n merged.set(upsampled, remaining);\n this._playBuf = merged;\n this._playPos = 0;\n }\n\n // CAPTURE: append context-rate input, then emit as many 8k μ-law samples as\n // the accumulated buffer allows. Keeps a fractional read head so resampling\n // is continuous across blocks.\n _captureAndEmit(input) {\n var prevLen = this._capBuf.length;\n var headStart = Math.floor(this._capPos);\n // The resample read head can overshoot past the end of the previous buffer\n // (the loop below advances pos by up to one full step beyond the last\n // produced sample). Clamp how much of the old buffer we drop so grown.set never gets\n // a negative offset (which would throw and freeze the worklet after block 1).\n var dropped = headStart < prevLen ? headStart : prevLen;\n var tailLen = prevLen - dropped; // >= 0\n var grown = new Float32Array(tailLen + input.length);\n if (tailLen > 0) grown.set(this._capBuf.subarray(dropped), 0);\n grown.set(input, tailLen);\n this._capBuf = grown;\n this._capPos -= dropped; // read head relative to the new buffer start\n\n var ctxRate = sampleRate;\n var step = ctxRate / TARGET_RATE; // input samples per 8k output sample\n var last = this._capBuf.length - 1;\n var out = [];\n var pos = this._capPos;\n while (pos + 1 <= last) {\n var i0 = Math.floor(pos);\n var frac = pos - i0;\n var a = this._capBuf[i0];\n var b = this._capBuf[i0 + 1];\n var s = a + (b - a) * frac; // float [-1,1]\n var pcm = s < 0 ? s * 0x8000 : s * 0x7fff;\n out.push(encodeMuLawSample(pcm | 0));\n pos += step;\n }\n this._capPos = pos;\n\n if (out.length > 0 && !this._muted) {\n var bytes = new Uint8Array(out);\n // Transfer the buffer to avoid a copy on the way to the main thread.\n this.port.postMessage({ type: \"mulaw\", payload: bytes }, [bytes.buffer]);\n }\n }\n\n process(inputs, outputs) {\n // --- capture ---\n var input = inputs[0];\n if (input && input.length > 0 && input[0] && input[0].length > 0) {\n this._captureAndEmit(input[0]);\n }\n\n // --- playback ---\n var output = outputs[0];\n if (output && output.length > 0) {\n var chan0 = output[0];\n var frames = chan0.length;\n var avail = this._playBuf.length - this._playPos;\n var toCopy = avail < frames ? avail : frames;\n for (var i = 0; i < toCopy; i++) {\n var v = this._playBuf[this._playPos + i];\n for (var c = 0; c < output.length; c++) output[c][i] = v;\n }\n for (var j = toCopy; j < frames; j++) {\n for (var c2 = 0; c2 < output.length; c2++) output[c2][j] = 0;\n }\n this._playPos += toCopy;\n // Reclaim memory once the buffer is fully drained.\n if (this._playPos >= this._playBuf.length) {\n this._playBuf = new Float32Array(0);\n this._playPos = 0;\n }\n }\n\n return true; // keep processor alive\n }\n}\n\nregisterProcessor(\"__PROCESSOR_NAME__\", OperatorAudioProcessor);\n`;\n\n/** Registered name of the worklet processor. */\nexport const OPERATOR_WORKLET_NAME = \"audin-operator-audio-processor\";\n\n/** The processor source with its registered name substituted in. */\nexport const OPERATOR_WORKLET_SOURCE = PROCESSOR_SOURCE.replace(\n \"__PROCESSOR_NAME__\",\n OPERATOR_WORKLET_NAME\n);\n\n/**\n * Build a Blob URL for the worklet module. The caller is responsible for\n * `URL.revokeObjectURL` after `addModule` resolves. Lives here (not in\n * audio-client) so the source string and its packaging stay together.\n */\nexport function createWorkletBlobUrl(): string {\n const blob = new Blob([OPERATOR_WORKLET_SOURCE], {\n type: \"application/javascript\",\n });\n return URL.createObjectURL(blob);\n}\n","/**\n * AudioBridge — the audio leg of a single call.\n *\n * Joins three things for one `callSid`:\n * 1. the microphone (`getUserMedia`),\n * 2. an `AudioWorklet` that does the μ-law 8 kHz transcoding + resampling\n * (see `audio-worklet.ts`), and\n * 3. the audio WebSocket to the Audin service.\n *\n * Data flow:\n * mic → AudioContext source → worklet → (μ-law bytes) → WS binary frame\n * WS binary frame → (μ-law bytes) → worklet → AudioContext destination → 🔊\n *\n * Control (mute / DTMF / hangup) is sent as JSON text frames on the same WS.\n *\n * The bridge owns the lifecycle of everything it creates and tears it ALL\n * down on `close()` (mic tracks stopped, worklet disconnected, context closed,\n * socket closed) so a call leak can't keep the microphone hot.\n *\n * No provider names appear anywhere here — it is just \"audio over a WebSocket\".\n */\n\nimport {\n OPERATOR_WORKLET_NAME,\n createWorkletBlobUrl,\n} from \"./audio-worklet.js\";\nimport type { OperatorLogger } from \"./types.js\";\n\n/** Reason strings the bridge reports to its `onClosed` callback. */\nexport type BridgeCloseReason =\n | \"local_hangup\"\n | \"remote_close\"\n | \"ws_error\"\n | \"mic_error\"\n | \"teardown\";\n\nexport interface AudioBridgeOptions {\n /** `wss://…/operator-audio/ws/:callSid?token=…` — already fully formed. */\n audioWsUrl: string;\n /** Microphone constraints for `getUserMedia`. */\n audioConstraints: MediaTrackConstraints;\n logger: OperatorLogger;\n /** Called once when the bridge finishes tearing down (any reason). */\n onClosed: (reason: BridgeCloseReason) => void;\n}\n\n/**\n * Narrow surface of the global APIs the bridge needs. Declared so the file\n * type-checks under a DOM lib without us reaching for `any`.\n */\ntype GetUserMedia = (constraints: {\n audio: MediaTrackConstraints | boolean;\n}) => Promise<MediaStream>;\n\nexport class AudioBridge {\n private readonly opts: AudioBridgeOptions;\n\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private micStream: MediaStream | null = null;\n private sourceNode: MediaStreamAudioSourceNode | null = null;\n private workletNode: AudioWorkletNode | null = null;\n private blobUrl: string | null = null;\n\n private closed = false;\n private muted = false;\n\n constructor(opts: AudioBridgeOptions) {\n this.opts = opts;\n }\n\n /**\n * Open the WS, capture the mic, load the worklet and wire the graph. Resolves\n * once audio is flowing both ways (or throws on a fatal setup error, having\n * already torn down whatever was partially created).\n */\n async start(): Promise<void> {\n try {\n await this.openSocket();\n await this.setupAudioGraph();\n } catch (err) {\n this.opts.logger.error(\"[audio] start failed:\", err);\n this.close(\"mic_error\");\n throw err;\n }\n }\n\n /** Mute / unmute the operator microphone toward the far end. */\n setMuted(on: boolean): void {\n this.muted = on;\n // Gate inside the worklet (drops capture frames) …\n this.workletNode?.port.postMessage({ type: \"mute\", on });\n // … and tell the server so the relay also gates (belt and suspenders).\n this.sendControl({ type: \"mute\", on });\n }\n\n get isMuted(): boolean {\n return this.muted;\n }\n\n /**\n * Send a DTMF digit as a control message.\n *\n * Note: not yet honored end-to-end — the server does not currently forward\n * the tones onto the telephone network, so this is a functional no-op for\n * the far end today (the control message itself stays valid for the future).\n */\n sendDtmf(digit: string): void {\n this.sendControl({ type: \"dtmf\", digit });\n }\n\n /** Tell the server to hang up, then tear down locally. */\n hangup(): void {\n this.sendControl({ type: \"hangup\" });\n this.close(\"local_hangup\");\n }\n\n /**\n * Tear down everything this bridge created. Idempotent. Fires `onClosed`\n * exactly once with the FIRST reason it was closed with.\n */\n close(reason: BridgeCloseReason): void {\n if (this.closed) return;\n this.closed = true;\n\n // Stop the mic first so the capture indicator clears immediately.\n if (this.micStream) {\n for (const track of this.micStream.getTracks()) {\n try {\n track.stop();\n } catch {\n /* ignore */\n }\n }\n this.micStream = null;\n }\n\n try {\n this.workletNode?.port.postMessage({ type: \"flush\" });\n } catch {\n /* ignore */\n }\n try {\n this.workletNode?.disconnect();\n } catch {\n /* ignore */\n }\n try {\n this.sourceNode?.disconnect();\n } catch {\n /* ignore */\n }\n this.workletNode = null;\n this.sourceNode = null;\n\n if (this.audioContext) {\n this.audioContext.close().catch(() => {\n /* ignore */\n });\n this.audioContext = null;\n }\n\n if (this.ws) {\n try {\n // 1000 = normal closure.\n this.ws.close(1000, reason);\n } catch {\n /* ignore */\n }\n this.ws = null;\n }\n\n if (this.blobUrl) {\n URL.revokeObjectURL(this.blobUrl);\n this.blobUrl = null;\n }\n\n this.opts.onClosed(reason);\n }\n\n // ──────────────────────────────────────────────────────────────────────\n\n private openSocket(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let settled = false;\n const ws = new WebSocket(this.opts.audioWsUrl);\n // Binary frames arrive as ArrayBuffer (avoids a Blob round-trip).\n ws.binaryType = \"arraybuffer\";\n this.ws = ws;\n\n ws.onopen = () => {\n settled = true;\n this.opts.logger.debug(\"[audio] WS open\");\n resolve();\n };\n\n ws.onmessage = (ev: MessageEvent) => this.onWsMessage(ev);\n\n ws.onerror = (ev) => {\n this.opts.logger.warn(\"[audio] WS error\", ev);\n if (!settled) {\n settled = true;\n reject(new Error(\"audio WebSocket failed to open\"));\n }\n };\n\n ws.onclose = (ev: CloseEvent) => {\n this.opts.logger.debug(\n `[audio] WS closed code=${ev.code} reason=${ev.reason}`\n );\n // A server-initiated close (or any close after open) tears the bridge\n // down as a remote close, unless we're already closing locally.\n if (!this.closed) {\n this.close(\"remote_close\");\n }\n };\n });\n }\n\n private onWsMessage(ev: MessageEvent): void {\n const data = ev.data;\n // BINARY → μ-law audio from the far end → feed the worklet for playback.\n if (data instanceof ArrayBuffer) {\n const bytes = new Uint8Array(data);\n // Copy into a transferable buffer for the worklet message.\n const copy = bytes.slice();\n this.workletNode?.port.postMessage({ type: \"mulaw\", payload: copy }, [\n copy.buffer,\n ]);\n return;\n }\n // TEXT → JSON control/notification from the server (e.g. error). We don't\n // act on much here; the presence channel drives lifecycle. Log for debug.\n if (typeof data === \"string\") {\n this.opts.logger.debug(\"[audio] control:\", data);\n }\n }\n\n private async setupAudioGraph(): Promise<void> {\n const Ctx: typeof AudioContext =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext;\n if (!Ctx) {\n throw new Error(\"Web Audio API not available in this environment\");\n }\n const audioContext = new Ctx();\n this.audioContext = audioContext;\n // Autoplay policies can leave the context suspended until a user gesture.\n if (audioContext.state === \"suspended\") {\n await audioContext.resume().catch(() => {\n /* a gesture may be required; playback resumes when it happens */\n });\n }\n\n // Load the worklet module from a Blob URL (single-file bundle friendly).\n this.blobUrl = createWorkletBlobUrl();\n await audioContext.audioWorklet.addModule(this.blobUrl);\n\n // Capture the microphone.\n const getUserMedia: GetUserMedia | undefined =\n navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices);\n if (!getUserMedia) {\n throw new Error(\"getUserMedia not available in this environment\");\n }\n let micStream: MediaStream;\n try {\n micStream = await getUserMedia({ audio: this.opts.audioConstraints });\n } catch (err) {\n const e = new Error(\"microphone permission denied or unavailable\");\n (e as { code?: string }).code = \"MIC_PERMISSION_DENIED\";\n (e as { cause?: unknown }).cause = err;\n throw e;\n }\n this.micStream = micStream;\n\n // Wire: mic source → worklet → destination.\n const sourceNode = audioContext.createMediaStreamSource(micStream);\n const workletNode = new AudioWorkletNode(\n audioContext,\n OPERATOR_WORKLET_NAME,\n {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n outputChannelCount: [1],\n }\n );\n this.sourceNode = sourceNode;\n this.workletNode = workletNode;\n\n // Surface any uncaught error thrown inside the worklet's process() so it is\n // not silently swallowed (without a handler the processor stops with no log).\n workletNode.onprocessorerror = (e) =>\n this.opts.logger.error(\"[audio] worklet processor error\", e);\n\n // μ-law bytes produced by the worklet (from the mic) → send on the WS.\n workletNode.port.onmessage = (e: MessageEvent) => {\n const msg = e.data as { type?: string; payload?: Uint8Array };\n if (msg?.type === \"mulaw\" && msg.payload) {\n this.sendAudio(msg.payload);\n }\n };\n\n sourceNode.connect(workletNode);\n workletNode.connect(audioContext.destination);\n\n // Apply any mute that was requested before the graph existed.\n if (this.muted) {\n workletNode.port.postMessage({ type: \"mute\", on: true });\n }\n }\n\n private sendAudio(bytes: Uint8Array): void {\n const ws = this.ws;\n if (!ws || ws.readyState !== WebSocket.OPEN) return;\n try {\n // Send the raw μ-law bytes as a single binary frame.\n ws.send(bytes);\n } catch (err) {\n this.opts.logger.warn(\"[audio] send audio failed:\", err);\n }\n }\n\n private sendControl(message: Record<string, unknown>): void {\n const ws = this.ws;\n if (!ws || ws.readyState !== WebSocket.OPEN) return;\n try {\n ws.send(JSON.stringify(message));\n } catch (err) {\n this.opts.logger.warn(\"[audio] send control failed:\", err);\n }\n }\n}\n","/**\n * CallHandle — the concrete `OperatorCall` returned to the application.\n *\n * It is a thin, stateful facade: lifecycle transitions and the actual\n * accept/reject/dial/teardown side-effects live in `AudinOperator`, which\n * injects them here as callbacks. This keeps the handle dumb (no socket / no\n * audio knowledge) while giving the app a stable object whose getters reflect\n * the live state the operator mutates.\n */\n\nimport type {\n OperatorCall,\n CallDirection,\n CallState,\n CallEndReason,\n} from \"./types.js\";\n\n/** Side-effect callbacks injected by {@link AudinOperator}. */\nexport interface CallHandleDeps {\n onAccept: (call: CallHandle) => void;\n onReject: (call: CallHandle) => void;\n onMute: (call: CallHandle, on: boolean) => void;\n onDtmf: (call: CallHandle, digit: string) => void;\n onHangup: (call: CallHandle) => void;\n}\n\nexport interface CallHandleInit {\n callSid: string;\n direction: CallDirection;\n from?: string;\n to?: string;\n /** Initial state — `ringing` for inbound offers, `connecting` for outbound. */\n state: CallState;\n}\n\nconst DTMF_RE = /^[0-9*#]$/;\n\nexport class CallHandle implements OperatorCall {\n readonly callSid: string;\n readonly direction: CallDirection;\n readonly from?: string;\n readonly to?: string;\n\n private _state: CallState;\n private _endReason?: CallEndReason;\n private _muted = false;\n private readonly deps: CallHandleDeps;\n\n constructor(init: CallHandleInit, deps: CallHandleDeps) {\n this.callSid = init.callSid;\n this.direction = init.direction;\n this.from = init.from;\n this.to = init.to;\n this._state = init.state;\n this.deps = deps;\n }\n\n get state(): CallState {\n return this._state;\n }\n\n get endReason(): CallEndReason | undefined {\n return this._endReason;\n }\n\n get muted(): boolean {\n return this._muted;\n }\n\n // ── public API ──────────────────────────────────────────────────────────\n\n accept(): void {\n if (this._state !== \"ringing\") return;\n this._state = \"connecting\";\n this.deps.onAccept(this);\n }\n\n reject(): void {\n if (this._state !== \"ringing\") return;\n // Mark ended BEFORE the dep so that when AudinOperator emits `callEnded`\n // from onReject, this handle already reflects state=\"ended\" / reason.\n this.markEnded(\"rejected\");\n this.deps.onReject(this);\n }\n\n mute(on: boolean): void {\n if (this._state === \"ended\") return;\n this._muted = on;\n this.deps.onMute(this, on);\n }\n\n sendDtmf(digit: string): void {\n if (this._state === \"ended\") return;\n if (!DTMF_RE.test(digit)) {\n throw new Error(`invalid DTMF digit: ${JSON.stringify(digit)}`);\n }\n this.deps.onDtmf(this, digit);\n }\n\n hangup(): void {\n if (this._state === \"ended\") return;\n // Mark ended BEFORE the dep so the handle reflects the terminal state by\n // the time AudinOperator emits `callEnded` (idempotent if the bridge\n // teardown also marks it).\n this.markEnded(\"hangup\");\n this.deps.onHangup(this);\n }\n\n // ── internal state transitions (called by AudinOperator) ─────────────────\n\n /** Move to `connecting` (audio bridge opening). */\n setConnecting(): void {\n if (this._state === \"ended\") return;\n this._state = \"connecting\";\n }\n\n /** Move to `active` (audio flowing). */\n setActive(): void {\n if (this._state === \"ended\") return;\n this._state = \"active\";\n }\n\n /** Terminal transition. Idempotent — keeps the first end reason. */\n markEnded(reason: CallEndReason): void {\n if (this._state === \"ended\") return;\n this._state = \"ended\";\n this._endReason = reason;\n }\n}\n","/**\n * TokenManager — centralises session-token acquisition for the SDK.\n *\n * Both transports (presence WS, audio WS) and the REST helpers\n * ({@link AudinOperator.listPhoneNumbers}) need the SAME short-lived session\n * token. Rather than each call site hitting {@link GetTokenFn} independently,\n * they go through {@link TokenManager.ensureToken}, which:\n * - returns a cached token while it is still valid, or\n * - fetches a fresh one via `getToken`, caches it, and returns it.\n *\n * Validity is decided from the token's optional `expiresAt` (ISO-8601),\n * applying a safety skew so we never present a token that's about to lapse\n * mid-request. When no `expiresAt` is supplied, we fall back to a conservative\n * TTL so a token is still reused across the closely-spaced calls of a single\n * connect (e.g. listPhoneNumbers → goOnline) without being held forever.\n *\n * On an auth failure (e.g. a 401 from REST, or an auth-related WS close), the\n * caller invokes {@link TokenManager.invalidate} so the next `ensureToken`\n * re-mints. Concurrent `ensureToken` calls share a single in-flight fetch.\n */\n\nimport type { GetTokenFn, OperatorSessionToken } from \"./types.js\";\n\n/** Refresh this many ms BEFORE `expiresAt` to avoid mid-request expiry. */\nconst EXPIRY_SKEW_MS = 30_000;\n/** TTL assumed when the token carries no `expiresAt`. Conservative on purpose. */\nconst DEFAULT_TTL_MS = 60_000;\n\ninterface CachedToken {\n token: string;\n /** Epoch ms after which we must re-mint. */\n expiresAtMs: number;\n}\n\nexport class TokenManager {\n private readonly getToken: GetTokenFn;\n private cached: CachedToken | null = null;\n private inFlight: Promise<string> | null = null;\n\n constructor(getToken: GetTokenFn) {\n this.getToken = getToken;\n }\n\n /**\n * Resolve a valid session token, reusing the cached one when possible.\n * Concurrent callers share a single underlying `getToken()` invocation.\n */\n async ensureToken(): Promise<string> {\n const now = Date.now();\n if (this.cached && this.cached.expiresAtMs > now) {\n return this.cached.token;\n }\n if (this.inFlight) {\n return this.inFlight;\n }\n\n this.inFlight = this.fetchAndCache().finally(() => {\n this.inFlight = null;\n });\n return this.inFlight;\n }\n\n /**\n * Drop the cached token so the next {@link ensureToken} re-mints. Call this\n * on an auth failure (401 / auth-related WS close).\n */\n invalidate(): void {\n this.cached = null;\n }\n\n private async fetchAndCache(): Promise<string> {\n const session = await this.getToken();\n if (!session?.token) {\n throw new TokenError(\n \"TOKEN_INVALID\",\n \"getToken() returned no token\"\n );\n }\n this.cached = {\n token: session.token,\n expiresAtMs: computeExpiry(session, Date.now()),\n };\n return session.token;\n }\n}\n\n/** Tagged error so callers can distinguish token problems from transport ones. */\nexport class TokenError extends Error {\n readonly code: string;\n constructor(code: string, message: string) {\n super(message);\n this.name = \"TokenError\";\n this.code = code;\n }\n}\n\nfunction computeExpiry(session: OperatorSessionToken, nowMs: number): number {\n if (session.expiresAt) {\n const parsed = Date.parse(session.expiresAt);\n if (!Number.isNaN(parsed)) {\n // Never trust a token right up to its stated expiry.\n return Math.max(parsed - EXPIRY_SKEW_MS, nowMs);\n }\n }\n return nowMs + DEFAULT_TTL_MS;\n}\n","/**\n * AudinOperator — the public entry point of `@audin.ai/operator-sdk`.\n *\n * A headless (no-UI) controller an operator's browser app drives to make and\n * receive phone calls through Audin. It hides:\n * - the signalling channel (PresenceClient: availability, ringing, dialing),\n * - the media channel (AudioBridge: microphone, μ-law transcoding, playback),\n * - reconnection, heartbeats and token refresh.\n *\n * Credentials never enter the browser: the app supplies a `getToken` callback\n * that calls ITS OWN backend (which holds the account key) to mint a\n * short-lived session token. The SDK only ever sees that token.\n *\n * MVP concurrency: ONE active call at a time. While a call is live, an inbound\n * offer is auto-declined (`reject`) and `dial()` rejects with an error. This\n * keeps the audio graph and state machine simple; multi-line can layer on top\n * later without changing the public surface.\n *\n * Naming is provider-neutral throughout — this is \"the Audin operator\n * softphone\", nothing more.\n */\n\nimport { TypedEmitter } from \"./emitter.js\";\nimport { PresenceClient } from \"./presence-client.js\";\nimport type { PresenceServerMessage } from \"./presence-client.js\";\nimport { AudioBridge } from \"./audio-client.js\";\nimport type { BridgeCloseReason } from \"./audio-client.js\";\nimport { CallHandle } from \"./call.js\";\nimport { TokenManager } from \"./token-manager.js\";\nimport type {\n AudinOperatorConfig,\n AudinOperatorEventMap,\n AudinOperatorEventName,\n AudinOperatorListener,\n DialOptions,\n OperatorCall,\n OperatorLogger,\n OperatorPhoneNumber,\n PresenceState,\n} from \"./types.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 25_000;\nconst DEFAULT_BACKOFF_MS = [1000, 2000, 5000, 10_000, 30_000];\nconst DEFAULT_AUDIO_CONSTRAINTS: MediaTrackConstraints = {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n};\n/** How long to wait for `outbound_started` after `start_outbound`. */\nconst OUTBOUND_ACK_TIMEOUT_MS = 15_000;\n\nexport class AudinOperator extends TypedEmitter<AudinOperatorEventMap> {\n private readonly presence: PresenceClient;\n private readonly coreWsHost: string;\n private readonly coreHttpBase: string;\n private readonly tokens: TokenManager;\n /**\n * Microphone constraints handed to the next {@link AudioBridge} when a call's\n * audio leg opens. Mutable so {@link setAudioConstraints} can switch the input\n * device at runtime; read fresh in {@link openAudioBridge} (never captured\n * earlier), so the change applies from the next call.\n */\n private audioConstraints: MediaTrackConstraints;\n private readonly logger: OperatorLogger;\n\n private presenceState: PresenceState = \"offline\";\n\n /** The single active/ringing call (MVP: one at a time). */\n private activeCall: CallHandle | null = null;\n private activeBridge: AudioBridge | null = null;\n\n /** Pending outbound dial awaiting its `outbound_started` ack. */\n private pendingOutbound: {\n to: string;\n callerId: string;\n resolve: (call: OperatorCall) => void;\n reject: (err: Error) => void;\n timer: ReturnType<typeof setTimeout>;\n } | null = null;\n\n constructor(config: AudinOperatorConfig) {\n super();\n this.tokens = new TokenManager(config.getToken);\n this.logger = config.logger ?? console;\n this.audioConstraints =\n config.audioConstraints ?? DEFAULT_AUDIO_CONSTRAINTS;\n this.coreWsHost = toWsBase(config.coreUrl);\n this.coreHttpBase = toHttpBase(config.coreUrl);\n\n this.presence = new PresenceClient({\n presenceWsUrl: `${this.coreWsHost}/operator-presence/ws`,\n ensureToken: () => this.tokens.ensureToken(),\n invalidateToken: () => this.tokens.invalidate(),\n heartbeatIntervalMs:\n config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS,\n reconnectBackoffMs: config.reconnectBackoffMs ?? DEFAULT_BACKOFF_MS,\n logger: this.logger,\n onMessage: (msg) => this.handlePresenceMessage(msg),\n onOpen: () => this.setPresenceState(\"online\"),\n onReconnecting: () => this.setPresenceState(\"reconnecting\"),\n onError: (code, message, cause) =>\n this.emitError(code, message, cause),\n });\n }\n\n // ── typed event surface (re-export emitter methods publicly) ─────────────\n\n on<K extends AudinOperatorEventName>(\n event: K,\n listener: AudinOperatorListener<K>\n ): () => void {\n return super.on(event, listener);\n }\n\n off<K extends AudinOperatorEventName>(\n event: K,\n listener: AudinOperatorListener<K>\n ): void {\n super.off(event, listener);\n }\n\n once<K extends AudinOperatorEventName>(\n event: K,\n listener: AudinOperatorListener<K>\n ): () => void {\n return super.once(event, listener);\n }\n\n // ── numbers ────────────────────────────────────────────────────────────\n\n /**\n * List the phone numbers this account owns and may go online on / dial from.\n *\n * Fetches over REST using the SAME short-lived session token the WebSockets\n * use (obtained through `getToken`) — the Account API Key never enters the\n * browser. On a 401 the cached token is invalidated and the request is\n * retried ONCE with a fresh token; a second 401 throws an\n * {@link OperatorRequestError} with code `UNAUTHORIZED`. Other non-OK\n * responses throw with code `REQUEST_FAILED`.\n *\n * Use a returned number's `id` for {@link goOnline} and its `phoneNumber`\n * (E.164) as the `callerId` for {@link dial}.\n */\n async listPhoneNumbers(): Promise<OperatorPhoneNumber[]> {\n return this.fetchPhoneNumbers(true);\n }\n\n private async fetchPhoneNumbers(\n retryOn401: boolean\n ): Promise<OperatorPhoneNumber[]> {\n const token = await this.tokens.ensureToken();\n const url = `${this.coreHttpBase}/operator/phone-numbers`;\n\n let res: Response;\n try {\n res = await fetch(url, {\n headers: { Authorization: `Bearer ${token}` },\n });\n } catch (err) {\n throw new OperatorRequestError(\n \"REQUEST_FAILED\",\n \"failed to reach the operator service\",\n err\n );\n }\n\n if (res.status === 401) {\n // Token rejected — drop it and (once) retry with a fresh mint.\n this.tokens.invalidate();\n if (retryOn401) {\n return this.fetchPhoneNumbers(false);\n }\n throw new OperatorRequestError(\n \"UNAUTHORIZED\",\n \"session token rejected fetching phone numbers (401)\"\n );\n }\n\n if (!res.ok) {\n throw new OperatorRequestError(\n \"REQUEST_FAILED\",\n `phone-numbers request failed with status ${res.status}`\n );\n }\n\n let body: { phoneNumbers?: unknown };\n try {\n body = await res.json();\n } catch (err) {\n throw new OperatorRequestError(\n \"REQUEST_FAILED\",\n \"phone-numbers response was not valid JSON\",\n err\n );\n }\n\n return Array.isArray(body.phoneNumbers)\n ? (body.phoneNumbers as OperatorPhoneNumber[])\n : [];\n }\n\n // ── lifecycle ────────────────────────────────────────────────────────────\n\n /**\n * Go online and start accepting inbound calls on `phoneNumberIds`. Connects\n * the presence channel (fetching a token via `getToken`) and announces\n * availability. Safe to call again to change the number set.\n */\n async goOnline(phoneNumberIds: string[]): Promise<void> {\n if (!Array.isArray(phoneNumberIds) || phoneNumberIds.length === 0) {\n throw new Error(\"goOnline requires a non-empty phoneNumberIds array\");\n }\n this.setPresenceState(\"connecting\");\n if (!this.presence.isOpen) {\n await this.presence.connect();\n }\n this.presence.setAvailable(phoneNumberIds);\n }\n\n /**\n * Go offline: drop availability, tear down any active call, and close the\n * presence channel (no auto-reconnect until `goOnline` again).\n */\n async goOffline(): Promise<void> {\n if (this.activeCall) {\n this.teardownActiveCall(\"offline\");\n }\n this.presence.disconnect();\n this.setPresenceState(\"offline\");\n }\n\n /** Current presence channel state. */\n get state(): PresenceState {\n return this.presenceState;\n }\n\n /** The current active/ringing call, if any. */\n get currentCall(): OperatorCall | null {\n return this.activeCall;\n }\n\n /**\n * Switch the microphone constraints used to capture the operator's audio —\n * typically to pick a specific input device, e.g.\n * `setAudioConstraints({ deviceId: { exact: id }, echoCancellation: true })`.\n *\n * Takes effect from the NEXT call: a call already in progress keeps the\n * device its {@link AudioBridge} opened with (this does not re-open the\n * microphone mid-call). The new constraints are read when the next\n * `dial`/accepted-inbound opens its audio leg.\n */\n setAudioConstraints(constraints: MediaTrackConstraints): void {\n this.audioConstraints = constraints;\n }\n\n // ── outbound ─────────────────────────────────────────────────────────────\n\n /**\n * Place an outbound call to `to` (E.164), presenting `options.callerId`\n * (which must be an active number your account owns). Resolves once the call\n * is accepted by the platform and the audio bridge is opening; rejects on\n * validation failure, a busy operator, or if the platform doesn't ack in\n * time.\n */\n dial(to: string, options: DialOptions): Promise<OperatorCall> {\n if (!this.presence.isOpen) {\n return Promise.reject(\n new Error(\"not online — call goOnline() before dialing\")\n );\n }\n if (this.activeCall) {\n return Promise.reject(\n new Error(\"an active call is already in progress (MVP: one at a time)\")\n );\n }\n if (this.pendingOutbound) {\n return Promise.reject(new Error(\"an outbound dial is already pending\"));\n }\n if (!to || typeof to !== \"string\") {\n return Promise.reject(new Error(\"dial requires a destination number\"));\n }\n if (!options?.callerId) {\n return Promise.reject(new Error(\"dial requires options.callerId\"));\n }\n\n return new Promise<OperatorCall>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pendingOutbound = null;\n reject(new Error(\"outbound call was not acknowledged in time\"));\n }, OUTBOUND_ACK_TIMEOUT_MS);\n\n this.pendingOutbound = {\n to,\n callerId: options.callerId,\n resolve,\n reject,\n timer,\n };\n this.presence.startOutbound(