@runtypelabs/voice 0.2.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Runtype
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @runtypelabs/voice
2
+
3
+ Browser voice calls to Runtype, with a framework-independent client, React hook,
4
+ and Persona adapter. The dashboard uses the same client as custom applications.
5
+ The core has no runtime dependencies; React and Persona are optional peers.
6
+
7
+ ## React
8
+
9
+ ```sh
10
+ pnpm add @runtypelabs/voice
11
+ ```
12
+
13
+ ```tsx
14
+ 'use client'
15
+
16
+ import { useVoiceClient } from '@runtypelabs/voice/react'
17
+
18
+ export function AgentVoice({ agentId, clientToken }: { agentId: string; clientToken: string }) {
19
+ const voice = useVoiceClient({ agentId, clientToken })
20
+ const inCall = voice.status !== 'idle' && voice.status !== 'error'
21
+ return (
22
+ <div>
23
+ <p role="status">{voice.error ?? voice.status}</p>
24
+ <button onClick={() => (inCall ? voice.endCall() : void voice.startCall())}>
25
+ {inCall ? 'End call' : 'Start call'}
26
+ </button>
27
+ {inCall && <button onClick={voice.toggleMute}>{voice.isMuted ? 'Unmute' : 'Mute'}</button>}
28
+ {voice.canCancel && <button onClick={voice.cancelResponse}>Stop response</button>}
29
+ {voice.transcript.map((entry, index) => (
30
+ <p key={index}>
31
+ {entry.role}: {entry.content}
32
+ </p>
33
+ ))}
34
+ </div>
35
+ )
36
+ }
37
+ ```
38
+
39
+ Pass `apiUrl: 'https://api.runtype-staging.com'` for staging or a full HTTP(S) /
40
+ WebSocket base URL for another environment. The default is `https://api.runtype.com`.
41
+ Proxy path prefixes are preserved: `https://example.com/api` connects through
42
+ `wss://example.com/api/ws/agents/{agentId}/voice`. The proxy must forward WebSocket
43
+ upgrades and the `Sec-WebSocket-Protocol` header to the API.
44
+ Use a browser client token authorized for the agent and embedding origin.
45
+ Do not pass a server API key. Tokens use the WebSocket subprotocol, never the URL.
46
+ `clientToken` can also be a function returning a token or a promise; it is called
47
+ once per new call. `startCall(tokenOverride)` overrides that call's token.
48
+
49
+ The hook ends the call on unmount and when `agentId` or `apiUrl` changes.
50
+ Changing `clientToken` affects the next call without interrupting the current one.
51
+ Startup and connection failures populate `error` and `status: 'error'`.
52
+
53
+ ## Plain JavaScript
54
+
55
+ ```ts
56
+ import { VoiceClient } from '@runtypelabs/voice'
57
+
58
+ const voice = new VoiceClient({ agentId, clientToken })
59
+ const unsubscribe = voice.subscribe(() => renderVoiceState(voice.getSnapshot()))
60
+ startButton.onclick = () => {
61
+ void voice.startCall()
62
+ }
63
+ stopButton.onclick = () => voice.cancelResponse()
64
+
65
+ // Call when the owning view is removed.
66
+ function dispose() {
67
+ unsubscribe()
68
+ voice.endCall()
69
+ }
70
+ ```
71
+
72
+ Construction and module imports are safe during server rendering. Starting a
73
+ call requires a browser secure context, microphone permission, WebSocket,
74
+ AudioContext, and AudioWorklet. Initiate calls from a user click. A restrictive
75
+ CSP must allow the configured WebSocket origin and the blob AudioWorklet module.
76
+ This is a browser package, not a React Native audio implementation.
77
+
78
+ ## Persona
79
+
80
+ The adapter implements Persona 4.22's `VoiceProvider` API and delivers transcripts
81
+ through `onTranscript`, avoiding duplicate text dispatches. Use it as the custom
82
+ provider in the widget's `config`:
83
+
84
+ ```ts
85
+ import { createPersonaVoiceProvider } from '@runtypelabs/voice/persona'
86
+
87
+ const config = {
88
+ voiceRecognition: {
89
+ enabled: true,
90
+ provider: {
91
+ type: 'custom' as const,
92
+ custom: () => createPersonaVoiceProvider({ agentId, clientToken }),
93
+ },
94
+ },
95
+ }
96
+ ```
97
+
98
+ **Widget release prerequisite:** Persona 4.22.0's factory accepts this adapter,
99
+ but its built-in microphone controls incorrectly route custom providers to
100
+ browser dictation. Use a Persona release containing
101
+ [the custom voice controls fix](https://github.com/runtypelabs/persona/pull/434)
102
+ before enabling this configuration. The adapter does not replace
103
+ Persona's built-in provider automatically. Existing script-tag installations
104
+ also need that widget release and a bundled adapter supplied by their host.
105
+
106
+ ## Interruption contract
107
+
108
+ The agent's saved mode arrives in `session_config` and remains authoritative:
109
+
110
+ The client requests `voiceProtocol=runtype-browser-v1` when connecting. The API routes
111
+ Cloudflare calls using this protocol to its PCM browser engine; the legacy Cloudflare
112
+ Durable Object uses a different protocol and remains available to older clients.
113
+
114
+ - `none`: speaking does not clear playback; `canCancel` is false.
115
+ - `cancel`: tap `cancelResponse()` to clear local playback immediately and send `cancel`, then speak again.
116
+ Speaking during a reply does not interrupt it.
117
+ - `barge-in`: server speech detection sends `audio_clear`; manual cancellation is also available.
118
+
119
+ The microphone streams continuously during a call. In `cancel` mode, the client sends silence
120
+ while a reply is being generated or played, resuming microphone audio after playback drains
121
+ or the server acknowledges a manual cancellation. The server owns speech
122
+ detection and turn-taking. The client does not infer interruptions from volume.
123
+ After manual cancellation, old audio is discarded until the server acknowledges
124
+ with `audio_clear`; late assistant transcripts are discarded across that same boundary.
125
+ Pending Blob conversions and stale player events are invalidated.
126
+ `audio_end` means synthesis completed, while `speaking` continues until playback drains.
127
+
128
+ Persona's explicit `stopPlayback()` and the client's matching method also stop
129
+ playback in `none` mode. They discard the current reply locally through `audio_end`
130
+ without sending a mode-disallowed cancellation or ending the call. Ordinary
131
+ `cancelResponse()` still respects the agent's mode. Disconnecting the Persona
132
+ adapter releases its callbacks; register callbacks again when reusing it.
133
+ When a server supplies `turnId` on final transcripts, the adapter forwards it
134
+ as Persona's optional fourth callback argument `{ turnId }`. Untagged servers
135
+ continue to use the ordered `audio_clear` cancellation boundary.
136
+
137
+ Requires the server cancellation lifecycle from [core PR #8185](https://github.com/runtypelabs/core/pull/8185). An older server
138
+ without `session_config` leaves cancellation disabled; an older server that does
139
+ not acknowledge `cancel` cannot support this cancellation protocol.
140
+
141
+ ## Development
142
+
143
+ ```sh
144
+ pnpm --filter @runtypelabs/voice test
145
+ pnpm --filter @runtypelabs/voice typecheck
146
+ pnpm --filter @runtypelabs/voice build
147
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,488 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ VoiceClient: () => VoiceClient
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/pcm-player.ts
28
+ var PCM_SAMPLE_RATE = 24e3;
29
+ var PCM_WATERLINE_SAMPLES = 3600;
30
+ var PCM_PLAYER_WORKLET = `
31
+ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
32
+ constructor() {
33
+ super()
34
+ this.chunks = []
35
+ this.readOffset = 0
36
+ this.buffered = 0
37
+ this.waiting = true
38
+ // INVARIANT: Only report drained after end-of-stream, never during a jitter gap.
39
+ this.eosSeen = false
40
+ this.revision = 0
41
+ this.port.onmessage = (e) => {
42
+ const msg = e.data
43
+ this.revision = msg.revision
44
+ if (msg.type === 'push') {
45
+ this.eosSeen = false
46
+ this.chunks.push(msg.samples)
47
+ this.buffered += msg.samples.length
48
+ if (this.waiting && this.buffered >= ${PCM_WATERLINE_SAMPLES}) {
49
+ this.waiting = false
50
+ }
51
+ } else if (msg.type === 'eos') {
52
+ this.eosSeen = true
53
+ // INVARIANT: Short replies drain even below the waterline.
54
+ if (this.waiting && this.buffered > 0) this.waiting = false
55
+ // INVARIANT: An empty completed stream reports drained immediately.
56
+ if (this.buffered === 0) {
57
+ this.eosSeen = false
58
+ this.port.postMessage({ type: 'drained', revision: this.revision })
59
+ }
60
+ } else if (msg.type === 'clear') {
61
+ this.chunks = []
62
+ this.readOffset = 0
63
+ this.buffered = 0
64
+ this.waiting = true
65
+ this.eosSeen = false
66
+ }
67
+ }
68
+ }
69
+ process(inputs, outputs) {
70
+ const out = outputs[0][0]
71
+ if (!out || this.waiting) return true // outputs are pre-zeroed: silence
72
+ let i = 0
73
+ while (i < out.length && this.buffered > 0) {
74
+ const chunk = this.chunks[0]
75
+ out[i++] = chunk[this.readOffset++]
76
+ this.buffered--
77
+ if (this.readOffset >= chunk.length) {
78
+ this.chunks.shift()
79
+ this.readOffset = 0
80
+ }
81
+ }
82
+ if (this.buffered === 0) {
83
+ this.waiting = true // mid-reply underrun: re-buffer silently
84
+ if (this.eosSeen) {
85
+ this.eosSeen = false
86
+ this.port.postMessage({ type: 'drained', revision: this.revision })
87
+ }
88
+ }
89
+ return true
90
+ }
91
+ }
92
+ registerProcessor('runtype-pcm-player', RuntypePcmPlayerProcessor)
93
+ `;
94
+ async function createPcmPlayer(onDrained) {
95
+ let revision = 0;
96
+ const context = new AudioContext({ sampleRate: PCM_SAMPLE_RATE });
97
+ const moduleUrl = URL.createObjectURL(
98
+ new Blob([PCM_PLAYER_WORKLET], { type: "application/javascript" })
99
+ );
100
+ try {
101
+ if (context.state === "suspended") await context.resume();
102
+ await context.audioWorklet.addModule(moduleUrl);
103
+ } catch (err) {
104
+ context.close().catch(() => {
105
+ });
106
+ throw err;
107
+ } finally {
108
+ URL.revokeObjectURL(moduleUrl);
109
+ }
110
+ const node = new AudioWorkletNode(context, "runtype-pcm-player", {
111
+ numberOfInputs: 0,
112
+ numberOfOutputs: 1,
113
+ outputChannelCount: [1]
114
+ });
115
+ node.port.onmessage = (e) => {
116
+ if (e.data?.type === "drained" && e.data.revision === revision) onDrained();
117
+ };
118
+ node.connect(context.destination);
119
+ return {
120
+ async push(data) {
121
+ const pushRevision = revision;
122
+ const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
123
+ if (pushRevision !== revision) return;
124
+ const samples = pcm16FrameToFloat32(buffer);
125
+ if (samples.length === 0) return;
126
+ node.port.postMessage({ type: "push", samples, revision }, [samples.buffer]);
127
+ },
128
+ endOfStream() {
129
+ node.port.postMessage({ type: "eos", revision });
130
+ },
131
+ clear() {
132
+ revision += 1;
133
+ node.port.postMessage({ type: "clear", revision });
134
+ },
135
+ close() {
136
+ revision += 1;
137
+ node.port.onmessage = null;
138
+ node.disconnect();
139
+ context.close().catch(() => {
140
+ });
141
+ }
142
+ };
143
+ }
144
+ function pcm16FrameToFloat32(buffer) {
145
+ const view = new DataView(buffer);
146
+ let offset = 0;
147
+ if (buffer.byteLength >= 44 && view.getUint32(0, false) === 1380533830) {
148
+ offset = 44;
149
+ }
150
+ const sampleCount = Math.floor((buffer.byteLength - offset) / 2);
151
+ const out = new Float32Array(sampleCount);
152
+ for (let i = 0; i < sampleCount; i++) {
153
+ out[i] = view.getInt16(offset + i * 2, true) / 32768;
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // src/voice-client.ts
159
+ var CAPTURE_SAMPLE_RATE = 16e3;
160
+ var CAPTURE_BUFFER_SIZE = 4096;
161
+ function initialSnapshot() {
162
+ return {
163
+ status: "idle",
164
+ transcript: [],
165
+ interimTranscript: null,
166
+ metrics: null,
167
+ audioLevel: 0,
168
+ isMuted: false,
169
+ error: null,
170
+ errorDetails: void 0,
171
+ interruptionMode: "none",
172
+ canCancel: false
173
+ };
174
+ }
175
+ var VoiceClient = class {
176
+ constructor(options) {
177
+ this.options = options;
178
+ }
179
+ options;
180
+ snapshot = initialSnapshot();
181
+ listeners = /* @__PURE__ */ new Set();
182
+ socket = null;
183
+ context = null;
184
+ stream = null;
185
+ source = null;
186
+ processor = null;
187
+ player = null;
188
+ generation = 0;
189
+ playbackRevision = 0;
190
+ pushChain = Promise.resolve();
191
+ awaitingClear = false;
192
+ stoppedResponse = false;
193
+ responseEnded = true;
194
+ hasPendingAudio = false;
195
+ getSnapshot = () => this.snapshot;
196
+ subscribe = (listener) => {
197
+ this.listeners.add(listener);
198
+ return () => {
199
+ this.listeners.delete(listener);
200
+ };
201
+ };
202
+ update(patch) {
203
+ const next = { ...this.snapshot, ...patch };
204
+ next.canCancel = !this.awaitingClear && next.interruptionMode !== "none" && (next.status === "speaking" || next.status === "thinking");
205
+ this.snapshot = next;
206
+ for (const listener of this.listeners) listener();
207
+ }
208
+ setStatus(status) {
209
+ this.update({ status });
210
+ }
211
+ /** Acquire microphone access and open a call. Invoke from a user gesture. Failures populate snapshot.error. */
212
+ startCall = async (tokenOverride) => {
213
+ if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
214
+ this.cleanup();
215
+ const generation = this.generation;
216
+ this.update({ ...initialSnapshot(), status: "connecting" });
217
+ try {
218
+ const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
219
+ if (generation !== this.generation) return;
220
+ if (!token) throw new Error("Voice token unavailable. Please retry.");
221
+ if (!this.options.agentId) throw new Error("Voice requires an agentId.");
222
+ const url = new URL(this.options.apiUrl ?? "https://api.runtype.com");
223
+ if (!["https:", "http:", "wss:", "ws:"].includes(url.protocol) || url.username || url.password) {
224
+ throw new Error("Voice requires an HTTP or WebSocket API base URL without credentials.");
225
+ }
226
+ url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
227
+ const basePath = url.pathname.replace(/\/+$/, "");
228
+ url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
229
+ url.search = "";
230
+ url.searchParams.set("voiceProtocol", "runtype-browser-v1");
231
+ url.hash = "";
232
+ const stream = await navigator.mediaDevices.getUserMedia({
233
+ audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
234
+ });
235
+ if (generation !== this.generation) {
236
+ stream.getTracks().forEach((track) => track.stop());
237
+ return;
238
+ }
239
+ this.stream = stream;
240
+ const context = new AudioContext({ sampleRate: CAPTURE_SAMPLE_RATE });
241
+ this.context = context;
242
+ if (context.state === "suspended") await context.resume();
243
+ if (generation !== this.generation) return;
244
+ const player = await createPcmPlayer(() => {
245
+ if (generation !== this.generation) return;
246
+ this.hasPendingAudio = false;
247
+ if (this.snapshot.status === "speaking") this.setStatus("listening");
248
+ });
249
+ if (generation !== this.generation) {
250
+ player.close();
251
+ return;
252
+ }
253
+ this.player = player;
254
+ const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
255
+ socket.binaryType = "arraybuffer";
256
+ this.socket = socket;
257
+ socket.onopen = () => {
258
+ if (generation !== this.generation) return;
259
+ try {
260
+ this.startCapture(context, stream, socket, generation);
261
+ this.setStatus("listening");
262
+ } catch (error) {
263
+ this.fail(error);
264
+ }
265
+ };
266
+ socket.onmessage = (event) => {
267
+ if (generation === this.generation) this.handleMessage(event.data, player);
268
+ };
269
+ socket.onerror = () => {
270
+ if (generation === this.generation) this.fail(new Error("Voice connection failed"));
271
+ };
272
+ socket.onclose = (event) => {
273
+ if (generation !== this.generation) return;
274
+ if (event.code === 1e3) this.endCall();
275
+ else this.fail(new Error(`Connection closed: ${event.reason || "unknown reason"}`));
276
+ };
277
+ } catch (error) {
278
+ if (generation === this.generation) this.fail(error);
279
+ }
280
+ };
281
+ endCall = () => {
282
+ this.cleanup();
283
+ this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
284
+ };
285
+ toggleMute = () => {
286
+ this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
287
+ };
288
+ cancelResponse = () => {
289
+ if (!this.snapshot.canCancel) return;
290
+ this.stopPlayback();
291
+ };
292
+ /** Stop the current reply explicitly, including local playback when interruptions are disabled. */
293
+ stopPlayback = () => {
294
+ if (this.awaitingClear || this.socket?.readyState !== WebSocket.OPEN || !["speaking", "thinking"].includes(this.snapshot.status))
295
+ return;
296
+ this.clearPlayback();
297
+ if (this.snapshot.interruptionMode === "none") {
298
+ this.stoppedResponse = !this.responseEnded;
299
+ this.setStatus("listening");
300
+ return;
301
+ }
302
+ this.awaitingClear = true;
303
+ this.setStatus("listening");
304
+ this.socket.send(JSON.stringify({ type: "cancel" }));
305
+ };
306
+ fail(error) {
307
+ this.cleanup();
308
+ const denied = error instanceof Error && (error.name === "NotAllowedError" || /Permission denied|NotAllowed/.test(error.message));
309
+ this.update({
310
+ status: "error",
311
+ errorDetails: void 0,
312
+ audioLevel: 0,
313
+ interimTranscript: null,
314
+ error: denied ? "Microphone access denied. Please check browser permissions." : error instanceof Error ? error.message : "Failed to start voice call"
315
+ });
316
+ }
317
+ cleanup() {
318
+ this.generation += 1;
319
+ if (this.processor) this.processor.onaudioprocess = null;
320
+ this.processor?.disconnect();
321
+ this.processor = null;
322
+ this.source?.disconnect();
323
+ this.source = null;
324
+ this.stream?.getTracks().forEach((track) => track.stop());
325
+ this.stream = null;
326
+ void this.context?.close().catch(() => {
327
+ });
328
+ this.context = null;
329
+ this.player?.close();
330
+ this.player = null;
331
+ const socket = this.socket;
332
+ this.socket = null;
333
+ if (socket) {
334
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
335
+ socket.close(1e3, "User ended call");
336
+ }
337
+ this.playbackRevision += 1;
338
+ this.pushChain = Promise.resolve();
339
+ this.hasPendingAudio = false;
340
+ this.awaitingClear = false;
341
+ this.stoppedResponse = false;
342
+ this.responseEnded = true;
343
+ }
344
+ clearPlayback() {
345
+ this.playbackRevision += 1;
346
+ this.pushChain = Promise.resolve();
347
+ this.player?.clear();
348
+ this.hasPendingAudio = false;
349
+ }
350
+ handleMessage(data, player) {
351
+ if (data instanceof ArrayBuffer || data instanceof Blob) {
352
+ if (this.awaitingClear || this.stoppedResponse) return;
353
+ this.responseEnded = false;
354
+ this.hasPendingAudio = true;
355
+ this.setStatus("speaking");
356
+ const revision = this.playbackRevision;
357
+ this.pushChain = this.pushChain.then(async () => {
358
+ if (revision !== this.playbackRevision) return;
359
+ try {
360
+ await player.push(data);
361
+ } catch (error) {
362
+ if (revision === this.playbackRevision) this.fail(error);
363
+ }
364
+ });
365
+ return;
366
+ }
367
+ if (typeof data !== "string") return;
368
+ let msg;
369
+ try {
370
+ const parsed = JSON.parse(data);
371
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
372
+ msg = parsed;
373
+ } catch {
374
+ return;
375
+ }
376
+ switch (msg.type) {
377
+ case "session_config":
378
+ if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
379
+ this.update({ interruptionMode: msg.interruptionMode });
380
+ }
381
+ break;
382
+ case "transcript_interim":
383
+ this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
384
+ break;
385
+ case "transcript_final":
386
+ if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
387
+ if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
388
+ this.responseEnded = false;
389
+ this.update({
390
+ interimTranscript: null,
391
+ transcript: [
392
+ ...this.snapshot.transcript,
393
+ {
394
+ role: msg.role,
395
+ content: msg.text,
396
+ timestamp: Date.now(),
397
+ ...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
398
+ }
399
+ ],
400
+ status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
401
+ });
402
+ break;
403
+ case "audio_end": {
404
+ this.responseEnded = true;
405
+ if (this.stoppedResponse) {
406
+ this.stoppedResponse = false;
407
+ break;
408
+ }
409
+ if (this.awaitingClear) break;
410
+ const revision = this.playbackRevision;
411
+ this.pushChain = this.pushChain.then(() => {
412
+ if (revision === this.playbackRevision) player.endOfStream();
413
+ });
414
+ if (!this.hasPendingAudio && this.snapshot.status === "speaking")
415
+ this.setStatus("listening");
416
+ break;
417
+ }
418
+ case "audio_clear":
419
+ this.clearPlayback();
420
+ this.awaitingClear = false;
421
+ this.stoppedResponse = false;
422
+ this.responseEnded = true;
423
+ this.setStatus("listening");
424
+ break;
425
+ case "metrics": {
426
+ const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
427
+ this.update({
428
+ metrics: {
429
+ llmMs: number(msg.llm_ms),
430
+ // @snake-case-ok: Existing voice wire contract.
431
+ ttsMs: number(msg.tts_ms),
432
+ // @snake-case-ok: Existing voice wire contract.
433
+ firstAudioMs: number(msg.first_audio_ms),
434
+ // @snake-case-ok: Existing voice wire contract.
435
+ totalMs: number(msg.total_ms)
436
+ // @snake-case-ok: Existing voice wire contract.
437
+ }
438
+ });
439
+ break;
440
+ }
441
+ case "error": {
442
+ const details = msg.details;
443
+ this.fail(
444
+ new Error(
445
+ typeof msg.error === "string" ? msg.error : typeof msg.message === "string" ? msg.message : "Voice error"
446
+ )
447
+ );
448
+ if (details && typeof details.code === "string" && details.code.startsWith("MCP_") && typeof details.serverName === "string" && typeof details.diagnosticId === "string") {
449
+ this.update({
450
+ errorDetails: {
451
+ code: details.code,
452
+ ...typeof details.serverId === "string" ? { serverId: details.serverId } : {},
453
+ serverName: details.serverName,
454
+ diagnosticId: details.diagnosticId
455
+ }
456
+ });
457
+ }
458
+ break;
459
+ }
460
+ }
461
+ }
462
+ startCapture(context, stream, socket, generation) {
463
+ const source = context.createMediaStreamSource(stream);
464
+ this.source = source;
465
+ const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
466
+ this.processor = processor;
467
+ processor.onaudioprocess = (event) => {
468
+ if (generation !== this.generation || this.snapshot.isMuted) return;
469
+ const input = event.inputBuffer.getChannelData(0);
470
+ let sum = 0;
471
+ for (const sample of input) sum += sample * sample;
472
+ this.update({ audioLevel: Math.sqrt(sum / input.length) });
473
+ if (socket.readyState !== WebSocket.OPEN) return;
474
+ const pcm = new Int16Array(input.length);
475
+ if (this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
476
+ socket.send(pcm.buffer);
477
+ return;
478
+ }
479
+ for (let i = 0; i < input.length; i++) {
480
+ const sample = Math.max(-1, Math.min(1, input[i]));
481
+ pcm[i] = sample < 0 ? sample * 32768 : sample * 32767;
482
+ }
483
+ socket.send(pcm.buffer);
484
+ };
485
+ source.connect(processor);
486
+ processor.connect(context.destination);
487
+ }
488
+ };
@@ -0,0 +1,41 @@
1
+ import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-9SfA7oHc.cjs';
2
+ export { I as InterruptionMode, T as TranscriptEntry, b as VoiceMetrics, c as VoiceStatus } from './types-9SfA7oHc.cjs';
3
+
4
+ /** Browser microphone and playback lifecycle for Runtype's voice WebSocket endpoint. */
5
+ declare class VoiceClient {
6
+ private readonly options;
7
+ private snapshot;
8
+ private readonly listeners;
9
+ private socket;
10
+ private context;
11
+ private stream;
12
+ private source;
13
+ private processor;
14
+ private player;
15
+ private generation;
16
+ private playbackRevision;
17
+ private pushChain;
18
+ private awaitingClear;
19
+ private stoppedResponse;
20
+ private responseEnded;
21
+ private hasPendingAudio;
22
+ constructor(options: VoiceClientOptions);
23
+ getSnapshot: () => VoiceSnapshot;
24
+ subscribe: (listener: () => void) => (() => void);
25
+ private update;
26
+ private setStatus;
27
+ /** Acquire microphone access and open a call. Invoke from a user gesture. Failures populate snapshot.error. */
28
+ startCall: (tokenOverride?: string) => Promise<void>;
29
+ endCall: () => void;
30
+ toggleMute: () => void;
31
+ cancelResponse: () => void;
32
+ /** Stop the current reply explicitly, including local playback when interruptions are disabled. */
33
+ stopPlayback: () => void;
34
+ private fail;
35
+ private cleanup;
36
+ private clearPlayback;
37
+ private handleMessage;
38
+ private startCapture;
39
+ }
40
+
41
+ export { VoiceClient, VoiceClientOptions, VoiceSnapshot };