@craftedxp/voice-js 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts","../src/worklets/mic-downsampler.worklet.js","../src/AudioCapture.ts","../src/AudioPlayback.ts","../src/ReconnectingWebSocket.ts","../src/protocol.ts","../src/clientTools.ts","../src/VoiceClient.ts","../src/browser.ts"],"sourcesContent":["// Public configuration surface.\n//\n// `configureVoiceClient` returns a small factory that knows how to mint\n// `ct_` tokens via your callback. Per-call you call `factory.startCall`.\n//\n// Parity with @craftedxp/voice-rn:\n// voice-rn calls `configureVoiceClient` once at app startup as a\n// side-effect (singleton), because in RN there is exactly one host\n// app and one running `<AgentCall>` at a time. In JS environments\n// the same process can drive multiple clients (multi-tenant\n// dashboards, terminal multiplexers, electron apps with several\n// panels), so the JS SDK returns the factory rather than mutating a\n// module-level singleton. Same option names, same callback shape.\n//\n// Auth model:\n// - `apiKey` is INTENTIONALLY ABSENT from the public surface.\n// Pre-0.2 had it; baking an `sk_` into a JS bundle ships your\n// server-grade credentials to every client. The right pattern is\n// `fetchToken` — your code asks YOUR backend for a short-lived\n// `ct_` and your backend uses its `sk_` (via @craftedxp/sdk-node)\n// to mint it.\n// - For tests + local prototypes a bare `token` may be passed to\n// `startCall` directly, bypassing `fetchToken`. Don't ship that to\n// production — `ct_` lifetimes are short and you want the SDK to\n// re-mint on expiry.\n\nimport type { CallEndEvent, CallError, CallState, TranscriptEntry, VolumeEvent } from './protocol'\nimport type { ClientToolMap } from './clientTools'\n\n// ---------------------------------------------------------------------------\n// fetchToken contract — matches voice-rn FetchTokenArgs verbatim.\n// ---------------------------------------------------------------------------\n\nexport interface FetchTokenArgs {\n /** The agent the SDK is about to call. */\n agentId: string\n /**\n * Optional consumer-side user identifier. Round-tripped to the server\n * as `contactId` for Phase 11 contact memory. The SDK does not\n * inspect this; your backend uses it to scope the token mint.\n */\n userId?: string\n /**\n * Per-call structured context lowered into the agent's effective\n * system prompt server-side at session open. Opaque to the SDK.\n */\n context?: Record<string, unknown>\n /**\n * String key/value pairs round-tripped on the `call.ended` webhook.\n * Capped at 1 KB total server-side. NOT lowered into the system prompt.\n */\n metadata?: Record<string, string>\n}\n\nexport type FetchToken = (args: FetchTokenArgs) => Promise<string>\n\n// ---------------------------------------------------------------------------\n// Factory configuration\n// ---------------------------------------------------------------------------\n\nexport interface VoiceClientConfig {\n /**\n * Full HTTPS URL of the Voxline server. The WebSocket scheme is\n * derived: `https` → `wss`, `http` → `ws`. No trailing slash needed.\n */\n apiBase: string\n /**\n * Called by the SDK whenever it needs a fresh `ct_` token (initial\n * connect; mid-call refresh on `token_expired`). Your implementation\n * should hit YOUR backend, which holds the `sk_` API key and mints\n * via `POST /v1/call-tokens` (or `client.callTokens.mint` from\n * @craftedxp/sdk-node). Never embed `sk_` in JS code that ships to a\n * client.\n */\n fetchToken: FetchToken\n /**\n * Optional metadata applied to EVERY startCall. Per-call `metadata`\n * in `startCall` is merged on top (per-call wins on key conflicts).\n * Useful for dashboard-wide tags like `{ surface: 'web', appVersion }`.\n */\n defaultMetadata?: Record<string, string>\n /**\n * Optional context applied to EVERY startCall. Per-call `context` in\n * `startCall` is merged on top. Useful for cross-call invariants like\n * the signed-in user's locale.\n */\n defaultContext?: Record<string, unknown>\n}\n\n// ---------------------------------------------------------------------------\n// Per-call options — startCall({ ... })\n// ---------------------------------------------------------------------------\n\nexport interface StartCallOptions {\n /** The agent to call. */\n agentId: string\n /** Per-call user identifier. Round-tripped to fetchToken as `userId`. */\n userId?: string\n /**\n * Per-call structured context. Merged on top of `defaultContext`\n * configured at factory time.\n */\n context?: Record<string, unknown>\n /**\n * Per-call metadata. Merged on top of `defaultMetadata` configured\n * at factory time.\n */\n metadata?: Record<string, string>\n /**\n * When false, the SDK + server stay full-duplex but barge-in is\n * suppressed. Useful for alarm-style flows where the user shouldn't\n * accidentally interrupt the script. Default true.\n */\n bargeIn?: boolean\n /**\n * Client-side tools the agent's LLM can call mid-conversation. Each\n * tool's handler runs on the consumer's side; result is fed back to\n * the LLM through the existing call WebSocket. Schema and handler\n * colocate. Validated synchronously at startCall — bad input throws.\n *\n * See docs/integration-echocheck.md for the wire protocol and the\n * server-side guarantees.\n */\n clientTools?: ClientToolMap\n /**\n * Test-only escape hatch — pass a pre-minted `ct_` directly and skip\n * the `fetchToken` call. Don't use this in production code: tokens\n * expire and the SDK can't re-mint without the callback.\n */\n token?: string\n\n // Event callbacks — same shape as voice-rn UseVoiceCallOptions.\n onStateChange?: (state: CallState) => void\n onTranscript?: (entries: TranscriptEntry[]) => void\n onError?: (err: CallError) => void\n onEnd?: (end: CallEndEvent) => void\n /** Volume-meter event for VU UIs. ~10 Hz cadence (browser bundle only). */\n onVolume?: (vol: VolumeEvent) => void\n /**\n * Fires when the server signals barge-in (the user started talking\n * mid-agent-turn). The browser bundle automatically flushes its\n * built-in audio playback before this callback runs; the callback is\n * fired regardless. Node / Electron consumers with custom playback\n * should drain their audio queue here so the agent goes silent\n * immediately.\n */\n onInterrupt?: () => void\n /**\n * Fires on `agent_turn_start` — the server has begun a new agent\n * turn. The state-machine transition to `agent_speaking` happens at\n * the same moment via `onStateChange`; use this when you want a\n * precise turn anchor (e.g. \"agent has been speaking for N ms\" UIs)\n * without diffing state.\n */\n onAgentTurnStart?: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Call handle returned from startCall — matches voice-rn\n// VoiceCallController where it makes sense.\n// ---------------------------------------------------------------------------\n\nexport interface Call {\n /** Current state. Snapshot — subscribe via onStateChange for live updates. */\n readonly state: CallState\n /** Full transcript so far. Snapshot — subscribe via onTranscript for live updates. */\n readonly transcript: TranscriptEntry[]\n /** True after `mute()` and before `unmute()`. */\n readonly isMuted: boolean\n /** End the call locally. Closes the WS, stops the mic, fires onEnd. Idempotent. */\n end: () => void\n /** Mute mic frames. Wire stays active so server endpointing doesn't false-positive. Idempotent. */\n mute: () => void\n /** Unmute mic frames. Idempotent. */\n unmute: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Factory contract — what configureVoiceClient returns. The actual\n// implementation differs between browser (audio-equipped) and node (raw\n// PCM) bundles; both satisfy this interface.\n// ---------------------------------------------------------------------------\n\nexport interface VoiceClientFactory {\n /** Read back the resolved config (post trailing-slash normalisation). */\n readonly config: VoiceClientConfig\n /**\n * Open a fresh call. Returns when the WS is open; rejects on\n * pre-flight failure (missing config, fetchToken throw, etc). Mid-\n * call failures arrive via the per-call `onError` callback — they\n * don't reject this promise.\n */\n startCall: (options: StartCallOptions) => Promise<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pre-flight validation. Pulled out so both bundles share the exact\n// same \"missing field\" error messages.\n// ---------------------------------------------------------------------------\n\nexport function normalizeConfig(config: VoiceClientConfig): VoiceClientConfig {\n if (!config) throw new Error('configureVoiceClient: config is required')\n if ('apiKey' in (config as object)) {\n throw new Error(\n 'configureVoiceClient: `apiKey` is no longer supported. Embedding sk_ in JS code ships server-grade credentials to every client. Pass `fetchToken: async ({ agentId }) => { /* call YOUR backend mint */ }` instead — see the @craftedxp/voice-js README for the migration recipe.',\n )\n }\n if (!config.apiBase) {\n throw new Error('configureVoiceClient: apiBase is required')\n }\n if (typeof config.fetchToken !== 'function') {\n throw new Error('configureVoiceClient: fetchToken must be a function')\n }\n return {\n ...config,\n apiBase: config.apiBase.replace(/\\/+$/, ''),\n }\n}\n\n// Merge factory-level defaults with per-call overrides. Per-call wins.\nexport function mergeStartCallContext(\n factory: VoiceClientConfig,\n call: StartCallOptions,\n): { context?: Record<string, unknown>; metadata?: Record<string, string> } {\n const context =\n factory.defaultContext || call.context\n ? { ...(factory.defaultContext ?? {}), ...(call.context ?? {}) }\n : undefined\n const metadata =\n factory.defaultMetadata || call.metadata\n ? { ...(factory.defaultMetadata ?? {}), ...(call.metadata ?? {}) }\n : undefined\n return { context, metadata }\n}\n\nexport type { ClientTool, ClientToolMap } from './clientTools'\n","// AudioWorklet — runs off the main thread in the audio rendering graph.\n//\n// Input: Float32 samples at the AudioContext's native sampleRate (typically\n// 48000 Hz on desktop, 44100 Hz on some iOS devices).\n// Output: 16 kHz mono Int16 PCM, shipped to the main thread via\n// `port.postMessage(ArrayBuffer, [ArrayBuffer])` (transferred, not copied).\n//\n// Why AudioWorklet instead of ScriptProcessorNode: ScriptProcessorNode is\n// deprecated + main-thread-bound, so any JS jank produces audible audio\n// glitches. AudioWorklet's `process()` runs on the audio rendering thread\n// at the graph's block cadence (128 frames by default) and backpressures\n// via returning `true` / `false`.\n//\n// This file is loaded as text (see tsup.config.ts loader) and registered\n// at runtime via `audioWorklet.addModule(blobUrl)`.\n\nclass MicDownsampler extends AudioWorkletProcessor {\n constructor() {\n super()\n // Target sample rate for STT. Matches Deepgram Nova-3 + the platform's\n // server-side SAMPLE_RATE constant in AgentCallHandler.\n this.targetRate = 16000\n // Accumulator for the downsample. We collect incoming samples and emit\n // an Int16 chunk when we've accumulated ~1024 target-rate samples\n // (~64 ms at 16 kHz) — matches the mobile SDK's chunk size so both\n // platforms have the same server-side framing.\n this.outputFrames = 1024\n this.acc = []\n // Running index used for fractional resampling.\n this.readCursor = 0\n }\n\n // `inputs[0][0]` = first channel of first input. 128 Float32 samples per\n // call at the context's sampleRate. Return true = keep processing.\n process(inputs) {\n const input = inputs[0]\n if (!input || input.length === 0) return true\n const channel = input[0]\n if (!channel || channel.length === 0) return true\n\n const ctxRate = sampleRate // global inside AudioWorkletProcessor\n const ratio = ctxRate / this.targetRate\n\n // Simple linear-interp downsample. For 48000 → 16000 that's 3:1, which\n // linear handles fine for voice. Anti-alias filtering would be\n // theoretically better but inaudible for speech.\n for (let i = 0; i < channel.length; i++) {\n this.acc.push(channel[i])\n }\n\n while (this.acc.length - this.readCursor >= ratio * this.outputFrames) {\n const out = new Int16Array(this.outputFrames)\n let readIdx = this.readCursor\n for (let i = 0; i < this.outputFrames; i++) {\n // Linear interp between floor(readIdx) and ceil(readIdx)\n const low = Math.floor(readIdx)\n const high = Math.min(low + 1, this.acc.length - 1)\n const frac = readIdx - low\n const sample = this.acc[low] * (1 - frac) + this.acc[high] * frac\n // Clip + convert to int16\n const clipped = Math.max(-1, Math.min(1, sample))\n out[i] = clipped < 0 ? clipped * 0x8000 : clipped * 0x7fff\n readIdx += ratio\n }\n // Transfer the ArrayBuffer (zero-copy) to the main thread.\n this.port.postMessage(out.buffer, [out.buffer])\n this.readCursor = readIdx\n }\n\n // Garbage-collect the consumed portion of `acc` every so often so it\n // doesn't grow without bound. Leave ~one chunk of headroom.\n if (this.readCursor > ratio * this.outputFrames) {\n this.acc = this.acc.slice(Math.floor(this.readCursor))\n this.readCursor -= Math.floor(this.readCursor)\n }\n\n return true\n }\n}\n\nregisterProcessor('mic-downsampler', MicDownsampler)\n","// Mic capture pipeline:\n// getUserMedia → AudioContext → AudioWorkletNode → Int16 PCM chunks\n//\n// We register the worklet from an inline Blob URL so consumers don't need\n// to host or bundle a separate .js file. The worklet source is loaded as\n// text at build time via tsup's custom loader (see tsup.config.ts).\n\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore — text-loaded by tsup\nimport workletSource from './worklets/mic-downsampler.worklet.js'\n\nexport type OnChunk = (pcm: ArrayBuffer) => void\nexport type OnVolume = (rms01: number) => void\nexport type OnError = (err: Error) => void\n\nexport interface CaptureOptions {\n onChunk: OnChunk\n // Optional — when set we compute input RMS and call this at ~10Hz. Used\n // for volume visualisers in consumer UIs.\n onVolume?: OnVolume\n onError?: OnError\n}\n\nexport interface CaptureController {\n start: () => Promise<void>\n stop: () => void\n mute: (muted: boolean) => void\n isCapturing: () => boolean\n}\n\n// Volume-meter cadence. 100ms = 10Hz updates; smoother than per-chunk\n// (16Hz) without flicker.\nconst VOLUME_INTERVAL_MS = 100\n\nexport const createAudioCapture = (options: CaptureOptions): CaptureController => {\n let audioContext: AudioContext | null = null\n let mediaStream: MediaStream | null = null\n let sourceNode: MediaStreamAudioSourceNode | null = null\n let workletNode: AudioWorkletNode | null = null\n let analyser: AnalyserNode | null = null\n let volumeTimer: ReturnType<typeof setInterval> | null = null\n let muted = false\n let capturing = false\n\n const computeRms = (buf: Float32Array): number => {\n let sum = 0\n for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]\n const rms = Math.sqrt(sum / buf.length)\n // Clamp to 0..1 — RMS of a full-scale signal is 0.707, so boost a bit\n // so \"normal speech\" hits ~0.5 in the visualiser.\n return Math.min(1, rms * 1.8)\n }\n\n const start = async () => {\n if (capturing) return\n try {\n // Browsers only grant getUserMedia on secure contexts (https or\n // localhost). The thrown DOMException is clear enough — we let it\n // propagate with a wrapped error message for clarity.\n mediaStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n // Hand tuning for voice agent use: we want the raw signal so the\n // server-side STT can do its own noise handling. Disable browser\n // AEC/AGC/NR — experimentally they fight with whatever processing\n // the TTS playback path feeds back in over speakers.\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n channelCount: 1,\n },\n })\n\n audioContext = new AudioContext()\n // Most browsers suspend AudioContext until a user gesture. connect()\n // is idempotent; if we were constructed inside a click/touch handler\n // it's already running.\n if (audioContext.state === 'suspended') await audioContext.resume()\n\n // Load + register the worklet from an inline Blob URL. One-time per\n // context; subsequent `new AudioWorkletNode` calls are cheap.\n const blob = new Blob([workletSource], { type: 'application/javascript' })\n const url = URL.createObjectURL(blob)\n try {\n await audioContext.audioWorklet.addModule(url)\n } finally {\n URL.revokeObjectURL(url)\n }\n\n sourceNode = audioContext.createMediaStreamSource(mediaStream)\n workletNode = new AudioWorkletNode(audioContext, 'mic-downsampler')\n\n // Worklet → main thread: Int16 PCM chunks\n workletNode.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {\n if (muted) return\n options.onChunk(event.data)\n }\n\n // Analyser sits in parallel to the worklet for the volume meter. Not\n // strictly necessary — we could compute RMS inside the worklet and\n // post it too — but AnalyserNode avoids extra bridge chatter.\n if (options.onVolume) {\n analyser = audioContext.createAnalyser()\n analyser.fftSize = 256\n sourceNode.connect(analyser)\n const buf = new Float32Array(analyser.fftSize)\n volumeTimer = setInterval(() => {\n if (!analyser) return\n analyser.getFloatTimeDomainData(buf)\n options.onVolume?.(computeRms(buf))\n }, VOLUME_INTERVAL_MS)\n }\n\n sourceNode.connect(workletNode)\n // The worklet doesn't need to feed into the destination — its only\n // job is to emit messages. But the AudioContext graph keeps the\n // worklet running only if it's connected somewhere; connect to a\n // zero-gain node to park it.\n const sink = audioContext.createGain()\n sink.gain.value = 0\n workletNode.connect(sink).connect(audioContext.destination)\n\n capturing = true\n } catch (err) {\n const wrapped =\n err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'capture failed')\n options.onError?.(wrapped)\n throw wrapped\n }\n }\n\n const stop = () => {\n if (!capturing) return\n capturing = false\n if (volumeTimer) {\n clearInterval(volumeTimer)\n volumeTimer = null\n }\n try {\n workletNode?.disconnect()\n analyser?.disconnect()\n sourceNode?.disconnect()\n } catch {\n // already disconnected\n }\n workletNode = null\n analyser = null\n sourceNode = null\n if (mediaStream) {\n for (const track of mediaStream.getTracks()) track.stop()\n mediaStream = null\n }\n if (audioContext && audioContext.state !== 'closed') {\n void audioContext.close().catch(() => undefined)\n }\n audioContext = null\n }\n\n return {\n start,\n stop,\n mute: (v) => {\n muted = v\n },\n isCapturing: () => capturing,\n }\n}\n","// Playback pipeline:\n// WebSocket binary frame (Int16 PCM @ 16 kHz) → AudioBufferSourceNode\n// chained for gapless playback → AudioContext destination.\n//\n// We deliberately don't use MediaSource or a single streaming source —\n// each agent TTS chunk is scheduled as its own short AudioBuffer so we\n// can interrupt (flush the queue) the moment a barge-in lands. One chunk\n// ≈ 20–100ms of audio.\n\nexport type OnVolume = (rms01: number) => void\nexport type OnAgentSpeakingChange = (speaking: boolean) => void\n\nexport interface PlaybackOptions {\n // Sample rate of the incoming PCM — matches AgentCallHandler.SAMPLE_RATE.\n sampleRate?: number\n onVolume?: OnVolume\n onSpeakingChange?: OnAgentSpeakingChange\n}\n\nexport interface PlaybackController {\n enqueue: (pcm: ArrayBuffer) => void\n flush: () => void\n close: () => void\n resume: () => Promise<void>\n}\n\nconst DEFAULT_SAMPLE_RATE = 16_000\nconst VOLUME_INTERVAL_MS = 100\n\nexport const createAudioPlayback = (options: PlaybackOptions = {}): PlaybackController => {\n const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE\n let audioContext: AudioContext | null = null\n let gainNode: GainNode | null = null\n let analyser: AnalyserNode | null = null\n let volumeTimer: ReturnType<typeof setInterval> | null = null\n let nextStartTime = 0\n let scheduledNodes: AudioBufferSourceNode[] = []\n let speaking = false\n\n const ensureContext = async () => {\n if (audioContext) {\n if (audioContext.state === 'suspended') await audioContext.resume()\n return\n }\n // Pass the target sample rate as a hint. Browsers may choose a\n // different rate (48000 is most common); `createBuffer(numChannels,\n // length, sampleRate)` with a different rate than the context will\n // trigger resampling on `start()`, which the browser handles natively.\n audioContext = new AudioContext({ sampleRate })\n gainNode = audioContext.createGain()\n if (options.onVolume) {\n analyser = audioContext.createAnalyser()\n analyser.fftSize = 256\n gainNode.connect(analyser)\n const buf = new Float32Array(analyser.fftSize)\n volumeTimer = setInterval(() => {\n if (!analyser) return\n analyser.getFloatTimeDomainData(buf)\n let sum = 0\n for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]\n const rms = Math.sqrt(sum / buf.length)\n options.onVolume?.(Math.min(1, rms * 1.8))\n }, VOLUME_INTERVAL_MS)\n }\n gainNode.connect(audioContext.destination)\n nextStartTime = audioContext.currentTime\n }\n\n const setSpeaking = (v: boolean) => {\n if (v === speaking) return\n speaking = v\n options.onSpeakingChange?.(v)\n }\n\n const pruneFinished = () => {\n const now = audioContext?.currentTime ?? 0\n scheduledNodes = scheduledNodes.filter((n) => {\n const node = n as AudioBufferSourceNode & { _endsAt?: number }\n return (node._endsAt ?? 0) > now\n })\n if (scheduledNodes.length === 0) setSpeaking(false)\n }\n\n const enqueue = (pcm: ArrayBuffer) => {\n // We don't want to await here — WS onmessage is hot-path, and AudioContext\n // resume is cheap once warmed. `void` the promise and proceed on next\n // tick; if the context isn't ready, the first chunk may land a hair\n // late but subsequent chunks catch up.\n if (!audioContext) {\n void ensureContext().then(() => enqueue(pcm))\n return\n }\n if (!audioContext || !gainNode) return\n\n const int16 = new Int16Array(pcm)\n if (int16.length === 0) return\n\n const audioBuffer = audioContext.createBuffer(1, int16.length, sampleRate)\n const float32 = audioBuffer.getChannelData(0)\n for (let i = 0; i < int16.length; i++) {\n float32[i] = int16[i] / 0x8000\n }\n\n const node = audioContext.createBufferSource()\n node.buffer = audioBuffer\n node.connect(gainNode)\n\n const now = audioContext.currentTime\n // Schedule back-to-back. If we've fallen behind (mic lag, WS gap),\n // re-anchor to now so we don't skew the whole queue further.\n const startAt = Math.max(now, nextStartTime)\n node.start(startAt)\n\n const duration = int16.length / sampleRate\n ;(node as AudioBufferSourceNode & { _endsAt?: number })._endsAt = startAt + duration\n nextStartTime = startAt + duration\n\n scheduledNodes.push(node)\n setSpeaking(true)\n\n // Prune after every enqueue so `speaking` collapses quickly when the\n // queue drains. Cheap O(n) over a short list.\n node.onended = () => pruneFinished()\n }\n\n // Barge-in: stop anything pending, reset the schedule pointer.\n //\n // We can't simply `.stop()` playing sources mid-buffer and expect the\n // AudioContext's destination to be silent immediately — Safari in\n // particular sometimes plays out a few ms of tail. Reliable approach:\n // swap the gainNode out so nothing after this point is audible, then\n // clean up the old one on the next tick.\n const flush = () => {\n if (!audioContext || !gainNode) return\n for (const node of scheduledNodes) {\n try {\n node.stop()\n } catch {\n // already stopped\n }\n }\n scheduledNodes = []\n // Swap gainNode so even stubborn trailing samples on the old node\n // don't reach the speakers.\n gainNode.disconnect()\n gainNode = audioContext.createGain()\n if (analyser) {\n analyser.disconnect()\n gainNode.connect(analyser)\n }\n gainNode.connect(audioContext.destination)\n nextStartTime = audioContext.currentTime\n setSpeaking(false)\n }\n\n const close = () => {\n flush()\n if (volumeTimer) {\n clearInterval(volumeTimer)\n volumeTimer = null\n }\n if (audioContext && audioContext.state !== 'closed') {\n void audioContext.close().catch(() => undefined)\n }\n audioContext = null\n gainNode = null\n analyser = null\n }\n\n const resume = async () => {\n await ensureContext()\n }\n\n return { enqueue, flush, close, resume }\n}\n","// Minimal auto-reconnecting WebSocket wrapper.\n//\n// Scope: transparent reconnection on unexpected drops (network blip,\n// server restart). We deliberately do NOT try to preserve call state\n// across reconnects — the server's AgentCallHandler is session-scoped\n// and won't resume where we left off. If the consumer needs mid-call\n// resilience, that's a server-side resumable-session feature, not a\n// client-side retry.\n//\n// In v1 we reconnect only when `connect()` has been called and\n// `.close(1000, ...)` has NOT been called by the consumer (i.e., we drop\n// because of network, not because the user hung up). On unclean close\n// the consumer gets a fresh connection + a `reconnected` event so they\n// can re-issue any greeting / context.\n//\n// Transport agnostic — accepts a WebSocket-constructor factory so the\n// browser bundle uses native `WebSocket` and the node bundle injects the\n// `ws` package.\n\nexport type RWSEvent =\n | { type: 'open' }\n | { type: 'reconnected' }\n | { type: 'message'; data: string | ArrayBuffer }\n | { type: 'close'; code: number; reason: string; permanent: boolean }\n | { type: 'error'; error: Error }\n\n// Minimal WebSocket-like contract. Both browser `WebSocket` and the\n// `ws` package's WebSocket satisfy it.\nexport interface WebSocketLike {\n binaryType: string\n readyState: number\n onopen: ((ev: unknown) => void) | null\n onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null\n onerror: ((ev: unknown) => void) | null\n onclose: ((ev: { code: number; reason: string }) => void) | null\n send: (data: string | ArrayBuffer | ArrayBufferView) => void\n close: (code?: number, reason?: string) => void\n}\n\nexport type WebSocketFactory = (url: string) => WebSocketLike\n\nexport interface RWSOptions {\n url: string\n // Factory so we can swap browser-native WebSocket for the `ws` package\n // in node. Browser entry pre-fills with a wrapper around globalThis.WebSocket.\n wsFactory: WebSocketFactory\n // Cap the number of auto-reconnect attempts. `0` disables retry entirely\n // (closer to native WebSocket semantics). Default: 3.\n maxRetries?: number\n // Initial backoff; doubles up to maxBackoffMs.\n initialBackoffMs?: number\n maxBackoffMs?: number\n}\n\nconst READYSTATE_OPEN = 1\nconst READYSTATE_CLOSED = 3\n\nexport const createReconnectingWebSocket = (\n options: RWSOptions,\n onEvent: (ev: RWSEvent) => void,\n) => {\n const maxRetries = options.maxRetries ?? 3\n const initialBackoff = options.initialBackoffMs ?? 500\n const maxBackoff = options.maxBackoffMs ?? 8000\n\n let ws: WebSocketLike | null = null\n let intentionalClose = false\n let retries = 0\n let backoff = initialBackoff\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null\n\n const openOnce = () => {\n ws = options.wsFactory(options.url)\n // Binary frames arrive as Int16 PCM from the server. ArrayBuffer is\n // more convenient than Blob (one fewer async read) — we `.arrayBuffer()`\n // a Blob manually only if we receive one unexpectedly.\n ws.binaryType = 'arraybuffer'\n ws.onopen = () => {\n if (retries === 0) onEvent({ type: 'open' })\n else onEvent({ type: 'reconnected' })\n retries = 0\n backoff = initialBackoff\n }\n ws.onmessage = (ev) => {\n onEvent({ type: 'message', data: ev.data as string | ArrayBuffer })\n }\n ws.onerror = () => {\n onEvent({ type: 'error', error: new Error('WebSocket error') })\n }\n ws.onclose = (ev) => {\n ws = null\n const shouldRetry = !intentionalClose && retries < maxRetries\n if (!shouldRetry) {\n onEvent({\n type: 'close',\n code: ev.code,\n reason: ev.reason,\n permanent: true,\n })\n return\n }\n onEvent({\n type: 'close',\n code: ev.code,\n reason: ev.reason,\n permanent: false,\n })\n retries++\n const delay = Math.min(backoff, maxBackoff)\n backoff = Math.min(backoff * 2, maxBackoff)\n reconnectTimer = setTimeout(openOnce, delay)\n }\n }\n\n openOnce()\n\n return {\n send: (data: string | ArrayBuffer | ArrayBufferView) => {\n if (ws && ws.readyState === READYSTATE_OPEN) ws.send(data)\n },\n close: (code = 1000, reason = 'client-requested') => {\n intentionalClose = true\n if (reconnectTimer) {\n clearTimeout(reconnectTimer)\n reconnectTimer = null\n }\n try {\n ws?.close(code, reason)\n } catch {\n // already closed\n }\n },\n readyState: () => ws?.readyState ?? READYSTATE_CLOSED,\n }\n}\n\nexport type ReconnectingWebSocket = ReturnType<typeof createReconnectingWebSocket>\n","// Wire-protocol types + stateless transcript reducer.\n//\n// Shared between the browser and node bundles. The protocol surface is\n// matched (where it makes sense) to @craftedxp/voice-rn 0.3.x so a\n// consumer can write the same `onTranscript` / `onStateChange` /\n// `onError` / `onEnd` shape for both web and RN clients.\n\nimport type { ClientToolCallFrame } from './clientTools'\n\n// ---------------------------------------------------------------------------\n// State machine\n// ---------------------------------------------------------------------------\n\nexport type CallState =\n | 'idle'\n | 'connecting'\n | 'listening'\n | 'user_speaking'\n | 'agent_speaking'\n | 'ended'\n | 'error'\n\n// ---------------------------------------------------------------------------\n// Transcript model — matches voice-rn TranscriptEntry\n// ---------------------------------------------------------------------------\n\nexport type TranscriptEntry =\n | { id: string; role: 'user'; text: string; committed: boolean }\n | { id: string; role: 'agent'; text: string; interrupted?: boolean }\n | { id: string; role: 'tool'; text: string }\n | { id: string; role: 'system'; text: string }\n\n// ---------------------------------------------------------------------------\n// Stable error code contract — matches voice-rn CallErrorCode where the\n// failure modes overlap. Web-specific codes (mic_denied via getUserMedia\n// rejection, etc.) keep their voice-rn names so cross-platform consumers\n// can write one switch statement.\n// ---------------------------------------------------------------------------\n\nexport type CallErrorCode =\n // Programming errors — surface loudly to the host's developer.\n | 'missing_credentials'\n | 'forbidden'\n // Browser audio failures.\n | 'mic_denied'\n | 'mic_start_failed'\n | 'audio_session_failed'\n // Auth lifecycle.\n | 'token_expired'\n | 'token_invalid'\n | 'unauthorized'\n // Network / connectivity.\n | 'network_unreachable'\n | 'socket_error'\n // Business state.\n | 'payment_required'\n | 'not_found'\n // End-of-call states surfaced via onError (also via onEnd reason='timeout').\n | 'silence_timeout'\n // Catch-all for unexpected server / 5xx / 1011.\n | 'server_error'\n\nexport interface CallError {\n code: CallErrorCode\n message: string\n}\n\n// ---------------------------------------------------------------------------\n// End-of-call signal — matches voice-rn CallEndReason / CallEndEvent\n// ---------------------------------------------------------------------------\n\nexport type CallEndReason = 'agent_ended' | 'user_hangup' | 'timeout' | 'error'\n\nexport interface CallEndEvent {\n reason: CallEndReason\n // Present iff reason === 'error'. Mirrors the code from the most\n // recent onError.\n errorCode?: CallErrorCode\n // Wallclock from start() resolving to the WS close. Useful for billing\n // / \"you spoke for 1m23s\" UIs without forcing the host to track\n // timestamps themselves.\n durationMs: number\n}\n\n// ---------------------------------------------------------------------------\n// Volume meter event (browser bundle only — node bundle leaves volume to\n// the consumer if they're processing PCM themselves).\n// ---------------------------------------------------------------------------\n\nexport interface VolumeEvent {\n // 0-1 RMS over the last ~100ms. Bind to a waveform / level meter.\n input: number\n output: number\n}\n\n// ---------------------------------------------------------------------------\n// Server → client message envelope. Loose typing — the server can add new\n// `type` values without breaking the SDK; unknown types are ignored by\n// the dispatch in handleServerMessage.\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = Record<string, unknown> & { type?: string }\n\n// ---------------------------------------------------------------------------\n// Stateless transcript reducer + state-machine helpers. Both the browser\n// and node clients call into these so the shape of the transcript stays\n// identical across environments.\n// ---------------------------------------------------------------------------\n\nexport interface ProtocolState {\n state: CallState\n transcript: TranscriptEntry[]\n agentBubbleId: string | null\n idCounter: number\n // Reason latched from the server's call_end frame. Read by the\n // surrounding client when the WS finally closes so onEnd fires with the\n // right reason instead of falling back to user_hangup.\n endReason: CallEndReason | null\n}\n\nexport const createProtocolState = (): ProtocolState => ({\n state: 'idle',\n transcript: [],\n agentBubbleId: null,\n idCounter: 0,\n endReason: null,\n})\n\n// Side-effect callbacks the protocol layer fires as it processes server\n// frames. The surrounding client wires these up to event emitters\n// (browser) or to user-supplied callbacks (node).\nexport interface ProtocolCallbacks {\n onState: (next: CallState) => void\n onTranscript: (entries: TranscriptEntry[]) => void\n onError: (err: CallError) => void\n // Fires on `interrupt` — caller should flush its audio playback queue.\n onInterrupt: () => void\n // Fires on `agent_turn_start` — caller may want to reset its turn\n // anchor for \"agent has been speaking N ms\" UIs.\n onAgentTurnStart: () => void\n // Fires on `call_end` — caller closes its WS and resolves onEnd.\n onCallEnd: (reason: CallEndReason) => void\n // Fires on `connected` — caller should send the client_tools_register\n // frame here so the server's first-turn grace window unblocks.\n onConnected: () => void\n // Fires on `client_tool_call` — caller dispatches to the matching\n // tool handler and posts back a client_tool_result frame.\n onClientToolCall: (frame: ClientToolCallFrame) => void\n}\n\n// Map server-supplied endReason strings onto our SDK-side CallEndReason.\nconst mapEndReason = (raw: string): CallEndReason => {\n if (raw === 'agent_ended') return 'agent_ended'\n if (raw === 'caller_hung_up') return 'user_hangup'\n if (raw === 'silence_timeout' || raw === 'max_duration') return 'timeout'\n return 'error'\n}\n\n// Pure-ish transcript reducer + dispatcher. Mutates `state` in place to\n// match the imperative pattern used by both clients; returns nothing.\n//\n// The \"agent_text\" / \"transcript\" interim handling is identical to\n// voice-rn's useVoiceCall — one growing user bubble while interim, one\n// growing agent bubble per turn.\nexport function handleServerMessage(\n raw: string,\n state: ProtocolState,\n cb: ProtocolCallbacks,\n): void {\n let msg: ServerMessage\n try {\n msg = JSON.parse(raw)\n } catch {\n return\n }\n\n switch (msg.type) {\n case 'connected':\n cb.onConnected()\n setState(state, 'listening', cb)\n return\n\n case 'transcript': {\n const text = (msg.text as string) ?? ''\n if (!text) return\n const isFinal = !!msg.isFinal\n if (!isFinal) setState(state, 'user_speaking', cb)\n upsertUserPartial(state, text, isFinal)\n cb.onTranscript(state.transcript)\n return\n }\n\n case 'agent_turn_start': {\n const id = `m${state.idCounter++}`\n state.agentBubbleId = id\n state.transcript = [...state.transcript, { id, role: 'agent', text: '' }]\n cb.onTranscript(state.transcript)\n cb.onAgentTurnStart()\n setState(state, 'agent_speaking', cb)\n return\n }\n\n case 'agent_text': {\n const delta = (msg.text as string) ?? ''\n if (!delta || !state.agentBubbleId) return\n const id = state.agentBubbleId\n state.transcript = state.transcript.map((e) =>\n e.id === id && e.role === 'agent' ? { ...e, text: e.text + delta } : e,\n )\n cb.onTranscript(state.transcript)\n return\n }\n\n case 'agent_turn_end':\n state.agentBubbleId = null\n setState(state, 'listening', cb)\n return\n\n case 'interrupt':\n cb.onInterrupt()\n return\n\n case 'agent_turn_abort': {\n const committed = ((msg.committedText as string) ?? '').trim()\n if (state.agentBubbleId) {\n const id = state.agentBubbleId\n if (committed) {\n state.transcript = state.transcript.map((e) =>\n e.id === id && e.role === 'agent' ? { ...e, text: committed, interrupted: true } : e,\n )\n } else {\n state.transcript = state.transcript.filter((e) => e.id !== id)\n }\n cb.onTranscript(state.transcript)\n }\n state.agentBubbleId = null\n return\n }\n\n case 'tool_call':\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'tool',\n text: `→ ${String(msg.tool ?? '?')}(${msg.args ? JSON.stringify(msg.args) : ''})`,\n },\n ]\n cb.onTranscript(state.transcript)\n return\n\n case 'tool_result':\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'tool',\n text: `${msg.ok ? '✓' : '✗'} ${String(msg.tool ?? '?')}`,\n },\n ]\n cb.onTranscript(state.transcript)\n return\n\n case 'client_tool_call': {\n const toolCallId = String(msg.toolCallId ?? '')\n const name = String(msg.name ?? '')\n const args = (msg.args as Record<string, unknown>) ?? {}\n if (!toolCallId || !name) return\n cb.onClientToolCall({ toolCallId, name, args })\n return\n }\n\n case 'call_end': {\n const reasonRaw = String(msg.reason ?? '')\n const reason = mapEndReason(reasonRaw)\n state.endReason = reason\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'system',\n text: `call ended${reasonRaw ? ` (${reasonRaw})` : ''}`,\n },\n ]\n cb.onTranscript(state.transcript)\n cb.onCallEnd(reason)\n return\n }\n\n case 'error': {\n const code = (msg.code as CallErrorCode) ?? 'server_error'\n const message = (msg.message as string) ?? 'server error'\n cb.onError({ code, message })\n return\n }\n }\n}\n\nconst setState = (state: ProtocolState, next: CallState, cb: ProtocolCallbacks) => {\n if (state.state === next) return\n state.state = next\n cb.onState(next)\n}\n\n// Find the last uncommitted user bubble and grow it; or append a new\n// uncommitted bubble. Mirrors voice-rn upsertUserPartial.\nconst upsertUserPartial = (state: ProtocolState, text: string, isFinal: boolean) => {\n let idx = -1\n for (let i = state.transcript.length - 1; i >= 0; i--) {\n const e = state.transcript[i]\n if (e.role === 'user' && e.committed === false) {\n idx = i\n break\n }\n }\n if (idx === -1) {\n state.transcript = [\n ...state.transcript,\n { id: `m${state.idCounter++}`, role: 'user', text, committed: isFinal },\n ]\n return\n }\n const target = state.transcript[idx] as Extract<TranscriptEntry, { role: 'user' }>\n const next = [...state.transcript]\n next[idx] = { ...target, text, committed: isFinal }\n state.transcript = next\n}\n\n// ---------------------------------------------------------------------------\n// WebSocket URL builder. Identical between browser + node bundles —\n// kept here so the rare server-side change to the path / query shape is\n// a one-line edit.\n// ---------------------------------------------------------------------------\n\nexport interface BuildWsUrlArgs {\n apiBase: string\n agentId: string\n token: string\n bargeIn?: boolean\n}\n\nexport function buildWsUrl(args: BuildWsUrlArgs): string {\n const base = new URL(args.apiBase)\n const proto = base.protocol === 'https:' ? 'wss:' : 'ws:'\n const bargeQS = args.bargeIn === false ? '&barge=off' : ''\n return `${proto}//${base.host}/v1/agents/${encodeURIComponent(args.agentId)}/call?token=${encodeURIComponent(args.token)}${bargeQS}`\n}\n","// Bundle-agnostic client-tools core for @craftedxp/voice-js.\n//\n// Imports neither `ws` nor DOM `WebSocket` — the dispatcher takes an\n// injected `send` callback so the same code runs in browsers and Node.\n//\n// Wire protocol (matches the server's Zod schemas in\n// server/src/agents/clientToolFrames.ts):\n//\n// client → server client_tools_register { type, tools[] }\n// server → client client_tool_call { type, toolCallId, name, args }\n// client → server client_tool_result { type, toolCallId, result?, error? }\n\nexport interface ClientTool {\n description: string\n parameters: Record<string, unknown> // JSON Schema\n usage?: string // ≤500 chars; appended to agent system prompt\n timeoutMs?: number // server-enforced; (0, 30_000]\n example?: string // surface-only UI hint; not sent on wire\n handler: (args: Record<string, unknown>) => Promise<string | object> | string | object\n}\n\nexport type ClientToolMap = Record<string, ClientTool>\n\n// Server → client frame shape. Used by both protocol.ts (when surfacing\n// the frame to a callback) and dispatchClientToolCall (when consuming it).\nexport interface ClientToolCallFrame {\n toolCallId: string\n name: string\n args: Record<string, unknown>\n}\n\nconst NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/\nconst MAX_TOOLS = 64\nconst MAX_USAGE = 500\nconst MAX_TIMEOUT_MS = 30_000\n\n// Synchronous, throw-on-error validation. Called once at startCall time\n// before the WS opens, so bad input fails fast at the configuration\n// boundary instead of mid-call.\nexport const validateClientToolMap = (tools: ClientToolMap | undefined): void => {\n if (tools === undefined) return\n if (typeof tools !== 'object' || tools === null || Array.isArray(tools)) {\n throw new Error('clientTools must be an object keyed by tool name')\n }\n const entries = Object.entries(tools)\n if (entries.length > MAX_TOOLS) {\n throw new Error(`clientTools may declare at most 64 tools (got ${entries.length})`)\n }\n for (const [name, def] of entries) {\n if (!NAME_RE.test(name)) {\n throw new Error(\n `clientTools[\"${name}\"]: name must be a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$)`,\n )\n }\n if (!def || typeof def !== 'object') {\n throw new Error(`clientTools[\"${name}\"]: must be an object`)\n }\n if (typeof def.description !== 'string' || def.description.length === 0) {\n throw new Error(`clientTools[\"${name}\"]: must have a description`)\n }\n if (typeof def.handler !== 'function') {\n throw new Error(`clientTools[\"${name}\"]: must have a handler function`)\n }\n if (def.usage !== undefined && def.usage.length > MAX_USAGE) {\n throw new Error(`clientTools[\"${name}\"]: usage must be ≤500 chars`)\n }\n if (\n def.timeoutMs !== undefined &&\n (!Number.isFinite(def.timeoutMs) || def.timeoutMs <= 0 || def.timeoutMs > MAX_TIMEOUT_MS)\n ) {\n throw new Error(`clientTools[\"${name}\"]: timeoutMs must be in (0, 30000]`)\n }\n }\n}\n\n// Build the registration frame the SDK sends right after `connected`.\n// Strips runtime-only fields (handler, example) and only includes the\n// optional fields when set.\nexport const buildRegisterFrame = (tools: ClientToolMap): object => ({\n type: 'client_tools_register',\n tools: Object.entries(tools).map(([name, def]) => ({\n name,\n description: def.description,\n parameters: def.parameters,\n ...(def.usage !== undefined ? { usage: def.usage } : {}),\n ...(def.timeoutMs !== undefined ? { timeoutMs: def.timeoutMs } : {}),\n })),\n})\n\ntype FrameSender = (frame: object) => void\n\n// Run the matching handler asynchronously and post a result/error frame\n// back through `send`. Send-side throws are swallowed (the WS may have\n// closed mid-call; the server has already cancelled the in-flight\n// invocation on its side).\nexport const dispatchClientToolCall = (\n send: FrameSender,\n tools: ClientToolMap,\n frame: ClientToolCallFrame,\n): void => {\n const safeSend = (payload: object) => {\n try {\n send(payload)\n } catch {\n // WS likely closed mid-handler. Server-side already cancelled.\n }\n }\n\n const tool = tools[frame.name]\n if (!tool) {\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n error: `No handler for ${frame.name}`,\n })\n return\n }\n\n void (async () => {\n try {\n const out = await tool.handler(frame.args)\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n result: typeof out === 'string' ? out : JSON.stringify(out),\n })\n } catch (err) {\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n })()\n}\n","// Browser VoiceClient — drives one in-progress call.\n//\n// Created by `BrowserVoiceFactory.startCall` (see browser.ts). Most\n// consumers never touch this class directly — they receive a `Call`\n// handle that exposes `end`/`mute`/`unmute` and subscribe to events\n// via the `onState` / `onTranscript` / `onError` / `onEnd` callbacks\n// they passed to `startCall`.\n//\n// This is the rough equivalent of @craftedxp/voice-rn's `useVoiceCall`\n// hook, but environment-agnostic (no React).\n\nimport { createAudioCapture, type CaptureController } from './AudioCapture'\nimport { createAudioPlayback, type PlaybackController } from './AudioPlayback'\nimport {\n createReconnectingWebSocket,\n type ReconnectingWebSocket,\n type WebSocketFactory,\n} from './ReconnectingWebSocket'\nimport {\n buildWsUrl,\n createProtocolState,\n handleServerMessage,\n type CallEndReason,\n type CallError,\n type ProtocolState,\n} from './protocol'\nimport type { Call, StartCallOptions, VoiceClientConfig } from './config'\nimport { buildRegisterFrame, dispatchClientToolCall, validateClientToolMap } from './clientTools'\n\ninterface VoiceClientArgs {\n // Pre-resolved by the factory.\n config: VoiceClientConfig\n options: StartCallOptions\n token: string\n // Browser uses native WebSocket; node injects `ws`.\n wsFactory: WebSocketFactory\n}\n\nexport class BrowserVoiceClient implements Call {\n private readonly args: VoiceClientArgs\n private readonly proto: ProtocolState\n\n private rws: ReconnectingWebSocket | null = null\n private capture: CaptureController | null = null\n private playback: PlaybackController | null = null\n\n private muted = false\n private inputVolume = 0\n private outputVolume = 0\n private startedAt: number | null = null\n private endedFired = false\n private lastError: CallError | null = null\n\n constructor(args: VoiceClientArgs) {\n this.args = args\n this.proto = createProtocolState()\n validateClientToolMap(args.options.clientTools)\n }\n\n // ---------------------------------------------------------------\n // Call interface\n // ---------------------------------------------------------------\n\n get state() {\n return this.proto.state\n }\n\n get transcript() {\n return this.proto.transcript.slice()\n }\n\n get isMuted() {\n return this.muted\n }\n\n end = () => {\n this.teardown('user_hangup')\n }\n\n mute = () => {\n if (this.muted) return\n this.muted = true\n this.capture?.mute(true)\n }\n\n unmute = () => {\n if (!this.muted) return\n this.muted = false\n this.capture?.mute(false)\n }\n\n // ---------------------------------------------------------------\n // Lifecycle — called by the factory immediately after construction.\n // Resolves once the WS is open and capture is starting; mid-call\n // failures arrive via `onError`.\n // ---------------------------------------------------------------\n\n async start(): Promise<void> {\n this.setState('connecting')\n this.startedAt = Date.now()\n\n const url = buildWsUrl({\n apiBase: this.args.config.apiBase,\n agentId: this.args.options.agentId,\n token: this.args.token,\n bargeIn: this.args.options.bargeIn,\n })\n\n // Playback context needs a user gesture before its AudioContext can\n // produce sound. In practice startCall is invoked inside a click\n // handler; resume() is cheap and idempotent.\n this.playback = createAudioPlayback({\n onVolume: (v) => {\n this.outputVolume = v\n this.args.options.onVolume?.({ input: this.inputVolume, output: v })\n },\n })\n try {\n await this.playback.resume()\n } catch {\n // AudioContext may refuse if we weren't called from a gesture.\n // First enqueue() will retry the resume.\n }\n\n this.rws = createReconnectingWebSocket(\n {\n url,\n wsFactory: this.args.wsFactory,\n maxRetries: 3,\n },\n (ev) => this.handleSocketEvent(ev),\n )\n }\n\n // ---------------------------------------------------------------\n // Internal\n // ---------------------------------------------------------------\n\n private sendClientToolsRegister = () => {\n const frame = buildRegisterFrame(this.args.options.clientTools ?? {})\n this.rws?.send(JSON.stringify(frame))\n }\n\n private setState = (next: ProtocolState['state']) => {\n if (this.proto.state === next) return\n this.proto.state = next\n this.args.options.onStateChange?.(next)\n }\n\n private emitError = (err: CallError) => {\n this.lastError = err\n this.args.options.onError?.(err)\n }\n\n private handleSocketEvent = (\n ev:\n | { type: 'open' }\n | { type: 'reconnected' }\n | { type: 'message'; data: string | ArrayBuffer }\n | { type: 'close'; code: number; reason: string; permanent: boolean }\n | { type: 'error'; error: Error },\n ) => {\n switch (ev.type) {\n case 'open':\n void this.startCapture()\n break\n case 'reconnected':\n // Treat as a fresh call — server session is gone, no resume.\n this.proto.transcript = []\n this.proto.agentBubbleId = null\n this.args.options.onTranscript?.(this.proto.transcript)\n void this.startCapture()\n this.setState('listening')\n break\n case 'message':\n if (typeof ev.data === 'string') {\n handleServerMessage(ev.data, this.proto, {\n onState: this.setState,\n onTranscript: (entries) => this.args.options.onTranscript?.(entries),\n onError: this.emitError,\n onInterrupt: () => {\n this.playback?.flush()\n this.args.options.onInterrupt?.()\n },\n onAgentTurnStart: () => this.args.options.onAgentTurnStart?.(),\n onCallEnd: (reason) => this.teardown(reason),\n onConnected: () => this.sendClientToolsRegister(),\n onClientToolCall: (frame) =>\n dispatchClientToolCall(\n (f) => this.rws?.send(JSON.stringify(f)),\n this.args.options.clientTools ?? {},\n frame,\n ),\n })\n } else {\n this.playback?.enqueue(ev.data)\n }\n break\n case 'close':\n if (ev.permanent) {\n // Pick the most informative reason: server-supplied wins, then\n // last error, then default to user_hangup.\n const reason: CallEndReason =\n this.proto.endReason ?? (this.lastError ? 'error' : 'user_hangup')\n this.teardown(reason)\n }\n break\n case 'error':\n this.emitError({ code: 'socket_error', message: ev.error.message })\n break\n }\n }\n\n private startCapture = async () => {\n if (this.capture?.isCapturing()) return\n this.capture = createAudioCapture({\n onChunk: (pcm) => {\n this.rws?.send(pcm)\n },\n onVolume: (v) => {\n this.inputVolume = v\n this.args.options.onVolume?.({ input: v, output: this.outputVolume })\n },\n onError: (err) => {\n this.emitError({\n code: err.name === 'NotAllowedError' ? 'mic_denied' : 'mic_start_failed',\n message: err.message,\n })\n },\n })\n if (this.muted) this.capture.mute(true)\n try {\n await this.capture.start()\n } catch {\n // onError already emitted inside createAudioCapture.\n }\n }\n\n private teardown = (reason: CallEndReason) => {\n this.capture?.stop()\n this.capture = null\n this.playback?.close()\n this.playback = null\n try {\n this.rws?.close(1000, reason)\n } catch {\n // already closed\n }\n this.rws = null\n this.setState('ended')\n this.fireEndOnce(reason)\n }\n\n private fireEndOnce = (reason: CallEndReason) => {\n if (this.endedFired) return\n this.endedFired = true\n const startedAt = this.startedAt ?? Date.now()\n this.args.options.onEnd?.({\n reason,\n errorCode: reason === 'error' ? this.lastError?.code : undefined,\n durationMs: Date.now() - startedAt,\n })\n }\n}\n","// Browser entry — `import { configureVoiceClient } from '@craftedxp/voice-js'`\n//\n// Pre-injects:\n// - native `WebSocket` (globalThis.WebSocket)\n// - browser audio adapter (AudioContext + AudioWorklet + getUserMedia)\n//\n// Electron renderer + browser tabs both pick this entry via the\n// `browser` condition in package.json `exports`.\n\nimport {\n mergeStartCallContext,\n normalizeConfig,\n type Call,\n type StartCallOptions,\n type VoiceClientConfig,\n type VoiceClientFactory,\n} from './config'\nimport type { WebSocketFactory, WebSocketLike } from './ReconnectingWebSocket'\nimport { BrowserVoiceClient } from './VoiceClient'\n\n// Adapter from native browser WebSocket → our WebSocketLike contract.\n// One-line wrapper; the shapes already match.\nconst browserWsFactory: WebSocketFactory = (url) =>\n new (globalThis as unknown as { WebSocket: new (u: string) => WebSocketLike }).WebSocket(url)\n\nclass BrowserVoiceFactory implements VoiceClientFactory {\n readonly config: VoiceClientConfig\n\n constructor(config: VoiceClientConfig) {\n this.config = config\n }\n\n startCall = async (options: StartCallOptions): Promise<Call> => {\n if (!options.agentId) {\n throw new Error('startCall: agentId is required')\n }\n\n const { context, metadata } = mergeStartCallContext(this.config, options)\n const fetchArgs = {\n agentId: options.agentId,\n userId: options.userId,\n context,\n metadata,\n }\n\n // Test escape hatch — caller passed a pre-minted token, skip mint.\n let token: string\n if (options.token) {\n token = options.token\n } else {\n token = await this.config.fetchToken(fetchArgs)\n if (!token) {\n throw new Error('configureVoiceClient.fetchToken returned empty token')\n }\n }\n\n const client = new BrowserVoiceClient({\n config: this.config,\n // Carry merged context/metadata through to startCall so server can\n // see what the SDK saw.\n options: { ...options, context, metadata },\n token,\n wsFactory: browserWsFactory,\n })\n await client.start()\n return client\n }\n}\n\n/**\n * One-time SDK setup. Returns a factory you call `startCall` on for\n * every voice call.\n *\n * Example:\n * const voice = configureVoiceClient({\n * apiBase: 'https://api.your-server.com',\n * fetchToken: async ({ agentId }) => {\n * const r = await fetch('/api/voice-token', {\n * method: 'POST',\n * body: JSON.stringify({ agentId }),\n * })\n * return (await r.json()).token\n * },\n * })\n *\n * // Per call (typically inside a click handler):\n * const call = await voice.startCall({\n * agentId: 'agt_xxx',\n * onTranscript: (entries) => render(entries),\n * onEnd: ({ reason }) => log(reason),\n * })\n * call.mute()\n * call.end()\n */\nexport function configureVoiceClient(config: VoiceClientConfig): VoiceClientFactory {\n return new BrowserVoiceFactory(normalizeConfig(config))\n}\n\n// ---------------------------------------------------------------------------\n// Public re-exports — types + advanced primitives.\n// ---------------------------------------------------------------------------\n\nexport type {\n Call,\n ClientTool,\n ClientToolMap,\n FetchToken,\n FetchTokenArgs,\n StartCallOptions,\n VoiceClientConfig,\n VoiceClientFactory,\n} from './config'\n\nexport type {\n CallEndEvent,\n CallEndReason,\n CallError,\n CallErrorCode,\n CallState,\n TranscriptEntry,\n VolumeEvent,\n} from './protocol'\n\n// Low-level primitives — exposed for advanced consumers who want to\n// drive just the mic, just the playback, or build a custom transport on\n// top of the protocol decoder. Most users should stick to\n// `configureVoiceClient().startCall()`.\nexport { createAudioCapture } from './AudioCapture'\nexport type { CaptureController, CaptureOptions, OnChunk, OnError, OnVolume } from './AudioCapture'\n\nexport { createAudioPlayback } from './AudioPlayback'\nexport type { PlaybackController, PlaybackOptions, OnAgentSpeakingChange } from './AudioPlayback'\n\nexport { createReconnectingWebSocket } from './ReconnectingWebSocket'\nexport type {\n ReconnectingWebSocket,\n RWSEvent,\n RWSOptions,\n WebSocketFactory,\n WebSocketLike,\n} from './ReconnectingWebSocket'\n\nexport { handleServerMessage, createProtocolState, buildWsUrl } from './protocol'\nexport type { ProtocolState, ProtocolCallbacks, ServerMessage } from './protocol'\n"],"mappings":"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","names":["VOLUME_INTERVAL_MS"]}
1
+ {"version":3,"sources":["../src/config.ts","../src/worklets/mic-downsampler.worklet.js","../src/AudioCapture.ts","../src/AudioPlayback.ts","../src/ReconnectingWebSocket.ts","../src/protocol.ts","../src/clientTools.ts","../src/VoiceClient.ts","../src/browser.ts"],"sourcesContent":["// Public configuration surface.\n//\n// `configureVoiceClient` returns a small factory that knows how to mint\n// `ct_` tokens via your callback. Per-call you call `factory.startCall`.\n//\n// Parity with @craftedxp/voice-rn:\n// voice-rn calls `configureVoiceClient` once at app startup as a\n// side-effect (singleton), because in RN there is exactly one host\n// app and one running `<AgentCall>` at a time. In JS environments\n// the same process can drive multiple clients (multi-tenant\n// dashboards, terminal multiplexers, electron apps with several\n// panels), so the JS SDK returns the factory rather than mutating a\n// module-level singleton. Same option names, same callback shape.\n//\n// Auth model:\n// - `apiKey` is INTENTIONALLY ABSENT from the public surface.\n// Pre-0.2 had it; baking an `sk_` into a JS bundle ships your\n// server-grade credentials to every client. The right pattern is\n// `fetchToken` — your code asks YOUR backend for a short-lived\n// `ct_` and your backend uses its `sk_` (via @craftedxp/sdk-node)\n// to mint it.\n// - For tests + local prototypes a bare `token` may be passed to\n// `startCall` directly, bypassing `fetchToken`. Don't ship that to\n// production — `ct_` lifetimes are short and you want the SDK to\n// re-mint on expiry.\n\nimport type { CallEndEvent, CallError, CallState, TranscriptEntry, VolumeEvent } from './protocol'\nimport type { ClientToolMap } from './clientTools'\n\n// ---------------------------------------------------------------------------\n// fetchToken contract — matches voice-rn FetchTokenArgs verbatim.\n// ---------------------------------------------------------------------------\n\nexport interface FetchTokenArgs {\n /** The agent the SDK is about to call. */\n agentId: string\n /**\n * Optional consumer-side user identifier. Round-tripped to the server\n * as `contactId` for Phase 11 contact memory. The SDK does not\n * inspect this; your backend uses it to scope the token mint.\n */\n userId?: string\n /**\n * Per-call structured context lowered into the agent's effective\n * system prompt server-side at session open. Opaque to the SDK.\n */\n context?: Record<string, unknown>\n /**\n * String key/value pairs round-tripped on the `call.ended` webhook.\n * Capped at 1 KB total server-side. NOT lowered into the system prompt.\n */\n metadata?: Record<string, string>\n}\n\nexport type FetchToken = (args: FetchTokenArgs) => Promise<string>\n\n// ---------------------------------------------------------------------------\n// Factory configuration\n// ---------------------------------------------------------------------------\n\nexport interface VoiceClientConfig {\n /**\n * Full HTTPS URL of the Voxline server. The WebSocket scheme is\n * derived: `https` → `wss`, `http` → `ws`. No trailing slash needed.\n */\n apiBase: string\n /**\n * Called by the SDK whenever it needs a fresh `ct_` token (initial\n * connect; mid-call refresh on `token_expired`). Your implementation\n * should hit YOUR backend, which holds the `sk_` API key and mints\n * via `POST /v1/call-tokens` (or `client.callTokens.mint` from\n * @craftedxp/sdk-node). Never embed `sk_` in JS code that ships to a\n * client.\n */\n fetchToken: FetchToken\n /**\n * Optional metadata applied to EVERY startCall. Per-call `metadata`\n * in `startCall` is merged on top (per-call wins on key conflicts).\n * Useful for dashboard-wide tags like `{ surface: 'web', appVersion }`.\n */\n defaultMetadata?: Record<string, string>\n /**\n * Optional context applied to EVERY startCall. Per-call `context` in\n * `startCall` is merged on top. Useful for cross-call invariants like\n * the signed-in user's locale.\n */\n defaultContext?: Record<string, unknown>\n}\n\n// ---------------------------------------------------------------------------\n// Per-call options — startCall({ ... })\n// ---------------------------------------------------------------------------\n\nexport interface StartCallOptions {\n /** The agent to call. */\n agentId: string\n /** Per-call user identifier. Round-tripped to fetchToken as `userId`. */\n userId?: string\n /**\n * Per-call structured context. Merged on top of `defaultContext`\n * configured at factory time.\n */\n context?: Record<string, unknown>\n /**\n * Per-call metadata. Merged on top of `defaultMetadata` configured\n * at factory time.\n */\n metadata?: Record<string, string>\n /**\n * When false, the SDK + server stay full-duplex but barge-in is\n * suppressed. Useful for alarm-style flows where the user shouldn't\n * accidentally interrupt the script. Default true.\n */\n bargeIn?: boolean\n /**\n * Client-side tools the agent's LLM can call mid-conversation. Each\n * tool's handler runs on the consumer's side; result is fed back to\n * the LLM through the existing call WebSocket. Schema and handler\n * colocate. Validated synchronously at startCall — bad input throws.\n *\n * See docs/integration-echocheck.md for the wire protocol and the\n * server-side guarantees.\n */\n clientTools?: ClientToolMap\n /**\n * Test-only escape hatch — pass a pre-minted `ct_` directly and skip\n * the `fetchToken` call. Don't use this in production code: tokens\n * expire and the SDK can't re-mint without the callback.\n */\n token?: string\n\n // Event callbacks — same shape as voice-rn UseVoiceCallOptions.\n onStateChange?: (state: CallState) => void\n onTranscript?: (entries: TranscriptEntry[]) => void\n onError?: (err: CallError) => void\n onEnd?: (end: CallEndEvent) => void\n /** Volume-meter event for VU UIs. ~10 Hz cadence (browser bundle only). */\n onVolume?: (vol: VolumeEvent) => void\n /**\n * Fires when the server signals barge-in (the user started talking\n * mid-agent-turn). The browser bundle automatically flushes its\n * built-in audio playback before this callback runs; the callback is\n * fired regardless. Node / Electron consumers with custom playback\n * should drain their audio queue here so the agent goes silent\n * immediately.\n */\n onInterrupt?: () => void\n /**\n * Fires on `agent_turn_start` — the server has begun a new agent\n * turn. The state-machine transition to `agent_speaking` happens at\n * the same moment via `onStateChange`; use this when you want a\n * precise turn anchor (e.g. \"agent has been speaking for N ms\" UIs)\n * without diffing state.\n */\n onAgentTurnStart?: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Call handle returned from startCall — matches voice-rn\n// VoiceCallController where it makes sense.\n// ---------------------------------------------------------------------------\n\nexport interface Call {\n /** Current state. Snapshot — subscribe via onStateChange for live updates. */\n readonly state: CallState\n /** Full transcript so far. Snapshot — subscribe via onTranscript for live updates. */\n readonly transcript: TranscriptEntry[]\n /** True after `mute()` and before `unmute()`. */\n readonly isMuted: boolean\n /** End the call locally. Closes the WS, stops the mic, fires onEnd. Idempotent. */\n end: () => void\n /** Mute mic frames. Wire stays active so server endpointing doesn't false-positive. Idempotent. */\n mute: () => void\n /** Unmute mic frames. Idempotent. */\n unmute: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Factory contract — what configureVoiceClient returns. The actual\n// implementation differs between browser (audio-equipped) and node (raw\n// PCM) bundles; both satisfy this interface.\n// ---------------------------------------------------------------------------\n\nexport interface VoiceClientFactory {\n /** Read back the resolved config (post trailing-slash normalisation). */\n readonly config: VoiceClientConfig\n /**\n * Open a fresh call. Returns when the WS is open; rejects on\n * pre-flight failure (missing config, fetchToken throw, etc). Mid-\n * call failures arrive via the per-call `onError` callback — they\n * don't reject this promise.\n */\n startCall: (options: StartCallOptions) => Promise<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pre-flight validation. Pulled out so both bundles share the exact\n// same \"missing field\" error messages.\n// ---------------------------------------------------------------------------\n\nexport function normalizeConfig(config: VoiceClientConfig): VoiceClientConfig {\n if (!config) throw new Error('configureVoiceClient: config is required')\n if ('apiKey' in (config as object)) {\n throw new Error(\n 'configureVoiceClient: `apiKey` is no longer supported. Embedding sk_ in JS code ships server-grade credentials to every client. Pass `fetchToken: async ({ agentId }) => { /* call YOUR backend mint */ }` instead — see the @craftedxp/voice-js README for the migration recipe.',\n )\n }\n if (!config.apiBase) {\n throw new Error('configureVoiceClient: apiBase is required')\n }\n if (typeof config.fetchToken !== 'function') {\n throw new Error('configureVoiceClient: fetchToken must be a function')\n }\n return {\n ...config,\n apiBase: config.apiBase.replace(/\\/+$/, ''),\n }\n}\n\n// Merge factory-level defaults with per-call overrides. Per-call wins.\nexport function mergeStartCallContext(\n factory: VoiceClientConfig,\n call: StartCallOptions,\n): { context?: Record<string, unknown>; metadata?: Record<string, string> } {\n const context =\n factory.defaultContext || call.context\n ? { ...(factory.defaultContext ?? {}), ...(call.context ?? {}) }\n : undefined\n const metadata =\n factory.defaultMetadata || call.metadata\n ? { ...(factory.defaultMetadata ?? {}), ...(call.metadata ?? {}) }\n : undefined\n return { context, metadata }\n}\n\nexport type { ClientTool, ClientToolMap } from './clientTools'\n","// AudioWorklet — runs off the main thread in the audio rendering graph.\n//\n// Input: Float32 samples at the AudioContext's native sampleRate (typically\n// 48000 Hz on desktop, 44100 Hz on some iOS devices).\n// Output: 16 kHz mono Int16 PCM, shipped to the main thread via\n// `port.postMessage(ArrayBuffer, [ArrayBuffer])` (transferred, not copied).\n//\n// Why AudioWorklet instead of ScriptProcessorNode: ScriptProcessorNode is\n// deprecated + main-thread-bound, so any JS jank produces audible audio\n// glitches. AudioWorklet's `process()` runs on the audio rendering thread\n// at the graph's block cadence (128 frames by default) and backpressures\n// via returning `true` / `false`.\n//\n// This file is loaded as text (see tsup.config.ts loader) and registered\n// at runtime via `audioWorklet.addModule(blobUrl)`.\n\nclass MicDownsampler extends AudioWorkletProcessor {\n constructor() {\n super()\n // Target sample rate for STT. Matches Deepgram Nova-3 + the platform's\n // server-side SAMPLE_RATE constant in AgentCallHandler.\n this.targetRate = 16000\n // Accumulator for the downsample. We collect incoming samples and emit\n // an Int16 chunk when we've accumulated ~1024 target-rate samples\n // (~64 ms at 16 kHz) — matches the mobile SDK's chunk size so both\n // platforms have the same server-side framing.\n this.outputFrames = 1024\n this.acc = []\n // Running index used for fractional resampling.\n this.readCursor = 0\n }\n\n // `inputs[0][0]` = first channel of first input. 128 Float32 samples per\n // call at the context's sampleRate. Return true = keep processing.\n process(inputs) {\n const input = inputs[0]\n if (!input || input.length === 0) return true\n const channel = input[0]\n if (!channel || channel.length === 0) return true\n\n const ctxRate = sampleRate // global inside AudioWorkletProcessor\n const ratio = ctxRate / this.targetRate\n\n // Simple linear-interp downsample. For 48000 → 16000 that's 3:1, which\n // linear handles fine for voice. Anti-alias filtering would be\n // theoretically better but inaudible for speech.\n for (let i = 0; i < channel.length; i++) {\n this.acc.push(channel[i])\n }\n\n while (this.acc.length - this.readCursor >= ratio * this.outputFrames) {\n const out = new Int16Array(this.outputFrames)\n let readIdx = this.readCursor\n for (let i = 0; i < this.outputFrames; i++) {\n // Linear interp between floor(readIdx) and ceil(readIdx)\n const low = Math.floor(readIdx)\n const high = Math.min(low + 1, this.acc.length - 1)\n const frac = readIdx - low\n const sample = this.acc[low] * (1 - frac) + this.acc[high] * frac\n // Clip + convert to int16\n const clipped = Math.max(-1, Math.min(1, sample))\n out[i] = clipped < 0 ? clipped * 0x8000 : clipped * 0x7fff\n readIdx += ratio\n }\n // Transfer the ArrayBuffer (zero-copy) to the main thread.\n this.port.postMessage(out.buffer, [out.buffer])\n this.readCursor = readIdx\n }\n\n // Garbage-collect the consumed portion of `acc` every so often so it\n // doesn't grow without bound. Leave ~one chunk of headroom.\n if (this.readCursor > ratio * this.outputFrames) {\n this.acc = this.acc.slice(Math.floor(this.readCursor))\n this.readCursor -= Math.floor(this.readCursor)\n }\n\n return true\n }\n}\n\nregisterProcessor('mic-downsampler', MicDownsampler)\n","// Mic capture pipeline:\n// getUserMedia → AudioContext → AudioWorkletNode → Int16 PCM chunks\n//\n// We register the worklet from an inline Blob URL so consumers don't need\n// to host or bundle a separate .js file. The worklet source is loaded as\n// text at build time via tsup's custom loader (see tsup.config.ts).\n\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore — text-loaded by tsup\nimport workletSource from './worklets/mic-downsampler.worklet.js'\n\nexport type OnChunk = (pcm: ArrayBuffer) => void\nexport type OnVolume = (rms01: number) => void\nexport type OnError = (err: Error) => void\n\nexport interface CaptureOptions {\n onChunk: OnChunk\n // Optional — when set we compute input RMS and call this at ~10Hz. Used\n // for volume visualisers in consumer UIs.\n onVolume?: OnVolume\n onError?: OnError\n}\n\nexport interface CaptureController {\n start: () => Promise<void>\n stop: () => void\n mute: (muted: boolean) => void\n isCapturing: () => boolean\n}\n\n// Volume-meter cadence. 100ms = 10Hz updates; smoother than per-chunk\n// (16Hz) without flicker.\nconst VOLUME_INTERVAL_MS = 100\n\nexport const createAudioCapture = (options: CaptureOptions): CaptureController => {\n let audioContext: AudioContext | null = null\n let mediaStream: MediaStream | null = null\n let sourceNode: MediaStreamAudioSourceNode | null = null\n let workletNode: AudioWorkletNode | null = null\n let analyser: AnalyserNode | null = null\n let volumeTimer: ReturnType<typeof setInterval> | null = null\n let muted = false\n let capturing = false\n\n const computeRms = (buf: Float32Array): number => {\n let sum = 0\n for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]\n const rms = Math.sqrt(sum / buf.length)\n // Clamp to 0..1 — RMS of a full-scale signal is 0.707, so boost a bit\n // so \"normal speech\" hits ~0.5 in the visualiser.\n return Math.min(1, rms * 1.8)\n }\n\n const start = async () => {\n if (capturing) return\n try {\n // Browsers only grant getUserMedia on secure contexts (https or\n // localhost). The thrown DOMException is clear enough — we let it\n // propagate with a wrapped error message for clarity.\n mediaStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n // Hand tuning for voice agent use: we want the raw signal so the\n // server-side STT can do its own noise handling. Disable browser\n // AEC/AGC/NR — experimentally they fight with whatever processing\n // the TTS playback path feeds back in over speakers.\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n channelCount: 1,\n },\n })\n\n audioContext = new AudioContext()\n // Most browsers suspend AudioContext until a user gesture. connect()\n // is idempotent; if we were constructed inside a click/touch handler\n // it's already running.\n if (audioContext.state === 'suspended') await audioContext.resume()\n\n // Load + register the worklet from an inline Blob URL. One-time per\n // context; subsequent `new AudioWorkletNode` calls are cheap.\n const blob = new Blob([workletSource], { type: 'application/javascript' })\n const url = URL.createObjectURL(blob)\n try {\n await audioContext.audioWorklet.addModule(url)\n } finally {\n URL.revokeObjectURL(url)\n }\n\n sourceNode = audioContext.createMediaStreamSource(mediaStream)\n workletNode = new AudioWorkletNode(audioContext, 'mic-downsampler')\n\n // Worklet → main thread: Int16 PCM chunks\n workletNode.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {\n if (muted) return\n options.onChunk(event.data)\n }\n\n // Analyser sits in parallel to the worklet for the volume meter. Not\n // strictly necessary — we could compute RMS inside the worklet and\n // post it too — but AnalyserNode avoids extra bridge chatter.\n if (options.onVolume) {\n analyser = audioContext.createAnalyser()\n analyser.fftSize = 256\n sourceNode.connect(analyser)\n const buf = new Float32Array(analyser.fftSize)\n volumeTimer = setInterval(() => {\n if (!analyser) return\n analyser.getFloatTimeDomainData(buf)\n options.onVolume?.(computeRms(buf))\n }, VOLUME_INTERVAL_MS)\n }\n\n sourceNode.connect(workletNode)\n // The worklet doesn't need to feed into the destination — its only\n // job is to emit messages. But the AudioContext graph keeps the\n // worklet running only if it's connected somewhere; connect to a\n // zero-gain node to park it.\n const sink = audioContext.createGain()\n sink.gain.value = 0\n workletNode.connect(sink).connect(audioContext.destination)\n\n capturing = true\n } catch (err) {\n const wrapped =\n err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'capture failed')\n options.onError?.(wrapped)\n throw wrapped\n }\n }\n\n const stop = () => {\n if (!capturing) return\n capturing = false\n if (volumeTimer) {\n clearInterval(volumeTimer)\n volumeTimer = null\n }\n try {\n workletNode?.disconnect()\n analyser?.disconnect()\n sourceNode?.disconnect()\n } catch {\n // already disconnected\n }\n workletNode = null\n analyser = null\n sourceNode = null\n if (mediaStream) {\n for (const track of mediaStream.getTracks()) track.stop()\n mediaStream = null\n }\n if (audioContext && audioContext.state !== 'closed') {\n void audioContext.close().catch(() => undefined)\n }\n audioContext = null\n }\n\n return {\n start,\n stop,\n mute: (v) => {\n muted = v\n },\n isCapturing: () => capturing,\n }\n}\n","// Playback pipeline:\n// WebSocket binary frame (Int16 PCM @ 16 kHz) → AudioBufferSourceNode\n// chained for gapless playback → AudioContext destination.\n//\n// We deliberately don't use MediaSource or a single streaming source —\n// each agent TTS chunk is scheduled as its own short AudioBuffer so we\n// can interrupt (flush the queue) the moment a barge-in lands. One chunk\n// ≈ 20–100ms of audio.\n\nexport type OnVolume = (rms01: number) => void\nexport type OnAgentSpeakingChange = (speaking: boolean) => void\n\nexport interface PlaybackOptions {\n // Sample rate of the incoming PCM — matches AgentCallHandler.SAMPLE_RATE.\n sampleRate?: number\n onVolume?: OnVolume\n onSpeakingChange?: OnAgentSpeakingChange\n}\n\nexport interface PlaybackController {\n enqueue: (pcm: ArrayBuffer) => void\n flush: () => void\n close: () => void\n resume: () => Promise<void>\n}\n\nconst DEFAULT_SAMPLE_RATE = 16_000\nconst VOLUME_INTERVAL_MS = 100\n\nexport const createAudioPlayback = (options: PlaybackOptions = {}): PlaybackController => {\n const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE\n let audioContext: AudioContext | null = null\n let gainNode: GainNode | null = null\n let analyser: AnalyserNode | null = null\n let volumeTimer: ReturnType<typeof setInterval> | null = null\n let nextStartTime = 0\n let scheduledNodes: AudioBufferSourceNode[] = []\n let speaking = false\n\n const ensureContext = async () => {\n if (audioContext) {\n if (audioContext.state === 'suspended') await audioContext.resume()\n return\n }\n // Pass the target sample rate as a hint. Browsers may choose a\n // different rate (48000 is most common); `createBuffer(numChannels,\n // length, sampleRate)` with a different rate than the context will\n // trigger resampling on `start()`, which the browser handles natively.\n audioContext = new AudioContext({ sampleRate })\n gainNode = audioContext.createGain()\n if (options.onVolume) {\n analyser = audioContext.createAnalyser()\n analyser.fftSize = 256\n gainNode.connect(analyser)\n const buf = new Float32Array(analyser.fftSize)\n volumeTimer = setInterval(() => {\n if (!analyser) return\n analyser.getFloatTimeDomainData(buf)\n let sum = 0\n for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]\n const rms = Math.sqrt(sum / buf.length)\n options.onVolume?.(Math.min(1, rms * 1.8))\n }, VOLUME_INTERVAL_MS)\n }\n gainNode.connect(audioContext.destination)\n nextStartTime = audioContext.currentTime\n }\n\n const setSpeaking = (v: boolean) => {\n if (v === speaking) return\n speaking = v\n options.onSpeakingChange?.(v)\n }\n\n const pruneFinished = () => {\n const now = audioContext?.currentTime ?? 0\n scheduledNodes = scheduledNodes.filter((n) => {\n const node = n as AudioBufferSourceNode & { _endsAt?: number }\n return (node._endsAt ?? 0) > now\n })\n if (scheduledNodes.length === 0) setSpeaking(false)\n }\n\n const enqueue = (pcm: ArrayBuffer) => {\n // We don't want to await here — WS onmessage is hot-path, and AudioContext\n // resume is cheap once warmed. `void` the promise and proceed on next\n // tick; if the context isn't ready, the first chunk may land a hair\n // late but subsequent chunks catch up.\n if (!audioContext) {\n void ensureContext().then(() => enqueue(pcm))\n return\n }\n if (!audioContext || !gainNode) return\n\n const int16 = new Int16Array(pcm)\n if (int16.length === 0) return\n\n const audioBuffer = audioContext.createBuffer(1, int16.length, sampleRate)\n const float32 = audioBuffer.getChannelData(0)\n for (let i = 0; i < int16.length; i++) {\n float32[i] = int16[i] / 0x8000\n }\n\n const node = audioContext.createBufferSource()\n node.buffer = audioBuffer\n node.connect(gainNode)\n\n const now = audioContext.currentTime\n // Schedule back-to-back. If we've fallen behind (mic lag, WS gap),\n // re-anchor to now so we don't skew the whole queue further.\n const startAt = Math.max(now, nextStartTime)\n node.start(startAt)\n\n const duration = int16.length / sampleRate\n ;(node as AudioBufferSourceNode & { _endsAt?: number })._endsAt = startAt + duration\n nextStartTime = startAt + duration\n\n scheduledNodes.push(node)\n setSpeaking(true)\n\n // Prune after every enqueue so `speaking` collapses quickly when the\n // queue drains. Cheap O(n) over a short list.\n node.onended = () => pruneFinished()\n }\n\n // Barge-in: stop anything pending, reset the schedule pointer.\n //\n // We can't simply `.stop()` playing sources mid-buffer and expect the\n // AudioContext's destination to be silent immediately — Safari in\n // particular sometimes plays out a few ms of tail. Reliable approach:\n // swap the gainNode out so nothing after this point is audible, then\n // clean up the old one on the next tick.\n const flush = () => {\n if (!audioContext || !gainNode) return\n for (const node of scheduledNodes) {\n try {\n node.stop()\n } catch {\n // already stopped\n }\n }\n scheduledNodes = []\n // Swap gainNode so even stubborn trailing samples on the old node\n // don't reach the speakers.\n gainNode.disconnect()\n gainNode = audioContext.createGain()\n if (analyser) {\n analyser.disconnect()\n gainNode.connect(analyser)\n }\n gainNode.connect(audioContext.destination)\n nextStartTime = audioContext.currentTime\n setSpeaking(false)\n }\n\n const close = () => {\n flush()\n if (volumeTimer) {\n clearInterval(volumeTimer)\n volumeTimer = null\n }\n if (audioContext && audioContext.state !== 'closed') {\n void audioContext.close().catch(() => undefined)\n }\n audioContext = null\n gainNode = null\n analyser = null\n }\n\n const resume = async () => {\n await ensureContext()\n }\n\n return { enqueue, flush, close, resume }\n}\n","// Minimal auto-reconnecting WebSocket wrapper.\n//\n// Scope: transparent reconnection on unexpected drops (network blip,\n// server restart). We deliberately do NOT try to preserve call state\n// across reconnects — the server's AgentCallHandler is session-scoped\n// and won't resume where we left off. If the consumer needs mid-call\n// resilience, that's a server-side resumable-session feature, not a\n// client-side retry.\n//\n// In v1 we reconnect only when `connect()` has been called and\n// `.close(1000, ...)` has NOT been called by the consumer (i.e., we drop\n// because of network, not because the user hung up). On unclean close\n// the consumer gets a fresh connection + a `reconnected` event so they\n// can re-issue any greeting / context.\n//\n// Transport agnostic — accepts a WebSocket-constructor factory so the\n// browser bundle uses native `WebSocket` and the node bundle injects the\n// `ws` package.\n\nexport type RWSEvent =\n | { type: 'open' }\n | { type: 'reconnected' }\n | { type: 'message'; data: string | ArrayBuffer }\n | { type: 'close'; code: number; reason: string; permanent: boolean }\n | { type: 'error'; error: Error }\n\n// Minimal WebSocket-like contract. Both browser `WebSocket` and the\n// `ws` package's WebSocket satisfy it.\nexport interface WebSocketLike {\n binaryType: string\n readyState: number\n onopen: ((ev: unknown) => void) | null\n onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null\n onerror: ((ev: unknown) => void) | null\n onclose: ((ev: { code: number; reason: string }) => void) | null\n send: (data: string | ArrayBuffer | ArrayBufferView) => void\n close: (code?: number, reason?: string) => void\n}\n\nexport type WebSocketFactory = (url: string) => WebSocketLike\n\nexport interface RWSOptions {\n url: string\n // Factory so we can swap browser-native WebSocket for the `ws` package\n // in node. Browser entry pre-fills with a wrapper around globalThis.WebSocket.\n wsFactory: WebSocketFactory\n // Cap the number of auto-reconnect attempts. `0` disables retry entirely\n // (closer to native WebSocket semantics). Default: 3.\n maxRetries?: number\n // Initial backoff; doubles up to maxBackoffMs.\n initialBackoffMs?: number\n maxBackoffMs?: number\n}\n\nconst READYSTATE_OPEN = 1\nconst READYSTATE_CLOSED = 3\n\nexport const createReconnectingWebSocket = (\n options: RWSOptions,\n onEvent: (ev: RWSEvent) => void,\n) => {\n const maxRetries = options.maxRetries ?? 3\n const initialBackoff = options.initialBackoffMs ?? 500\n const maxBackoff = options.maxBackoffMs ?? 8000\n\n let ws: WebSocketLike | null = null\n let intentionalClose = false\n let retries = 0\n let backoff = initialBackoff\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null\n\n const openOnce = () => {\n ws = options.wsFactory(options.url)\n // Binary frames arrive as Int16 PCM from the server. ArrayBuffer is\n // more convenient than Blob (one fewer async read) — we `.arrayBuffer()`\n // a Blob manually only if we receive one unexpectedly.\n ws.binaryType = 'arraybuffer'\n ws.onopen = () => {\n if (retries === 0) onEvent({ type: 'open' })\n else onEvent({ type: 'reconnected' })\n retries = 0\n backoff = initialBackoff\n }\n ws.onmessage = (ev) => {\n onEvent({ type: 'message', data: ev.data as string | ArrayBuffer })\n }\n ws.onerror = () => {\n onEvent({ type: 'error', error: new Error('WebSocket error') })\n }\n ws.onclose = (ev) => {\n ws = null\n const shouldRetry = !intentionalClose && retries < maxRetries\n if (!shouldRetry) {\n onEvent({\n type: 'close',\n code: ev.code,\n reason: ev.reason,\n permanent: true,\n })\n return\n }\n onEvent({\n type: 'close',\n code: ev.code,\n reason: ev.reason,\n permanent: false,\n })\n retries++\n const delay = Math.min(backoff, maxBackoff)\n backoff = Math.min(backoff * 2, maxBackoff)\n reconnectTimer = setTimeout(openOnce, delay)\n }\n }\n\n openOnce()\n\n return {\n send: (data: string | ArrayBuffer | ArrayBufferView) => {\n if (ws && ws.readyState === READYSTATE_OPEN) ws.send(data)\n },\n close: (code = 1000, reason = 'client-requested') => {\n intentionalClose = true\n if (reconnectTimer) {\n clearTimeout(reconnectTimer)\n reconnectTimer = null\n }\n try {\n ws?.close(code, reason)\n } catch {\n // already closed\n }\n },\n readyState: () => ws?.readyState ?? READYSTATE_CLOSED,\n }\n}\n\nexport type ReconnectingWebSocket = ReturnType<typeof createReconnectingWebSocket>\n","// Wire-protocol types + stateless transcript reducer.\n//\n// Shared between the browser and node bundles. The protocol surface is\n// matched (where it makes sense) to @craftedxp/voice-rn 0.3.x so a\n// consumer can write the same `onTranscript` / `onStateChange` /\n// `onError` / `onEnd` shape for both web and RN clients.\n\nimport type { ClientToolCallFrame } from './clientTools'\n\n// ---------------------------------------------------------------------------\n// State machine\n// ---------------------------------------------------------------------------\n\nexport type CallState =\n | 'idle'\n | 'connecting'\n | 'listening'\n | 'user_speaking'\n | 'agent_speaking'\n | 'ended'\n | 'error'\n\n// ---------------------------------------------------------------------------\n// Transcript model — matches voice-rn TranscriptEntry\n// ---------------------------------------------------------------------------\n\nexport type TranscriptEntry =\n | { id: string; role: 'user'; text: string; committed: boolean }\n | { id: string; role: 'agent'; text: string; interrupted?: boolean }\n | { id: string; role: 'tool'; text: string }\n | { id: string; role: 'system'; text: string }\n\n// ---------------------------------------------------------------------------\n// Stable error code contract — matches voice-rn CallErrorCode where the\n// failure modes overlap. Web-specific codes (mic_denied via getUserMedia\n// rejection, etc.) keep their voice-rn names so cross-platform consumers\n// can write one switch statement.\n// ---------------------------------------------------------------------------\n\nexport type CallErrorCode =\n // Programming errors — surface loudly to the host's developer.\n | 'missing_credentials'\n | 'forbidden'\n // Browser audio failures.\n | 'mic_denied'\n | 'mic_start_failed'\n | 'audio_session_failed'\n // Auth lifecycle.\n | 'token_expired'\n | 'token_invalid'\n | 'unauthorized'\n // Network / connectivity.\n | 'network_unreachable'\n | 'socket_error'\n // Business state.\n | 'payment_required'\n | 'not_found'\n // End-of-call states surfaced via onError (also via onEnd reason='timeout').\n | 'silence_timeout'\n // Catch-all for unexpected server / 5xx / 1011.\n | 'server_error'\n\nexport interface CallError {\n code: CallErrorCode\n message: string\n}\n\n// ---------------------------------------------------------------------------\n// End-of-call signal — matches voice-rn CallEndReason / CallEndEvent\n// ---------------------------------------------------------------------------\n\nexport type CallEndReason = 'agent_ended' | 'user_hangup' | 'timeout' | 'error'\n\nexport interface CallEndEvent {\n reason: CallEndReason\n // Present iff reason === 'error'. Mirrors the code from the most\n // recent onError.\n errorCode?: CallErrorCode\n // Wallclock from start() resolving to the WS close. Useful for billing\n // / \"you spoke for 1m23s\" UIs without forcing the host to track\n // timestamps themselves.\n durationMs: number\n}\n\n// ---------------------------------------------------------------------------\n// Volume meter event (browser bundle only — node bundle leaves volume to\n// the consumer if they're processing PCM themselves).\n// ---------------------------------------------------------------------------\n\nexport interface VolumeEvent {\n // 0-1 RMS over the last ~100ms. Bind to a waveform / level meter.\n input: number\n output: number\n}\n\n// ---------------------------------------------------------------------------\n// Server → client message envelope. Loose typing — the server can add new\n// `type` values without breaking the SDK; unknown types are ignored by\n// the dispatch in handleServerMessage.\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = Record<string, unknown> & { type?: string }\n\n// ---------------------------------------------------------------------------\n// Stateless transcript reducer + state-machine helpers. Both the browser\n// and node clients call into these so the shape of the transcript stays\n// identical across environments.\n// ---------------------------------------------------------------------------\n\nexport interface ProtocolState {\n state: CallState\n transcript: TranscriptEntry[]\n agentBubbleId: string | null\n idCounter: number\n // Reason latched from the server's call_end frame. Read by the\n // surrounding client when the WS finally closes so onEnd fires with the\n // right reason instead of falling back to user_hangup.\n endReason: CallEndReason | null\n}\n\nexport const createProtocolState = (): ProtocolState => ({\n state: 'idle',\n transcript: [],\n agentBubbleId: null,\n idCounter: 0,\n endReason: null,\n})\n\n// Side-effect callbacks the protocol layer fires as it processes server\n// frames. The surrounding client wires these up to event emitters\n// (browser) or to user-supplied callbacks (node).\nexport interface ProtocolCallbacks {\n onState: (next: CallState) => void\n onTranscript: (entries: TranscriptEntry[]) => void\n onError: (err: CallError) => void\n // Fires on `interrupt` — caller should flush its audio playback queue.\n onInterrupt: () => void\n // Fires on `agent_turn_start` — caller may want to reset its turn\n // anchor for \"agent has been speaking N ms\" UIs.\n onAgentTurnStart: () => void\n // Fires on `call_end` — caller closes its WS and resolves onEnd.\n onCallEnd: (reason: CallEndReason) => void\n // Fires on `connected` — caller should send the client_tools_register\n // frame here so the server's first-turn grace window unblocks.\n onConnected: () => void\n // Fires on `client_tool_call` — caller dispatches to the matching\n // tool handler and posts back a client_tool_result frame.\n onClientToolCall: (frame: ClientToolCallFrame) => void\n}\n\n// Map server-supplied endReason strings onto our SDK-side CallEndReason.\nconst mapEndReason = (raw: string): CallEndReason => {\n if (raw === 'agent_ended') return 'agent_ended'\n if (raw === 'caller_hung_up') return 'user_hangup'\n if (raw === 'silence_timeout' || raw === 'max_duration') return 'timeout'\n return 'error'\n}\n\n// Pure-ish transcript reducer + dispatcher. Mutates `state` in place to\n// match the imperative pattern used by both clients; returns nothing.\n//\n// The \"agent_text\" / \"transcript\" interim handling is identical to\n// voice-rn's useVoiceCall — one growing user bubble while interim, one\n// growing agent bubble per turn.\nexport function handleServerMessage(\n raw: string,\n state: ProtocolState,\n cb: ProtocolCallbacks,\n): void {\n let msg: ServerMessage\n try {\n msg = JSON.parse(raw)\n } catch {\n return\n }\n\n switch (msg.type) {\n case 'connected':\n cb.onConnected()\n setState(state, 'listening', cb)\n return\n\n case 'transcript': {\n const text = (msg.text as string) ?? ''\n if (!text) return\n const isFinal = !!msg.isFinal\n if (!isFinal) setState(state, 'user_speaking', cb)\n upsertUserPartial(state, text, isFinal)\n cb.onTranscript(state.transcript)\n return\n }\n\n case 'agent_turn_start': {\n const id = `m${state.idCounter++}`\n state.agentBubbleId = id\n state.transcript = [...state.transcript, { id, role: 'agent', text: '' }]\n cb.onTranscript(state.transcript)\n cb.onAgentTurnStart()\n setState(state, 'agent_speaking', cb)\n return\n }\n\n case 'agent_text': {\n const delta = (msg.text as string) ?? ''\n if (!delta || !state.agentBubbleId) return\n const id = state.agentBubbleId\n state.transcript = state.transcript.map((e) =>\n e.id === id && e.role === 'agent' ? { ...e, text: e.text + delta } : e,\n )\n cb.onTranscript(state.transcript)\n return\n }\n\n case 'agent_turn_end':\n state.agentBubbleId = null\n setState(state, 'listening', cb)\n return\n\n case 'interrupt':\n cb.onInterrupt()\n return\n\n case 'agent_turn_abort': {\n const committed = ((msg.committedText as string) ?? '').trim()\n if (state.agentBubbleId) {\n const id = state.agentBubbleId\n if (committed) {\n state.transcript = state.transcript.map((e) =>\n e.id === id && e.role === 'agent' ? { ...e, text: committed, interrupted: true } : e,\n )\n } else {\n state.transcript = state.transcript.filter((e) => e.id !== id)\n }\n cb.onTranscript(state.transcript)\n }\n state.agentBubbleId = null\n return\n }\n\n case 'tool_call':\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'tool',\n text: `→ ${String(msg.tool ?? '?')}(${msg.args ? JSON.stringify(msg.args) : ''})`,\n },\n ]\n cb.onTranscript(state.transcript)\n return\n\n case 'tool_result':\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'tool',\n text: `${msg.ok ? '✓' : '✗'} ${String(msg.tool ?? '?')}`,\n },\n ]\n cb.onTranscript(state.transcript)\n return\n\n case 'client_tool_call': {\n const toolCallId = String(msg.toolCallId ?? '')\n const name = String(msg.name ?? '')\n const args = (msg.args as Record<string, unknown>) ?? {}\n if (!toolCallId || !name) return\n cb.onClientToolCall({ toolCallId, name, args })\n return\n }\n\n case 'call_end': {\n const reasonRaw = String(msg.reason ?? '')\n const reason = mapEndReason(reasonRaw)\n state.endReason = reason\n state.transcript = [\n ...state.transcript,\n {\n id: `m${state.idCounter++}`,\n role: 'system',\n text: `call ended${reasonRaw ? ` (${reasonRaw})` : ''}`,\n },\n ]\n cb.onTranscript(state.transcript)\n cb.onCallEnd(reason)\n return\n }\n\n case 'error': {\n const code = (msg.code as CallErrorCode) ?? 'server_error'\n const message = (msg.message as string) ?? 'server error'\n cb.onError({ code, message })\n return\n }\n }\n}\n\nconst setState = (state: ProtocolState, next: CallState, cb: ProtocolCallbacks) => {\n if (state.state === next) return\n // The consumer's setState (cb.onState) is responsible for both\n // updating state.state and firing the user's onStateChange callback.\n // We deliberately don't mutate here — if we did, the consumer's\n // setState would early-return on the dedup check and never fire\n // onStateChange for server-driven transitions.\n cb.onState(next)\n}\n\n// Find the last uncommitted user bubble and grow it; or append a new\n// uncommitted bubble. Mirrors voice-rn upsertUserPartial.\nconst upsertUserPartial = (state: ProtocolState, text: string, isFinal: boolean) => {\n let idx = -1\n for (let i = state.transcript.length - 1; i >= 0; i--) {\n const e = state.transcript[i]\n if (e.role === 'user' && e.committed === false) {\n idx = i\n break\n }\n }\n if (idx === -1) {\n state.transcript = [\n ...state.transcript,\n { id: `m${state.idCounter++}`, role: 'user', text, committed: isFinal },\n ]\n return\n }\n const target = state.transcript[idx] as Extract<TranscriptEntry, { role: 'user' }>\n const next = [...state.transcript]\n next[idx] = { ...target, text, committed: isFinal }\n state.transcript = next\n}\n\n// ---------------------------------------------------------------------------\n// WebSocket URL builder. Identical between browser + node bundles —\n// kept here so the rare server-side change to the path / query shape is\n// a one-line edit.\n// ---------------------------------------------------------------------------\n\nexport interface BuildWsUrlArgs {\n apiBase: string\n agentId: string\n token: string\n bargeIn?: boolean\n}\n\nexport function buildWsUrl(args: BuildWsUrlArgs): string {\n const base = new URL(args.apiBase)\n const proto = base.protocol === 'https:' ? 'wss:' : 'ws:'\n const bargeQS = args.bargeIn === false ? '&barge=off' : ''\n return `${proto}//${base.host}/v1/agents/${encodeURIComponent(args.agentId)}/call?token=${encodeURIComponent(args.token)}${bargeQS}`\n}\n","// Bundle-agnostic client-tools core for @craftedxp/voice-js.\n//\n// Imports neither `ws` nor DOM `WebSocket` — the dispatcher takes an\n// injected `send` callback so the same code runs in browsers and Node.\n//\n// Wire protocol (matches the server's Zod schemas in\n// server/src/agents/clientToolFrames.ts):\n//\n// client → server client_tools_register { type, tools[] }\n// server → client client_tool_call { type, toolCallId, name, args }\n// client → server client_tool_result { type, toolCallId, result?, error? }\n\nexport interface ClientTool {\n description: string\n parameters: Record<string, unknown> // JSON Schema\n usage?: string // ≤500 chars; appended to agent system prompt\n timeoutMs?: number // server-enforced; (0, 30_000]\n example?: string // surface-only UI hint; not sent on wire\n handler: (args: Record<string, unknown>) => Promise<string | object> | string | object\n}\n\nexport type ClientToolMap = Record<string, ClientTool>\n\n// Server → client frame shape. Used by both protocol.ts (when surfacing\n// the frame to a callback) and dispatchClientToolCall (when consuming it).\nexport interface ClientToolCallFrame {\n toolCallId: string\n name: string\n args: Record<string, unknown>\n}\n\nconst NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/\nconst MAX_TOOLS = 64\nconst MAX_USAGE = 500\nconst MAX_TIMEOUT_MS = 30_000\n\n// Synchronous, throw-on-error validation. Called once at startCall time\n// before the WS opens, so bad input fails fast at the configuration\n// boundary instead of mid-call.\nexport const validateClientToolMap = (tools: ClientToolMap | undefined): void => {\n if (tools === undefined) return\n if (typeof tools !== 'object' || tools === null || Array.isArray(tools)) {\n throw new Error('clientTools must be an object keyed by tool name')\n }\n const entries = Object.entries(tools)\n if (entries.length > MAX_TOOLS) {\n throw new Error(`clientTools may declare at most 64 tools (got ${entries.length})`)\n }\n for (const [name, def] of entries) {\n if (!NAME_RE.test(name)) {\n throw new Error(\n `clientTools[\"${name}\"]: name must be a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$)`,\n )\n }\n if (!def || typeof def !== 'object') {\n throw new Error(`clientTools[\"${name}\"]: must be an object`)\n }\n if (typeof def.description !== 'string' || def.description.length === 0) {\n throw new Error(`clientTools[\"${name}\"]: must have a description`)\n }\n if (typeof def.handler !== 'function') {\n throw new Error(`clientTools[\"${name}\"]: must have a handler function`)\n }\n if (def.usage !== undefined && def.usage.length > MAX_USAGE) {\n throw new Error(`clientTools[\"${name}\"]: usage must be ≤500 chars`)\n }\n if (\n def.timeoutMs !== undefined &&\n (!Number.isFinite(def.timeoutMs) || def.timeoutMs <= 0 || def.timeoutMs > MAX_TIMEOUT_MS)\n ) {\n throw new Error(`clientTools[\"${name}\"]: timeoutMs must be in (0, 30000]`)\n }\n }\n}\n\n// Build the registration frame the SDK sends right after `connected`.\n// Strips runtime-only fields (handler, example) and only includes the\n// optional fields when set.\nexport const buildRegisterFrame = (tools: ClientToolMap): object => ({\n type: 'client_tools_register',\n tools: Object.entries(tools).map(([name, def]) => ({\n name,\n description: def.description,\n parameters: def.parameters,\n ...(def.usage !== undefined ? { usage: def.usage } : {}),\n ...(def.timeoutMs !== undefined ? { timeoutMs: def.timeoutMs } : {}),\n })),\n})\n\ntype FrameSender = (frame: object) => void\n\n// Run the matching handler asynchronously and post a result/error frame\n// back through `send`. Send-side throws are swallowed (the WS may have\n// closed mid-call; the server has already cancelled the in-flight\n// invocation on its side).\nexport const dispatchClientToolCall = (\n send: FrameSender,\n tools: ClientToolMap,\n frame: ClientToolCallFrame,\n): void => {\n const safeSend = (payload: object) => {\n try {\n send(payload)\n } catch {\n // WS likely closed mid-handler. Server-side already cancelled.\n }\n }\n\n const tool = tools[frame.name]\n if (!tool) {\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n error: `No handler for ${frame.name}`,\n })\n return\n }\n\n void (async () => {\n try {\n const out = await tool.handler(frame.args)\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n result: typeof out === 'string' ? out : JSON.stringify(out),\n })\n } catch (err) {\n safeSend({\n type: 'client_tool_result',\n toolCallId: frame.toolCallId,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n })()\n}\n","// Browser VoiceClient — drives one in-progress call.\n//\n// Created by `BrowserVoiceFactory.startCall` (see browser.ts). Most\n// consumers never touch this class directly — they receive a `Call`\n// handle that exposes `end`/`mute`/`unmute` and subscribe to events\n// via the `onState` / `onTranscript` / `onError` / `onEnd` callbacks\n// they passed to `startCall`.\n//\n// This is the rough equivalent of @craftedxp/voice-rn's `useVoiceCall`\n// hook, but environment-agnostic (no React).\n\nimport { createAudioCapture, type CaptureController } from './AudioCapture'\nimport { createAudioPlayback, type PlaybackController } from './AudioPlayback'\nimport {\n createReconnectingWebSocket,\n type ReconnectingWebSocket,\n type WebSocketFactory,\n} from './ReconnectingWebSocket'\nimport {\n buildWsUrl,\n createProtocolState,\n handleServerMessage,\n type CallEndReason,\n type CallError,\n type ProtocolState,\n} from './protocol'\nimport type { Call, StartCallOptions, VoiceClientConfig } from './config'\nimport { buildRegisterFrame, dispatchClientToolCall, validateClientToolMap } from './clientTools'\n\ninterface VoiceClientArgs {\n // Pre-resolved by the factory.\n config: VoiceClientConfig\n options: StartCallOptions\n token: string\n // Browser uses native WebSocket; node injects `ws`.\n wsFactory: WebSocketFactory\n}\n\nexport class BrowserVoiceClient implements Call {\n private readonly args: VoiceClientArgs\n private readonly proto: ProtocolState\n\n private rws: ReconnectingWebSocket | null = null\n private capture: CaptureController | null = null\n private playback: PlaybackController | null = null\n\n private muted = false\n private inputVolume = 0\n private outputVolume = 0\n private startedAt: number | null = null\n private endedFired = false\n private lastError: CallError | null = null\n\n constructor(args: VoiceClientArgs) {\n this.args = args\n this.proto = createProtocolState()\n validateClientToolMap(args.options.clientTools)\n }\n\n // ---------------------------------------------------------------\n // Call interface\n // ---------------------------------------------------------------\n\n get state() {\n return this.proto.state\n }\n\n get transcript() {\n return this.proto.transcript.slice()\n }\n\n get isMuted() {\n return this.muted\n }\n\n end = () => {\n this.teardown('user_hangup')\n }\n\n mute = () => {\n if (this.muted) return\n this.muted = true\n this.capture?.mute(true)\n }\n\n unmute = () => {\n if (!this.muted) return\n this.muted = false\n this.capture?.mute(false)\n }\n\n // ---------------------------------------------------------------\n // Lifecycle — called by the factory immediately after construction.\n // Resolves once the WS is open and capture is starting; mid-call\n // failures arrive via `onError`.\n // ---------------------------------------------------------------\n\n async start(): Promise<void> {\n this.setState('connecting')\n this.startedAt = Date.now()\n\n const url = buildWsUrl({\n apiBase: this.args.config.apiBase,\n agentId: this.args.options.agentId,\n token: this.args.token,\n bargeIn: this.args.options.bargeIn,\n })\n\n // Playback context needs a user gesture before its AudioContext can\n // produce sound. In practice startCall is invoked inside a click\n // handler; resume() is cheap and idempotent.\n this.playback = createAudioPlayback({\n onVolume: (v) => {\n this.outputVolume = v\n this.args.options.onVolume?.({ input: this.inputVolume, output: v })\n },\n })\n try {\n await this.playback.resume()\n } catch {\n // AudioContext may refuse if we weren't called from a gesture.\n // First enqueue() will retry the resume.\n }\n\n this.rws = createReconnectingWebSocket(\n {\n url,\n wsFactory: this.args.wsFactory,\n maxRetries: 3,\n },\n (ev) => this.handleSocketEvent(ev),\n )\n }\n\n // ---------------------------------------------------------------\n // Internal\n // ---------------------------------------------------------------\n\n private sendClientToolsRegister = () => {\n const frame = buildRegisterFrame(this.args.options.clientTools ?? {})\n this.rws?.send(JSON.stringify(frame))\n }\n\n private setState = (next: ProtocolState['state']) => {\n if (this.proto.state === next) return\n this.proto.state = next\n this.args.options.onStateChange?.(next)\n }\n\n private emitError = (err: CallError) => {\n this.lastError = err\n this.args.options.onError?.(err)\n }\n\n private handleSocketEvent = (\n ev:\n | { type: 'open' }\n | { type: 'reconnected' }\n | { type: 'message'; data: string | ArrayBuffer }\n | { type: 'close'; code: number; reason: string; permanent: boolean }\n | { type: 'error'; error: Error },\n ) => {\n switch (ev.type) {\n case 'open':\n void this.startCapture()\n break\n case 'reconnected':\n // Treat as a fresh call — server session is gone, no resume.\n this.proto.transcript = []\n this.proto.agentBubbleId = null\n this.args.options.onTranscript?.(this.proto.transcript)\n void this.startCapture()\n this.setState('listening')\n break\n case 'message':\n if (typeof ev.data === 'string') {\n handleServerMessage(ev.data, this.proto, {\n onState: this.setState,\n onTranscript: (entries) => this.args.options.onTranscript?.(entries),\n onError: this.emitError,\n onInterrupt: () => {\n this.playback?.flush()\n this.args.options.onInterrupt?.()\n },\n onAgentTurnStart: () => this.args.options.onAgentTurnStart?.(),\n onCallEnd: (reason) => this.teardown(reason),\n onConnected: () => this.sendClientToolsRegister(),\n onClientToolCall: (frame) =>\n dispatchClientToolCall(\n (f) => this.rws?.send(JSON.stringify(f)),\n this.args.options.clientTools ?? {},\n frame,\n ),\n })\n } else {\n this.playback?.enqueue(ev.data)\n }\n break\n case 'close':\n if (ev.permanent) {\n // Pick the most informative reason: server-supplied wins, then\n // last error, then default to user_hangup.\n const reason: CallEndReason =\n this.proto.endReason ?? (this.lastError ? 'error' : 'user_hangup')\n this.teardown(reason)\n }\n break\n case 'error':\n this.emitError({ code: 'socket_error', message: ev.error.message })\n break\n }\n }\n\n private startCapture = async () => {\n if (this.capture?.isCapturing()) return\n this.capture = createAudioCapture({\n onChunk: (pcm) => {\n this.rws?.send(pcm)\n },\n onVolume: (v) => {\n this.inputVolume = v\n this.args.options.onVolume?.({ input: v, output: this.outputVolume })\n },\n onError: (err) => {\n this.emitError({\n code: err.name === 'NotAllowedError' ? 'mic_denied' : 'mic_start_failed',\n message: err.message,\n })\n },\n })\n if (this.muted) this.capture.mute(true)\n try {\n await this.capture.start()\n } catch {\n // onError already emitted inside createAudioCapture.\n }\n }\n\n private teardown = (reason: CallEndReason) => {\n this.capture?.stop()\n this.capture = null\n this.playback?.close()\n this.playback = null\n try {\n this.rws?.close(1000, reason)\n } catch {\n // already closed\n }\n this.rws = null\n this.setState('ended')\n this.fireEndOnce(reason)\n }\n\n private fireEndOnce = (reason: CallEndReason) => {\n if (this.endedFired) return\n this.endedFired = true\n const startedAt = this.startedAt ?? Date.now()\n this.args.options.onEnd?.({\n reason,\n errorCode: reason === 'error' ? this.lastError?.code : undefined,\n durationMs: Date.now() - startedAt,\n })\n }\n}\n","// Browser entry — `import { configureVoiceClient } from '@craftedxp/voice-js'`\n//\n// Pre-injects:\n// - native `WebSocket` (globalThis.WebSocket)\n// - browser audio adapter (AudioContext + AudioWorklet + getUserMedia)\n//\n// Electron renderer + browser tabs both pick this entry via the\n// `browser` condition in package.json `exports`.\n\nimport {\n mergeStartCallContext,\n normalizeConfig,\n type Call,\n type StartCallOptions,\n type VoiceClientConfig,\n type VoiceClientFactory,\n} from './config'\nimport type { WebSocketFactory, WebSocketLike } from './ReconnectingWebSocket'\nimport { BrowserVoiceClient } from './VoiceClient'\n\n// Adapter from native browser WebSocket → our WebSocketLike contract.\n// One-line wrapper; the shapes already match.\nconst browserWsFactory: WebSocketFactory = (url) =>\n new (globalThis as unknown as { WebSocket: new (u: string) => WebSocketLike }).WebSocket(url)\n\nclass BrowserVoiceFactory implements VoiceClientFactory {\n readonly config: VoiceClientConfig\n\n constructor(config: VoiceClientConfig) {\n this.config = config\n }\n\n startCall = async (options: StartCallOptions): Promise<Call> => {\n if (!options.agentId) {\n throw new Error('startCall: agentId is required')\n }\n\n const { context, metadata } = mergeStartCallContext(this.config, options)\n const fetchArgs = {\n agentId: options.agentId,\n userId: options.userId,\n context,\n metadata,\n }\n\n // Test escape hatch — caller passed a pre-minted token, skip mint.\n let token: string\n if (options.token) {\n token = options.token\n } else {\n token = await this.config.fetchToken(fetchArgs)\n if (!token) {\n throw new Error('configureVoiceClient.fetchToken returned empty token')\n }\n }\n\n const client = new BrowserVoiceClient({\n config: this.config,\n // Carry merged context/metadata through to startCall so server can\n // see what the SDK saw.\n options: { ...options, context, metadata },\n token,\n wsFactory: browserWsFactory,\n })\n await client.start()\n return client\n }\n}\n\n/**\n * One-time SDK setup. Returns a factory you call `startCall` on for\n * every voice call.\n *\n * Example:\n * const voice = configureVoiceClient({\n * apiBase: 'https://api.your-server.com',\n * fetchToken: async ({ agentId }) => {\n * const r = await fetch('/api/voice-token', {\n * method: 'POST',\n * body: JSON.stringify({ agentId }),\n * })\n * return (await r.json()).token\n * },\n * })\n *\n * // Per call (typically inside a click handler):\n * const call = await voice.startCall({\n * agentId: 'agt_xxx',\n * onTranscript: (entries) => render(entries),\n * onEnd: ({ reason }) => log(reason),\n * })\n * call.mute()\n * call.end()\n */\nexport function configureVoiceClient(config: VoiceClientConfig): VoiceClientFactory {\n return new BrowserVoiceFactory(normalizeConfig(config))\n}\n\n// ---------------------------------------------------------------------------\n// Public re-exports — types + advanced primitives.\n// ---------------------------------------------------------------------------\n\nexport type {\n Call,\n ClientTool,\n ClientToolMap,\n FetchToken,\n FetchTokenArgs,\n StartCallOptions,\n VoiceClientConfig,\n VoiceClientFactory,\n} from './config'\n\nexport type {\n CallEndEvent,\n CallEndReason,\n CallError,\n CallErrorCode,\n CallState,\n TranscriptEntry,\n VolumeEvent,\n} from './protocol'\n\n// Low-level primitives — exposed for advanced consumers who want to\n// drive just the mic, just the playback, or build a custom transport on\n// top of the protocol decoder. Most users should stick to\n// `configureVoiceClient().startCall()`.\nexport { createAudioCapture } from './AudioCapture'\nexport type { CaptureController, CaptureOptions, OnChunk, OnError, OnVolume } from './AudioCapture'\n\nexport { createAudioPlayback } from './AudioPlayback'\nexport type { PlaybackController, PlaybackOptions, OnAgentSpeakingChange } from './AudioPlayback'\n\nexport { createReconnectingWebSocket } from './ReconnectingWebSocket'\nexport type {\n ReconnectingWebSocket,\n RWSEvent,\n RWSOptions,\n WebSocketFactory,\n WebSocketLike,\n} from './ReconnectingWebSocket'\n\nexport { handleServerMessage, createProtocolState, buildWsUrl } from './protocol'\nexport type { ProtocolState, ProtocolCallbacks, ServerMessage } from './protocol'\n"],"mappings":"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","names":["VOLUME_INTERVAL_MS"]}
@@ -79,7 +79,7 @@ class MicDownsampler extends AudioWorkletProcessor {
79
79
  }
