@runtypelabs/persona 3.34.1 → 3.35.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 +11 -11
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
- package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +10 -10
- package/dist/index.cjs +46 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +514 -158
- package/dist/index.d.ts +514 -158
- package/dist/index.global.js +41 -89
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -44
- 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 +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +53 -0
- package/dist/plugin-kit.d.cts +4 -4
- package/dist/plugin-kit.d.ts +4 -4
- package/dist/runtype-tts.js +1 -0
- package/dist/smart-dom-reader.cjs +1 -1
- package/dist/smart-dom-reader.d.cts +352 -103
- package/dist/smart-dom-reader.d.ts +352 -103
- package/dist/smart-dom-reader.js +1 -1
- package/dist/theme-editor-preview.cjs +203 -0
- package/dist/theme-editor-preview.d.cts +5748 -0
- package/dist/theme-editor-preview.d.ts +5748 -0
- package/dist/theme-editor-preview.js +203 -0
- package/dist/theme-editor.cjs +14 -131
- package/dist/theme-editor.d.cts +362 -574
- package/dist/theme-editor.d.ts +362 -574
- package/dist/theme-editor.js +14 -131
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1 -1
- package/dist/theme-reference.d.ts +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +80 -0
- package/dist/voice-worklet-player.d.cts +215 -0
- package/dist/voice-worklet-player.d.ts +215 -0
- package/dist/voice-worklet-player.js +80 -0
- package/dist/widget.css +54 -36
- package/package.json +16 -2
- package/src/animations/glyph-cycle.ts +11 -14
- package/src/animations/wipe.ts +1 -1
- package/src/ask-user-question-tool.test.ts +1 -1
- package/src/ask-user-question-tool.ts +6 -8
- package/src/client.test.ts +12 -12
- package/src/client.ts +30 -31
- package/src/codegen.test.ts +1 -1
- package/src/codegen.ts +1 -1
- package/src/components/approval-bubble.test.ts +1 -1
- package/src/components/approval-bubble.ts +3 -3
- package/src/components/artifact-pane.ts +3 -3
- package/src/components/ask-user-question-bubble.test.ts +14 -14
- package/src/components/ask-user-question-bubble.ts +10 -10
- package/src/components/composer-builder.ts +17 -16
- package/src/components/composer-parts.test.ts +54 -0
- package/src/components/composer-parts.ts +95 -126
- package/src/components/demo-carousel.ts +1 -1
- package/src/components/event-stream-view.test.ts +7 -7
- package/src/components/event-stream-view.ts +5 -5
- package/src/components/header-builder.ts +33 -27
- package/src/components/header-layouts.ts +1 -1
- package/src/components/header-parts.test.ts +90 -0
- package/src/components/header-parts.ts +62 -93
- package/src/components/launcher.test.ts +11 -0
- package/src/components/launcher.ts +1 -1
- package/src/components/message-bubble.test.ts +74 -2
- package/src/components/message-bubble.ts +25 -11
- package/src/components/messages.ts +2 -2
- package/src/components/panel.test.ts +1 -1
- package/src/components/panel.ts +92 -97
- package/src/components/pill-composer-builder.ts +28 -24
- package/src/components/reasoning-bubble.ts +2 -2
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +1 -1
- package/src/components/widget-view.test.ts +181 -0
- package/src/components/widget-view.ts +234 -0
- package/src/defaults.ts +54 -51
- package/src/generated/runtype-openapi-contract.ts +5 -0
- package/src/index-core.ts +30 -4
- package/src/index-global.ts +73 -6
- package/src/index.ts +7 -1
- package/src/install.test.ts +11 -11
- package/src/install.ts +28 -18
- package/src/launcher-global.ts +25 -14
- package/src/markdown-parsers-eager.ts +20 -0
- package/src/markdown-parsers-entry.ts +4 -0
- package/src/markdown-parsers-loader.ts +49 -0
- package/src/plugin-kit.test.ts +1 -1
- package/src/plugin-kit.ts +7 -7
- package/src/plugins/types.ts +5 -5
- package/src/postprocessors.ts +27 -14
- package/src/runtime/host-layout.test.ts +1 -1
- package/src/runtime/host-layout.ts +4 -5
- package/src/runtime/init.ts +2 -2
- package/src/session.test.ts +5 -6
- package/src/session.ts +333 -227
- package/src/session.voice.test.ts +146 -0
- package/src/session.webmcp.test.ts +24 -25
- package/src/smart-dom-reader.ts +3 -4
- package/src/styles/widget.css +54 -36
- package/src/suggest-replies-tool.test.ts +3 -3
- package/src/suggest-replies-tool.ts +5 -5
- package/src/testing/index.ts +1 -1
- package/src/theme-editor/color-utils.ts +2 -2
- package/src/theme-editor/index.ts +4 -13
- package/src/theme-editor/presets.ts +1 -1
- package/src/theme-editor/preview-utils.ts +22 -20
- package/src/theme-editor/preview.ts +4 -1
- package/src/theme-editor/role-mappings.ts +3 -3
- package/src/theme-editor/sections.ts +21 -20
- package/src/theme-editor/types.ts +1 -1
- package/src/theme-editor/webmcp/index.ts +1 -1
- package/src/theme-editor/webmcp/summary.ts +1 -2
- package/src/theme-editor/webmcp/tools.ts +30 -30
- package/src/theme-editor/webmcp/types.ts +2 -2
- package/src/theme-editor-preview.ts +10 -0
- package/src/theme-reference.ts +26 -26
- package/src/types.ts +368 -99
- package/src/ui.approval-plugin.test.ts +3 -3
- package/src/ui.ask-user-question-plugin.test.ts +9 -9
- package/src/ui.component-directive.test.ts +4 -4
- package/src/ui.composer-bar.test.ts +10 -11
- package/src/ui.composer-keyboard.test.ts +5 -5
- package/src/ui.header-icon-color.test.ts +59 -0
- package/src/ui.scroll.test.ts +5 -6
- package/src/ui.stop-button.test.ts +3 -4
- package/src/ui.suggest-replies.test.ts +2 -2
- package/src/ui.ts +292 -131
- package/src/utils/buttons.ts +1 -1
- package/src/utils/code-generators.ts +1 -1
- package/src/utils/composer-history.ts +3 -3
- package/src/utils/dom-context.test.ts +2 -2
- package/src/utils/dom-context.ts +7 -7
- package/src/utils/dom.test.ts +67 -0
- package/src/utils/dom.ts +75 -0
- package/src/utils/dropdown.ts +2 -2
- package/src/utils/event-stream-buffer.test.ts +2 -2
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +5 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/sanitize.ts +32 -16
- package/src/utils/sequence-buffer.test.ts +8 -8
- package/src/utils/sequence-buffer.ts +4 -4
- package/src/utils/smart-dom-adapter.test.ts +1 -1
- package/src/utils/smart-dom-adapter.ts +4 -5
- package/src/utils/speech-text.test.ts +122 -0
- package/src/utils/speech-text.ts +101 -0
- package/src/utils/stream-animation.test.ts +3 -3
- package/src/utils/stream-animation.ts +6 -6
- package/src/utils/theme.test.ts +1 -1
- package/src/utils/throughput-tracker.test.ts +11 -11
- package/src/utils/throughput-tracker.ts +9 -9
- package/src/utils/tokens.ts +13 -13
- package/src/utils/virtual-scroller.ts +1 -1
- package/src/vendor/smart-dom-reader/README.md +4 -4
- package/src/vendor/smart-dom-reader/index.d.ts +1 -1
- package/src/vendor/smart-dom-reader/index.js +2 -2
- package/src/version.ts +1 -1
- package/src/voice/audio-playback-manager.test.ts +159 -0
- package/src/voice/audio-playback-manager.ts +145 -30
- package/src/voice/browser-speech-engine.ts +130 -0
- package/src/voice/fallback-speech-engine.ts +89 -0
- package/src/voice/index.ts +21 -0
- package/src/voice/read-aloud-controller.test.ts +143 -0
- package/src/voice/read-aloud-controller.ts +136 -0
- package/src/voice/runtype-speech-engine.test.ts +280 -0
- package/src/voice/runtype-speech-engine.ts +211 -0
- package/src/voice/runtype-tts-entry.ts +10 -0
- package/src/voice/runtype-tts-loader.test.ts +47 -0
- package/src/voice/runtype-tts-loader.ts +42 -0
- package/src/voice/runtype-voice-provider.ts +332 -537
- package/src/voice/voice-factory.ts +26 -4
- package/src/voice/voice.test.ts +321 -7
- package/src/voice/worklet-playback-engine.ts +272 -0
- package/src/voice-worklet-player.ts +39 -0
- package/src/webmcp-bridge.test.ts +6 -6
- package/src/webmcp-bridge.ts +24 -25
- package/src/webmcp-polyfill.ts +1 -1
- package/src/voice/voice-activity-detector.ts +0 -90
|
@@ -1,572 +1,392 @@
|
|
|
1
1
|
// Runtype Voice Provider
|
|
2
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// Real-time streaming voice client for Runtype's `/ws/agents/:agentId/voice`
|
|
4
|
+
// endpoint. The "call" is a single WebSocket session:
|
|
5
|
+
//
|
|
6
|
+
// - up: continuous mic audio as raw PCM16 LE mono @ 16kHz (binary frames)
|
|
7
|
+
// - down: WAV-wrapped PCM16 LE mono @ 24kHz audio (binary frames) +
|
|
8
|
+
// JSON control frames (transcript_interim / transcript_final /
|
|
9
|
+
// audio_end / metrics).
|
|
10
|
+
//
|
|
11
|
+
// The server's STT owns turn-taking, so the client streams continuously and
|
|
12
|
+
// has no client-side VAD, barge-in monitoring, or batch upload. Auth rides the
|
|
13
|
+
// `Sec-WebSocket-Protocol` subprotocol (`['runtype.bearer', clientToken]`),
|
|
14
|
+
// never the query string: the token is never placed in a URL or logged.
|
|
15
|
+
//
|
|
16
|
+
// A continuous always-hot mic is, in UX terms, a permanent barge-in session, so
|
|
17
|
+
// `getInterruptionMode()` reports the constant `'barge-in'` and the existing
|
|
18
|
+
// mic-button wiring (ui.ts) treats a click as "hang up at any state" unchanged.
|
|
3
19
|
|
|
4
20
|
import type {
|
|
5
21
|
VoiceProvider,
|
|
6
22
|
VoiceResult,
|
|
7
23
|
VoiceStatus,
|
|
8
24
|
VoiceConfig,
|
|
25
|
+
VoiceMetrics,
|
|
26
|
+
VoicePlaybackEngine,
|
|
9
27
|
} from "../types";
|
|
10
28
|
import { AudioPlaybackManager } from "./audio-playback-manager";
|
|
11
|
-
|
|
29
|
+
|
|
30
|
+
const CAPTURE_SAMPLE_RATE = 16000;
|
|
31
|
+
const PLAYBACK_SAMPLE_RATE = 24000;
|
|
32
|
+
const CAPTURE_BUFFER_SIZE = 4096;
|
|
33
|
+
const RIFF_MAGIC = 0x52494646; // "RIFF"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
|
|
37
|
+
* payload. The ElevenLabs realtime path WAV-wraps each frame; the Cloudflare DO
|
|
38
|
+
* path may send raw PCM: detect the RIFF magic and handle both.
|
|
39
|
+
*/
|
|
40
|
+
function stripWavHeader(buf: ArrayBuffer): Uint8Array {
|
|
41
|
+
if (buf.byteLength >= 44) {
|
|
42
|
+
const view = new DataView(buf);
|
|
43
|
+
if (view.getUint32(0, false) === RIFF_MAGIC) {
|
|
44
|
+
return new Uint8Array(buf, 44);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return new Uint8Array(buf);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Derive a ws(s):// base URL from a configured host (full URL or bare host). */
|
|
51
|
+
function toWsBase(host: string): string {
|
|
52
|
+
const trimmed = host.replace(/\/+$/, "");
|
|
53
|
+
if (/^wss?:\/\//i.test(trimmed)) return trimmed;
|
|
54
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/^http/i, "ws");
|
|
55
|
+
const secure =
|
|
56
|
+
typeof window !== "undefined" && window.location?.protocol === "https:";
|
|
57
|
+
return `${secure ? "wss:" : "ws:"}//${trimmed}`;
|
|
58
|
+
}
|
|
12
59
|
|
|
13
60
|
export class RuntypeVoiceProvider implements VoiceProvider {
|
|
14
61
|
type: "runtype" = "runtype";
|
|
62
|
+
|
|
15
63
|
private ws: WebSocket | null = null;
|
|
16
|
-
private
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
private
|
|
64
|
+
private captureContext: AudioContext | null = null;
|
|
65
|
+
private mediaStream: MediaStream | null = null;
|
|
66
|
+
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
|
67
|
+
private processor: ScriptProcessorNode | null = null;
|
|
68
|
+
private playback: VoicePlaybackEngine | null = null;
|
|
69
|
+
|
|
70
|
+
// True while a call (WS session) is live: drives the idempotent start guard
|
|
71
|
+
// and `isBargeInActive()`.
|
|
72
|
+
private callLive = false;
|
|
25
73
|
private isSpeaking = false;
|
|
26
74
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
75
|
+
// Invalidates in-flight async work (playback-engine creation, late frames,
|
|
76
|
+
// status transitions) after a teardown/restart so a stale callback can't act
|
|
77
|
+
// on a newer call's resources. Bumped on every start and every cleanup.
|
|
78
|
+
private callGeneration = 0;
|
|
30
79
|
|
|
31
|
-
//
|
|
32
|
-
private
|
|
33
|
-
private currentAudioUrl: string | null = null;
|
|
34
|
-
private currentRequestId: string | null = null;
|
|
35
|
-
private interruptionMode: "none" | "cancel" | "barge-in" = "none";
|
|
80
|
+
// Distinguishes a user-initiated close (code 1000) from a dropped connection.
|
|
81
|
+
private intentionalClose = false;
|
|
36
82
|
|
|
37
|
-
|
|
38
|
-
private
|
|
83
|
+
private resultCallbacks: ((result: VoiceResult) => void)[] = [];
|
|
84
|
+
private errorCallbacks: ((error: Error) => void)[] = [];
|
|
85
|
+
private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
|
|
86
|
+
private transcriptCallbacks: ((
|
|
87
|
+
role: "user" | "assistant",
|
|
88
|
+
text: string,
|
|
89
|
+
isFinal: boolean,
|
|
90
|
+
) => void)[] = [];
|
|
91
|
+
private metricsCallbacks: ((metrics: VoiceMetrics) => void)[] = [];
|
|
39
92
|
|
|
40
93
|
constructor(private config: VoiceConfig["runtype"]) {}
|
|
41
94
|
|
|
42
|
-
|
|
43
|
-
getInterruptionMode(): "none" | "cancel" | "barge-in" {
|
|
44
|
-
return this.interruptionMode;
|
|
45
|
-
}
|
|
95
|
+
// --- VoiceProvider lifecycle ----------------------------------------------
|
|
46
96
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
return this.interruptionMode === "barge-in" && this.mediaStream !== null;
|
|
50
|
-
}
|
|
97
|
+
/** No-op: the WS session opens lazily in `startListening` (the "call"). */
|
|
98
|
+
async connect(): Promise<void> {}
|
|
51
99
|
|
|
52
|
-
/**
|
|
53
|
-
async
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
/** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
|
|
101
|
+
async startListening(): Promise<void> {
|
|
102
|
+
if (this.callLive) return; // idempotent: a call is already live
|
|
103
|
+
|
|
104
|
+
const agentId = this.config?.agentId;
|
|
105
|
+
const token = this.config?.clientToken;
|
|
106
|
+
const host = this.config?.host;
|
|
107
|
+
if (!agentId) throw new Error("Runtype voice requires an agentId");
|
|
108
|
+
if (!token) throw new Error("Runtype voice requires a clientToken");
|
|
109
|
+
if (!host) throw new Error("Runtype voice requires a host (or widget apiUrl)");
|
|
110
|
+
|
|
111
|
+
const generation = ++this.callGeneration;
|
|
112
|
+
this.intentionalClose = false;
|
|
113
|
+
this.callLive = true;
|
|
64
114
|
|
|
65
|
-
async connect() {
|
|
66
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
67
|
-
return; // Already connected
|
|
68
|
-
}
|
|
69
115
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
116
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
117
|
+
audio: {
|
|
118
|
+
sampleRate: CAPTURE_SAMPLE_RATE,
|
|
119
|
+
channelCount: 1,
|
|
120
|
+
echoCancellation: true,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (generation !== this.callGeneration) {
|
|
124
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
125
|
+
return;
|
|
73
126
|
}
|
|
127
|
+
this.mediaStream = stream;
|
|
74
128
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const clientToken = this.config?.clientToken;
|
|
84
|
-
if (!agentId || !clientToken) {
|
|
85
|
-
throw new Error("agentId and clientToken are required");
|
|
129
|
+
// Create + resume both contexts inside the click gesture (iOS autoplay).
|
|
130
|
+
const AudioCtx =
|
|
131
|
+
(window as any).AudioContext || (window as any).webkitAudioContext;
|
|
132
|
+
const captureContext: AudioContext = new AudioCtx({
|
|
133
|
+
sampleRate: CAPTURE_SAMPLE_RATE,
|
|
134
|
+
});
|
|
135
|
+
if (captureContext.state === "suspended") {
|
|
136
|
+
await captureContext.resume().catch(() => {});
|
|
86
137
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
138
|
+
this.captureContext = captureContext;
|
|
139
|
+
|
|
140
|
+
const engine = this.config?.createPlaybackEngine
|
|
141
|
+
? await this.config.createPlaybackEngine()
|
|
142
|
+
: new AudioPlaybackManager(PLAYBACK_SAMPLE_RATE);
|
|
143
|
+
if (generation !== this.callGeneration) {
|
|
144
|
+
// Torn down while async work was in flight: free what we acquired.
|
|
145
|
+
void engine.destroy();
|
|
146
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
147
|
+
captureContext.close().catch(() => {});
|
|
148
|
+
return;
|
|
91
149
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
" Check: API running on port 8787? Valid client token? Agent voice enabled? Token allowedOrigins includes this page?";
|
|
101
|
-
|
|
102
|
-
await new Promise<void>((resolve, reject) => {
|
|
103
|
-
if (!this.ws) return reject(new Error("WebSocket not created"));
|
|
104
|
-
let rejected = false;
|
|
105
|
-
const doReject = (msg: string) => {
|
|
106
|
-
if (rejected) return;
|
|
107
|
-
rejected = true;
|
|
108
|
-
clearTimeout(timeout);
|
|
109
|
-
reject(new Error(msg));
|
|
110
|
-
};
|
|
111
|
-
const timeout = setTimeout(
|
|
112
|
-
() => doReject("WebSocket connection timed out." + hint),
|
|
113
|
-
10000
|
|
114
|
-
);
|
|
115
|
-
this.ws!.addEventListener(
|
|
116
|
-
"open",
|
|
117
|
-
() => {
|
|
118
|
-
if (!rejected) {
|
|
119
|
-
rejected = true;
|
|
120
|
-
clearTimeout(timeout);
|
|
121
|
-
resolve();
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
{ once: true }
|
|
125
|
-
);
|
|
126
|
-
this.ws!.addEventListener(
|
|
127
|
-
"error",
|
|
128
|
-
() => {
|
|
129
|
-
doReject(
|
|
130
|
-
"WebSocket connection failed to " + safeUrl + "." + hint
|
|
131
|
-
);
|
|
132
|
-
},
|
|
133
|
-
{ once: true }
|
|
134
|
-
);
|
|
135
|
-
this.ws!.addEventListener(
|
|
136
|
-
"close",
|
|
137
|
-
(evt) => {
|
|
138
|
-
if (!evt.wasClean && !rejected) {
|
|
139
|
-
const codeMsg =
|
|
140
|
-
evt.code !== 1006 ? ` (code ${evt.code})` : "";
|
|
141
|
-
doReject(
|
|
142
|
-
"WebSocket connection failed" + codeMsg + "." + hint
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
{ once: true }
|
|
147
|
-
);
|
|
150
|
+
this.playback = engine;
|
|
151
|
+
engine.onFinished(() => {
|
|
152
|
+
if (generation !== this.callGeneration) return;
|
|
153
|
+
this.isSpeaking = false;
|
|
154
|
+
// Reply drained: the call stays open, so return to listening.
|
|
155
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
156
|
+
this.emitStatus("listening");
|
|
157
|
+
}
|
|
148
158
|
});
|
|
149
159
|
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
160
|
+
const wsUrl = `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
|
|
161
|
+
// Token rides the subprotocol; `runtype.bearer` is the marker the server
|
|
162
|
+
// echoes as the negotiated subprotocol (browsers fail the handshake if an
|
|
163
|
+
// offered subprotocol goes unanswered).
|
|
164
|
+
const ws = new WebSocket(wsUrl, ["runtype.bearer", token]);
|
|
165
|
+
ws.binaryType = "arraybuffer";
|
|
166
|
+
this.ws = ws;
|
|
167
|
+
|
|
168
|
+
ws.onopen = () => {
|
|
169
|
+
if (generation !== this.callGeneration) return;
|
|
170
|
+
this.emitStatus("listening");
|
|
171
|
+
this.startCapture(captureContext, stream, ws, generation);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
ws.onmessage = (event) => this.handleMessage(event, generation);
|
|
175
|
+
|
|
176
|
+
ws.onerror = () => {
|
|
177
|
+
if (generation !== this.callGeneration) return;
|
|
178
|
+
this.emitError(new Error("Voice connection failed"));
|
|
179
|
+
this.emitStatus("error");
|
|
180
|
+
this.cleanup();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
ws.onclose = (evt) => {
|
|
184
|
+
if (this.intentionalClose) {
|
|
185
|
+
this.intentionalClose = false;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (generation !== this.callGeneration) return;
|
|
189
|
+
if (evt.code !== 1000) {
|
|
190
|
+
const codeMsg = evt.code ? ` (code ${evt.code})` : "";
|
|
191
|
+
this.emitError(new Error(`Voice connection closed${codeMsg}`));
|
|
192
|
+
this.emitStatus("error");
|
|
193
|
+
} else {
|
|
194
|
+
this.emitStatus("idle");
|
|
195
|
+
}
|
|
196
|
+
this.cleanup();
|
|
197
|
+
};
|
|
154
198
|
} catch (error) {
|
|
155
|
-
this.
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
199
|
+
this.cleanup();
|
|
200
|
+
this.emitError(error as Error);
|
|
201
|
+
this.emitStatus("error");
|
|
158
202
|
throw error;
|
|
159
203
|
}
|
|
160
204
|
}
|
|
161
205
|
|
|
162
|
-
|
|
163
|
-
|
|
206
|
+
/** End the call (hang up). */
|
|
207
|
+
async stopListening(): Promise<void> {
|
|
208
|
+
this.cleanup();
|
|
209
|
+
this.emitStatus("idle");
|
|
210
|
+
}
|
|
164
211
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
212
|
+
/** Tear down the call and drop all callbacks (used by `cleanupVoice`). */
|
|
213
|
+
async disconnect(): Promise<void> {
|
|
214
|
+
this.cleanup();
|
|
215
|
+
this.emitStatus("disconnected");
|
|
216
|
+
this.resultCallbacks = [];
|
|
217
|
+
this.errorCallbacks = [];
|
|
218
|
+
this.statusCallbacks = [];
|
|
219
|
+
this.transcriptCallbacks = [];
|
|
220
|
+
this.metricsCallbacks = [];
|
|
221
|
+
}
|
|
168
222
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
223
|
+
/** Stop the spoken reply without ending the call. */
|
|
224
|
+
stopPlayback(): void {
|
|
225
|
+
if (this.playback) this.playback.flush();
|
|
226
|
+
this.isSpeaking = false;
|
|
227
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
228
|
+
this.emitStatus("listening");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
172
231
|
|
|
173
|
-
|
|
174
|
-
this.errorCallbacks.forEach((cb) => cb(new Error("WebSocket error")));
|
|
175
|
-
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
176
|
-
};
|
|
232
|
+
// --- Barge-in surface (constants for the continuous hot-mic model) --------
|
|
177
233
|
|
|
178
|
-
|
|
179
|
-
|
|
234
|
+
/** A continuous call is a permanent barge-in session. */
|
|
235
|
+
getInterruptionMode(): "none" | "cancel" | "barge-in" {
|
|
236
|
+
return "barge-in";
|
|
237
|
+
}
|
|
180
238
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
239
|
+
/** True while the call (hot mic) is live. */
|
|
240
|
+
isBargeInActive(): boolean {
|
|
241
|
+
return this.callLive;
|
|
242
|
+
}
|
|
187
243
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
244
|
+
/** "Hang up" the always-on mic. */
|
|
245
|
+
async deactivateBargeIn(): Promise<void> {
|
|
246
|
+
this.cleanup();
|
|
247
|
+
this.emitStatus("idle");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Capture ---------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
private startCapture(
|
|
253
|
+
context: AudioContext,
|
|
254
|
+
stream: MediaStream,
|
|
255
|
+
ws: WebSocket,
|
|
256
|
+
generation: number,
|
|
257
|
+
): void {
|
|
258
|
+
const source = context.createMediaStreamSource(stream);
|
|
259
|
+
this.sourceNode = source;
|
|
260
|
+
const processor = context.createScriptProcessor(CAPTURE_BUFFER_SIZE, 1, 1);
|
|
261
|
+
this.processor = processor;
|
|
262
|
+
|
|
263
|
+
processor.onaudioprocess = (e) => {
|
|
264
|
+
if (generation !== this.callGeneration) return;
|
|
265
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
266
|
+
const input = e.inputBuffer.getChannelData(0);
|
|
267
|
+
const pcm16 = new Int16Array(input.length);
|
|
268
|
+
for (let i = 0; i < input.length; i++) {
|
|
269
|
+
const s = Math.max(-1, Math.min(1, input[i]));
|
|
270
|
+
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
196
271
|
}
|
|
272
|
+
ws.send(pcm16.buffer);
|
|
197
273
|
};
|
|
274
|
+
|
|
275
|
+
source.connect(processor);
|
|
276
|
+
// The processor must be connected to the graph to run; it writes no output,
|
|
277
|
+
// so the destination receives silence (no mic echo).
|
|
278
|
+
processor.connect(context.destination);
|
|
198
279
|
}
|
|
199
280
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
281
|
+
// --- Downstream ------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
private handleMessage(event: MessageEvent, generation: number): void {
|
|
284
|
+
if (generation !== this.callGeneration) return;
|
|
285
|
+
|
|
286
|
+
if (event.data instanceof ArrayBuffer) {
|
|
287
|
+
this.handleAudioFrame(event.data, generation);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let msg: any;
|
|
292
|
+
try {
|
|
293
|
+
msg = JSON.parse(event.data as string);
|
|
294
|
+
} catch {
|
|
295
|
+
return; // non-JSON, non-binary frame: ignore
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
switch (msg.type) {
|
|
299
|
+
case "transcript_interim":
|
|
300
|
+
this.emitStatus("listening");
|
|
301
|
+
this.emitTranscript("user", msg.text ?? "", false);
|
|
207
302
|
break;
|
|
208
303
|
|
|
209
|
-
case "
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
text: message.response.agentResponseText || message.response.transcript,
|
|
215
|
-
transcript: message.response.transcript,
|
|
216
|
-
audio: message.response.audio,
|
|
217
|
-
confidence: 0.95,
|
|
218
|
-
provider: "runtype",
|
|
219
|
-
}),
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
// Batch path: play TTS audio if present in the response (backward compat)
|
|
223
|
-
if (message.response.audio?.base64) {
|
|
224
|
-
this.isSpeaking = true;
|
|
225
|
-
this.statusCallbacks.forEach((cb) => cb("speaking"));
|
|
226
|
-
this.playAudio(message.response.audio).catch((err) =>
|
|
227
|
-
this.errorCallbacks.forEach((cb) => cb(err instanceof Error ? err : new Error(String(err)))),
|
|
228
|
-
);
|
|
229
|
-
} else if (!message.response.audio?.base64) {
|
|
230
|
-
// Streaming path: text-only voice_response — audio will arrive as
|
|
231
|
-
// binary chunks followed by audio_end. Transition to speaking state
|
|
232
|
-
// once the first audio chunk arrives (see handleAudioChunk).
|
|
233
|
-
// Stay in processing state until then.
|
|
234
|
-
}
|
|
304
|
+
case "transcript_final": {
|
|
305
|
+
const role = msg.role === "assistant" ? "assistant" : "user";
|
|
306
|
+
// user final → agent is now thinking; assistant final → reply incoming.
|
|
307
|
+
this.emitStatus(role === "user" ? "processing" : "speaking");
|
|
308
|
+
this.emitTranscript(role, msg.text ?? "", true);
|
|
235
309
|
break;
|
|
310
|
+
}
|
|
236
311
|
|
|
237
312
|
case "audio_end":
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// All PCM chunks have been sent — signal the playback manager
|
|
241
|
-
if (this.playbackManager) {
|
|
242
|
-
this.playbackManager.markStreamEnd();
|
|
313
|
+
if (this.playback) {
|
|
314
|
+
this.playback.markStreamEnd();
|
|
243
315
|
} else {
|
|
244
|
-
// No audio chunks arrived — go idle
|
|
245
316
|
this.isSpeaking = false;
|
|
246
|
-
this.
|
|
247
|
-
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
317
|
+
this.emitStatus("listening");
|
|
248
318
|
}
|
|
249
319
|
break;
|
|
250
320
|
|
|
251
|
-
case "
|
|
252
|
-
|
|
253
|
-
|
|
321
|
+
case "metrics":
|
|
322
|
+
this.emitMetrics({
|
|
323
|
+
llmMs: msg.llm_ms,
|
|
324
|
+
ttsMs: msg.tts_ms,
|
|
325
|
+
firstAudioMs: msg.first_audio_ms,
|
|
326
|
+
totalMs: msg.total_ms,
|
|
327
|
+
});
|
|
254
328
|
break;
|
|
255
329
|
|
|
256
330
|
case "error":
|
|
257
|
-
this.
|
|
258
|
-
this.
|
|
259
|
-
this.isProcessing = false;
|
|
260
|
-
break;
|
|
261
|
-
|
|
262
|
-
case "pong":
|
|
263
|
-
// Heartbeat response
|
|
331
|
+
this.emitError(new Error(msg.error || "Voice error"));
|
|
332
|
+
this.emitStatus("error");
|
|
264
333
|
break;
|
|
265
334
|
}
|
|
266
335
|
}
|
|
267
336
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
if (!this.currentRequestId) return; // discard late chunks after cancel
|
|
274
|
-
|
|
275
|
-
// Lazily create playback manager on first chunk
|
|
276
|
-
if (!this.playbackManager) {
|
|
277
|
-
this.playbackManager = new AudioPlaybackManager(24000);
|
|
278
|
-
this.playbackManager.onFinished(() => {
|
|
279
|
-
this.isSpeaking = false;
|
|
280
|
-
this.playbackManager = null;
|
|
281
|
-
this.vad.stop(); // stop speech monitoring — audio ended naturally
|
|
282
|
-
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Transition to speaking on first chunk
|
|
337
|
+
private handleAudioFrame(buf: ArrayBuffer, generation: number): void {
|
|
338
|
+
if (generation !== this.callGeneration) return;
|
|
339
|
+
if (!this.playback) return;
|
|
340
|
+
const pcm = stripWavHeader(buf);
|
|
341
|
+
if (pcm.length === 0) return;
|
|
287
342
|
if (!this.isSpeaking) {
|
|
288
343
|
this.isSpeaking = true;
|
|
289
|
-
this.
|
|
290
|
-
this.startBargeInMonitoring().catch(() => {}); // no-op if not barge-in mode
|
|
344
|
+
this.emitStatus("speaking");
|
|
291
345
|
}
|
|
292
|
-
|
|
293
|
-
this.playbackManager.enqueue(pcmData);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Stop playback / cancel in-flight request and return to idle.
|
|
298
|
-
* This is the public "stop only" action — does NOT start recording.
|
|
299
|
-
*/
|
|
300
|
-
stopPlayback(): void {
|
|
301
|
-
if (!this.isProcessing && !this.isSpeaking) return;
|
|
302
|
-
this.cancelCurrentPlayback();
|
|
303
|
-
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
346
|
+
this.playback.enqueue(pcm);
|
|
304
347
|
}
|
|
305
348
|
|
|
306
|
-
|
|
307
|
-
* Cancel the current playback and in-flight server request.
|
|
308
|
-
* Internal helper — does NOT fire status callbacks (caller decides next state).
|
|
309
|
-
*/
|
|
310
|
-
private cancelCurrentPlayback(): void {
|
|
311
|
-
// Stop batch playback (Audio element)
|
|
312
|
-
if (this.currentAudio) {
|
|
313
|
-
this.currentAudio.pause();
|
|
314
|
-
this.currentAudio.src = "";
|
|
315
|
-
this.currentAudio = null;
|
|
316
|
-
}
|
|
317
|
-
if (this.currentAudioUrl) {
|
|
318
|
-
URL.revokeObjectURL(this.currentAudioUrl);
|
|
319
|
-
this.currentAudioUrl = null;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Stop streaming playback (AudioPlaybackManager)
|
|
323
|
-
if (this.playbackManager) {
|
|
324
|
-
this.playbackManager.flush();
|
|
325
|
-
this.playbackManager = null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Tell server to abort the in-flight request
|
|
329
|
-
if (this.currentRequestId && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
330
|
-
this.ws.send(
|
|
331
|
-
JSON.stringify({
|
|
332
|
-
type: "cancel",
|
|
333
|
-
requestId: this.currentRequestId,
|
|
334
|
-
}),
|
|
335
|
-
);
|
|
336
|
-
}
|
|
349
|
+
// --- Teardown --------------------------------------------------------------
|
|
337
350
|
|
|
338
|
-
|
|
339
|
-
|
|
351
|
+
private cleanup(): void {
|
|
352
|
+
// Invalidate any in-flight async continuation / late frames first.
|
|
353
|
+
this.callGeneration += 1;
|
|
354
|
+
this.callLive = false;
|
|
340
355
|
this.isSpeaking = false;
|
|
341
|
-
}
|
|
342
356
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (this.interruptionMode !== "none") {
|
|
348
|
-
this.cancelCurrentPlayback();
|
|
349
|
-
} else {
|
|
350
|
-
// Mode is "none" — block mic while processing or speaking
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Reuse existing mic stream in barge-in mode (mic stays hot)
|
|
356
|
-
if (!this.mediaStream) {
|
|
357
|
-
const constraints =
|
|
358
|
-
this.interruptionMode === "barge-in"
|
|
359
|
-
? { audio: { echoCancellation: true, noiseSuppression: true } }
|
|
360
|
-
: { audio: true };
|
|
361
|
-
this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
362
|
-
}
|
|
363
|
-
const w = this.w!;
|
|
364
|
-
if (!this.audioContext) {
|
|
365
|
-
this.audioContext = new (w.AudioContext || w.webkitAudioContext)();
|
|
366
|
-
}
|
|
367
|
-
const audioContext = this.audioContext!;
|
|
368
|
-
|
|
369
|
-
// VAD-based silence detection — fires once when user stops talking
|
|
370
|
-
const pauseDuration = this.config?.pauseDuration ?? 2000;
|
|
371
|
-
const silenceThreshold = this.config?.silenceThreshold ?? 0.01;
|
|
372
|
-
this.vad.start(
|
|
373
|
-
audioContext,
|
|
374
|
-
this.mediaStream,
|
|
375
|
-
"silence",
|
|
376
|
-
{ threshold: silenceThreshold, duration: pauseDuration },
|
|
377
|
-
() => this.stopListening(),
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
this.mediaRecorder = new MediaRecorder(this.mediaStream);
|
|
381
|
-
this.audioChunks = [];
|
|
382
|
-
|
|
383
|
-
this.mediaRecorder.ondataavailable = (event) => {
|
|
384
|
-
if (event.data.size > 0) {
|
|
385
|
-
this.audioChunks.push(event.data);
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
this.mediaRecorder.onstop = async () => {
|
|
390
|
-
if (this.audioChunks.length > 0) {
|
|
391
|
-
this.isProcessing = true;
|
|
392
|
-
this.statusCallbacks.forEach((cb) => cb("processing"));
|
|
393
|
-
this.processingStartCallbacks.forEach((cb) => cb());
|
|
394
|
-
|
|
395
|
-
const mimeType =
|
|
396
|
-
this.mediaRecorder?.mimeType || "audio/webm";
|
|
397
|
-
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
|
398
|
-
await this.sendAudio(audioBlob);
|
|
399
|
-
this.audioChunks = [];
|
|
400
|
-
}
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
this.mediaRecorder.start(1000);
|
|
404
|
-
this.statusCallbacks.forEach((cb) => cb("listening"));
|
|
405
|
-
} catch (error) {
|
|
406
|
-
this.errorCallbacks.forEach((cb) => cb(error as Error));
|
|
407
|
-
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
408
|
-
throw error;
|
|
357
|
+
if (this.processor) {
|
|
358
|
+
this.processor.onaudioprocess = null;
|
|
359
|
+
this.processor.disconnect();
|
|
360
|
+
this.processor = null;
|
|
409
361
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
this.vad.stop();
|
|
414
|
-
|
|
415
|
-
if (this.mediaRecorder) {
|
|
416
|
-
if (this.interruptionMode !== "barge-in") {
|
|
417
|
-
this.mediaRecorder.stream.getTracks().forEach((track) => track.stop());
|
|
418
|
-
}
|
|
419
|
-
this.mediaRecorder.stop();
|
|
420
|
-
this.mediaRecorder = null;
|
|
362
|
+
if (this.sourceNode) {
|
|
363
|
+
this.sourceNode.disconnect();
|
|
364
|
+
this.sourceNode = null;
|
|
421
365
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (this.mediaStream) {
|
|
426
|
-
this.mediaStream.getTracks().forEach((track) => track.stop());
|
|
427
|
-
this.mediaStream = null;
|
|
428
|
-
}
|
|
429
|
-
if (this.audioContext) {
|
|
430
|
-
await this.audioContext.close();
|
|
431
|
-
this.audioContext = null;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Start VAD in speech mode during agent playback — detects when the user
|
|
440
|
-
* starts talking so we can interrupt (barge-in). No-op in other modes.
|
|
441
|
-
* Acquires mic if needed (e.g., first response where stopListening tore it down).
|
|
442
|
-
*/
|
|
443
|
-
private async startBargeInMonitoring(): Promise<void> {
|
|
444
|
-
if (this.interruptionMode !== "barge-in") return;
|
|
445
|
-
|
|
446
|
-
// Acquire mic pipeline if not already available (first response scenario)
|
|
447
|
-
const w = this.w;
|
|
448
|
-
if (!this.mediaStream && w) {
|
|
449
|
-
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
450
|
-
audio: { echoCancellation: true, noiseSuppression: true },
|
|
451
|
-
});
|
|
366
|
+
if (this.mediaStream) {
|
|
367
|
+
this.mediaStream.getTracks().forEach((t) => t.stop());
|
|
368
|
+
this.mediaStream = null;
|
|
452
369
|
}
|
|
453
|
-
if (
|
|
454
|
-
this.
|
|
370
|
+
if (this.captureContext) {
|
|
371
|
+
this.captureContext.close().catch(() => {});
|
|
372
|
+
this.captureContext = null;
|
|
455
373
|
}
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const speechThreshold = this.config?.silenceThreshold ?? 0.01;
|
|
460
|
-
const speechDebounce = 200; // 200ms sustained sound = real speech, not echo blip
|
|
461
|
-
|
|
462
|
-
this.vad.start(
|
|
463
|
-
audioContext,
|
|
464
|
-
this.mediaStream,
|
|
465
|
-
"speech",
|
|
466
|
-
{ threshold: speechThreshold, duration: speechDebounce },
|
|
467
|
-
() => this.handleBargeIn(),
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Handle a barge-in event: cancel playback and immediately start recording.
|
|
473
|
-
*/
|
|
474
|
-
private handleBargeIn(): void {
|
|
475
|
-
this.cancelCurrentPlayback();
|
|
476
|
-
this.startListening().catch((err) => {
|
|
477
|
-
this.errorCallbacks.forEach((cb) =>
|
|
478
|
-
cb(err instanceof Error ? err : new Error(String(err))),
|
|
479
|
-
);
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
private generateRequestId(): string {
|
|
484
|
-
return "vreq_" + Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
private async sendAudio(audioBlob: Blob) {
|
|
488
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
489
|
-
this.errorCallbacks.forEach((cb) =>
|
|
490
|
-
cb(new Error("WebSocket not connected")),
|
|
491
|
-
);
|
|
492
|
-
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
493
|
-
return;
|
|
374
|
+
if (this.playback) {
|
|
375
|
+
void this.playback.destroy();
|
|
376
|
+
this.playback = null;
|
|
494
377
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
this.ws
|
|
503
|
-
JSON.stringify({
|
|
504
|
-
type: "audio_input",
|
|
505
|
-
audio: base64Audio,
|
|
506
|
-
format,
|
|
507
|
-
sampleRate: 16000,
|
|
508
|
-
voiceId: this.config?.voiceId,
|
|
509
|
-
requestId,
|
|
510
|
-
}),
|
|
511
|
-
);
|
|
512
|
-
} catch (error) {
|
|
513
|
-
this.errorCallbacks.forEach((cb) => cb(error as Error));
|
|
514
|
-
this.statusCallbacks.forEach((cb) => cb("error"));
|
|
378
|
+
if (this.ws) {
|
|
379
|
+
this.intentionalClose = true;
|
|
380
|
+
try {
|
|
381
|
+
this.ws.close(1000, "client ended call");
|
|
382
|
+
} catch {
|
|
383
|
+
// ignore
|
|
384
|
+
}
|
|
385
|
+
this.ws = null;
|
|
515
386
|
}
|
|
516
387
|
}
|
|
517
388
|
|
|
518
|
-
|
|
519
|
-
if (mimeType.includes("wav")) return "wav";
|
|
520
|
-
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
|
|
521
|
-
return "webm";
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
private blobToBase64(blob: Blob): Promise<string> {
|
|
525
|
-
return new Promise((resolve, reject) => {
|
|
526
|
-
const reader = new FileReader();
|
|
527
|
-
reader.onload = () => {
|
|
528
|
-
const result = reader.result as string;
|
|
529
|
-
// Remove data URL prefix
|
|
530
|
-
const base64 = result.split(",")[1];
|
|
531
|
-
resolve(base64);
|
|
532
|
-
};
|
|
533
|
-
reader.onerror = reject;
|
|
534
|
-
reader.readAsDataURL(blob);
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Decode base64 audio and play it through the browser.
|
|
540
|
-
*/
|
|
541
|
-
private async playAudio(audio: { base64: string; format?: string }): Promise<void> {
|
|
542
|
-
if (!audio.base64) return;
|
|
543
|
-
const byteString = atob(audio.base64);
|
|
544
|
-
const bytes = new Uint8Array(byteString.length);
|
|
545
|
-
for (let i = 0; i < byteString.length; i++) {
|
|
546
|
-
bytes[i] = byteString.charCodeAt(i);
|
|
547
|
-
}
|
|
548
|
-
const format = audio.format || "mp3";
|
|
549
|
-
const mimeType =
|
|
550
|
-
format === "mp3" ? "audio/mpeg" : `audio/${format}`;
|
|
551
|
-
const blob = new Blob([bytes], { type: mimeType });
|
|
552
|
-
const url = URL.createObjectURL(blob);
|
|
553
|
-
const audioEl = new Audio(url);
|
|
554
|
-
|
|
555
|
-
// Store references so playback can be cancelled
|
|
556
|
-
this.currentAudio = audioEl;
|
|
557
|
-
this.currentAudioUrl = url;
|
|
558
|
-
|
|
559
|
-
audioEl.onended = () => {
|
|
560
|
-
URL.revokeObjectURL(url);
|
|
561
|
-
if (this.currentAudio === audioEl) {
|
|
562
|
-
this.currentAudio = null;
|
|
563
|
-
this.currentAudioUrl = null;
|
|
564
|
-
this.isSpeaking = false;
|
|
565
|
-
this.statusCallbacks.forEach((cb) => cb("idle"));
|
|
566
|
-
}
|
|
567
|
-
};
|
|
568
|
-
await audioEl.play();
|
|
569
|
-
}
|
|
389
|
+
// --- Callback registration + emit -----------------------------------------
|
|
570
390
|
|
|
571
391
|
onResult(callback: (result: VoiceResult) => void): void {
|
|
572
392
|
this.resultCallbacks.push(callback);
|
|
@@ -580,58 +400,33 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
580
400
|
this.statusCallbacks.push(callback);
|
|
581
401
|
}
|
|
582
402
|
|
|
583
|
-
|
|
584
|
-
|
|
403
|
+
onTranscript(
|
|
404
|
+
callback: (role: "user" | "assistant", text: string, isFinal: boolean) => void,
|
|
405
|
+
): void {
|
|
406
|
+
this.transcriptCallbacks.push(callback);
|
|
585
407
|
}
|
|
586
408
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
this.currentAudio.pause();
|
|
591
|
-
this.currentAudio.src = "";
|
|
592
|
-
this.currentAudio = null;
|
|
593
|
-
}
|
|
594
|
-
if (this.currentAudioUrl) {
|
|
595
|
-
URL.revokeObjectURL(this.currentAudioUrl);
|
|
596
|
-
this.currentAudioUrl = null;
|
|
597
|
-
}
|
|
598
|
-
// Stop streaming playback
|
|
599
|
-
if (this.playbackManager) {
|
|
600
|
-
await this.playbackManager.destroy();
|
|
601
|
-
this.playbackManager = null;
|
|
602
|
-
}
|
|
603
|
-
this.currentRequestId = null;
|
|
604
|
-
this.isSpeaking = false;
|
|
605
|
-
|
|
606
|
-
this.vad.stop();
|
|
607
|
-
await this.stopListening();
|
|
409
|
+
onMetrics(callback: (metrics: VoiceMetrics) => void): void {
|
|
410
|
+
this.metricsCallbacks.push(callback);
|
|
411
|
+
}
|
|
608
412
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
this.mediaStream = null;
|
|
613
|
-
}
|
|
614
|
-
if (this.audioContext) {
|
|
615
|
-
await this.audioContext.close();
|
|
616
|
-
this.audioContext = null;
|
|
617
|
-
}
|
|
413
|
+
private emitStatus(status: VoiceStatus): void {
|
|
414
|
+
this.statusCallbacks.forEach((cb) => cb(status));
|
|
415
|
+
}
|
|
618
416
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
} catch (error) {
|
|
623
|
-
// Ignore errors during disconnect
|
|
624
|
-
}
|
|
625
|
-
this.ws = null;
|
|
626
|
-
}
|
|
417
|
+
private emitError(error: Error): void {
|
|
418
|
+
this.errorCallbacks.forEach((cb) => cb(error));
|
|
419
|
+
}
|
|
627
420
|
|
|
628
|
-
|
|
421
|
+
private emitTranscript(
|
|
422
|
+
role: "user" | "assistant",
|
|
423
|
+
text: string,
|
|
424
|
+
isFinal: boolean,
|
|
425
|
+
): void {
|
|
426
|
+
this.transcriptCallbacks.forEach((cb) => cb(role, text, isFinal));
|
|
629
427
|
}
|
|
630
428
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
634
|
-
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
635
|
-
}
|
|
429
|
+
private emitMetrics(metrics: VoiceMetrics): void {
|
|
430
|
+
this.metricsCallbacks.forEach((cb) => cb(metrics));
|
|
636
431
|
}
|
|
637
432
|
}
|