@audin.ai/operator-sdk
Version:
Headless browser SDK for the Audin operator softphone — make and receive calls over the Audin operator WebSockets.
1,300 lines (1,285 loc) • 38.1 kB
JavaScript
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, (r) => {
s(), t(r);
});
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 r of [...s])
try {
r(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 (r) {
const i = r?.code ?? "TOKEN_FETCH_FAILED";
this.opts.onError(i, "session token unavailable", r), 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 = (r) => {
let i;
try {
i = JSON.parse(
typeof r.data == "string" ? r.data : String(r.data)
);
} catch {
this.opts.logger.warn("[presence] non-JSON message ignored");
return;
}
this.opts.onMessage(i);
}, s.onerror = (r) => {
this.opts.logger.warn("[presence] WS error", r), this.opts.onError("WS_ERROR", "presence socket error");
}, s.onclose = (r) => {
this.opts.logger.debug(
`[presence] WS closed code=${r.code} reason=${r.reason}`
), this.stopHeartbeat(), this.ws = null, _(r.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 _(n) {
return n === 1008 || n === 4001 || n === 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", S = A.replace(
"__PROCESSOR_NAME__",
m
);
function E() {
const n = new Blob([S], {
type: "application/javascript"
});
return URL.createObjectURL(n);
}
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 r = new WebSocket(this.opts.audioWsUrl);
r.binaryType = "arraybuffer", this.ws = r, r.onopen = () => {
s = !0, this.opts.logger.debug("[audio] WS open"), e();
}, r.onmessage = (i) => this.onWsMessage(i), r.onerror = (i) => {
this.opts.logger.warn("[audio] WS error", i), s || (s = !0, t(new Error("audio WebSocket failed to open")));
}, r.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 r = new Uint8Array(t).slice();
this.workletNode?.port.postMessage({ type: "mulaw", payload: r }, [
r.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 = E(), 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 r;
try {
r = 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 = r;
const i = t.createMediaStreamSource(r), 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 P {
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 R(
"TOKEN_INVALID",
"getToken() returned no token"
);
return this.cached = {
token: e.token,
expiresAtMs: B(e, Date.now())
}, e.token;
}
}
class R extends Error {
constructor(e, t) {
super(t), this.name = "TokenError", this.code = e;
}
}
function B(n, e) {
if (n.expiresAt) {
const t = Date.parse(n.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 P(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, r) => this.emitError(t, s, r)
});
}
// ── 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 r;
try {
r = await fetch(s, {
headers: { Authorization: `Bearer ${t}` }
});
} catch (a) {
throw new c(
"REQUEST_FAILED",
"failed to reach the operator service",
a
);
}
if (r.status === 401) {
if (this.tokens.invalidate(), e)
return this.fetchPhoneNumbers(!1);
throw new c(
"UNAUTHORIZED",
"session token rejected fetching phone numbers (401)"
);
}
if (!r.ok)
throw new c(
"REQUEST_FAILED",
`phone-numbers request failed with status ${r.status}`
);
let i;
try {
i = await r.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, r) => {
const i = setTimeout(() => {
this.pendingOutbound = null, r(new Error("outbound call was not acknowledged in time"));
}, I);
this.pendingOutbound = {
to: e,
callerId: t.callerId,
resolve: s,
reject: r,
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 "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, r = 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: r,
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 r = this.makeCall({
callSid: t,
direction: "outbound",
to: s.to,
state: "connecting"
});
this.activeCall = r, s.resolve(r), this.openAudioBridge(r);
}
// ── 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)}`, r = new k({
audioWsUrl: s,
audioConstraints: this.audioConstraints,
logger: this.logger,
onClosed: (i) => this.onBridgeClosed(e, i)
});
this.activeBridge = r;
try {
await r.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 && r.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) {
this.activeBridge && (this.activeBridge.close("teardown"), this.activeBridge = null), e.markEnded(t), this.activeCall === e && (this.activeCall = null), this.emitEvent("callEnded", e);
}
teardownActiveCall(e) {
const t = this.activeCall;
t && (this.activeBridge && (this.activeBridge.close("teardown"), this.activeBridge = null), t.markEnded(e), this.activeCall = null, 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(n) {
let e;
try {
e = new URL(n);
} catch {
throw new Error(`invalid coreUrl: ${JSON.stringify(n)}`);
}
return `${e.protocol === "https:" || e.protocol === "wss:" ? "wss:" : "ws:"}//${e.host}`;
}
function W(n) {
let e;
try {
e = new URL(n);
} catch {
throw new Error(`invalid coreUrl: ${JSON.stringify(n)}`);
}
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(n) {
return Array.isArray(n) ? n.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(n) {
let e = n;
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], r = e >> s + 3 & 15;
return ~(t | s << 4 | r) & 255;
}
function $(n) {
const e = ~n & 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(n) {
const e = new Uint8Array(n.length);
for (let t = 0; t < n.length; t++)
e[t] = D(n[t]);
return e;
}
function K(n) {
const e = new Int16Array(n.length);
for (let t = 0; t < n.length; t++)
e[t] = $(n[t]);
return e;
}
function g(n, e, t) {
if (e <= 0 || t <= 0)
throw new Error("resampleLinear: sample rates must be positive");
if (e === t || n.length === 0)
return n.slice();
const s = e / t, r = Math.round(n.length / s), i = new Float32Array(r), a = n.length - 1;
for (let o = 0; o < r; o++) {
const l = o * s, h = Math.floor(l), w = l - h, u = n[Math.min(h, a)], y = n[Math.min(h + 1, a)];
i[o] = u + (y - u) * w;
}
return i;
}
function q(n, e) {
return g(n, e, 8e3);
}
function G(n, e) {
return g(n, 8e3, e);
}
function J(n) {
const e = new Int16Array(n.length);
for (let t = 0; t < n.length; t++) {
let s = n[t];
s > 1 ? s = 1 : s < -1 && (s = -1), e[t] = s < 0 ? s * 32768 : s * 32767;
}
return e;
}
function X(n) {
const e = new Float32Array(n.length);
for (let t = 0; t < n.length; t++) {
const s = n[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