80
80
 
81
81
  registerProcessor('mic-downsampler', MicDownsampler)
82
- `;var X=100,W=n=>{let e=null,t=null,r=null,o=null,a=null,i=null,l=!1,d=!1,h=s=>{let p=0;for(let g=0;g<s.length;g++)p+=s[g]*s[g];let y=Math.sqrt(p/s.length);return Math.min(1,y*1.8)};return{start:async()=>{if(!d)try{t=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,channelCount:1}}),e=new AudioContext,e.state==="suspended"&&await e.resume();let s=new Blob([O],{type:"application/javascript"}),p=URL.createObjectURL(s);try{await e.audioWorklet.addModule(p)}finally{URL.revokeObjectURL(p)}if(r=e.createMediaStreamSource(t),o=new AudioWorkletNode(e,"mic-downsampler"),o.port.onmessage=g=>{l||n.onChunk(g.data)},n.onVolume){a=e.createAnalyser(),a.fftSize=256,r.connect(a);let g=new Float32Array(a.fftSize);i=setInterval(()=>{a&&(a.getFloatTimeDomainData(g),n.onVolume?.(h(g)))},X)}r.connect(o);let y=e.createGain();y.gain.value=0,o.connect(y).connect(e.destination),d=!0}catch(s){let p=s instanceof Error?s:new Error(typeof s=="string"?s:"capture failed");throw n.onError?.(p),p}},stop:()=>{if(d){d=!1,i&&(clearInterval(i),i=null);try{o?.disconnect(),a?.disconnect(),r?.disconnect()}catch{}if(o=null,a=null,r=null,t){for(let s of t.getTracks())s.stop();t=null}e&&e.state!=="closed"&&e.close().catch(()=>{}),e=null}},mute:s=>{l=s},isCapturing:()=>d}};var B=(n={})=>{let e=n.sampleRate??16e3,t=null,r=null,o=null,a=null,i=0,l=[],d=!1,h=async()=>{if(t){t.state==="suspended"&&await t.resume();return}if(t=new AudioContext({sampleRate:e}),r=t.createGain(),n.onVolume){o=t.createAnalyser(),o.fftSize=256,r.connect(o);let c=new Float32Array(o.fftSize);a=setInterval(()=>{if(!o)return;o.getFloatTimeDomainData(c);let f=0;for(let C=0;C<c.length;C++)f+=c[C]*c[C];let k=Math.sqrt(f/c.length);n.onVolume?.(Math.min(1,k*1.8))},100)}r.connect(t.destination),i=t.currentTime},m=c=>{c!==d&&(d=c,n.onSpeakingChange?.(c))},u=()=>{let c=t?.currentTime??0;l=l.filter(f=>(f._endsAt??0)>c),l.length===0&&m(!1)},s=c=>{if(!t){h().then(()=>s(c));return}if(!t||!r)return;let f=new Int16Array(c);if(f.length===0)return;let k=t.createBuffer(1,f.length,e),C=k.getChannelData(0);for(let S=0;S<f.length;S++)C[S]=f[S]/32768;let b=t.createBufferSource();b.buffer=k,b.connect(r);let G=t.currentTime,w=Math.max(G,i);b.start(w);let I=f.length/e;b._endsAt=w+I,i=w+I,l.push(b),m(!0),b.onended=()=>u()},p=()=>{if(!(!t||!r)){for(let c of l)try{c.stop()}catch{}l=[],r.disconnect(),r=t.createGain(),o&&(o.disconnect(),r.connect(o)),r.connect(t.destination),i=t.currentTime,m(!1)}};return{enqueue:s,flush:p,close:()=>{p(),a&&(clearInterval(a),a=null),t&&t.state!=="closed"&&t.close().catch(()=>{}),t=null,r=null,o=null},resume:async()=>{await h()}}};var P=(n,e)=>{let t=n.maxRetries??3,r=n.initialBackoffMs??500,o=n.maxBackoffMs??8e3,a=null,i=!1,l=0,d=r,h=null,m=()=>{a=n.wsFactory(n.url),a.binaryType="arraybuffer",a.onopen=()=>{e(l===0?{type:"open"}:{type:"reconnected"}),l=0,d=r},a.onmessage=u=>{e({type:"message",data:u.data})},a.onerror=()=>{e({type:"error",error:new Error("WebSocket error")})},a.onclose=u=>{if(a=null,!(!i&&l<t)){e({type:"close",code:u.code,reason:u.reason,permanent:!0});return}e({type:"close",code:u.code,reason:u.reason,permanent:!1}),l++;let p=Math.min(d,o);d=Math.min(d*2,o),h=setTimeout(m,p)}};return m(),{send:u=>{a&&a.readyState===1&&a.send(u)},close:(u=1e3,s="client-requested")=>{i=!0,h&&(clearTimeout(h),h=null);try{a?.close(u,s)}catch{}},readyState:()=>a?.readyState??3}};var L=()=>({state:"idle",transcript:[],agentBubbleId:null,idCounter:0,endReason:null}),Y=n=>n==="agent_ended"?"agent_ended":n==="caller_hung_up"?"user_hangup":n==="silence_timeout"||n==="max_duration"?"timeout":"error";function N(n,e,t){let r;try{r=JSON.parse(n)}catch{return}switch(r.type){case"connected":t.onConnected(),x(e,"listening",t);return;case"transcript":{let o=r.text??"";if(!o)return;let a=!!r.isFinal;a||x(e,"user_speaking",t),K(e,o,a),t.onTranscript(e.transcript);return}case"agent_turn_start":{let o=`m${e.idCounter++}`;e.agentBubbleId=o,e.transcript=[...e.transcript,{id:o,role:"agent",text:""}],t.onTranscript(e.transcript),t.onAgentTurnStart(),x(e,"agent_speaking",t);return}case"agent_text":{let o=r.text??"";if(!o||!e.agentBubbleId)return;let a=e.agentBubbleId;e.transcript=e.transcript.map(i=>i.id===a&&i.role==="agent"?{...i,text:i.text+o}:i),t.onTranscript(e.transcript);return}case"agent_turn_end":e.agentBubbleId=null,x(e,"listening",t);return;case"interrupt":t.onInterrupt();return;case"agent_turn_abort":{let o=(r.committedText??"").trim();if(e.agentBubbleId){let a=e.agentBubbleId;o?e.transcript=e.transcript.map(i=>i.id===a&&i.role==="agent"?{...i,text:o,interrupted:!0}:i):e.transcript=e.transcript.filter(i=>i.id!==a),t.onTranscript(e.transcript)}e.agentBubbleId=null;return}case"tool_call":e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"tool",text:`\u2192 ${String(r.tool??"?")}(${r.args?JSON.stringify(r.args):""})`}],t.onTranscript(e.transcript);return;case"tool_result":e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"tool",text:`${r.ok?"\u2713":"\u2717"} ${String(r.tool??"?")}`}],t.onTranscript(e.transcript);return;case"client_tool_call":{let o=String(r.toolCallId??""),a=String(r.name??""),i=r.args??{};if(!o||!a)return;t.onClientToolCall({toolCallId:o,name:a,args:i});return}case"call_end":{let o=String(r.reason??""),a=Y(o);e.endReason=a,e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"system",text:`call ended${o?` (${o})`:""}`}],t.onTranscript(e.transcript),t.onCallEnd(a);return}case"error":{let o=r.code??"server_error",a=r.message??"server error";t.onError({code:o,message:a});return}}}var x=(n,e,t)=>{n.state!==e&&(n.state=e,t.onState(e))},K=(n,e,t)=>{let r=-1;for(let i=n.transcript.length-1;i>=0;i--){let l=n.transcript[i];if(l.role==="user"&&l.committed===!1){r=i;break}}if(r===-1){n.transcript=[...n.transcript,{id:`m${n.idCounter++}`,role:"user",text:e,committed:t}];return}let o=n.transcript[r],a=[...n.transcript];a[r]={...o,text:e,committed:t},n.transcript=a};function $(n){let e=new URL(n.apiBase),t=e.protocol==="https:"?"wss:":"ws:",r=n.bargeIn===!1?"&barge=off":"";return`${t}//${e.host}/v1/agents/${encodeURIComponent(n.agentId)}/call?token=${encodeURIComponent(n.token)}${r}`}var Z=/^[a-zA-Z_][a-zA-Z0-9_]*$/;var D=n=>{if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("clientTools must be an object keyed by tool name");let e=Object.entries(n);if(e.length>64)throw new Error(`clientTools may declare at most 64 tools (got ${e.length})`);for(let[t,r]of e){if(!Z.test(t))throw new Error(`clientTools["${t}"]: name must be a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$)`);if(!r||typeof r!="object")throw new Error(`clientTools["${t}"]: must be an object`);if(typeof r.description!="string"||r.description.length===0)throw new Error(`clientTools["${t}"]: must have a description`);if(typeof r.handler!="function")throw new Error(`clientTools["${t}"]: must have a handler function`);if(r.usage!==void 0&&r.usage.length>500)throw new Error(`clientTools["${t}"]: usage must be \u2264500 chars`);if(r.timeoutMs!==void 0&&(!Number.isFinite(r.timeoutMs)||r.timeoutMs<=0||r.timeoutMs>3e4))throw new Error(`clientTools["${t}"]: timeoutMs must be in (0, 30000]`)}},U=n=>({type:"client_tools_register",tools:Object.entries(n).map(([e,t])=>({name:e,description:t.description,parameters:t.parameters,...t.usage!==void 0?{usage:t.usage}:{},...t.timeoutMs!==void 0?{timeoutMs:t.timeoutMs}:{}}))}),z=(n,e,t)=>{let r=a=>{try{n(a)}catch{}},o=e[t.name];if(!o){r({type:"client_tool_result",toolCallId:t.toolCallId,error:`No handler for ${t.name}`});return}(async()=>{try{let a=await o.handler(t.args);r({type:"client_tool_result",toolCallId:t.toolCallId,result:typeof a=="string"?a:JSON.stringify(a)})}catch(a){r({type:"client_tool_result",toolCallId:t.toolCallId,error:a instanceof Error?a.message:String(a)})}})()};var v=class{constructor(e){this.rws=null;this.capture=null;this.playback=null;this.muted=!1;this.inputVolume=0;this.outputVolume=0;this.startedAt=null;this.endedFired=!1;this.lastError=null;this.end=()=>{this.teardown("user_hangup")};this.mute=()=>{this.muted||(this.muted=!0,this.capture?.mute(!0))};this.unmute=()=>{this.muted&&(this.muted=!1,this.capture?.mute(!1))};this.sendClientToolsRegister=()=>{let e=U(this.args.options.clientTools??{});this.rws?.send(JSON.stringify(e))};this.setState=e=>{this.proto.state!==e&&(this.proto.state=e,this.args.options.onStateChange?.(e))};this.emitError=e=>{this.lastError=e,this.args.options.onError?.(e)};this.handleSocketEvent=e=>{switch(e.type){case"open":this.startCapture();break;case"reconnected":this.proto.transcript=[],this.proto.agentBubbleId=null,this.args.options.onTranscript?.(this.proto.transcript),this.startCapture(),this.setState("listening");break;case"message":typeof e.data=="string"?N(e.data,this.proto,{onState:this.setState,onTranscript:t=>this.args.options.onTranscript?.(t),onError:this.emitError,onInterrupt:()=>{this.playback?.flush(),this.args.options.onInterrupt?.()},onAgentTurnStart:()=>this.args.options.onAgentTurnStart?.(),onCallEnd:t=>this.teardown(t),onConnected:()=>this.sendClientToolsRegister(),onClientToolCall:t=>z(r=>this.rws?.send(JSON.stringify(r)),this.args.options.clientTools??{},t)}):this.playback?.enqueue(e.data);break;case"close":if(e.permanent){let t=this.proto.endReason??(this.lastError?"error":"user_hangup");this.teardown(t)}break;case"error":this.emitError({code:"socket_error",message:e.error.message});break}};this.startCapture=async()=>{if(!this.capture?.isCapturing()){this.capture=W({onChunk:e=>{this.rws?.send(e)},onVolume:e=>{this.inputVolume=e,this.args.options.onVolume?.({input:e,output:this.outputVolume})},onError:e=>{this.emitError({code:e.name==="NotAllowedError"?"mic_denied":"mic_start_failed",message:e.message})}}),this.muted&&this.capture.mute(!0);try{await this.capture.start()}catch{}}};this.teardown=e=>{this.capture?.stop(),this.capture=null,this.playback?.close(),this.playback=null;try{this.rws?.close(1e3,e)}catch{}this.rws=null,this.setState("ended"),this.fireEndOnce(e)};this.fireEndOnce=e=>{if(this.endedFired)return;this.endedFired=!0;let t=this.startedAt??Date.now();this.args.options.onEnd?.({reason:e,errorCode:e==="error"?this.lastError?.code:void 0,durationMs:Date.now()-t})};this.args=e,this.proto=L(),D(e.options.clientTools)}get state(){return this.proto.state}get transcript(){return this.proto.transcript.slice()}get isMuted(){return this.muted}async start(){this.setState("connecting"),this.startedAt=Date.now();let e=$({apiBase:this.args.config.apiBase,agentId:this.args.options.agentId,token:this.args.token,bargeIn:this.args.options.bargeIn});this.playback=B({onVolume:t=>{this.outputVolume=t,this.args.options.onVolume?.({input:this.inputVolume,output:t})}});try{await this.playback.resume()}catch{}this.rws=P({url:e,wsFactory:this.args.wsFactory,maxRetries:3},t=>this.handleSocketEvent(t))}};var Q=n=>new globalThis.WebSocket(n),T=class{constructor(e){this.startCall=async e=>{if(!e.agentId)throw new Error("startCall: agentId is required");let{context:t,metadata:r}=F(this.config,e),o={agentId:e.agentId,userId:e.userId,context:t,metadata:r},a;if(e.token)a=e.token;else if(a=await this.config.fetchToken(o),!a)throw new Error("configureVoiceClient.fetchToken returned empty token");let i=new v({config:this.config,options:{...e,context:t,metadata:r},token:a,wsFactory:Q});return await i.start(),i};this.config=e}};function j(n){return new T(V(n))}var H="voice-agent-embed-root",ee="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5.3-3a.7.7 0 0 1 1.4 0 6.7 6.7 0 0 1-6 6.66V21h-1.4v-3.34A6.7 6.7 0 0 1 5.3 11a.7.7 0 0 1 1.4 0 5.3 5.3 0 0 0 10.6 0z",A="http://www.w3.org/2000/svg",q=n=>{let e=document.createElementNS(A,"svg");if(e.setAttribute("viewBox","0 0 24 24"),e.setAttribute("class","icon"),n.rect){let t=document.createElementNS(A,"rect");t.setAttribute("x","7"),t.setAttribute("y","7"),t.setAttribute("width","10"),t.setAttribute("height","10"),t.setAttribute("rx","1.5"),e.appendChild(t)}else if(n.path){let t=document.createElementNS(A,"path");t.setAttribute("d",n.path),e.appendChild(t)}return e},te=n=>{try{return JSON.parse(n)}catch{return}},re=n=>{if(n)try{return new URL(n.src,location.href).origin}catch{}return location.origin},ne=()=>{let n=document.querySelectorAll('script[src*="embed.js"]');for(let e of Array.from(n)){let t=e.dataset;if(t.apiKey&&console.error("[craftedxp/voice-js] data-api-key on the embed <script> is no longer supported. Mint a ct_ server-side via @craftedxp/sdk-node and inject it into data-token instead."),t.agentId&&t.token)return{apiBase:t.apiBase||re(e),token:t.token,agentId:t.agentId,vars:t.vars?te(t.vars):void 0,label:t.label||"Call agent",primaryColor:t.primaryColor||"#3d7dd0",position:t.position==="bottom-left"?"bottom-left":"bottom-right"}}return null},M=class{constructor(e){this.call=null;this.cfg=e,document.getElementById(H)?.remove(),this.host=document.createElement("div"),this.host.id=H,document.body.appendChild(this.host),this.shadow=this.host.attachShadow({mode:"open"}),this.mount()}mount(){let e=document.createElement("style");e.textContent=this.css(),this.shadow.appendChild(e);let t=document.createElement("div");t.className=`wrap ${this.cfg.position??"bottom-right"}`,this.panel=document.createElement("div"),this.panel.className="panel",this.panel.style.display="none";let r=document.createElement("div");r.className="panel-head";let o=document.createElement("div");o.className="title",o.textContent=this.cfg.label??"Call agent",this.statusEl=document.createElement("div"),this.statusEl.className="status",this.statusEl.textContent="idle",r.appendChild(o),r.appendChild(this.statusEl),this.transcriptEl=document.createElement("div"),this.transcriptEl.className="transcript",this.panel.appendChild(r),this.panel.appendChild(this.transcriptEl),t.appendChild(this.panel),this.fab=document.createElement("button"),this.fab.className="fab",this.fab.type="button",this.setFabState("idle"),this.fab.addEventListener("click",()=>this.toggle()),t.appendChild(this.fab),this.shadow.appendChild(t),(!this.cfg.agentId||!this.cfg.token)&&(this.setStatus("Widget not configured \u2014 data-token + data-agent-id are required","warn"),this.fab.disabled=!0,this.fab.title="Missing data-token or data-agent-id")}destroy(){this.call?.end(),this.call=null,this.host.remove()}css(){return`
82
+ `;var X=100,W=n=>{let e=null,t=null,r=null,o=null,a=null,i=null,l=!1,d=!1,h=s=>{let p=0;for(let g=0;g<s.length;g++)p+=s[g]*s[g];let y=Math.sqrt(p/s.length);return Math.min(1,y*1.8)};return{start:async()=>{if(!d)try{t=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,channelCount:1}}),e=new AudioContext,e.state==="suspended"&&await e.resume();let s=new Blob([O],{type:"application/javascript"}),p=URL.createObjectURL(s);try{await e.audioWorklet.addModule(p)}finally{URL.revokeObjectURL(p)}if(r=e.createMediaStreamSource(t),o=new AudioWorkletNode(e,"mic-downsampler"),o.port.onmessage=g=>{l||n.onChunk(g.data)},n.onVolume){a=e.createAnalyser(),a.fftSize=256,r.connect(a);let g=new Float32Array(a.fftSize);i=setInterval(()=>{a&&(a.getFloatTimeDomainData(g),n.onVolume?.(h(g)))},X)}r.connect(o);let y=e.createGain();y.gain.value=0,o.connect(y).connect(e.destination),d=!0}catch(s){let p=s instanceof Error?s:new Error(typeof s=="string"?s:"capture failed");throw n.onError?.(p),p}},stop:()=>{if(d){d=!1,i&&(clearInterval(i),i=null);try{o?.disconnect(),a?.disconnect(),r?.disconnect()}catch{}if(o=null,a=null,r=null,t){for(let s of t.getTracks())s.stop();t=null}e&&e.state!=="closed"&&e.close().catch(()=>{}),e=null}},mute:s=>{l=s},isCapturing:()=>d}};var B=(n={})=>{let e=n.sampleRate??16e3,t=null,r=null,o=null,a=null,i=0,l=[],d=!1,h=async()=>{if(t){t.state==="suspended"&&await t.resume();return}if(t=new AudioContext({sampleRate:e}),r=t.createGain(),n.onVolume){o=t.createAnalyser(),o.fftSize=256,r.connect(o);let c=new Float32Array(o.fftSize);a=setInterval(()=>{if(!o)return;o.getFloatTimeDomainData(c);let f=0;for(let C=0;C<c.length;C++)f+=c[C]*c[C];let k=Math.sqrt(f/c.length);n.onVolume?.(Math.min(1,k*1.8))},100)}r.connect(t.destination),i=t.currentTime},m=c=>{c!==d&&(d=c,n.onSpeakingChange?.(c))},u=()=>{let c=t?.currentTime??0;l=l.filter(f=>(f._endsAt??0)>c),l.length===0&&m(!1)},s=c=>{if(!t){h().then(()=>s(c));return}if(!t||!r)return;let f=new Int16Array(c);if(f.length===0)return;let k=t.createBuffer(1,f.length,e),C=k.getChannelData(0);for(let S=0;S<f.length;S++)C[S]=f[S]/32768;let b=t.createBufferSource();b.buffer=k,b.connect(r);let G=t.currentTime,w=Math.max(G,i);b.start(w);let I=f.length/e;b._endsAt=w+I,i=w+I,l.push(b),m(!0),b.onended=()=>u()},p=()=>{if(!(!t||!r)){for(let c of l)try{c.stop()}catch{}l=[],r.disconnect(),r=t.createGain(),o&&(o.disconnect(),r.connect(o)),r.connect(t.destination),i=t.currentTime,m(!1)}};return{enqueue:s,flush:p,close:()=>{p(),a&&(clearInterval(a),a=null),t&&t.state!=="closed"&&t.close().catch(()=>{}),t=null,r=null,o=null},resume:async()=>{await h()}}};var P=(n,e)=>{let t=n.maxRetries??3,r=n.initialBackoffMs??500,o=n.maxBackoffMs??8e3,a=null,i=!1,l=0,d=r,h=null,m=()=>{a=n.wsFactory(n.url),a.binaryType="arraybuffer",a.onopen=()=>{e(l===0?{type:"open"}:{type:"reconnected"}),l=0,d=r},a.onmessage=u=>{e({type:"message",data:u.data})},a.onerror=()=>{e({type:"error",error:new Error("WebSocket error")})},a.onclose=u=>{if(a=null,!(!i&&l<t)){e({type:"close",code:u.code,reason:u.reason,permanent:!0});return}e({type:"close",code:u.code,reason:u.reason,permanent:!1}),l++;let p=Math.min(d,o);d=Math.min(d*2,o),h=setTimeout(m,p)}};return m(),{send:u=>{a&&a.readyState===1&&a.send(u)},close:(u=1e3,s="client-requested")=>{i=!0,h&&(clearTimeout(h),h=null);try{a?.close(u,s)}catch{}},readyState:()=>a?.readyState??3}};var L=()=>({state:"idle",transcript:[],agentBubbleId:null,idCounter:0,endReason:null}),Y=n=>n==="agent_ended"?"agent_ended":n==="caller_hung_up"?"user_hangup":n==="silence_timeout"||n==="max_duration"?"timeout":"error";function N(n,e,t){let r;try{r=JSON.parse(n)}catch{return}switch(r.type){case"connected":t.onConnected(),x(e,"listening",t);return;case"transcript":{let o=r.text??"";if(!o)return;let a=!!r.isFinal;a||x(e,"user_speaking",t),K(e,o,a),t.onTranscript(e.transcript);return}case"agent_turn_start":{let o=`m${e.idCounter++}`;e.agentBubbleId=o,e.transcript=[...e.transcript,{id:o,role:"agent",text:""}],t.onTranscript(e.transcript),t.onAgentTurnStart(),x(e,"agent_speaking",t);return}case"agent_text":{let o=r.text??"";if(!o||!e.agentBubbleId)return;let a=e.agentBubbleId;e.transcript=e.transcript.map(i=>i.id===a&&i.role==="agent"?{...i,text:i.text+o}:i),t.onTranscript(e.transcript);return}case"agent_turn_end":e.agentBubbleId=null,x(e,"listening",t);return;case"interrupt":t.onInterrupt();return;case"agent_turn_abort":{let o=(r.committedText??"").trim();if(e.agentBubbleId){let a=e.agentBubbleId;o?e.transcript=e.transcript.map(i=>i.id===a&&i.role==="agent"?{...i,text:o,interrupted:!0}:i):e.transcript=e.transcript.filter(i=>i.id!==a),t.onTranscript(e.transcript)}e.agentBubbleId=null;return}case"tool_call":e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"tool",text:`\u2192 ${String(r.tool??"?")}(${r.args?JSON.stringify(r.args):""})`}],t.onTranscript(e.transcript);return;case"tool_result":e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"tool",text:`${r.ok?"\u2713":"\u2717"} ${String(r.tool??"?")}`}],t.onTranscript(e.transcript);return;case"client_tool_call":{let o=String(r.toolCallId??""),a=String(r.name??""),i=r.args??{};if(!o||!a)return;t.onClientToolCall({toolCallId:o,name:a,args:i});return}case"call_end":{let o=String(r.reason??""),a=Y(o);e.endReason=a,e.transcript=[...e.transcript,{id:`m${e.idCounter++}`,role:"system",text:`call ended${o?` (${o})`:""}`}],t.onTranscript(e.transcript),t.onCallEnd(a);return}case"error":{let o=r.code??"server_error",a=r.message??"server error";t.onError({code:o,message:a});return}}}var x=(n,e,t)=>{n.state!==e&&t.onState(e)},K=(n,e,t)=>{let r=-1;for(let i=n.transcript.length-1;i>=0;i--){let l=n.transcript[i];if(l.role==="user"&&l.committed===!1){r=i;break}}if(r===-1){n.transcript=[...n.transcript,{id:`m${n.idCounter++}`,role:"user",text:e,committed:t}];return}let o=n.transcript[r],a=[...n.transcript];a[r]={...o,text:e,committed:t},n.transcript=a};function $(n){let e=new URL(n.apiBase),t=e.protocol==="https:"?"wss:":"ws:",r=n.bargeIn===!1?"&barge=off":"";return`${t}//${e.host}/v1/agents/${encodeURIComponent(n.agentId)}/call?token=${encodeURIComponent(n.token)}${r}`}var Z=/^[a-zA-Z_][a-zA-Z0-9_]*$/;var D=n=>{if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("clientTools must be an object keyed by tool name");let e=Object.entries(n);if(e.length>64)throw new Error(`clientTools may declare at most 64 tools (got ${e.length})`);for(let[t,r]of e){if(!Z.test(t))throw new Error(`clientTools["${t}"]: name must be a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$)`);if(!r||typeof r!="object")throw new Error(`clientTools["${t}"]: must be an object`);if(typeof r.description!="string"||r.description.length===0)throw new Error(`clientTools["${t}"]: must have a description`);if(typeof r.handler!="function")throw new Error(`clientTools["${t}"]: must have a handler function`);if(r.usage!==void 0&&r.usage.length>500)throw new Error(`clientTools["${t}"]: usage must be \u2264500 chars`);if(r.timeoutMs!==void 0&&(!Number.isFinite(r.timeoutMs)||r.timeoutMs<=0||r.timeoutMs>3e4))throw new Error(`clientTools["${t}"]: timeoutMs must be in (0, 30000]`)}},U=n=>({type:"client_tools_register",tools:Object.entries(n).map(([e,t])=>({name:e,description:t.description,parameters:t.parameters,...t.usage!==void 0?{usage:t.usage}:{},...t.timeoutMs!==void 0?{timeoutMs:t.timeoutMs}:{}}))}),z=(n,e,t)=>{let r=a=>{try{n(a)}catch{}},o=e[t.name];if(!o){r({type:"client_tool_result",toolCallId:t.toolCallId,error:`No handler for ${t.name}`});return}(async()=>{try{let a=await o.handler(t.args);r({type:"client_tool_result",toolCallId:t.toolCallId,result:typeof a=="string"?a:JSON.stringify(a)})}catch(a){r({type:"client_tool_result",toolCallId:t.toolCallId,error:a instanceof Error?a.message:String(a)})}})()};var v=class{constructor(e){this.rws=null;this.capture=null;this.playback=null;this.muted=!1;this.inputVolume=0;this.outputVolume=0;this.startedAt=null;this.endedFired=!1;this.lastError=null;this.end=()=>{this.teardown("user_hangup")};this.mute=()=>{this.muted||(this.muted=!0,this.capture?.mute(!0))};this.unmute=()=>{this.muted&&(this.muted=!1,this.capture?.mute(!1))};this.sendClientToolsRegister=()=>{let e=U(this.args.options.clientTools??{});this.rws?.send(JSON.stringify(e))};this.setState=e=>{this.proto.state!==e&&(this.proto.state=e,this.args.options.onStateChange?.(e))};this.emitError=e=>{this.lastError=e,this.args.options.onError?.(e)};this.handleSocketEvent=e=>{switch(e.type){case"open":this.startCapture();break;case"reconnected":this.proto.transcript=[],this.proto.agentBubbleId=null,this.args.options.onTranscript?.(this.proto.transcript),this.startCapture(),this.setState("listening");break;case"message":typeof e.data=="string"?N(e.data,this.proto,{onState:this.setState,onTranscript:t=>this.args.options.onTranscript?.(t),onError:this.emitError,onInterrupt:()=>{this.playback?.flush(),this.args.options.onInterrupt?.()},onAgentTurnStart:()=>this.args.options.onAgentTurnStart?.(),onCallEnd:t=>this.teardown(t),onConnected:()=>this.sendClientToolsRegister(),onClientToolCall:t=>z(r=>this.rws?.send(JSON.stringify(r)),this.args.options.clientTools??{},t)}):this.playback?.enqueue(e.data);break;case"close":if(e.permanent){let t=this.proto.endReason??(this.lastError?"error":"user_hangup");this.teardown(t)}break;case"error":this.emitError({code:"socket_error",message:e.error.message});break}};this.startCapture=async()=>{if(!this.capture?.isCapturing()){this.capture=W({onChunk:e=>{this.rws?.send(e)},onVolume:e=>{this.inputVolume=e,this.args.options.onVolume?.({input:e,output:this.outputVolume})},onError:e=>{this.emitError({code:e.name==="NotAllowedError"?"mic_denied":"mic_start_failed",message:e.message})}}),this.muted&&this.capture.mute(!0);try{await this.capture.start()}catch{}}};this.teardown=e=>{this.capture?.stop(),this.capture=null,this.playback?.close(),this.playback=null;try{this.rws?.close(1e3,e)}catch{}this.rws=null,this.setState("ended"),this.fireEndOnce(e)};this.fireEndOnce=e=>{if(this.endedFired)return;this.endedFired=!0;let t=this.startedAt??Date.now();this.args.options.onEnd?.({reason:e,errorCode:e==="error"?this.lastError?.code:void 0,durationMs:Date.now()-t})};this.args=e,this.proto=L(),D(e.options.clientTools)}get state(){return this.proto.state}get transcript(){return this.proto.transcript.slice()}get isMuted(){return this.muted}async start(){this.setState("connecting"),this.startedAt=Date.now();let e=$({apiBase:this.args.config.apiBase,agentId:this.args.options.agentId,token:this.args.token,bargeIn:this.args.options.bargeIn});this.playback=B({onVolume:t=>{this.outputVolume=t,this.args.options.onVolume?.({input:this.inputVolume,output:t})}});try{await this.playback.resume()}catch{}this.rws=P({url:e,wsFactory:this.args.wsFactory,maxRetries:3},t=>this.handleSocketEvent(t))}};var Q=n=>new globalThis.WebSocket(n),T=class{constructor(e){this.startCall=async e=>{if(!e.agentId)throw new Error("startCall: agentId is required");let{context:t,metadata:r}=F(this.config,e),o={agentId:e.agentId,userId:e.userId,context:t,metadata:r},a;if(e.token)a=e.token;else if(a=await this.config.fetchToken(o),!a)throw new Error("configureVoiceClient.fetchToken returned empty token");let i=new v({config:this.config,options:{...e,context:t,metadata:r},token:a,wsFactory:Q});return await i.start(),i};this.config=e}};function j(n){return new T(V(n))}var H="voice-agent-embed-root",ee="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5.3-3a.7.7 0 0 1 1.4 0 6.7 6.7 0 0 1-6 6.66V21h-1.4v-3.34A6.7 6.7 0 0 1 5.3 11a.7.7 0 0 1 1.4 0 5.3 5.3 0 0 0 10.6 0z",A="http://www.w3.org/2000/svg",q=n=>{let e=document.createElementNS(A,"svg");if(e.setAttribute("viewBox","0 0 24 24"),e.setAttribute("class","icon"),n.rect){let t=document.createElementNS(A,"rect");t.setAttribute("x","7"),t.setAttribute("y","7"),t.setAttribute("width","10"),t.setAttribute("height","10"),t.setAttribute("rx","1.5"),e.appendChild(t)}else if(n.path){let t=document.createElementNS(A,"path");t.setAttribute("d",n.path),e.appendChild(t)}return e},te=n=>{try{return JSON.parse(n)}catch{return}},re=n=>{if(n)try{return new URL(n.src,location.href).origin}catch{}return location.origin},ne=()=>{let n=document.querySelectorAll('script[src*="embed.js"]');for(let e of Array.from(n)){let t=e.dataset;if(t.apiKey&&console.error("[craftedxp/voice-js] data-api-key on the embed <script> is no longer supported. Mint a ct_ server-side via @craftedxp/sdk-node and inject it into data-token instead."),t.agentId&&t.token)return{apiBase:t.apiBase||re(e),token:t.token,agentId:t.agentId,vars:t.vars?te(t.vars):void 0,label:t.label||"Call agent",primaryColor:t.primaryColor||"#3d7dd0",position:t.position==="bottom-left"?"bottom-left":"bottom-right"}}return null},M=class{constructor(e){this.call=null;this.cfg=e,document.getElementById(H)?.remove(),this.host=document.createElement("div"),this.host.id=H,document.body.appendChild(this.host),this.shadow=this.host.attachShadow({mode:"open"}),this.mount()}mount(){let e=document.createElement("style");e.textContent=this.css(),this.shadow.appendChild(e);let t=document.createElement("div");t.className=`wrap ${this.cfg.position??"bottom-right"}`,this.panel=document.createElement("div"),this.panel.className="panel",this.panel.style.display="none";let r=document.createElement("div");r.className="panel-head";let o=document.createElement("div");o.className="title",o.textContent=this.cfg.label??"Call agent",this.statusEl=document.createElement("div"),this.statusEl.className="status",this.statusEl.textContent="idle",r.appendChild(o),r.appendChild(this.statusEl),this.transcriptEl=document.createElement("div"),this.transcriptEl.className="transcript",this.panel.appendChild(r),this.panel.appendChild(this.transcriptEl),t.appendChild(this.panel),this.fab=document.createElement("button"),this.fab.className="fab",this.fab.type="button",this.setFabState("idle"),this.fab.addEventListener("click",()=>this.toggle()),t.appendChild(this.fab),this.shadow.appendChild(t),(!this.cfg.agentId||!this.cfg.token)&&(this.setStatus("Widget not configured \u2014 data-token + data-agent-id are required","warn"),this.fab.disabled=!0,this.fab.title="Missing data-token or data-agent-id")}destroy(){this.call?.end(),this.call=null,this.host.remove()}css(){return`
83
83
  :host, * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
84
84
  .wrap { position: fixed; z-index: 2147483000; display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
85
85
  .wrap.bottom-right { bottom: 24px; right: 24px; }
package/dist/node.js CHANGED
@@ -267,7 +267,6 @@ function handleServerMessage(raw, state, cb) {
267
267
  }
268
268
  var setState = (state, next, cb) => {
269
269
  if (state.state === next) return;
270
- state.state = next;
271
270
  cb.onState(next);
272
271
  };
273
272
  var upsertUserPartial = (state, text, isFinal) => {