@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 +21 -0
- package/README.md +147 -0
- package/dist/index.cjs +488 -0
- package/dist/index.d.cts +41 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +465 -0
- package/dist/persona.cjs +580 -0
- package/dist/persona.d.cts +7 -0
- package/dist/persona.d.ts +7 -0
- package/dist/persona.js +557 -0
- package/dist/react.cjs +515 -0
- package/dist/react.d.cts +27 -0
- package/dist/react.d.ts +27 -0
- package/dist/react.js +495 -0
- package/dist/types-9SfA7oHc.d.cts +40 -0
- package/dist/types-9SfA7oHc.d.ts +40 -0
- package/package.json +73 -0
package/dist/react.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/react.ts
|
|
4
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
5
|
+
|
|
6
|
+
// src/pcm-player.ts
|
|
7
|
+
var PCM_SAMPLE_RATE = 24e3;
|
|
8
|
+
var PCM_WATERLINE_SAMPLES = 3600;
|
|
9
|
+
var PCM_PLAYER_WORKLET = `
|
|
10
|
+
class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
11
|
+
constructor() {
|
|
12
|
+
super()
|
|
13
|
+
this.chunks = []
|
|
14
|
+
this.readOffset = 0
|
|
15
|
+
this.buffered = 0
|
|
16
|
+
this.waiting = true
|
|
17
|
+
// INVARIANT: Only report drained after end-of-stream, never during a jitter gap.
|
|
18
|
+
this.eosSeen = false
|
|
19
|
+
this.revision = 0
|
|
20
|
+
this.port.onmessage = (e) => {
|
|
21
|
+
const msg = e.data
|
|
22
|
+
this.revision = msg.revision
|
|
23
|
+
if (msg.type === 'push') {
|
|
24
|
+
this.eosSeen = false
|
|
25
|
+
this.chunks.push(msg.samples)
|
|
26
|
+
this.buffered += msg.samples.length
|
|
27
|
+
if (this.waiting && this.buffered >= ${PCM_WATERLINE_SAMPLES}) {
|
|
28
|
+
this.waiting = false
|
|
29
|
+
}
|
|
30
|
+
} else if (msg.type === 'eos') {
|
|
31
|
+
this.eosSeen = true
|
|
32
|
+
// INVARIANT: Short replies drain even below the waterline.
|
|
33
|
+
if (this.waiting && this.buffered > 0) this.waiting = false
|
|
34
|
+
// INVARIANT: An empty completed stream reports drained immediately.
|
|
35
|
+
if (this.buffered === 0) {
|
|
36
|
+
this.eosSeen = false
|
|
37
|
+
this.port.postMessage({ type: 'drained', revision: this.revision })
|
|
38
|
+
}
|
|
39
|
+
} else if (msg.type === 'clear') {
|
|
40
|
+
this.chunks = []
|
|
41
|
+
this.readOffset = 0
|
|
42
|
+
this.buffered = 0
|
|
43
|
+
this.waiting = true
|
|
44
|
+
this.eosSeen = false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
process(inputs, outputs) {
|
|
49
|
+
const out = outputs[0][0]
|
|
50
|
+
if (!out || this.waiting) return true // outputs are pre-zeroed: silence
|
|
51
|
+
let i = 0
|
|
52
|
+
while (i < out.length && this.buffered > 0) {
|
|
53
|
+
const chunk = this.chunks[0]
|
|
54
|
+
out[i++] = chunk[this.readOffset++]
|
|
55
|
+
this.buffered--
|
|
56
|
+
if (this.readOffset >= chunk.length) {
|
|
57
|
+
this.chunks.shift()
|
|
58
|
+
this.readOffset = 0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (this.buffered === 0) {
|
|
62
|
+
this.waiting = true // mid-reply underrun: re-buffer silently
|
|
63
|
+
if (this.eosSeen) {
|
|
64
|
+
this.eosSeen = false
|
|
65
|
+
this.port.postMessage({ type: 'drained', revision: this.revision })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
registerProcessor('runtype-pcm-player', RuntypePcmPlayerProcessor)
|
|
72
|
+
`;
|
|
73
|
+
async function createPcmPlayer(onDrained) {
|
|
74
|
+
let revision = 0;
|
|
75
|
+
const context = new AudioContext({ sampleRate: PCM_SAMPLE_RATE });
|
|
76
|
+
const moduleUrl = URL.createObjectURL(
|
|
77
|
+
new Blob([PCM_PLAYER_WORKLET], { type: "application/javascript" })
|
|
78
|
+
);
|
|
79
|
+
try {
|
|
80
|
+
if (context.state === "suspended") await context.resume();
|
|
81
|
+
await context.audioWorklet.addModule(moduleUrl);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
context.close().catch(() => {
|
|
84
|
+
});
|
|
85
|
+
throw err;
|
|
86
|
+
} finally {
|
|
87
|
+
URL.revokeObjectURL(moduleUrl);
|
|
88
|
+
}
|
|
89
|
+
const node = new AudioWorkletNode(context, "runtype-pcm-player", {
|
|
90
|
+
numberOfInputs: 0,
|
|
91
|
+
numberOfOutputs: 1,
|
|
92
|
+
outputChannelCount: [1]
|
|
93
|
+
});
|
|
94
|
+
node.port.onmessage = (e) => {
|
|
95
|
+
if (e.data?.type === "drained" && e.data.revision === revision) onDrained();
|
|
96
|
+
};
|
|
97
|
+
node.connect(context.destination);
|
|
98
|
+
return {
|
|
99
|
+
async push(data) {
|
|
100
|
+
const pushRevision = revision;
|
|
101
|
+
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
|
|
102
|
+
if (pushRevision !== revision) return;
|
|
103
|
+
const samples = pcm16FrameToFloat32(buffer);
|
|
104
|
+
if (samples.length === 0) return;
|
|
105
|
+
node.port.postMessage({ type: "push", samples, revision }, [samples.buffer]);
|
|
106
|
+
},
|
|
107
|
+
endOfStream() {
|
|
108
|
+
node.port.postMessage({ type: "eos", revision });
|
|
109
|
+
},
|
|
110
|
+
clear() {
|
|
111
|
+
revision += 1;
|
|
112
|
+
node.port.postMessage({ type: "clear", revision });
|
|
113
|
+
},
|
|
114
|
+
close() {
|
|
115
|
+
revision += 1;
|
|
116
|
+
node.port.onmessage = null;
|
|
117
|
+
node.disconnect();
|
|
118
|
+
context.close().catch(() => {
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function pcm16FrameToFloat32(buffer) {
|
|
124
|
+
const view = new DataView(buffer);
|
|
125
|
+
let offset = 0;
|
|
126
|
+
if (buffer.byteLength >= 44 && view.getUint32(0, false) === 1380533830) {
|
|
127
|
+
offset = 44;
|
|
128
|
+
}
|
|
129
|
+
const sampleCount = Math.floor((buffer.byteLength - offset) / 2);
|
|
130
|
+
const out = new Float32Array(sampleCount);
|
|
131
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
132
|
+
out[i] = view.getInt16(offset + i * 2, true) / 32768;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/voice-client.ts
|
|
138
|
+
var CAPTURE_SAMPLE_RATE = 16e3;
|
|
139
|
+
var CAPTURE_BUFFER_SIZE = 4096;
|
|
140
|
+
function initialSnapshot() {
|
|
141
|
+
return {
|
|
142
|
+
status: "idle",
|
|
143
|
+
transcript: [],
|
|
144
|
+
interimTranscript: null,
|
|
145
|
+
metrics: null,
|
|
146
|
+
audioLevel: 0,
|
|
147
|
+
isMuted: false,
|
|
148
|
+
error: null,
|
|
149
|
+
errorDetails: void 0,
|
|
150
|
+
interruptionMode: "none",
|
|
151
|
+
canCancel: false
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
var VoiceClient = class {
|
|
155
|
+
constructor(options) {
|
|
156
|
+
this.options = options;
|
|
157
|
+
}
|
|
158
|
+
options;
|
|
159
|
+
snapshot = initialSnapshot();
|
|
160
|
+
listeners = /* @__PURE__ */ new Set();
|
|
161
|
+
socket = null;
|
|
162
|
+
context = null;
|
|
163
|
+
stream = null;
|
|
164
|
+
source = null;
|
|
165
|
+
processor = null;
|
|
166
|
+
player = null;
|
|
167
|
+
generation = 0;
|
|
168
|
+
playbackRevision = 0;
|
|
169
|
+
pushChain = Promise.resolve();
|
|
170
|
+
awaitingClear = false;
|
|
171
|
+
stoppedResponse = false;
|
|
172
|
+
responseEnded = true;
|
|
173
|
+
hasPendingAudio = false;
|
|
174
|
+
getSnapshot = () => this.snapshot;
|
|
175
|
+
subscribe = (listener) => {
|
|
176
|
+
this.listeners.add(listener);
|
|
177
|
+
return () => {
|
|
178
|
+
this.listeners.delete(listener);
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
update(patch) {
|
|
182
|
+
const next = { ...this.snapshot, ...patch };
|
|
183
|
+
next.canCancel = !this.awaitingClear && next.interruptionMode !== "none" && (next.status === "speaking" || next.status === "thinking");
|
|
184
|
+
this.snapshot = next;
|
|
185
|
+
for (const listener of this.listeners) listener();
|
|
186
|
+
}
|
|
187
|
+
setStatus(status) {
|
|
188
|
+
this.update({ status });
|
|
189
|
+
}
|
|
190
|
+
/** Acquire microphone access and open a call. Invoke from a user gesture. Failures populate snapshot.error. */
|
|
191
|
+
startCall = async (tokenOverride) => {
|
|
192
|
+
if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
|
|
193
|
+
this.cleanup();
|
|
194
|
+
const generation = this.generation;
|
|
195
|
+
this.update({ ...initialSnapshot(), status: "connecting" });
|
|
196
|
+
try {
|
|
197
|
+
const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
|
|
198
|
+
if (generation !== this.generation) return;
|
|
199
|
+
if (!token) throw new Error("Voice token unavailable. Please retry.");
|
|
200
|
+
if (!this.options.agentId) throw new Error("Voice requires an agentId.");
|
|
201
|
+
const url = new URL(this.options.apiUrl ?? "https://api.runtype.com");
|
|
202
|
+
if (!["https:", "http:", "wss:", "ws:"].includes(url.protocol) || url.username || url.password) {
|
|
203
|
+
throw new Error("Voice requires an HTTP or WebSocket API base URL without credentials.");
|
|
204
|
+
}
|
|
205
|
+
url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
|
|
206
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
207
|
+
url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
|
|
208
|
+
url.search = "";
|
|
209
|
+
url.searchParams.set("voiceProtocol", "runtype-browser-v1");
|
|
210
|
+
url.hash = "";
|
|
211
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
212
|
+
audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
|
|
213
|
+
});
|
|
214
|
+
if (generation !== this.generation) {
|
|
215
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.stream = stream;
|
|
219
|
+
const context = new AudioContext({ sampleRate: CAPTURE_SAMPLE_RATE });
|
|
220
|
+
this.context = context;
|
|
221
|
+
if (context.state === "suspended") await context.resume();
|
|
222
|
+
if (generation !== this.generation) return;
|
|
223
|
+
const player = await createPcmPlayer(() => {
|
|
224
|
+
if (generation !== this.generation) return;
|
|
225
|
+
this.hasPendingAudio = false;
|
|
226
|
+
if (this.snapshot.status === "speaking") this.setStatus("listening");
|
|
227
|
+
});
|
|
228
|
+
if (generation !== this.generation) {
|
|
229
|
+
player.close();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.player = player;
|
|
233
|
+
const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
|
|
234
|
+
socket.binaryType = "arraybuffer";
|
|
235
|
+
this.socket = socket;
|
|
236
|
+
socket.onopen = () => {
|
|
237
|
+
if (generation !== this.generation) return;
|
|
238
|
+
try {
|
|
239
|
+
this.startCapture(context, stream, socket, generation);
|
|
240
|
+
this.setStatus("listening");
|
|
241
|
+
} catch (error) {
|
|
242
|
+
this.fail(error);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
socket.onmessage = (event) => {
|
|
246
|
+
if (generation === this.generation) this.handleMessage(event.data, player);
|
|
247
|
+
};
|
|
248
|
+
socket.onerror = () => {
|
|
249
|
+
if (generation === this.generation) this.fail(new Error("Voice connection failed"));
|
|
250
|
+
};
|
|
251
|
+
socket.onclose = (event) => {
|
|
252
|
+
if (generation !== this.generation) return;
|
|
253
|
+
if (event.code === 1e3) this.endCall();
|
|
254
|
+
else this.fail(new Error(`Connection closed: ${event.reason || "unknown reason"}`));
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (generation === this.generation) this.fail(error);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
endCall = () => {
|
|
261
|
+
this.cleanup();
|
|
262
|
+
this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
|
|
263
|
+
};
|
|
264
|
+
toggleMute = () => {
|
|
265
|
+
this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
|
|
266
|
+
};
|
|
267
|
+
cancelResponse = () => {
|
|
268
|
+
if (!this.snapshot.canCancel) return;
|
|
269
|
+
this.stopPlayback();
|
|
270
|
+
};
|
|
271
|
+
/** Stop the current reply explicitly, including local playback when interruptions are disabled. */
|
|
272
|
+
stopPlayback = () => {
|
|
273
|
+
if (this.awaitingClear || this.socket?.readyState !== WebSocket.OPEN || !["speaking", "thinking"].includes(this.snapshot.status))
|
|
274
|
+
return;
|
|
275
|
+
this.clearPlayback();
|
|
276
|
+
if (this.snapshot.interruptionMode === "none") {
|
|
277
|
+
this.stoppedResponse = !this.responseEnded;
|
|
278
|
+
this.setStatus("listening");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.awaitingClear = true;
|
|
282
|
+
this.setStatus("listening");
|
|
283
|
+
this.socket.send(JSON.stringify({ type: "cancel" }));
|
|
284
|
+
};
|
|
285
|
+
fail(error) {
|
|
286
|
+
this.cleanup();
|
|
287
|
+
const denied = error instanceof Error && (error.name === "NotAllowedError" || /Permission denied|NotAllowed/.test(error.message));
|
|
288
|
+
this.update({
|
|
289
|
+
status: "error",
|
|
290
|
+
errorDetails: void 0,
|
|
291
|
+
audioLevel: 0,
|
|
292
|
+
interimTranscript: null,
|
|
293
|
+
error: denied ? "Microphone access denied. Please check browser permissions." : error instanceof Error ? error.message : "Failed to start voice call"
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
cleanup() {
|
|
297
|
+
this.generation += 1;
|
|
298
|
+
if (this.processor) this.processor.onaudioprocess = null;
|
|
299
|
+
this.processor?.disconnect();
|
|
300
|
+
this.processor = null;
|
|
301
|
+
this.source?.disconnect();
|
|
302
|
+
this.source = null;
|
|
303
|
+
this.stream?.getTracks().forEach((track) => track.stop());
|
|
304
|
+
this.stream = null;
|
|
305
|
+
void this.context?.close().catch(() => {
|
|
306
|
+
});
|
|
307
|
+
this.context = null;
|
|
308
|
+
this.player?.close();
|
|
309
|
+
this.player = null;
|
|
310
|
+
const socket = this.socket;
|
|
311
|
+
this.socket = null;
|
|
312
|
+
if (socket) {
|
|
313
|
+
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
|
|
314
|
+
socket.close(1e3, "User ended call");
|
|
315
|
+
}
|
|
316
|
+
this.playbackRevision += 1;
|
|
317
|
+
this.pushChain = Promise.resolve();
|
|
318
|
+
this.hasPendingAudio = false;
|
|
319
|
+
this.awaitingClear = false;
|
|
320
|
+
this.stoppedResponse = false;
|
|
321
|
+
this.responseEnded = true;
|
|
322
|
+
}
|
|
323
|
+
clearPlayback() {
|
|
324
|
+
this.playbackRevision += 1;
|
|
325
|
+
this.pushChain = Promise.resolve();
|
|
326
|
+
this.player?.clear();
|
|
327
|
+
this.hasPendingAudio = false;
|
|
328
|
+
}
|
|
329
|
+
handleMessage(data, player) {
|
|
330
|
+
if (data instanceof ArrayBuffer || data instanceof Blob) {
|
|
331
|
+
if (this.awaitingClear || this.stoppedResponse) return;
|
|
332
|
+
this.responseEnded = false;
|
|
333
|
+
this.hasPendingAudio = true;
|
|
334
|
+
this.setStatus("speaking");
|
|
335
|
+
const revision = this.playbackRevision;
|
|
336
|
+
this.pushChain = this.pushChain.then(async () => {
|
|
337
|
+
if (revision !== this.playbackRevision) return;
|
|
338
|
+
try {
|
|
339
|
+
await player.push(data);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (revision === this.playbackRevision) this.fail(error);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (typeof data !== "string") return;
|
|
347
|
+
let msg;
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(data);
|
|
350
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
351
|
+
msg = parsed;
|
|
352
|
+
} catch {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
switch (msg.type) {
|
|
356
|
+
case "session_config":
|
|
357
|
+
if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
|
|
358
|
+
this.update({ interruptionMode: msg.interruptionMode });
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
case "transcript_interim":
|
|
362
|
+
this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
|
|
363
|
+
break;
|
|
364
|
+
case "transcript_final":
|
|
365
|
+
if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
|
|
366
|
+
if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
|
|
367
|
+
this.responseEnded = false;
|
|
368
|
+
this.update({
|
|
369
|
+
interimTranscript: null,
|
|
370
|
+
transcript: [
|
|
371
|
+
...this.snapshot.transcript,
|
|
372
|
+
{
|
|
373
|
+
role: msg.role,
|
|
374
|
+
content: msg.text,
|
|
375
|
+
timestamp: Date.now(),
|
|
376
|
+
...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
|
|
377
|
+
}
|
|
378
|
+
],
|
|
379
|
+
status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
|
|
380
|
+
});
|
|
381
|
+
break;
|
|
382
|
+
case "audio_end": {
|
|
383
|
+
this.responseEnded = true;
|
|
384
|
+
if (this.stoppedResponse) {
|
|
385
|
+
this.stoppedResponse = false;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
if (this.awaitingClear) break;
|
|
389
|
+
const revision = this.playbackRevision;
|
|
390
|
+
this.pushChain = this.pushChain.then(() => {
|
|
391
|
+
if (revision === this.playbackRevision) player.endOfStream();
|
|
392
|
+
});
|
|
393
|
+
if (!this.hasPendingAudio && this.snapshot.status === "speaking")
|
|
394
|
+
this.setStatus("listening");
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "audio_clear":
|
|
398
|
+
this.clearPlayback();
|
|
399
|
+
this.awaitingClear = false;
|
|
400
|
+
this.stoppedResponse = false;
|
|
401
|
+
this.responseEnded = true;
|
|
402
|
+
this.setStatus("listening");
|
|
403
|
+
break;
|
|
404
|
+
case "metrics": {
|
|
405
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
406
|
+
this.update({
|
|
407
|
+
metrics: {
|
|
408
|
+
llmMs: number(msg.llm_ms),
|
|
409
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
410
|
+
ttsMs: number(msg.tts_ms),
|
|
411
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
412
|
+
firstAudioMs: number(msg.first_audio_ms),
|
|
413
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
414
|
+
totalMs: number(msg.total_ms)
|
|
415
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "error": {
|
|
421
|
+
const details = msg.details;
|
|
422
|
+
this.fail(
|
|
423
|
+
new Error(
|
|
424
|
+
typeof msg.error === "string" ? msg.error : typeof msg.message === "string" ? msg.message : "Voice error"
|
|
425
|
+
)
|
|
426
|
+
);
|
|
427
|
+
if (details && typeof details.code === "string" && details.code.startsWith("MCP_") && typeof details.serverName === "string" && typeof details.diagnosticId === "string") {
|
|
428
|
+
this.update({
|
|
429
|
+
errorDetails: {
|
|
430
|
+
code: details.code,
|
|
431
|
+
...typeof details.serverId === "string" ? { serverId: details.serverId } : {},
|
|
432
|
+
serverName: details.serverName,
|
|
433
|
+
diagnosticId: details.diagnosticId
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
startCapture(context, stream, socket, generation) {
|
|
442
|
+
const source = context.createMediaStreamSource(stream);
|
|
443
|
+
this.source = source;
|
|
444
|
+
const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
|
|
445
|
+
this.processor = processor;
|
|
446
|
+
processor.onaudioprocess = (event) => {
|
|
447
|
+
if (generation !== this.generation || this.snapshot.isMuted) return;
|
|
448
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
449
|
+
let sum = 0;
|
|
450
|
+
for (const sample of input) sum += sample * sample;
|
|
451
|
+
this.update({ audioLevel: Math.sqrt(sum / input.length) });
|
|
452
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
453
|
+
const pcm = new Int16Array(input.length);
|
|
454
|
+
if (this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
|
|
455
|
+
socket.send(pcm.buffer);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
for (let i = 0; i < input.length; i++) {
|
|
459
|
+
const sample = Math.max(-1, Math.min(1, input[i]));
|
|
460
|
+
pcm[i] = sample < 0 ? sample * 32768 : sample * 32767;
|
|
461
|
+
}
|
|
462
|
+
socket.send(pcm.buffer);
|
|
463
|
+
};
|
|
464
|
+
source.connect(processor);
|
|
465
|
+
processor.connect(context.destination);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// src/react.ts
|
|
470
|
+
function useVoiceClient({ agentId, apiUrl, clientToken }) {
|
|
471
|
+
const tokenRef = useRef(clientToken);
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
tokenRef.current = clientToken;
|
|
474
|
+
}, [clientToken]);
|
|
475
|
+
const client = useMemo(
|
|
476
|
+
() => new VoiceClient({
|
|
477
|
+
agentId,
|
|
478
|
+
apiUrl,
|
|
479
|
+
clientToken: () => typeof tokenRef.current === "function" ? tokenRef.current() : tokenRef.current
|
|
480
|
+
}),
|
|
481
|
+
[agentId, apiUrl]
|
|
482
|
+
);
|
|
483
|
+
const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
484
|
+
useEffect(() => () => client.endCall(), [client]);
|
|
485
|
+
return {
|
|
486
|
+
...snapshot,
|
|
487
|
+
startCall: client.startCall,
|
|
488
|
+
endCall: client.endCall,
|
|
489
|
+
toggleMute: client.toggleMute,
|
|
490
|
+
cancelResponse: client.cancelResponse
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
export {
|
|
494
|
+
useVoiceClient
|
|
495
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type VoiceStatus = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'error';
|
|
2
|
+
type InterruptionMode = 'none' | 'cancel' | 'barge-in';
|
|
3
|
+
interface TranscriptEntry {
|
|
4
|
+
readonly role: 'user' | 'assistant';
|
|
5
|
+
readonly content: string;
|
|
6
|
+
readonly timestamp: number;
|
|
7
|
+
readonly turnId?: string;
|
|
8
|
+
}
|
|
9
|
+
interface VoiceMetrics {
|
|
10
|
+
readonly llmMs?: number;
|
|
11
|
+
readonly ttsMs?: number;
|
|
12
|
+
readonly firstAudioMs?: number;
|
|
13
|
+
readonly totalMs?: number;
|
|
14
|
+
}
|
|
15
|
+
interface VoiceSnapshot {
|
|
16
|
+
readonly status: VoiceStatus;
|
|
17
|
+
readonly transcript: readonly TranscriptEntry[];
|
|
18
|
+
readonly interimTranscript: string | null;
|
|
19
|
+
readonly metrics: VoiceMetrics | null;
|
|
20
|
+
readonly audioLevel: number;
|
|
21
|
+
readonly isMuted: boolean;
|
|
22
|
+
readonly error: string | null;
|
|
23
|
+
readonly errorDetails?: {
|
|
24
|
+
code: string;
|
|
25
|
+
serverId?: string;
|
|
26
|
+
serverName: string;
|
|
27
|
+
diagnosticId: string;
|
|
28
|
+
};
|
|
29
|
+
readonly interruptionMode: InterruptionMode;
|
|
30
|
+
readonly canCancel: boolean;
|
|
31
|
+
}
|
|
32
|
+
interface VoiceClientOptions {
|
|
33
|
+
agentId: string;
|
|
34
|
+
/** Browser client token, or a getter called once per call to obtain a fresh token. */
|
|
35
|
+
clientToken: string | (() => string | Promise<string>);
|
|
36
|
+
/** Absolute API base URL, including any proxy path prefix. Defaults to https://api.runtype.com. */
|
|
37
|
+
apiUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { InterruptionMode as I, TranscriptEntry as T, VoiceClientOptions as V, VoiceSnapshot as a, VoiceMetrics as b, VoiceStatus as c };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type VoiceStatus = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'error';
|
|
2
|
+
type InterruptionMode = 'none' | 'cancel' | 'barge-in';
|
|
3
|
+
interface TranscriptEntry {
|
|
4
|
+
readonly role: 'user' | 'assistant';
|
|
5
|
+
readonly content: string;
|
|
6
|
+
readonly timestamp: number;
|
|
7
|
+
readonly turnId?: string;
|
|
8
|
+
}
|
|
9
|
+
interface VoiceMetrics {
|
|
10
|
+
readonly llmMs?: number;
|
|
11
|
+
readonly ttsMs?: number;
|
|
12
|
+
readonly firstAudioMs?: number;
|
|
13
|
+
readonly totalMs?: number;
|
|
14
|
+
}
|
|
15
|
+
interface VoiceSnapshot {
|
|
16
|
+
readonly status: VoiceStatus;
|
|
17
|
+
readonly transcript: readonly TranscriptEntry[];
|
|
18
|
+
readonly interimTranscript: string | null;
|
|
19
|
+
readonly metrics: VoiceMetrics | null;
|
|
20
|
+
readonly audioLevel: number;
|
|
21
|
+
readonly isMuted: boolean;
|
|
22
|
+
readonly error: string | null;
|
|
23
|
+
readonly errorDetails?: {
|
|
24
|
+
code: string;
|
|
25
|
+
serverId?: string;
|
|
26
|
+
serverName: string;
|
|
27
|
+
diagnosticId: string;
|
|
28
|
+
};
|
|
29
|
+
readonly interruptionMode: InterruptionMode;
|
|
30
|
+
readonly canCancel: boolean;
|
|
31
|
+
}
|
|
32
|
+
interface VoiceClientOptions {
|
|
33
|
+
agentId: string;
|
|
34
|
+
/** Browser client token, or a getter called once per call to obtain a fresh token. */
|
|
35
|
+
clientToken: string | (() => string | Promise<string>);
|
|
36
|
+
/** Absolute API base URL, including any proxy path prefix. Defaults to https://api.runtype.com. */
|
|
37
|
+
apiUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { InterruptionMode as I, TranscriptEntry as T, VoiceClientOptions as V, VoiceSnapshot as a, VoiceMetrics as b, VoiceStatus as c };
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runtypelabs/voice",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Runtype browser voice client with React and Persona adapters",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./react": {
|
|
18
|
+
"types": "./dist/react.d.ts",
|
|
19
|
+
"import": "./dist/react.js",
|
|
20
|
+
"require": "./dist/react.cjs"
|
|
21
|
+
},
|
|
22
|
+
"./persona": {
|
|
23
|
+
"types": "./dist/persona.d.ts",
|
|
24
|
+
"import": "./dist/persona.js",
|
|
25
|
+
"require": "./dist/persona.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup",
|
|
35
|
+
"dev": "tsup --watch",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typecheck": "tsgo --noEmit",
|
|
38
|
+
"lint": "eslint .",
|
|
39
|
+
"prepublishOnly": "pnpm build"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18 <20",
|
|
43
|
+
"@runtypelabs/persona": ">=4.22.0 <5"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@runtypelabs/persona": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@runtypelabs/persona": "4.22.0",
|
|
55
|
+
"@testing-library/react": "^16.3.2",
|
|
56
|
+
"@types/react": "^19",
|
|
57
|
+
"@types/react-dom": "^19",
|
|
58
|
+
"jsdom": "^30.0.1",
|
|
59
|
+
"react": "^19",
|
|
60
|
+
"react-dom": "^19",
|
|
61
|
+
"tsup": "^8.0.0",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"vitest": "^4.1.0"
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "https://github.com/runtypelabs/core",
|
|
68
|
+
"directory": "packages/voice"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|