@runtypelabs/persona 4.25.0 → 4.26.1
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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +186 -8
- package/dist/approval-ui.d.ts +186 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +186 -8
- package/dist/artifacts-ui.d.ts +186 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-GKPCIQPB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-GOZWBDA5.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +186 -8
- package/dist/context-mentions.d.ts +186 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +189 -31
- package/dist/event-stream-view.d.ts +189 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +227 -667
- package/dist/forms-ui.d.ts +227 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -738
- package/dist/index.d.ts +233 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +186 -8
- package/dist/session-reconnect.d.ts +186 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +186 -8
- package/dist/smart-dom-reader.d.ts +186 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +188 -8
- package/dist/theme-editor-preview.d.ts +188 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +187 -23
- package/dist/theme-editor.d.ts +187 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-XLU6YK2N.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11656 -232
- package/dist/voice-runtime.d.ts +11656 -232
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +325 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +6 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +895 -1538
- package/src/session.voice.test.ts +1171 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +204 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +340 -2770
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +680 -189
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +1060 -8
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-7DA7MJH4.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
// JSON control frames (transcript_interim / transcript_final /
|
|
9
9
|
// audio_end / metrics).
|
|
10
10
|
//
|
|
11
|
+
// Full duplex (speech-to-speech, e.g. GPT-Live): the client always declares
|
|
12
|
+
// `voiceCapabilities=full-duplex-v1`; engines that don't speak it ignore the
|
|
13
|
+
// param. A full-duplex session announces itself with
|
|
14
|
+
// `session_config{speechMode:'speech_to_speech'}` and then sends turn-keyed
|
|
15
|
+
// `transcript_update{role,text,turnId,final}` frames (later frames replace the
|
|
16
|
+
// text for the same (turnId, role); user and assistant turns can overlap),
|
|
17
|
+
// `delegation_started/completed` around agent tool turns, and `audio_clear` on
|
|
18
|
+
// barge-in. Reply audio has no per-turn end marker in that mode, so playback is
|
|
19
|
+
// treated as continuous: the "speaking" status is released by a timer derived
|
|
20
|
+
// from the queued audio duration rather than by the engine's end-of-stream.
|
|
21
|
+
//
|
|
11
22
|
// The server's STT owns turn-taking, so the client streams continuously and
|
|
12
23
|
// has no client-side VAD, barge-in monitoring, or batch upload. Auth rides the
|
|
13
24
|
// `Sec-WebSocket-Protocol` subprotocol (`['runtype.bearer', clientToken]`),
|
|
@@ -24,20 +35,61 @@
|
|
|
24
35
|
// socket idles (no call) until `startListening()` sends `{"type":"start"}`; if
|
|
25
36
|
// it negotiates plain `runtype.bearer`, it ignored attach and the call is
|
|
26
37
|
// already live, so `startListening()` adopts it as is.
|
|
38
|
+
//
|
|
39
|
+
// Wire vocabulary v1 (contract Amendment 5). Every socket sends
|
|
40
|
+
// `voiceProtocol=runtype-browser-v1`, `clientVersion=persona/<version>` and the
|
|
41
|
+
// snake_case `clientCapabilities` it supports. The server answers with
|
|
42
|
+
// `session_config.capabilities` (the negotiated intersection), the only gate
|
|
43
|
+
// for the client frames `context`, `delegation_update` and client delegation:
|
|
44
|
+
// a server without `capabilities` gets none of them. Receivers ignore unknown
|
|
45
|
+
// frames, fields and enum values. Non-fatal problems arrive as `warning`
|
|
46
|
+
// frames; an `error` always ends the call.
|
|
47
|
+
//
|
|
48
|
+
// Client delegation (full duplex, when the session supplied a bridge and
|
|
49
|
+
// `clientDelegation` isn't false, and `client_delegation` was negotiated): a
|
|
50
|
+
// `delegation_started{delegationId, input}` asks the widget to run the turn
|
|
51
|
+
// through its chat pipeline (one at a time). The provider answers with exactly
|
|
52
|
+
// one terminal `delegation_result{delegationId, status, text}`. A turn that
|
|
53
|
+
// parks on a tool approval first sends the non-terminal
|
|
54
|
+
// `delegation_update{delegationId, status:'pending_approval', text}`, then the
|
|
55
|
+
// terminal result once the visitor decides (or the approval lapses or is
|
|
56
|
+
// replaced). A second gated tool in the same turn sends another update.
|
|
57
|
+
// `delegation_cancelled{delegationId}` (or a `warning` refusing a frame for
|
|
58
|
+
// it) stops all frames for that id; the chat turn still renders, and since
|
|
59
|
+
// nobody speaks it now, browser TTS may read it. The voice model's spoken read-back of
|
|
60
|
+
// an answered delegation is folded: the chat already shows it. With
|
|
61
|
+
// `delegation_read_back` negotiated it is the utterance the server tags with that
|
|
62
|
+
// `delegationId`; otherwise it is the first new assistant utterance after
|
|
63
|
+
// `delegation_completed`, unless the visitor speaks first. Transcripts in such a call are display-only captions, never sent to the
|
|
64
|
+
// agent as conversation; a user bubble becomes conversation only when it is
|
|
65
|
+
// submitted. With `context` negotiated, one `context{text}` frame (chat history
|
|
66
|
+
// as of call start plus the host's `callContext`) is held until the visitor's
|
|
67
|
+
// first final transcript or first delegation.
|
|
27
68
|
|
|
28
69
|
import type {
|
|
29
70
|
VoiceProvider,
|
|
30
71
|
VoiceResult,
|
|
31
72
|
VoiceStatus,
|
|
32
73
|
VoiceConfig,
|
|
74
|
+
VoiceDelegationRequest,
|
|
75
|
+
VoiceDelegationResult,
|
|
33
76
|
VoiceMetrics,
|
|
34
77
|
VoicePlaybackEngine,
|
|
78
|
+
VoiceSessionBridge,
|
|
79
|
+
VoiceTranscriptMetadata,
|
|
35
80
|
} from "../types";
|
|
36
81
|
import { AudioPlaybackManager } from "./audio-playback-manager";
|
|
82
|
+
import { VERSION } from "../version";
|
|
37
83
|
|
|
38
84
|
const CAPTURE_SAMPLE_RATE = 16000;
|
|
39
85
|
const PLAYBACK_SAMPLE_RATE = 24000;
|
|
40
86
|
const CAPTURE_BUFFER_SIZE = 4096;
|
|
87
|
+
/**
|
|
88
|
+
* Mic audio held while the call socket handshakes (a slow upgrade can take
|
|
89
|
+
* seconds, and the visitor is already talking): 8 s of 16-bit PCM, 256 KB,
|
|
90
|
+
* well under the server's startup queue. Past it the oldest frames go.
|
|
91
|
+
*/
|
|
92
|
+
const PRE_OPEN_AUDIO_MAX_BYTES = CAPTURE_SAMPLE_RATE * 2 * 8;
|
|
41
93
|
/**
|
|
42
94
|
* RMS-to-0..1 gain for the published capture level. Conversational speech sits
|
|
43
95
|
* around 0.05 to 0.3 RMS, so the raw value would never leave the bottom of the
|
|
@@ -68,6 +120,70 @@ type AttachedSocket = {
|
|
|
68
120
|
openedAt: number;
|
|
69
121
|
idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
70
122
|
};
|
|
123
|
+
/** Declared on every call; non-full-duplex engines ignore it. */
|
|
124
|
+
const FULL_DUPLEX_CAPABILITY = "full-duplex-v1";
|
|
125
|
+
/**
|
|
126
|
+
* Slack added to the computed end of queued speech-to-speech audio before the
|
|
127
|
+
* provider reports the reply as drained (covers engine prebuffer + scheduling).
|
|
128
|
+
*/
|
|
129
|
+
const CONTINUOUS_DRAIN_GRACE_MS = 300;
|
|
130
|
+
/**
|
|
131
|
+
* How long a client `cancel` waits for the server's `audio_clear` before audio
|
|
132
|
+
* is accepted again, so a lost acknowledgement can't mute the call for good.
|
|
133
|
+
*/
|
|
134
|
+
const CANCEL_ACK_TIMEOUT_MS = 2000;
|
|
135
|
+
/**
|
|
136
|
+
* How long a `delegation_result` waits for the call context still being built
|
|
137
|
+
* (a slow host `callContext`), so context always precedes the result. Past it,
|
|
138
|
+
* the result goes out and that context is dropped.
|
|
139
|
+
*/
|
|
140
|
+
const CONTEXT_BEFORE_RESULT_TIMEOUT_MS = 2000;
|
|
141
|
+
const VOICE_PROTOCOL = "runtype-browser-v1";
|
|
142
|
+
/** `approvalTimeoutMs` cap: below core's delegation deadline (600 s, restarted by each update). */
|
|
143
|
+
const APPROVAL_TIMEOUT_MAX_MS = 540_000;
|
|
144
|
+
const DISCLOSURE_TEXT = "You're talking to an AI assistant. Voice is processed by OpenAI.";
|
|
145
|
+
/** Call-start context frame: total cap, host share, history window, per-message cap. */
|
|
146
|
+
const CONTEXT_MAX_CHARS = 8000;
|
|
147
|
+
const CONTEXT_HOST_MAX_CHARS = 4000;
|
|
148
|
+
const CONTEXT_MESSAGES = 12;
|
|
149
|
+
const CONTEXT_MESSAGE_MAX_CHARS = 2000;
|
|
150
|
+
|
|
151
|
+
type TranscriptCallback = (
|
|
152
|
+
role: "user" | "assistant",
|
|
153
|
+
text: string,
|
|
154
|
+
isFinal: boolean,
|
|
155
|
+
metadata?: VoiceTranscriptMetadata,
|
|
156
|
+
) => void;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The call-start `context` text: "Conversation so far:" plus the last
|
|
160
|
+
* {@link CONTEXT_MESSAGES} messages (newest kept when over budget), then the
|
|
161
|
+
* host's extra context (at most {@link CONTEXT_HOST_MAX_CHARS}, so history
|
|
162
|
+
* always fits). Capped at {@link CONTEXT_MAX_CHARS}; "" when empty.
|
|
163
|
+
*/
|
|
164
|
+
export function buildCallContext(
|
|
165
|
+
history: Array<{ role: "user" | "assistant"; content: string }>,
|
|
166
|
+
extra: string,
|
|
167
|
+
): string {
|
|
168
|
+
const header = "Conversation so far:";
|
|
169
|
+
const tail = extra.slice(0, CONTEXT_HOST_MAX_CHARS).trim();
|
|
170
|
+
let budget = CONTEXT_MAX_CHARS - (tail ? tail.length + 2 : 0) - header.length;
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
for (const message of history.slice(-CONTEXT_MESSAGES).reverse()) {
|
|
173
|
+
let text = message.content.replace(/\s+/g, " ").trim();
|
|
174
|
+
if (!text) continue;
|
|
175
|
+
if (text.length > CONTEXT_MESSAGE_MAX_CHARS) {
|
|
176
|
+
text = `${text.slice(0, CONTEXT_MESSAGE_MAX_CHARS - 1)}…`;
|
|
177
|
+
}
|
|
178
|
+
const line = `${message.role === "user" ? "User" : "Assistant"}: ${text}`;
|
|
179
|
+
if (line.length + 1 > budget) break;
|
|
180
|
+
budget -= line.length + 1;
|
|
181
|
+
lines.unshift(line);
|
|
182
|
+
}
|
|
183
|
+
const parts = lines.length > 0 ? [`${header}\n${lines.join("\n")}`] : [];
|
|
184
|
+
if (tail) parts.push(tail);
|
|
185
|
+
return parts.join("\n\n");
|
|
186
|
+
}
|
|
71
187
|
|
|
72
188
|
/**
|
|
73
189
|
* Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
|
|
@@ -107,40 +223,72 @@ function clampAttachIdleMs(ms: number | undefined): number {
|
|
|
107
223
|
export class RuntypeVoiceProvider implements VoiceProvider {
|
|
108
224
|
type: "runtype" = "runtype";
|
|
109
225
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
226
|
+
#ws: WebSocket | null = null;
|
|
227
|
+
#captureContext: AudioContext | null = null;
|
|
228
|
+
#levelCallbacks: ((level: number) => void)[] = [];
|
|
229
|
+
#mediaStream: MediaStream | null = null;
|
|
230
|
+
#sourceNode: MediaStreamAudioSourceNode | null = null;
|
|
231
|
+
#processor: ScriptProcessorNode | null = null;
|
|
232
|
+
// Captured before the call socket opened; flushed in order on open.
|
|
233
|
+
#preOpenAudio: ArrayBuffer[] = [];
|
|
234
|
+
#preOpenBytes = 0;
|
|
235
|
+
#playback: VoicePlaybackEngine | null = null;
|
|
117
236
|
|
|
118
237
|
// True while a call (WS session) is live: drives the idempotent start guard
|
|
119
238
|
// and `isBargeInActive()`.
|
|
120
|
-
|
|
121
|
-
|
|
239
|
+
#callLive = false;
|
|
240
|
+
#isSpeaking = false;
|
|
122
241
|
|
|
123
242
|
// Invalidates in-flight async work (playback-engine creation, late frames,
|
|
124
243
|
// status transitions) after a teardown/restart so a stale callback can't act
|
|
125
244
|
// on a newer call's resources. Bumped on every start and every cleanup.
|
|
126
|
-
|
|
245
|
+
#callGeneration = 0;
|
|
127
246
|
|
|
128
247
|
// Distinguishes a user-initiated close (code 1000) from a dropped connection.
|
|
129
|
-
|
|
248
|
+
#intentionalClose = false;
|
|
130
249
|
|
|
131
250
|
// Prewarm latch and the (at most one) socket an `'attach'` prewarm opened.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
251
|
+
#lastPrewarmAt = Number.NEGATIVE_INFINITY;
|
|
252
|
+
#attached: AttachedSocket | null = null;
|
|
253
|
+
// Full-duplex (speech-to-speech) session state; reset on every cleanup.
|
|
254
|
+
#speechToSpeech = false;
|
|
255
|
+
#delegating = false;
|
|
256
|
+
// Until this time, audio and assistant text belong to a reply the client cancelled.
|
|
257
|
+
#cancelledUntil = 0;
|
|
258
|
+
#playbackEndsAt = 0;
|
|
259
|
+
#drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
260
|
+
// Engine `onFinished` callbacks are one-shot (cleared on fire and on flush),
|
|
261
|
+
// so the provider re-registers before each reply that may need it.
|
|
262
|
+
#finishedArmed = false;
|
|
263
|
+
// Client delegation (see header); per-call state reset on every cleanup.
|
|
264
|
+
private bridge: VoiceSessionBridge | null = null;
|
|
265
|
+
#clientDelegation = false;
|
|
266
|
+
// session_config.capabilities: the negotiated client frame types.
|
|
267
|
+
#capabilities = new Set<string>();
|
|
268
|
+
// Per delegation: aborts its pending approval follow-up (cancelled, or call end).
|
|
269
|
+
#followUps = new Map<string, AbortController>();
|
|
270
|
+
// Delegations the server cancelled (or refused a frame for): no more frames for them.
|
|
271
|
+
#cancelledDelegations = new Set<string>();
|
|
272
|
+
// session_config.callId: the server's id for this call, for logs.
|
|
273
|
+
#callId: string | undefined;
|
|
274
|
+
// Call-start context, built at session_config and held until the visitor's
|
|
275
|
+
// first final transcript (sent earlier, the voice model tends to answer it).
|
|
276
|
+
#contextSent = false;
|
|
277
|
+
#pendingContext: Promise<string> | null = null;
|
|
278
|
+
// The released context's send; settles once it went out (or never will).
|
|
279
|
+
#contextSend: { done: Promise<void>; drop: () => void } | null = null;
|
|
280
|
+
#delegations: Promise<void> = Promise.resolve();
|
|
281
|
+
// Delegations answered ok: their spoken read-back is folded.
|
|
282
|
+
#answered = new Set<string>();
|
|
283
|
+
#foldReadback = false;
|
|
284
|
+
#assistantTurns = new Set<string>();
|
|
285
|
+
#foldedTurns = new Set<string>();
|
|
286
|
+
|
|
287
|
+
#resultCallbacks: ((result: VoiceResult) => void)[] = [];
|
|
288
|
+
#errorCallbacks: ((error: Error) => void)[] = [];
|
|
289
|
+
#statusCallbacks: ((status: VoiceStatus) => void)[] = [];
|
|
290
|
+
#transcriptCallbacks: TranscriptCallback[] = [];
|
|
291
|
+
#metricsCallbacks: ((metrics: VoiceMetrics) => void)[] = [];
|
|
144
292
|
|
|
145
293
|
constructor(private config: VoiceConfig["runtype"]) {}
|
|
146
294
|
|
|
@@ -149,31 +297,35 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
149
297
|
/** No-op: the WS session opens lazily in `startListening` (the "call"). */
|
|
150
298
|
async connect(): Promise<void> {}
|
|
151
299
|
|
|
300
|
+
setSessionBridge(bridge: VoiceSessionBridge): void {
|
|
301
|
+
this.bridge = bridge;
|
|
302
|
+
}
|
|
303
|
+
|
|
152
304
|
/**
|
|
153
305
|
* Warm the voice path ahead of the click. Fire-and-forget and throttled to
|
|
154
306
|
* once per {@link PREWARM_THROTTLE_MS}; never emits an error or a status.
|
|
155
307
|
*/
|
|
156
308
|
prewarm(): void {
|
|
157
|
-
if (this
|
|
309
|
+
if (this.#callLive || this.#attached) return;
|
|
158
310
|
const agentId = this.config?.agentId;
|
|
159
311
|
const token = this.config?.clientToken;
|
|
160
312
|
const host = this.config?.host;
|
|
161
313
|
if (!agentId || !token || !host) return;
|
|
162
314
|
const now = Date.now();
|
|
163
|
-
if (now - this
|
|
164
|
-
this
|
|
315
|
+
if (now - this.#lastPrewarmAt < PREWARM_THROTTLE_MS) return;
|
|
316
|
+
this.#lastPrewarmAt = now;
|
|
165
317
|
try {
|
|
166
318
|
if (this.config?.prewarmMode === "attach") {
|
|
167
|
-
this
|
|
319
|
+
this.#attach(host, agentId, token);
|
|
168
320
|
} else {
|
|
169
|
-
this
|
|
321
|
+
this.#requestPrewarm(host, agentId, token);
|
|
170
322
|
}
|
|
171
323
|
} catch {
|
|
172
324
|
// A prewarm must never surface an error; the click connects normally.
|
|
173
325
|
}
|
|
174
326
|
}
|
|
175
327
|
|
|
176
|
-
|
|
328
|
+
#requestPrewarm(host: string, agentId: string, token: string): void {
|
|
177
329
|
if (typeof fetch !== "function") return;
|
|
178
330
|
const url = `${toHttpBase(host)}/v1/client/agents/${encodeURIComponent(agentId)}/voice/prewarm`;
|
|
179
331
|
void fetch(url, {
|
|
@@ -183,12 +335,12 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
183
335
|
}).catch(() => {});
|
|
184
336
|
}
|
|
185
337
|
|
|
186
|
-
|
|
187
|
-
const params = new URLSearchParams(
|
|
338
|
+
#attach(host: string, agentId: string, token: string): void {
|
|
339
|
+
const params = new URLSearchParams();
|
|
188
340
|
const configuredIdleMs = this.config?.attachIdleMs;
|
|
189
341
|
const idleMs = clampAttachIdleMs(configuredIdleMs);
|
|
190
342
|
if (configuredIdleMs !== undefined) params.set("attachIdleMs", String(idleMs));
|
|
191
|
-
const ws = new WebSocket(
|
|
343
|
+
const ws = new WebSocket(this.#voiceSocketUrl(host, agentId, params), [
|
|
192
344
|
"runtype.bearer",
|
|
193
345
|
"runtype.attach",
|
|
194
346
|
token,
|
|
@@ -206,18 +358,18 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
206
358
|
openedAt: 0,
|
|
207
359
|
idleTimer: undefined,
|
|
208
360
|
};
|
|
209
|
-
this
|
|
361
|
+
this.#attached = entry;
|
|
210
362
|
|
|
211
363
|
ws.onopen = () => {
|
|
212
|
-
if (this
|
|
364
|
+
if (this.#attached !== entry) return;
|
|
213
365
|
entry.state = ws.protocol === "runtype.attach" ? "attached" : "live";
|
|
214
366
|
entry.openedAt = Date.now();
|
|
215
|
-
this
|
|
367
|
+
this.#armAttachIdle(entry, idleMs);
|
|
216
368
|
if (entry.state === "attached") ws.send('{"type":"ping"}');
|
|
217
369
|
entry.settle();
|
|
218
370
|
};
|
|
219
371
|
ws.onmessage = (event) => {
|
|
220
|
-
if (this
|
|
372
|
+
if (this.#attached !== entry || typeof event.data !== "string") return;
|
|
221
373
|
let msg: { type?: unknown; idleMs?: unknown };
|
|
222
374
|
try {
|
|
223
375
|
msg = JSON.parse(event.data);
|
|
@@ -226,27 +378,27 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
226
378
|
}
|
|
227
379
|
// The server's idle window is authoritative; close no later than it does.
|
|
228
380
|
if (msg.type === "attached" && typeof msg.idleMs === "number") {
|
|
229
|
-
this
|
|
381
|
+
this.#armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
|
|
230
382
|
}
|
|
231
383
|
};
|
|
232
384
|
ws.onerror = () => {};
|
|
233
385
|
ws.onclose = () => {
|
|
234
|
-
if (this
|
|
386
|
+
if (this.#attached === entry) this.#releaseAttached();
|
|
235
387
|
};
|
|
236
388
|
}
|
|
237
389
|
|
|
238
|
-
|
|
390
|
+
#armAttachIdle(entry: AttachedSocket, ms: number): void {
|
|
239
391
|
clearTimeout(entry.idleTimer);
|
|
240
392
|
entry.idleTimer = setTimeout(() => {
|
|
241
|
-
if (this
|
|
393
|
+
if (this.#attached === entry) this.#releaseAttached();
|
|
242
394
|
}, Math.max(0, ms));
|
|
243
395
|
}
|
|
244
396
|
|
|
245
397
|
/** Close and forget the prewarmed socket, if any. Silent by design. */
|
|
246
|
-
|
|
247
|
-
const entry = this
|
|
398
|
+
#releaseAttached(): void {
|
|
399
|
+
const entry = this.#attached;
|
|
248
400
|
if (!entry) return;
|
|
249
|
-
this
|
|
401
|
+
this.#attached = null;
|
|
250
402
|
clearTimeout(entry.idleTimer);
|
|
251
403
|
const { ws } = entry;
|
|
252
404
|
ws.onopen = null;
|
|
@@ -269,8 +421,8 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
269
421
|
* Resolves `null` when there is none, it stalled, or it dropped, so the
|
|
270
422
|
* caller opens a fresh socket.
|
|
271
423
|
*/
|
|
272
|
-
|
|
273
|
-
const entry = this
|
|
424
|
+
async #takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
|
|
425
|
+
const entry = this.#attached;
|
|
274
426
|
if (!entry) return null;
|
|
275
427
|
if (entry.state === "connecting") {
|
|
276
428
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -282,23 +434,39 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
282
434
|
]);
|
|
283
435
|
clearTimeout(timer);
|
|
284
436
|
}
|
|
285
|
-
if (this
|
|
437
|
+
if (this.#attached !== entry) return null;
|
|
286
438
|
if (entry.state === "connecting" || entry.ws.readyState !== WebSocket.OPEN) {
|
|
287
|
-
this
|
|
439
|
+
this.#releaseAttached();
|
|
288
440
|
return null;
|
|
289
441
|
}
|
|
290
|
-
this
|
|
442
|
+
this.#attached = null;
|
|
291
443
|
clearTimeout(entry.idleTimer);
|
|
292
444
|
return { ws: entry.ws, live: entry.state === "live" };
|
|
293
445
|
}
|
|
294
446
|
|
|
295
|
-
|
|
296
|
-
|
|
447
|
+
/**
|
|
448
|
+
* The call socket URL. Every socket declares the full-duplex capability;
|
|
449
|
+
* `extra` adds params (e.g. an attach prewarm's). The token never goes in
|
|
450
|
+
* the URL, and these params are not secrets.
|
|
451
|
+
*/
|
|
452
|
+
#voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
|
|
453
|
+
const capabilities = ["partial_transcript", "context"];
|
|
454
|
+
if (this.bridge && this.config?.clientDelegation !== false)
|
|
455
|
+
capabilities.push("client_delegation", "delegation_update", "delegation_read_back");
|
|
456
|
+
if (attach) capabilities.unshift("attach");
|
|
457
|
+
const params = new URLSearchParams({
|
|
458
|
+
voiceProtocol: VOICE_PROTOCOL,
|
|
459
|
+
clientVersion: `persona/${VERSION}`,
|
|
460
|
+
voiceCapabilities: FULL_DUPLEX_CAPABILITY,
|
|
461
|
+
clientCapabilities: capabilities.join(","),
|
|
462
|
+
});
|
|
463
|
+
attach?.forEach((value, key) => params.set(key, value));
|
|
464
|
+
return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice?${params}`;
|
|
297
465
|
}
|
|
298
466
|
|
|
299
467
|
/** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
|
|
300
468
|
async startListening(): Promise<void> {
|
|
301
|
-
if (this
|
|
469
|
+
if (this.#callLive) return; // idempotent: a call is already live
|
|
302
470
|
|
|
303
471
|
const agentId = this.config?.agentId;
|
|
304
472
|
const token = this.config?.clientToken;
|
|
@@ -307,9 +475,9 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
307
475
|
if (!token) throw new Error("Runtype voice requires a clientToken");
|
|
308
476
|
if (!host) throw new Error("Runtype voice requires a host (or widget apiUrl)");
|
|
309
477
|
|
|
310
|
-
const generation = ++this
|
|
311
|
-
this
|
|
312
|
-
this
|
|
478
|
+
const generation = ++this.#callGeneration;
|
|
479
|
+
this.#intentionalClose = false;
|
|
480
|
+
this.#callLive = true;
|
|
313
481
|
|
|
314
482
|
try {
|
|
315
483
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
@@ -319,11 +487,11 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
319
487
|
echoCancellation: true,
|
|
320
488
|
},
|
|
321
489
|
});
|
|
322
|
-
if (generation !== this
|
|
490
|
+
if (generation !== this.#callGeneration) {
|
|
323
491
|
stream.getTracks().forEach((t) => t.stop());
|
|
324
492
|
return;
|
|
325
493
|
}
|
|
326
|
-
this
|
|
494
|
+
this.#mediaStream = stream;
|
|
327
495
|
|
|
328
496
|
// Create + resume both contexts inside the click gesture (iOS autoplay).
|
|
329
497
|
const AudioCtx =
|
|
@@ -334,115 +502,124 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
334
502
|
if (captureContext.state === "suspended") {
|
|
335
503
|
await captureContext.resume().catch(() => {});
|
|
336
504
|
}
|
|
337
|
-
this
|
|
505
|
+
this.#captureContext = captureContext;
|
|
338
506
|
|
|
339
507
|
const engine = this.config?.createPlaybackEngine
|
|
340
508
|
? await this.config.createPlaybackEngine()
|
|
341
509
|
: new AudioPlaybackManager(PLAYBACK_SAMPLE_RATE);
|
|
342
|
-
if (generation !== this
|
|
510
|
+
if (generation !== this.#callGeneration) {
|
|
343
511
|
// Torn down while async work was in flight: free what we acquired.
|
|
344
512
|
void engine.destroy();
|
|
345
513
|
stream.getTracks().forEach((t) => t.stop());
|
|
346
514
|
captureContext.close().catch(() => {});
|
|
347
515
|
return;
|
|
348
516
|
}
|
|
349
|
-
this
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Reply drained: the call stays open, so return to listening.
|
|
354
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
355
|
-
this.emitStatus("listening");
|
|
356
|
-
}
|
|
357
|
-
});
|
|
517
|
+
this.#playback = engine;
|
|
518
|
+
this.#armPlaybackFinished();
|
|
519
|
+
// Capture from now: frames buffer until the call socket is open.
|
|
520
|
+
this.#startCapture(captureContext, stream, generation);
|
|
358
521
|
|
|
359
|
-
const adopted = await this
|
|
360
|
-
if (generation !== this
|
|
522
|
+
const adopted = await this.#takeAttachedSocket();
|
|
523
|
+
if (generation !== this.#callGeneration) {
|
|
361
524
|
adopted?.ws.close(1000, "client ended call");
|
|
362
525
|
return;
|
|
363
526
|
}
|
|
364
527
|
if (adopted) {
|
|
365
528
|
const { ws } = adopted;
|
|
366
|
-
this
|
|
367
|
-
this
|
|
529
|
+
this.#ws = ws;
|
|
530
|
+
this.#bindCallSocket(ws, generation);
|
|
368
531
|
if (!adopted.live) ws.send('{"type":"start"}');
|
|
369
|
-
this
|
|
370
|
-
this
|
|
532
|
+
this.#flushPreOpenAudio(ws);
|
|
533
|
+
this.#emitStatus("listening");
|
|
371
534
|
return;
|
|
372
535
|
}
|
|
373
536
|
|
|
374
537
|
// Token rides the subprotocol; `runtype.bearer` is the marker the server
|
|
375
538
|
// echoes as the negotiated subprotocol (browsers fail the handshake if an
|
|
376
539
|
// offered subprotocol goes unanswered).
|
|
377
|
-
const ws = new WebSocket(this
|
|
540
|
+
const ws = new WebSocket(this.#voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
|
|
378
541
|
ws.binaryType = "arraybuffer";
|
|
379
|
-
this
|
|
542
|
+
this.#ws = ws;
|
|
380
543
|
|
|
381
544
|
ws.onopen = () => {
|
|
382
|
-
if (generation !== this
|
|
383
|
-
this
|
|
384
|
-
this
|
|
545
|
+
if (generation !== this.#callGeneration) return;
|
|
546
|
+
this.#flushPreOpenAudio(ws);
|
|
547
|
+
this.#emitStatus("listening");
|
|
385
548
|
};
|
|
386
|
-
this
|
|
549
|
+
this.#bindCallSocket(ws, generation);
|
|
387
550
|
} catch (error) {
|
|
388
|
-
this
|
|
389
|
-
this
|
|
390
|
-
this
|
|
551
|
+
this.#cleanup();
|
|
552
|
+
this.#emitError(error as Error);
|
|
553
|
+
this.#emitStatus("error");
|
|
391
554
|
throw error;
|
|
392
555
|
}
|
|
393
556
|
}
|
|
394
557
|
|
|
395
558
|
/** Route a call socket's frames, errors, and close into this call. */
|
|
396
|
-
|
|
397
|
-
ws.onmessage = (event) => this
|
|
559
|
+
#bindCallSocket(ws: WebSocket, generation: number): void {
|
|
560
|
+
ws.onmessage = (event) => this.#handleMessage(event, generation);
|
|
398
561
|
|
|
399
562
|
ws.onerror = () => {
|
|
400
|
-
if (generation !== this
|
|
401
|
-
this
|
|
402
|
-
this
|
|
403
|
-
this
|
|
563
|
+
if (generation !== this.#callGeneration) return;
|
|
564
|
+
this.#emitError(new Error("Voice connection failed"));
|
|
565
|
+
this.#emitStatus("error");
|
|
566
|
+
this.#cleanup();
|
|
404
567
|
};
|
|
405
568
|
|
|
406
569
|
ws.onclose = (evt) => {
|
|
407
|
-
if (this
|
|
408
|
-
this
|
|
570
|
+
if (this.#intentionalClose) {
|
|
571
|
+
this.#intentionalClose = false;
|
|
409
572
|
return;
|
|
410
573
|
}
|
|
411
|
-
if (generation !== this
|
|
574
|
+
if (generation !== this.#callGeneration) return;
|
|
412
575
|
if (evt.code !== 1000) {
|
|
413
576
|
const codeMsg = evt.code ? ` (code ${evt.code})` : "";
|
|
414
|
-
this
|
|
415
|
-
this
|
|
577
|
+
this.#emitError(new Error(`Voice connection closed${codeMsg}`));
|
|
578
|
+
this.#emitStatus("error");
|
|
416
579
|
} else {
|
|
417
|
-
this
|
|
580
|
+
this.#emitStatus("idle");
|
|
418
581
|
}
|
|
419
|
-
this
|
|
582
|
+
this.#cleanup();
|
|
420
583
|
};
|
|
421
584
|
}
|
|
422
585
|
|
|
586
|
+
/** The AI-disclosure notice for a live speech-to-speech call (`disclosureText`; `false` hides it). */
|
|
587
|
+
getDisclosure(): string | null {
|
|
588
|
+
const text = this.config?.disclosureText;
|
|
589
|
+
return this.#callLive && this.#speechToSpeech && text !== false ? text || DISCLOSURE_TEXT : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
423
592
|
/** End the call (hang up). */
|
|
424
593
|
async stopListening(): Promise<void> {
|
|
425
|
-
this
|
|
426
|
-
this
|
|
594
|
+
this.#cleanup();
|
|
595
|
+
this.#emitStatus("idle");
|
|
427
596
|
}
|
|
428
597
|
|
|
429
598
|
/** Tear down the call and drop all callbacks (used by `cleanupVoice`). */
|
|
430
599
|
async disconnect(): Promise<void> {
|
|
431
|
-
this
|
|
432
|
-
this
|
|
433
|
-
this
|
|
434
|
-
this
|
|
435
|
-
this
|
|
436
|
-
this
|
|
437
|
-
this
|
|
600
|
+
this.#cleanup();
|
|
601
|
+
this.#emitStatus("disconnected");
|
|
602
|
+
this.#resultCallbacks = [];
|
|
603
|
+
this.#errorCallbacks = [];
|
|
604
|
+
this.#statusCallbacks = [];
|
|
605
|
+
this.#transcriptCallbacks = [];
|
|
606
|
+
this.#metricsCallbacks = [];
|
|
438
607
|
}
|
|
439
608
|
|
|
440
|
-
/**
|
|
609
|
+
/**
|
|
610
|
+
* Stop the spoken reply without ending the call. In a speech-to-speech
|
|
611
|
+
* session this also asks the server to cancel the response; audio is dropped
|
|
612
|
+
* until its `audio_clear` acknowledgement arrives.
|
|
613
|
+
*/
|
|
441
614
|
stopPlayback(): void {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
this
|
|
615
|
+
this.#clearLocalPlayback();
|
|
616
|
+
const ws = this.#ws;
|
|
617
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
618
|
+
if (this.#speechToSpeech) {
|
|
619
|
+
this.#cancelledUntil = Date.now() + CANCEL_ACK_TIMEOUT_MS;
|
|
620
|
+
ws.send('{"type":"cancel"}');
|
|
621
|
+
}
|
|
622
|
+
this.#emitStatus("listening");
|
|
446
623
|
}
|
|
447
624
|
}
|
|
448
625
|
|
|
@@ -455,35 +632,33 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
455
632
|
|
|
456
633
|
/** True while the call (hot mic) is live. */
|
|
457
634
|
isBargeInActive(): boolean {
|
|
458
|
-
return this
|
|
635
|
+
return this.#callLive;
|
|
459
636
|
}
|
|
460
637
|
|
|
461
638
|
/** "Hang up" the always-on mic. */
|
|
462
639
|
onLevel(callback: (level: number) => void): void {
|
|
463
|
-
this
|
|
640
|
+
this.#levelCallbacks.push(callback);
|
|
464
641
|
}
|
|
465
642
|
|
|
466
643
|
async deactivateBargeIn(): Promise<void> {
|
|
467
|
-
this
|
|
468
|
-
this
|
|
644
|
+
this.#cleanup();
|
|
645
|
+
this.#emitStatus("idle");
|
|
469
646
|
}
|
|
470
647
|
|
|
471
648
|
// --- Capture ---------------------------------------------------------------
|
|
472
649
|
|
|
473
|
-
|
|
474
|
-
context: AudioContext,
|
|
475
|
-
stream: MediaStream,
|
|
476
|
-
ws: WebSocket,
|
|
477
|
-
generation: number,
|
|
478
|
-
): void {
|
|
650
|
+
#startCapture(context: AudioContext, stream: MediaStream, generation: number): void {
|
|
479
651
|
const source = context.createMediaStreamSource(stream);
|
|
480
|
-
this
|
|
652
|
+
this.#sourceNode = source;
|
|
481
653
|
const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
|
|
482
|
-
this
|
|
654
|
+
this.#processor = processor;
|
|
483
655
|
|
|
484
656
|
processor.onaudioprocess = (e) => {
|
|
485
|
-
if (generation !== this
|
|
486
|
-
|
|
657
|
+
if (generation !== this.#callGeneration) return;
|
|
658
|
+
// No socket yet (adopting a prewarm) or still handshaking: buffer.
|
|
659
|
+
const ws = this.#ws;
|
|
660
|
+
const open = ws?.readyState === WebSocket.OPEN;
|
|
661
|
+
if (ws && !open && ws.readyState !== WebSocket.CONNECTING) return;
|
|
487
662
|
const input = e.inputBuffer.getChannelData(0);
|
|
488
663
|
const pcm16 = new Int16Array(input.length);
|
|
489
664
|
let sumSquares = 0;
|
|
@@ -492,14 +667,23 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
492
667
|
sumSquares += s * s;
|
|
493
668
|
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
494
669
|
}
|
|
495
|
-
ws
|
|
670
|
+
if (ws && open) {
|
|
671
|
+
this.#flushPreOpenAudio(ws);
|
|
672
|
+
ws.send(pcm16.buffer);
|
|
673
|
+
} else {
|
|
674
|
+
this.#preOpenAudio.push(pcm16.buffer);
|
|
675
|
+
this.#preOpenBytes += pcm16.byteLength;
|
|
676
|
+
while (this.#preOpenBytes > PRE_OPEN_AUDIO_MAX_BYTES) {
|
|
677
|
+
this.#preOpenBytes -= this.#preOpenAudio.shift()!.byteLength;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
496
680
|
// Amplitude comes free from the buffer we already walked: no analyser
|
|
497
681
|
// node, no second capture. RMS is scaled because speech rarely exceeds
|
|
498
682
|
// ~0.3 RMS, so raw values would sit near the bottom of the 0..1 range.
|
|
499
|
-
if (this
|
|
683
|
+
if (this.#levelCallbacks.length > 0) {
|
|
500
684
|
const rms = Math.sqrt(sumSquares / input.length);
|
|
501
685
|
const level = Math.max(0, Math.min(1, rms * LEVEL_RMS_SCALE));
|
|
502
|
-
for (const cb of this
|
|
686
|
+
for (const cb of this.#levelCallbacks) cb(level);
|
|
503
687
|
}
|
|
504
688
|
};
|
|
505
689
|
|
|
@@ -509,13 +693,19 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
509
693
|
processor.connect(context.destination);
|
|
510
694
|
}
|
|
511
695
|
|
|
696
|
+
#flushPreOpenAudio(ws: WebSocket): void {
|
|
697
|
+
for (const frame of this.#preOpenAudio) ws.send(frame);
|
|
698
|
+
this.#preOpenAudio = [];
|
|
699
|
+
this.#preOpenBytes = 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
512
702
|
// --- Downstream ------------------------------------------------------------
|
|
513
703
|
|
|
514
|
-
|
|
515
|
-
if (generation !== this
|
|
704
|
+
#handleMessage(event: MessageEvent, generation: number): void {
|
|
705
|
+
if (generation !== this.#callGeneration) return;
|
|
516
706
|
|
|
517
707
|
if (event.data instanceof ArrayBuffer) {
|
|
518
|
-
this
|
|
708
|
+
this.#handleAudioFrame(event.data, generation);
|
|
519
709
|
return;
|
|
520
710
|
}
|
|
521
711
|
|
|
@@ -527,30 +717,150 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
527
717
|
}
|
|
528
718
|
|
|
529
719
|
switch (msg.type) {
|
|
720
|
+
case "session_config":
|
|
721
|
+
// The follow-up session_config carries only interruptionMode: keep the mode.
|
|
722
|
+
if (msg.speechMode) {
|
|
723
|
+
this.#speechToSpeech = msg.speechMode === "speech_to_speech";
|
|
724
|
+
this.#playback?.setContinuousMode?.(this.#speechToSpeech);
|
|
725
|
+
}
|
|
726
|
+
// The negotiated client frame types. A server without `capabilities`
|
|
727
|
+
// predates them: no client delegation, context or updates.
|
|
728
|
+
if (Array.isArray(msg.capabilities)) this.#capabilities = new Set(msg.capabilities);
|
|
729
|
+
if (typeof msg.callId === "string") this.#callId = msg.callId;
|
|
730
|
+
this.#clientDelegation = this.#speechToSpeech && this.#capabilities.has("client_delegation") && !!this.bridge;
|
|
731
|
+
// Speech-to-speech is known only now: let the UI show its disclosure.
|
|
732
|
+
if (this.#speechToSpeech && !this.#isSpeaking) this.#emitStatus("listening");
|
|
733
|
+
if (this.#capabilities.has("context") && !this.#contextSent) {
|
|
734
|
+
this.#contextSent = true;
|
|
735
|
+
this.#pendingContext = this.#buildContextText();
|
|
736
|
+
}
|
|
737
|
+
break;
|
|
738
|
+
|
|
739
|
+
case "transcript_update": {
|
|
740
|
+
const role = msg.role === "assistant" ? "assistant" : "user";
|
|
741
|
+
// A reply the client cancelled keeps streaming until the server clears it.
|
|
742
|
+
const utteranceId = msg.utteranceId ?? msg.turnId;
|
|
743
|
+
if (!utteranceId || (role === "assistant" && this.#isCancelling())) break;
|
|
744
|
+
const turnId = String(utteranceId);
|
|
745
|
+
if (role === "user") {
|
|
746
|
+
// Release on a final user transcript, not a partial: a mid-utterance
|
|
747
|
+
// context append makes the voice model answer early (or not delegate).
|
|
748
|
+
if (msg.final === true) this.#flushCallContext(generation);
|
|
749
|
+
this.#foldReadback = false;
|
|
750
|
+
} else {
|
|
751
|
+
// Read-back of a delegated result the chat already renders. A server
|
|
752
|
+
// that negotiated `delegation_read_back` tags it with its delegation;
|
|
753
|
+
// an older one rotates the assistant id at completion, so it is the
|
|
754
|
+
// first new id after it (a guess a filler finishing late defeats).
|
|
755
|
+
if (this.#capabilities.has("delegation_read_back")) {
|
|
756
|
+
// Fold only from the first frame: hiding an utterance already shown would strand a partial caption.
|
|
757
|
+
const tag = msg.delegationId;
|
|
758
|
+
if (typeof tag === "string" && this.#answered.has(tag) && !this.#assistantTurns.has(turnId)) {
|
|
759
|
+
this.#foldedTurns.add(turnId);
|
|
760
|
+
}
|
|
761
|
+
} else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
|
|
762
|
+
this.#foldedTurns.add(turnId);
|
|
763
|
+
this.#foldReadback = false;
|
|
764
|
+
}
|
|
765
|
+
this.#assistantTurns.add(turnId);
|
|
766
|
+
if (this.#foldedTurns.has(turnId)) break;
|
|
767
|
+
}
|
|
768
|
+
this.#emitTranscript(role, msg.text ?? "", msg.final === true, {
|
|
769
|
+
turnId,
|
|
770
|
+
...(typeof msg.startMs === "number" && { startMs: msg.startMs }),
|
|
771
|
+
...(typeof msg.endMs === "number" && { endMs: msg.endMs }),
|
|
772
|
+
// The chat pipeline owns the conversation: speech is only captioned.
|
|
773
|
+
...(this.#clientDelegation && { caption: true }),
|
|
774
|
+
});
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
case "delegation_started":
|
|
779
|
+
case "delegation_completed": {
|
|
780
|
+
const delegationId = String(msg.delegationId ?? msg.turnId);
|
|
781
|
+
if (msg.type === "delegation_started") {
|
|
782
|
+
this.#flushCallContext(generation);
|
|
783
|
+
// `input`: run this turn through the chat pipeline (client delegation).
|
|
784
|
+
const input = msg.input;
|
|
785
|
+
if (this.#clientDelegation && input) {
|
|
786
|
+
this.#runDelegation(
|
|
787
|
+
delegationId,
|
|
788
|
+
{
|
|
789
|
+
delegationId,
|
|
790
|
+
userText: String(input.text ?? ""),
|
|
791
|
+
userUtteranceIds: Array.isArray(input.userUtteranceIds)
|
|
792
|
+
? input.userUtteranceIds.filter((id: unknown): id is string => typeof id === "string")
|
|
793
|
+
: [],
|
|
794
|
+
messages: Array.isArray(input.messages) ? input.messages : [],
|
|
795
|
+
},
|
|
796
|
+
generation,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
} else if (msg.speak !== false && this.#answered.has(delegationId)) {
|
|
800
|
+
// Every spoken phase (the approval ask, the late result) of an answer
|
|
801
|
+
// the chat shows is read back. A refusal of one that never started
|
|
802
|
+
// here has no chat answer: it renders. `final` may never come.
|
|
803
|
+
this.#foldReadback = true;
|
|
804
|
+
}
|
|
805
|
+
this.#delegating = msg.type === "delegation_started";
|
|
806
|
+
if (!this.#isSpeaking && !this.#isCancelling()) {
|
|
807
|
+
this.#emitStatus(this.#delegating ? "processing" : "listening");
|
|
808
|
+
}
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
case "delegation_cancelled":
|
|
813
|
+
// The server gave up on it: no more frames for it, and its approval
|
|
814
|
+
// timers stop. The chat turn still renders. Past its deadline (or for
|
|
815
|
+
// an unknown reason), a parked approval card is also declined, as the
|
|
816
|
+
// approval TTL would; the call ending or the voice model cancelling
|
|
817
|
+
// leaves the card usable in the chat.
|
|
818
|
+
this.#dropDelegation(
|
|
819
|
+
String(msg.delegationId),
|
|
820
|
+
msg.reason !== "session_ending" && msg.reason !== "provider_cancelled",
|
|
821
|
+
);
|
|
822
|
+
break;
|
|
823
|
+
|
|
824
|
+
case "warning":
|
|
825
|
+
// Non-fatal (an unknown or refused frame): the call goes on. A refused
|
|
826
|
+
// delegation frame means the server is done with that delegation.
|
|
827
|
+
console.warn(`[Persona voice] ${msg.code}: ${msg.message ?? ""}`, this.#callId ?? "");
|
|
828
|
+
if (msg.delegationId && (msg.code === "UNKNOWN_DELEGATION" || msg.code === "LATE_RESULT_LIMIT")) {
|
|
829
|
+
this.#dropDelegation(String(msg.delegationId));
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
|
|
833
|
+
case "audio_clear":
|
|
834
|
+
// Barge-in (or our own cancel acknowledged): stop playback now.
|
|
835
|
+
this.#clearLocalPlayback();
|
|
836
|
+
this.#cancelledUntil = 0;
|
|
837
|
+
this.#emitStatus("listening");
|
|
838
|
+
break;
|
|
839
|
+
|
|
530
840
|
case "transcript_interim":
|
|
531
|
-
this
|
|
532
|
-
this
|
|
841
|
+
this.#emitStatus("listening");
|
|
842
|
+
this.#emitTranscript("user", msg.text ?? "", false);
|
|
533
843
|
break;
|
|
534
844
|
|
|
535
845
|
case "transcript_final": {
|
|
536
846
|
const role = msg.role === "assistant" ? "assistant" : "user";
|
|
537
847
|
// user final → agent is now thinking; assistant final → reply incoming.
|
|
538
|
-
this
|
|
539
|
-
this
|
|
848
|
+
this.#emitStatus(role === "user" ? "processing" : "speaking");
|
|
849
|
+
this.#emitTranscript(role, msg.text ?? "", true);
|
|
540
850
|
break;
|
|
541
851
|
}
|
|
542
852
|
|
|
543
853
|
case "audio_end":
|
|
544
|
-
if (this
|
|
545
|
-
this
|
|
854
|
+
if (this.#playback) {
|
|
855
|
+
this.#playback.markStreamEnd();
|
|
546
856
|
} else {
|
|
547
|
-
this
|
|
548
|
-
this
|
|
857
|
+
this.#isSpeaking = false;
|
|
858
|
+
this.#emitStatus("listening");
|
|
549
859
|
}
|
|
550
860
|
break;
|
|
551
861
|
|
|
552
862
|
case "metrics":
|
|
553
|
-
this
|
|
863
|
+
this.#emitMetrics({
|
|
554
864
|
llmMs: msg.llm_ms,
|
|
555
865
|
ttsMs: msg.tts_ms,
|
|
556
866
|
firstAudioMs: msg.first_audio_ms,
|
|
@@ -559,106 +869,287 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
559
869
|
break;
|
|
560
870
|
|
|
561
871
|
case "error":
|
|
562
|
-
this
|
|
563
|
-
this
|
|
872
|
+
this.#emitError(new Error(msg.error || "Voice error"));
|
|
873
|
+
this.#emitStatus("error");
|
|
564
874
|
break;
|
|
565
875
|
}
|
|
566
876
|
}
|
|
567
877
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
878
|
+
/** Send the call-start `context` frame (history + host context), if any. */
|
|
879
|
+
/**
|
|
880
|
+
* The call-start `context` text. History is read now, before any of this
|
|
881
|
+
* call's voice bubbles exist, so it never includes the utterance in progress.
|
|
882
|
+
*/
|
|
883
|
+
async #buildContextText(): Promise<string> {
|
|
884
|
+
const history = this.bridge?.getHistory() ?? [];
|
|
885
|
+
let extra = "";
|
|
886
|
+
try {
|
|
887
|
+
const source = this.config?.callContext;
|
|
888
|
+
extra = String((typeof source === "function" ? await source() : source) ?? "");
|
|
889
|
+
} catch {
|
|
890
|
+
// A failing host callback only loses its own part of the context.
|
|
891
|
+
}
|
|
892
|
+
return buildCallContext(history, extra);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Send the held call-start context, once: at the first final user transcript or delegation. */
|
|
896
|
+
#flushCallContext(generation: number): void {
|
|
897
|
+
const pending = this.#pendingContext;
|
|
898
|
+
if (!pending) return;
|
|
899
|
+
this.#pendingContext = null;
|
|
900
|
+
let dropped = false;
|
|
901
|
+
const done = pending.then((text) => {
|
|
902
|
+
const ws = this.#ws;
|
|
903
|
+
if (dropped || !text || generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return;
|
|
904
|
+
ws.send(JSON.stringify({ type: "context", text }));
|
|
905
|
+
});
|
|
906
|
+
this.#contextSend = { done, drop: () => (dropped = true) };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Let a released context go out first; a hung host callback is dropped. */
|
|
910
|
+
async #awaitContextSend(): Promise<void> {
|
|
911
|
+
const send = this.#contextSend;
|
|
912
|
+
if (!send) return;
|
|
913
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
914
|
+
const timedOut = await Promise.race([
|
|
915
|
+
send.done.then(() => false),
|
|
916
|
+
new Promise<boolean>((resolve) => {
|
|
917
|
+
timer = setTimeout(() => resolve(true), CONTEXT_BEFORE_RESULT_TIMEOUT_MS);
|
|
918
|
+
}),
|
|
919
|
+
]);
|
|
920
|
+
clearTimeout(timer);
|
|
921
|
+
if (timedOut) send.drop();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** No more frames for this delegation; its chat answer goes back to browser TTS. */
|
|
925
|
+
#dropDelegation(delegationId: string, expired = false): void {
|
|
926
|
+
this.#cancelledDelegations.add(delegationId);
|
|
927
|
+
this.bridge?.dropDelegation?.(delegationId, expired);
|
|
928
|
+
this.#followUps.get(delegationId)?.abort();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Run a delegated turn through the session bridge (one at a time) and answer it. */
|
|
932
|
+
#runDelegation(delegationId: string, request: VoiceDelegationRequest, generation: number): void {
|
|
933
|
+
const bridge = this.bridge!;
|
|
934
|
+
/** Send a frame for this delegation, unless the call or the delegation ended. */
|
|
935
|
+
const send = (type: "delegation_update" | "delegation_result", status: string, text: string) => {
|
|
936
|
+
const ws = this.#ws;
|
|
937
|
+
if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
|
|
938
|
+
if (this.#cancelledDelegations.has(delegationId)) return false;
|
|
939
|
+
ws.send(JSON.stringify({ type, delegationId, status, text }));
|
|
940
|
+
return true;
|
|
941
|
+
};
|
|
942
|
+
this.#delegations = this.#delegations.then(async () => {
|
|
943
|
+
// Cancelled while queued behind another turn: never start it.
|
|
944
|
+
if (generation !== this.#callGeneration || this.#cancelledDelegations.has(delegationId)) return;
|
|
945
|
+
const result: VoiceDelegationResult = await bridge
|
|
946
|
+
.runDelegatedTurn(request)
|
|
947
|
+
.catch(() => ({ status: "failed", text: "" }));
|
|
948
|
+
await this.#awaitContextSend();
|
|
949
|
+
// Parked on an approval: ask now (non-terminal), answer once the visitor
|
|
950
|
+
// decides. A server that can't take an update gets the ask as the result.
|
|
951
|
+
const parked = !!result.followUp;
|
|
952
|
+
const update = parked && this.#capabilities.has("delegation_update");
|
|
953
|
+
const sent = send(
|
|
954
|
+
update ? "delegation_update" : "delegation_result",
|
|
955
|
+
parked && !update ? "completed" : result.status,
|
|
956
|
+
result.text,
|
|
957
|
+
);
|
|
958
|
+
if (!sent) return;
|
|
959
|
+
if (result.status !== "failed" && result.inChat !== false) this.#answered.add(delegationId);
|
|
960
|
+
if (!result.followUp) return;
|
|
961
|
+
// The approval bookkeeping (expiry, supersede) runs either way.
|
|
962
|
+
const abort = new AbortController();
|
|
963
|
+
this.#followUps.set(delegationId, abort);
|
|
964
|
+
void result
|
|
965
|
+
.followUp({
|
|
966
|
+
signal: abort.signal,
|
|
967
|
+
// Under the server's 600 s deadline, which restarts at each update.
|
|
968
|
+
approvalTimeoutMs: this.config?.approvalTimeoutMs && Math.min(this.config.approvalTimeoutMs, APPROVAL_TIMEOUT_MAX_MS),
|
|
969
|
+
readBack: update,
|
|
970
|
+
// Another gated tool in the same turn: another (non-terminal) update.
|
|
971
|
+
onUpdate: (text) => send("delegation_update", "pending_approval", text),
|
|
972
|
+
})
|
|
973
|
+
.then((followUp) => {
|
|
974
|
+
this.#followUps.delete(delegationId);
|
|
975
|
+
// Hang-up and cancellation send nothing.
|
|
976
|
+
if (update && followUp && !abort.signal.aborted) send("delegation_result", followUp.status, followUp.text);
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
#handleAudioFrame(buf: ArrayBuffer, generation: number): void {
|
|
982
|
+
if (generation !== this.#callGeneration) return;
|
|
983
|
+
if (!this.#playback) return;
|
|
984
|
+
if (this.#isCancelling()) return;
|
|
571
985
|
const pcm = stripWavHeader(buf);
|
|
572
986
|
if (pcm.length === 0) return;
|
|
573
|
-
if (!this
|
|
574
|
-
this
|
|
575
|
-
this
|
|
987
|
+
if (!this.#isSpeaking) {
|
|
988
|
+
this.#isSpeaking = true;
|
|
989
|
+
this.#emitStatus("speaking");
|
|
990
|
+
}
|
|
991
|
+
this.#armPlaybackFinished();
|
|
992
|
+
this.#playback.enqueue(pcm);
|
|
993
|
+
if (this.#speechToSpeech) this.#scheduleContinuousDrain(pcm.length);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
#isCancelling(): boolean {
|
|
997
|
+
return Date.now() < this.#cancelledUntil;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Register the (one-shot) engine drain callback if none is pending. */
|
|
1001
|
+
#armPlaybackFinished(): void {
|
|
1002
|
+
if (this.#finishedArmed || !this.#playback) return;
|
|
1003
|
+
this.#finishedArmed = true;
|
|
1004
|
+
const generation = this.#callGeneration;
|
|
1005
|
+
this.#playback.onFinished(() => {
|
|
1006
|
+
if (generation !== this.#callGeneration) return;
|
|
1007
|
+
this.#finishedArmed = false;
|
|
1008
|
+
this.#handlePlaybackDrained();
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Speech-to-speech replies never get an end-of-stream, so estimate when the
|
|
1014
|
+
* queued audio finishes from its duration and release "speaking" then. The
|
|
1015
|
+
* engine is never marked ended, so the next reply's audio plays through the
|
|
1016
|
+
* same stream untouched and an underrun is never mistaken for a reply end.
|
|
1017
|
+
*/
|
|
1018
|
+
#scheduleContinuousDrain(byteLength: number): void {
|
|
1019
|
+
const now = Date.now();
|
|
1020
|
+
// PCM16 @ 24 kHz: 48 bytes per millisecond.
|
|
1021
|
+
this.#playbackEndsAt = Math.max(now, this.#playbackEndsAt) + byteLength / 48;
|
|
1022
|
+
clearTimeout(this.#drainTimer);
|
|
1023
|
+
this.#drainTimer = setTimeout(
|
|
1024
|
+
() => this.#handlePlaybackDrained(),
|
|
1025
|
+
this.#playbackEndsAt - now + CONTINUOUS_DRAIN_GRACE_MS,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
#handlePlaybackDrained(): void {
|
|
1030
|
+
this.#isSpeaking = false;
|
|
1031
|
+
this.#playbackEndsAt = 0;
|
|
1032
|
+
// Reply drained: the call stays open, so return to listening (or to
|
|
1033
|
+
// processing while an agent delegation is still running).
|
|
1034
|
+
if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
|
|
1035
|
+
this.#emitStatus(this.#delegating ? "processing" : "listening");
|
|
576
1036
|
}
|
|
577
|
-
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Drop queued audio locally and reset the speaking/drain bookkeeping. */
|
|
1040
|
+
#clearLocalPlayback(): void {
|
|
1041
|
+
this.#playback?.flush();
|
|
1042
|
+
// flush() discards registered callbacks: re-arm for the next reply.
|
|
1043
|
+
this.#finishedArmed = false;
|
|
1044
|
+
this.#armPlaybackFinished();
|
|
1045
|
+
this.#isSpeaking = false;
|
|
1046
|
+
this.#playbackEndsAt = 0;
|
|
1047
|
+
clearTimeout(this.#drainTimer);
|
|
578
1048
|
}
|
|
579
1049
|
|
|
580
1050
|
// --- Teardown --------------------------------------------------------------
|
|
581
1051
|
|
|
582
|
-
|
|
1052
|
+
#cleanup(): void {
|
|
583
1053
|
// Invalidate any in-flight async continuation / late frames first.
|
|
584
|
-
this
|
|
585
|
-
this
|
|
586
|
-
this
|
|
587
|
-
this
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1054
|
+
this.#callGeneration += 1;
|
|
1055
|
+
this.#callLive = false;
|
|
1056
|
+
this.#isSpeaking = false;
|
|
1057
|
+
this.#releaseAttached();
|
|
1058
|
+
this.#speechToSpeech = false;
|
|
1059
|
+
this.#delegating = false;
|
|
1060
|
+
this.#clientDelegation = false;
|
|
1061
|
+
this.#capabilities = new Set();
|
|
1062
|
+
for (const abort of this.#followUps.values()) abort.abort();
|
|
1063
|
+
this.#followUps.clear();
|
|
1064
|
+
this.#cancelledDelegations.clear();
|
|
1065
|
+
this.#callId = undefined;
|
|
1066
|
+
this.#contextSent = false;
|
|
1067
|
+
this.#pendingContext = null;
|
|
1068
|
+
this.#contextSend = null;
|
|
1069
|
+
this.#delegations = Promise.resolve();
|
|
1070
|
+
this.#answered.clear();
|
|
1071
|
+
this.#foldReadback = false;
|
|
1072
|
+
this.#assistantTurns.clear();
|
|
1073
|
+
this.#foldedTurns.clear();
|
|
1074
|
+
this.#finishedArmed = false;
|
|
1075
|
+
this.#cancelledUntil = 0;
|
|
1076
|
+
this.#playbackEndsAt = 0;
|
|
1077
|
+
clearTimeout(this.#drainTimer);
|
|
1078
|
+
this.#preOpenAudio = [];
|
|
1079
|
+
this.#preOpenBytes = 0;
|
|
1080
|
+
|
|
1081
|
+
if (this.#processor) {
|
|
1082
|
+
this.#processor.onaudioprocess = null;
|
|
1083
|
+
this.#processor.disconnect();
|
|
1084
|
+
this.#processor = null;
|
|
593
1085
|
}
|
|
594
|
-
if (this
|
|
595
|
-
this
|
|
596
|
-
this
|
|
1086
|
+
if (this.#sourceNode) {
|
|
1087
|
+
this.#sourceNode.disconnect();
|
|
1088
|
+
this.#sourceNode = null;
|
|
597
1089
|
}
|
|
598
|
-
if (this
|
|
599
|
-
this
|
|
600
|
-
this
|
|
1090
|
+
if (this.#mediaStream) {
|
|
1091
|
+
this.#mediaStream.getTracks().forEach((t) => t.stop());
|
|
1092
|
+
this.#mediaStream = null;
|
|
601
1093
|
}
|
|
602
|
-
if (this
|
|
603
|
-
this
|
|
604
|
-
this
|
|
1094
|
+
if (this.#captureContext) {
|
|
1095
|
+
this.#captureContext.close().catch(() => {});
|
|
1096
|
+
this.#captureContext = null;
|
|
605
1097
|
}
|
|
606
|
-
if (this
|
|
607
|
-
void this
|
|
608
|
-
this
|
|
1098
|
+
if (this.#playback) {
|
|
1099
|
+
void this.#playback.destroy();
|
|
1100
|
+
this.#playback = null;
|
|
609
1101
|
}
|
|
610
|
-
if (this
|
|
611
|
-
this
|
|
1102
|
+
if (this.#ws) {
|
|
1103
|
+
this.#intentionalClose = true;
|
|
612
1104
|
try {
|
|
613
|
-
this
|
|
1105
|
+
this.#ws.close(1000, "client ended call");
|
|
614
1106
|
} catch {
|
|
615
1107
|
// ignore
|
|
616
1108
|
}
|
|
617
|
-
this
|
|
1109
|
+
this.#ws = null;
|
|
618
1110
|
}
|
|
619
1111
|
}
|
|
620
1112
|
|
|
621
1113
|
// --- Callback registration + emit -----------------------------------------
|
|
622
1114
|
|
|
623
1115
|
onResult(callback: (result: VoiceResult) => void): void {
|
|
624
|
-
this
|
|
1116
|
+
this.#resultCallbacks.push(callback);
|
|
625
1117
|
}
|
|
626
1118
|
|
|
627
1119
|
onError(callback: (error: Error) => void): void {
|
|
628
|
-
this
|
|
1120
|
+
this.#errorCallbacks.push(callback);
|
|
629
1121
|
}
|
|
630
1122
|
|
|
631
1123
|
onStatusChange(callback: (status: VoiceStatus) => void): void {
|
|
632
|
-
this
|
|
1124
|
+
this.#statusCallbacks.push(callback);
|
|
633
1125
|
}
|
|
634
1126
|
|
|
635
|
-
onTranscript(
|
|
636
|
-
callback
|
|
637
|
-
): void {
|
|
638
|
-
this.transcriptCallbacks.push(callback);
|
|
1127
|
+
onTranscript(callback: TranscriptCallback): void {
|
|
1128
|
+
this.#transcriptCallbacks.push(callback);
|
|
639
1129
|
}
|
|
640
1130
|
|
|
641
1131
|
onMetrics(callback: (metrics: VoiceMetrics) => void): void {
|
|
642
|
-
this
|
|
1132
|
+
this.#metricsCallbacks.push(callback);
|
|
643
1133
|
}
|
|
644
1134
|
|
|
645
|
-
|
|
646
|
-
this
|
|
1135
|
+
#emitStatus(status: VoiceStatus): void {
|
|
1136
|
+
this.#statusCallbacks.forEach((cb) => cb(status));
|
|
647
1137
|
}
|
|
648
1138
|
|
|
649
|
-
|
|
650
|
-
this
|
|
1139
|
+
#emitError(error: Error): void {
|
|
1140
|
+
this.#errorCallbacks.forEach((cb) => cb(error));
|
|
651
1141
|
}
|
|
652
1142
|
|
|
653
|
-
|
|
1143
|
+
#emitTranscript(
|
|
654
1144
|
role: "user" | "assistant",
|
|
655
1145
|
text: string,
|
|
656
1146
|
isFinal: boolean,
|
|
1147
|
+
metadata?: VoiceTranscriptMetadata,
|
|
657
1148
|
): void {
|
|
658
|
-
this
|
|
1149
|
+
this.#transcriptCallbacks.forEach((cb) => cb(role, text, isFinal, metadata));
|
|
659
1150
|
}
|
|
660
1151
|
|
|
661
|
-
|
|
662
|
-
this
|
|
1152
|
+
#emitMetrics(metrics: VoiceMetrics): void {
|
|
1153
|
+
this.#metricsCallbacks.forEach((cb) => cb(metrics));
|
|
663
1154
|
}
|
|
664
1155
|
}
|