@runtypelabs/voice 0.2.4 → 0.4.0
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/README.md +149 -4
- package/dist/index.cjs +257 -25
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +257 -25
- package/dist/persona.cjs +319 -30
- package/dist/persona.d.cts +23 -4
- package/dist/persona.d.ts +23 -4
- package/dist/persona.js +319 -30
- package/dist/react.cjs +320 -32
- package/dist/react.d.cts +7 -4
- package/dist/react.d.ts +7 -4
- package/dist/react.js +321 -33
- package/dist/types-Dkh4LxEA.d.cts +100 -0
- package/dist/types-Dkh4LxEA.d.ts +100 -0
- package/package.json +1 -1
- package/dist/types-9SfA7oHc.d.cts +0 -40
- package/dist/types-9SfA7oHc.d.ts +0 -40
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-
|
|
2
|
-
export { I as InterruptionMode, T as TranscriptEntry, b as
|
|
1
|
+
import { V as VoiceClientOptions, a as VoiceSnapshot } from './types-Dkh4LxEA.js';
|
|
2
|
+
export { I as InterruptionMode, T as TranscriptEntry, b as VoiceArtifact, c as VoiceArtifactFile, d as VoiceMetrics, e as VoiceSession, f as VoiceStatus } from './types-Dkh4LxEA.js';
|
|
3
3
|
|
|
4
4
|
/** Browser microphone and playback lifecycle for Runtype's voice WebSocket endpoint. */
|
|
5
5
|
declare class VoiceClient {
|
|
@@ -19,6 +19,17 @@ declare class VoiceClient {
|
|
|
19
19
|
private stoppedResponse;
|
|
20
20
|
private responseEnded;
|
|
21
21
|
private hasPendingAudio;
|
|
22
|
+
/**
|
|
23
|
+
* The assistant entry built from clause captions for the turn being spoken. The
|
|
24
|
+
* authoritative transcript replaces it; a stop commits it, because it captions
|
|
25
|
+
* audio the caller already heard.
|
|
26
|
+
*/
|
|
27
|
+
private pendingAssistant;
|
|
28
|
+
/** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
|
|
29
|
+
private committedAssistantTurnId;
|
|
30
|
+
private activeTurnId;
|
|
31
|
+
private suppressedArtifactTurnId;
|
|
32
|
+
private fullDuplex;
|
|
22
33
|
constructor(options: VoiceClientOptions);
|
|
23
34
|
getSnapshot: () => VoiceSnapshot;
|
|
24
35
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -27,12 +38,24 @@ declare class VoiceClient {
|
|
|
27
38
|
/** Acquire microphone access and open a call. Invoke from a user gesture. Failures populate snapshot.error. */
|
|
28
39
|
startCall: (tokenOverride?: string) => Promise<void>;
|
|
29
40
|
endCall: () => void;
|
|
41
|
+
/** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
|
|
42
|
+
resetSession: () => void;
|
|
30
43
|
toggleMute: () => void;
|
|
31
44
|
cancelResponse: () => void;
|
|
32
45
|
/** Stop the current reply explicitly, including local playback when interruptions are disabled. */
|
|
33
46
|
stopPlayback: () => void;
|
|
34
47
|
private fail;
|
|
35
48
|
private cleanup;
|
|
49
|
+
/**
|
|
50
|
+
* Folds an `artifact` message into the snapshot. Frames of a turn the caller
|
|
51
|
+
* cancelled are dropped; a playback-only stop leaves the turn running, so its
|
|
52
|
+
* artifacts keep arriving and are kept.
|
|
53
|
+
*/
|
|
54
|
+
private applyArtifactMessage;
|
|
55
|
+
/** Settles the spoken clauses in place and ends the turn's claim on them. */
|
|
56
|
+
private commitClauseCaptions;
|
|
57
|
+
/** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
|
|
58
|
+
private appendClauseCaption;
|
|
36
59
|
private clearPlayback;
|
|
37
60
|
private handleMessage;
|
|
38
61
|
private startCapture;
|
package/dist/index.js
CHANGED
|
@@ -9,13 +9,16 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
|
9
9
|
this.readOffset = 0
|
|
10
10
|
this.buffered = 0
|
|
11
11
|
this.waiting = true
|
|
12
|
-
// INVARIANT:
|
|
12
|
+
// INVARIANT: Pipeline replies need EOS; continuous sessions report queue drain without a provider turn boundary.
|
|
13
13
|
this.eosSeen = false
|
|
14
|
+
this.continuous = false
|
|
14
15
|
this.revision = 0
|
|
15
16
|
this.port.onmessage = (e) => {
|
|
16
17
|
const msg = e.data
|
|
17
18
|
this.revision = msg.revision
|
|
18
|
-
if (msg.type === '
|
|
19
|
+
if (msg.type === 'continuous') {
|
|
20
|
+
this.continuous = msg.enabled
|
|
21
|
+
} else if (msg.type === 'push') {
|
|
19
22
|
this.eosSeen = false
|
|
20
23
|
this.chunks.push(msg.samples)
|
|
21
24
|
this.buffered += msg.samples.length
|
|
@@ -55,7 +58,7 @@ class RuntypePcmPlayerProcessor extends AudioWorkletProcessor {
|
|
|
55
58
|
}
|
|
56
59
|
if (this.buffered === 0) {
|
|
57
60
|
this.waiting = true // mid-reply underrun: re-buffer silently
|
|
58
|
-
if (this.eosSeen) {
|
|
61
|
+
if (this.eosSeen || this.continuous) {
|
|
59
62
|
this.eosSeen = false
|
|
60
63
|
this.port.postMessage({ type: 'drained', revision: this.revision })
|
|
61
64
|
}
|
|
@@ -99,6 +102,9 @@ async function createPcmPlayer(onDrained) {
|
|
|
99
102
|
if (samples.length === 0) return;
|
|
100
103
|
node.port.postMessage({ type: "push", samples, revision }, [samples.buffer]);
|
|
101
104
|
},
|
|
105
|
+
setContinuousMode(enabled) {
|
|
106
|
+
node.port.postMessage({ type: "continuous", enabled, revision });
|
|
107
|
+
},
|
|
102
108
|
endOfStream() {
|
|
103
109
|
node.port.postMessage({ type: "eos", revision });
|
|
104
110
|
},
|
|
@@ -143,9 +149,58 @@ function initialSnapshot() {
|
|
|
143
149
|
error: null,
|
|
144
150
|
errorDetails: void 0,
|
|
145
151
|
interruptionMode: "none",
|
|
146
|
-
canCancel: false
|
|
152
|
+
canCancel: false,
|
|
153
|
+
artifacts: [],
|
|
154
|
+
session: null
|
|
147
155
|
};
|
|
148
156
|
}
|
|
157
|
+
function readString(value) {
|
|
158
|
+
return typeof value === "string" && value ? value : void 0;
|
|
159
|
+
}
|
|
160
|
+
function readArtifactFile(value) {
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
162
|
+
const file = value;
|
|
163
|
+
const path = readString(file.path);
|
|
164
|
+
const mimeType = readString(file.mimeType);
|
|
165
|
+
if (!path || !mimeType) return void 0;
|
|
166
|
+
const language = readString(file.language);
|
|
167
|
+
return { path, mimeType, ...language ? { language } : {} };
|
|
168
|
+
}
|
|
169
|
+
function applyArtifactFrame(existing, frame, identity) {
|
|
170
|
+
if (frame.type === "artifact_start") {
|
|
171
|
+
const title = readString(frame.title);
|
|
172
|
+
const component = readString(frame.component);
|
|
173
|
+
const file = readArtifactFile(frame.file);
|
|
174
|
+
return {
|
|
175
|
+
...identity,
|
|
176
|
+
artifactType: frame.artifactType === "component" ? "component" : "markdown",
|
|
177
|
+
...title ? { title } : {},
|
|
178
|
+
...file ? { file } : {},
|
|
179
|
+
...component ? { component } : {},
|
|
180
|
+
content: "",
|
|
181
|
+
status: "streaming"
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!existing) return void 0;
|
|
185
|
+
if (frame.type === "artifact_delta") {
|
|
186
|
+
const delta = typeof frame.delta === "string" ? frame.delta : "";
|
|
187
|
+
return delta ? { ...existing, content: existing.content + delta } : void 0;
|
|
188
|
+
}
|
|
189
|
+
if (frame.type === "artifact_update") {
|
|
190
|
+
const component = readString(frame.component) ?? existing.component;
|
|
191
|
+
const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
|
|
192
|
+
return {
|
|
193
|
+
...existing,
|
|
194
|
+
...component ? { component } : {},
|
|
195
|
+
...props ? { props } : {}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
|
|
199
|
+
return void 0;
|
|
200
|
+
}
|
|
201
|
+
function readTurnId(msg) {
|
|
202
|
+
return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
|
|
203
|
+
}
|
|
149
204
|
var VoiceClient = class {
|
|
150
205
|
constructor(options) {
|
|
151
206
|
this.options = options;
|
|
@@ -166,6 +221,17 @@ var VoiceClient = class {
|
|
|
166
221
|
stoppedResponse = false;
|
|
167
222
|
responseEnded = true;
|
|
168
223
|
hasPendingAudio = false;
|
|
224
|
+
/**
|
|
225
|
+
* The assistant entry built from clause captions for the turn being spoken. The
|
|
226
|
+
* authoritative transcript replaces it; a stop commits it, because it captions
|
|
227
|
+
* audio the caller already heard.
|
|
228
|
+
*/
|
|
229
|
+
pendingAssistant = null;
|
|
230
|
+
/** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
|
|
231
|
+
committedAssistantTurnId = null;
|
|
232
|
+
activeTurnId = null;
|
|
233
|
+
suppressedArtifactTurnId = null;
|
|
234
|
+
fullDuplex = false;
|
|
169
235
|
getSnapshot = () => this.snapshot;
|
|
170
236
|
subscribe = (listener) => {
|
|
171
237
|
this.listeners.add(listener);
|
|
@@ -187,7 +253,7 @@ var VoiceClient = class {
|
|
|
187
253
|
if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
|
|
188
254
|
this.cleanup();
|
|
189
255
|
const generation = this.generation;
|
|
190
|
-
this.update({ ...initialSnapshot(), status: "connecting" });
|
|
256
|
+
this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
|
|
191
257
|
try {
|
|
192
258
|
const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
|
|
193
259
|
if (generation !== this.generation) return;
|
|
@@ -202,10 +268,26 @@ var VoiceClient = class {
|
|
|
202
268
|
url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
|
|
203
269
|
url.search = "";
|
|
204
270
|
url.searchParams.set("voiceProtocol", "runtype-browser-v1");
|
|
271
|
+
const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
|
|
272
|
+
url.searchParams.set("clientCapabilities", capabilities);
|
|
273
|
+
const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
|
|
274
|
+
if (sessionId) url.searchParams.set("sessionId", sessionId);
|
|
275
|
+
const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
|
|
276
|
+
if (generation !== this.generation) return;
|
|
277
|
+
if (this.options.visitorToken !== void 0 && !visitorToken) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"Voice visitor credential unavailable. Initialize the client session first."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
|
|
283
|
+
throw new Error("Invalid voice visitor credential.");
|
|
284
|
+
}
|
|
285
|
+
if (this.options.fullDuplex !== false)
|
|
286
|
+
url.searchParams.set("voiceCapabilities", "full-duplex-v1");
|
|
205
287
|
url.hash = "";
|
|
206
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
288
|
+
const stream = await (this.options.audioSource?.() ?? navigator.mediaDevices.getUserMedia({
|
|
207
289
|
audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
|
|
208
|
-
});
|
|
290
|
+
}));
|
|
209
291
|
if (generation !== this.generation) {
|
|
210
292
|
stream.getTracks().forEach((track) => track.stop());
|
|
211
293
|
return;
|
|
@@ -225,7 +307,11 @@ var VoiceClient = class {
|
|
|
225
307
|
return;
|
|
226
308
|
}
|
|
227
309
|
this.player = player;
|
|
228
|
-
const socket = new WebSocket(url.toString(), [
|
|
310
|
+
const socket = new WebSocket(url.toString(), [
|
|
311
|
+
"runtype.bearer",
|
|
312
|
+
token,
|
|
313
|
+
...visitorToken ? [visitorToken] : []
|
|
314
|
+
]);
|
|
229
315
|
socket.binaryType = "arraybuffer";
|
|
230
316
|
this.socket = socket;
|
|
231
317
|
socket.onopen = () => {
|
|
@@ -256,6 +342,11 @@ var VoiceClient = class {
|
|
|
256
342
|
this.cleanup();
|
|
257
343
|
this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
|
|
258
344
|
};
|
|
345
|
+
/** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
|
|
346
|
+
resetSession = () => {
|
|
347
|
+
if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
|
|
348
|
+
this.update({ session: null });
|
|
349
|
+
};
|
|
259
350
|
toggleMute = () => {
|
|
260
351
|
this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
|
|
261
352
|
};
|
|
@@ -270,9 +361,11 @@ var VoiceClient = class {
|
|
|
270
361
|
this.clearPlayback();
|
|
271
362
|
if (this.snapshot.interruptionMode === "none") {
|
|
272
363
|
this.stoppedResponse = !this.responseEnded;
|
|
364
|
+
this.commitClauseCaptions();
|
|
273
365
|
this.setStatus("listening");
|
|
274
366
|
return;
|
|
275
367
|
}
|
|
368
|
+
this.suppressedArtifactTurnId = this.activeTurnId;
|
|
276
369
|
this.awaitingClear = true;
|
|
277
370
|
this.setStatus("listening");
|
|
278
371
|
this.socket.send(JSON.stringify({ type: "cancel" }));
|
|
@@ -289,6 +382,7 @@ var VoiceClient = class {
|
|
|
289
382
|
});
|
|
290
383
|
}
|
|
291
384
|
cleanup() {
|
|
385
|
+
this.fullDuplex = false;
|
|
292
386
|
this.generation += 1;
|
|
293
387
|
if (this.processor) this.processor.onaudioprocess = null;
|
|
294
388
|
this.processor?.disconnect();
|
|
@@ -314,6 +408,82 @@ var VoiceClient = class {
|
|
|
314
408
|
this.awaitingClear = false;
|
|
315
409
|
this.stoppedResponse = false;
|
|
316
410
|
this.responseEnded = true;
|
|
411
|
+
this.commitClauseCaptions();
|
|
412
|
+
this.committedAssistantTurnId = null;
|
|
413
|
+
this.activeTurnId = null;
|
|
414
|
+
this.suppressedArtifactTurnId = null;
|
|
415
|
+
const settledArtifacts = this.snapshot.artifacts.filter(
|
|
416
|
+
(artifact) => artifact.status === "complete"
|
|
417
|
+
);
|
|
418
|
+
if (settledArtifacts.length !== this.snapshot.artifacts.length)
|
|
419
|
+
this.update({ artifacts: settledArtifacts });
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Folds an `artifact` message into the snapshot. Frames of a turn the caller
|
|
423
|
+
* cancelled are dropped; a playback-only stop leaves the turn running, so its
|
|
424
|
+
* artifacts keep arriving and are kept.
|
|
425
|
+
*/
|
|
426
|
+
applyArtifactMessage(msg) {
|
|
427
|
+
if (this.awaitingClear) return;
|
|
428
|
+
const frame = msg.event;
|
|
429
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
|
|
430
|
+
const event = frame;
|
|
431
|
+
const id = readString(event.id);
|
|
432
|
+
if (!id) return;
|
|
433
|
+
const turnId = readTurnId(msg);
|
|
434
|
+
if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
|
|
435
|
+
if (turnId !== null) this.activeTurnId = turnId;
|
|
436
|
+
const artifacts = this.snapshot.artifacts;
|
|
437
|
+
const index = artifacts.findIndex((artifact) => artifact.id === id);
|
|
438
|
+
const executionId = readString(msg.executionId);
|
|
439
|
+
const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
|
|
440
|
+
id,
|
|
441
|
+
turnId,
|
|
442
|
+
...executionId ? { executionId } : {}
|
|
443
|
+
});
|
|
444
|
+
if (!next) return;
|
|
445
|
+
this.update({
|
|
446
|
+
artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** Settles the spoken clauses in place and ends the turn's claim on them. */
|
|
450
|
+
commitClauseCaptions() {
|
|
451
|
+
this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
|
|
452
|
+
const settled = this.pendingAssistant !== null;
|
|
453
|
+
this.pendingAssistant = null;
|
|
454
|
+
if (!settled) return;
|
|
455
|
+
const transcript = this.snapshot.transcript;
|
|
456
|
+
const last = transcript[transcript.length - 1];
|
|
457
|
+
if (last?.role !== "assistant" || last.partial !== true) return;
|
|
458
|
+
const { partial: _partial, ...committed } = last;
|
|
459
|
+
this.update({ transcript: [...transcript.slice(0, -1), committed] });
|
|
460
|
+
}
|
|
461
|
+
/** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
|
|
462
|
+
appendClauseCaption(text, turnId) {
|
|
463
|
+
const transcript = this.snapshot.transcript;
|
|
464
|
+
const last = transcript[transcript.length - 1];
|
|
465
|
+
if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
|
|
466
|
+
return {
|
|
467
|
+
interimTranscript: null,
|
|
468
|
+
transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
|
|
469
|
+
status: "speaking"
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
this.pendingAssistant = { turnId };
|
|
473
|
+
return {
|
|
474
|
+
interimTranscript: null,
|
|
475
|
+
transcript: [
|
|
476
|
+
...transcript,
|
|
477
|
+
{
|
|
478
|
+
role: "assistant",
|
|
479
|
+
content: text,
|
|
480
|
+
timestamp: Date.now(),
|
|
481
|
+
partial: true,
|
|
482
|
+
...turnId ? { turnId } : {}
|
|
483
|
+
}
|
|
484
|
+
],
|
|
485
|
+
status: "speaking"
|
|
486
|
+
};
|
|
317
487
|
}
|
|
318
488
|
clearPlayback() {
|
|
319
489
|
this.playbackRevision += 1;
|
|
@@ -348,32 +518,89 @@ var VoiceClient = class {
|
|
|
348
518
|
return;
|
|
349
519
|
}
|
|
350
520
|
switch (msg.type) {
|
|
351
|
-
case "session_config":
|
|
521
|
+
case "session_config": {
|
|
522
|
+
this.fullDuplex = msg.speechMode === "speech_to_speech";
|
|
523
|
+
if (this.fullDuplex) player.setContinuousMode?.(true);
|
|
352
524
|
if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
|
|
353
525
|
this.update({ interruptionMode: msg.interruptionMode });
|
|
354
526
|
}
|
|
527
|
+
const sessionId = readString(msg.sessionId);
|
|
528
|
+
const conversationId = readString(msg.conversationId);
|
|
529
|
+
if (sessionId && conversationId) {
|
|
530
|
+
const session = { sessionId, conversationId };
|
|
531
|
+
this.update({ session });
|
|
532
|
+
this.options.onSession?.(session);
|
|
533
|
+
}
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
case "artifact":
|
|
537
|
+
this.applyArtifactMessage(msg);
|
|
538
|
+
break;
|
|
539
|
+
case "transcript_update": {
|
|
540
|
+
if (typeof msg.text !== "string" || typeof msg.turnId !== "string" || msg.role !== "user" && msg.role !== "assistant")
|
|
541
|
+
break;
|
|
542
|
+
if (msg.role === "assistant" && this.awaitingClear) break;
|
|
543
|
+
const entries = [...this.snapshot.transcript];
|
|
544
|
+
const index = entries.findIndex(
|
|
545
|
+
(entry2) => entry2.turnId === msg.turnId && entry2.role === msg.role
|
|
546
|
+
);
|
|
547
|
+
const entry = {
|
|
548
|
+
role: msg.role,
|
|
549
|
+
content: msg.text,
|
|
550
|
+
turnId: msg.turnId,
|
|
551
|
+
isFinal: msg.final === true,
|
|
552
|
+
timestamp: index >= 0 ? entries[index].timestamp : Date.now()
|
|
553
|
+
};
|
|
554
|
+
if (index >= 0) entries[index] = entry;
|
|
555
|
+
else entries.push(entry);
|
|
556
|
+
this.update({ transcript: entries, interimTranscript: null });
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case "delegation_started":
|
|
560
|
+
if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("thinking");
|
|
561
|
+
break;
|
|
562
|
+
case "delegation_completed":
|
|
563
|
+
if (!this.hasPendingAudio && !this.awaitingClear) this.setStatus("listening");
|
|
355
564
|
break;
|
|
356
565
|
case "transcript_interim":
|
|
357
566
|
this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
|
|
358
567
|
break;
|
|
359
|
-
case "
|
|
568
|
+
case "transcript_partial": {
|
|
569
|
+
if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
|
|
570
|
+
this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
|
|
571
|
+
if (this.awaitingClear || this.stoppedResponse) break;
|
|
572
|
+
this.responseEnded = false;
|
|
573
|
+
this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case "transcript_final": {
|
|
360
577
|
if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
|
|
361
|
-
|
|
578
|
+
const role = msg.role;
|
|
579
|
+
const text = msg.text;
|
|
580
|
+
if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
|
|
581
|
+
const turnId = readTurnId(msg);
|
|
582
|
+
if (turnId !== null) this.activeTurnId = turnId;
|
|
583
|
+
if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
|
|
584
|
+
break;
|
|
362
585
|
this.responseEnded = false;
|
|
586
|
+
const transcript = this.snapshot.transcript;
|
|
587
|
+
const last = transcript[transcript.length - 1];
|
|
588
|
+
const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
|
|
589
|
+
this.pendingAssistant = null;
|
|
590
|
+
const entry = {
|
|
591
|
+
role,
|
|
592
|
+
content: text,
|
|
593
|
+
// INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
|
|
594
|
+
timestamp: supersedes && last ? last.timestamp : Date.now(),
|
|
595
|
+
...turnId ? { turnId } : {}
|
|
596
|
+
};
|
|
363
597
|
this.update({
|
|
364
598
|
interimTranscript: null,
|
|
365
|
-
transcript: [
|
|
366
|
-
|
|
367
|
-
{
|
|
368
|
-
role: msg.role,
|
|
369
|
-
content: msg.text,
|
|
370
|
-
timestamp: Date.now(),
|
|
371
|
-
...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
|
|
372
|
-
}
|
|
373
|
-
],
|
|
374
|
-
status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
|
|
599
|
+
transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
|
|
600
|
+
status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
|
|
375
601
|
});
|
|
376
602
|
break;
|
|
603
|
+
}
|
|
377
604
|
case "audio_end": {
|
|
378
605
|
this.responseEnded = true;
|
|
379
606
|
if (this.stoppedResponse) {
|
|
@@ -390,10 +617,12 @@ var VoiceClient = class {
|
|
|
390
617
|
break;
|
|
391
618
|
}
|
|
392
619
|
case "audio_clear":
|
|
620
|
+
this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
|
|
393
621
|
this.clearPlayback();
|
|
394
622
|
this.awaitingClear = false;
|
|
395
623
|
this.stoppedResponse = false;
|
|
396
624
|
this.responseEnded = true;
|
|
625
|
+
this.commitClauseCaptions();
|
|
397
626
|
this.setStatus("listening");
|
|
398
627
|
break;
|
|
399
628
|
case "metrics": {
|
|
@@ -406,8 +635,11 @@ var VoiceClient = class {
|
|
|
406
635
|
// @snake-case-ok: Existing voice wire contract.
|
|
407
636
|
firstAudioMs: number(msg.first_audio_ms),
|
|
408
637
|
// @snake-case-ok: Existing voice wire contract.
|
|
409
|
-
totalMs: number(msg.total_ms)
|
|
638
|
+
totalMs: number(msg.total_ms),
|
|
639
|
+
// @snake-case-ok: Existing voice wire contract.
|
|
640
|
+
firstSynthesisMs: number(msg.first_synthesis_ms),
|
|
410
641
|
// @snake-case-ok: Existing voice wire contract.
|
|
642
|
+
incremental: msg.incremental === true
|
|
411
643
|
}
|
|
412
644
|
});
|
|
413
645
|
break;
|
|
@@ -439,14 +671,14 @@ var VoiceClient = class {
|
|
|
439
671
|
const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
|
|
440
672
|
this.processor = processor;
|
|
441
673
|
processor.onaudioprocess = (event) => {
|
|
442
|
-
if (generation !== this.generation || this.snapshot.isMuted) return;
|
|
674
|
+
if (generation !== this.generation || this.snapshot.isMuted && !this.fullDuplex) return;
|
|
443
675
|
const input = event.inputBuffer.getChannelData(0);
|
|
444
676
|
let sum = 0;
|
|
445
677
|
for (const sample of input) sum += sample * sample;
|
|
446
|
-
this.update({ audioLevel: Math.sqrt(sum / input.length) });
|
|
678
|
+
this.update({ audioLevel: this.snapshot.isMuted ? 0 : Math.sqrt(sum / input.length) });
|
|
447
679
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
448
680
|
const pcm = new Int16Array(input.length);
|
|
449
|
-
if (this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
|
|
681
|
+
if (this.snapshot.isMuted || this.snapshot.interruptionMode === "cancel" && (this.awaitingClear || this.snapshot.status === "thinking" || this.hasPendingAudio || this.snapshot.status === "speaking")) {
|
|
450
682
|
socket.send(pcm.buffer);
|
|
451
683
|
return;
|
|
452
684
|
}
|