@runtypelabs/persona 3.34.0 → 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.
Files changed (186) hide show
  1. package/README.md +11 -11
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
  5. package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +11 -11
  9. package/dist/codegen.js +10 -10
  10. package/dist/index.cjs +46 -44
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +514 -158
  13. package/dist/index.d.ts +514 -158
  14. package/dist/index.global.js +41 -89
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +46 -44
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/launcher.global.js +2 -2
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/markdown-parsers.js +53 -0
  23. package/dist/plugin-kit.d.cts +4 -4
  24. package/dist/plugin-kit.d.ts +4 -4
  25. package/dist/runtype-tts.js +1 -0
  26. package/dist/smart-dom-reader.cjs +1 -1
  27. package/dist/smart-dom-reader.d.cts +352 -103
  28. package/dist/smart-dom-reader.d.ts +352 -103
  29. package/dist/smart-dom-reader.js +1 -1
  30. package/dist/theme-editor-preview.cjs +203 -0
  31. package/dist/theme-editor-preview.d.cts +5748 -0
  32. package/dist/theme-editor-preview.d.ts +5748 -0
  33. package/dist/theme-editor-preview.js +203 -0
  34. package/dist/theme-editor.cjs +14 -131
  35. package/dist/theme-editor.d.cts +362 -574
  36. package/dist/theme-editor.d.ts +362 -574
  37. package/dist/theme-editor.js +14 -131
  38. package/dist/theme-reference.cjs +1 -1
  39. package/dist/theme-reference.d.cts +1 -1
  40. package/dist/theme-reference.d.ts +1 -1
  41. package/dist/theme-reference.js +1 -1
  42. package/dist/voice-worklet-player.cjs +80 -0
  43. package/dist/voice-worklet-player.d.cts +215 -0
  44. package/dist/voice-worklet-player.d.ts +215 -0
  45. package/dist/voice-worklet-player.js +80 -0
  46. package/dist/widget.css +54 -36
  47. package/package.json +16 -2
  48. package/src/animations/glyph-cycle.ts +11 -14
  49. package/src/animations/wipe.ts +1 -1
  50. package/src/ask-user-question-tool.test.ts +1 -1
  51. package/src/ask-user-question-tool.ts +6 -8
  52. package/src/client.test.ts +67 -12
  53. package/src/client.ts +40 -32
  54. package/src/codegen.test.ts +1 -1
  55. package/src/codegen.ts +1 -1
  56. package/src/components/approval-bubble.test.ts +1 -1
  57. package/src/components/approval-bubble.ts +3 -3
  58. package/src/components/artifact-pane.ts +3 -3
  59. package/src/components/ask-user-question-bubble.test.ts +14 -14
  60. package/src/components/ask-user-question-bubble.ts +10 -10
  61. package/src/components/composer-builder.ts +17 -16
  62. package/src/components/composer-parts.test.ts +54 -0
  63. package/src/components/composer-parts.ts +95 -126
  64. package/src/components/demo-carousel.ts +1 -1
  65. package/src/components/event-stream-view.test.ts +7 -7
  66. package/src/components/event-stream-view.ts +5 -5
  67. package/src/components/header-builder.ts +33 -27
  68. package/src/components/header-layouts.ts +1 -1
  69. package/src/components/header-parts.test.ts +90 -0
  70. package/src/components/header-parts.ts +62 -93
  71. package/src/components/launcher.test.ts +11 -0
  72. package/src/components/launcher.ts +1 -1
  73. package/src/components/message-bubble.test.ts +74 -2
  74. package/src/components/message-bubble.ts +25 -11
  75. package/src/components/messages.ts +2 -2
  76. package/src/components/panel.test.ts +1 -1
  77. package/src/components/panel.ts +92 -97
  78. package/src/components/pill-composer-builder.ts +28 -24
  79. package/src/components/reasoning-bubble.ts +2 -2
  80. package/src/components/suggestions.ts +1 -1
  81. package/src/components/tool-bubble.ts +1 -1
  82. package/src/components/widget-view.test.ts +181 -0
  83. package/src/components/widget-view.ts +234 -0
  84. package/src/defaults.ts +54 -51
  85. package/src/generated/runtype-openapi-contract.ts +5 -0
  86. package/src/index-core.ts +30 -4
  87. package/src/index-global.ts +73 -6
  88. package/src/index.ts +7 -1
  89. package/src/install.test.ts +11 -11
  90. package/src/install.ts +28 -18
  91. package/src/launcher-global.ts +25 -14
  92. package/src/markdown-parsers-eager.ts +20 -0
  93. package/src/markdown-parsers-entry.ts +4 -0
  94. package/src/markdown-parsers-loader.ts +49 -0
  95. package/src/plugin-kit.test.ts +1 -1
  96. package/src/plugin-kit.ts +7 -7
  97. package/src/plugins/types.ts +5 -5
  98. package/src/postprocessors.ts +27 -14
  99. package/src/runtime/host-layout.test.ts +1 -1
  100. package/src/runtime/host-layout.ts +4 -5
  101. package/src/runtime/init.ts +2 -2
  102. package/src/session.test.ts +5 -6
  103. package/src/session.ts +333 -227
  104. package/src/session.voice.test.ts +146 -0
  105. package/src/session.webmcp.test.ts +24 -25
  106. package/src/smart-dom-reader.ts +3 -4
  107. package/src/styles/widget.css +54 -36
  108. package/src/suggest-replies-tool.test.ts +3 -3
  109. package/src/suggest-replies-tool.ts +5 -5
  110. package/src/testing/index.ts +1 -1
  111. package/src/theme-editor/color-utils.ts +2 -2
  112. package/src/theme-editor/index.ts +4 -13
  113. package/src/theme-editor/presets.ts +1 -1
  114. package/src/theme-editor/preview-utils.ts +22 -20
  115. package/src/theme-editor/preview.ts +4 -1
  116. package/src/theme-editor/role-mappings.ts +3 -3
  117. package/src/theme-editor/sections.ts +21 -20
  118. package/src/theme-editor/types.ts +1 -1
  119. package/src/theme-editor/webmcp/index.ts +1 -1
  120. package/src/theme-editor/webmcp/summary.ts +1 -2
  121. package/src/theme-editor/webmcp/tools.ts +30 -30
  122. package/src/theme-editor/webmcp/types.ts +2 -2
  123. package/src/theme-editor-preview.ts +10 -0
  124. package/src/theme-reference.ts +26 -26
  125. package/src/types.ts +368 -99
  126. package/src/ui.approval-plugin.test.ts +3 -3
  127. package/src/ui.ask-user-question-plugin.test.ts +9 -9
  128. package/src/ui.component-directive.test.ts +4 -4
  129. package/src/ui.composer-bar.test.ts +10 -11
  130. package/src/ui.composer-keyboard.test.ts +5 -5
  131. package/src/ui.header-icon-color.test.ts +59 -0
  132. package/src/ui.scroll.test.ts +5 -6
  133. package/src/ui.stop-button.test.ts +3 -4
  134. package/src/ui.suggest-replies.test.ts +2 -2
  135. package/src/ui.ts +292 -131
  136. package/src/utils/buttons.ts +1 -1
  137. package/src/utils/code-generators.ts +1 -1
  138. package/src/utils/composer-history.ts +3 -3
  139. package/src/utils/dom-context.test.ts +2 -2
  140. package/src/utils/dom-context.ts +7 -7
  141. package/src/utils/dom.test.ts +67 -0
  142. package/src/utils/dom.ts +75 -0
  143. package/src/utils/dropdown.ts +2 -2
  144. package/src/utils/event-stream-buffer.test.ts +2 -2
  145. package/src/utils/message-fingerprint.test.ts +9 -0
  146. package/src/utils/message-fingerprint.ts +5 -0
  147. package/src/utils/morph.ts +1 -1
  148. package/src/utils/sanitize.ts +32 -16
  149. package/src/utils/sequence-buffer.test.ts +8 -8
  150. package/src/utils/sequence-buffer.ts +4 -4
  151. package/src/utils/smart-dom-adapter.test.ts +1 -1
  152. package/src/utils/smart-dom-adapter.ts +4 -5
  153. package/src/utils/speech-text.test.ts +122 -0
  154. package/src/utils/speech-text.ts +101 -0
  155. package/src/utils/stream-animation.test.ts +3 -3
  156. package/src/utils/stream-animation.ts +6 -6
  157. package/src/utils/theme.test.ts +1 -1
  158. package/src/utils/throughput-tracker.test.ts +11 -11
  159. package/src/utils/throughput-tracker.ts +9 -9
  160. package/src/utils/tokens.ts +13 -13
  161. package/src/utils/virtual-scroller.ts +1 -1
  162. package/src/vendor/smart-dom-reader/README.md +4 -4
  163. package/src/vendor/smart-dom-reader/index.d.ts +1 -1
  164. package/src/vendor/smart-dom-reader/index.js +2 -2
  165. package/src/version.ts +1 -1
  166. package/src/voice/audio-playback-manager.test.ts +159 -0
  167. package/src/voice/audio-playback-manager.ts +145 -30
  168. package/src/voice/browser-speech-engine.ts +130 -0
  169. package/src/voice/fallback-speech-engine.ts +89 -0
  170. package/src/voice/index.ts +21 -0
  171. package/src/voice/read-aloud-controller.test.ts +143 -0
  172. package/src/voice/read-aloud-controller.ts +136 -0
  173. package/src/voice/runtype-speech-engine.test.ts +280 -0
  174. package/src/voice/runtype-speech-engine.ts +211 -0
  175. package/src/voice/runtype-tts-entry.ts +10 -0
  176. package/src/voice/runtype-tts-loader.test.ts +47 -0
  177. package/src/voice/runtype-tts-loader.ts +42 -0
  178. package/src/voice/runtype-voice-provider.ts +332 -537
  179. package/src/voice/voice-factory.ts +26 -4
  180. package/src/voice/voice.test.ts +321 -7
  181. package/src/voice/worklet-playback-engine.ts +272 -0
  182. package/src/voice-worklet-player.ts +39 -0
  183. package/src/webmcp-bridge.test.ts +6 -6
  184. package/src/webmcp-bridge.ts +24 -25
  185. package/src/webmcp-polyfill.ts +1 -1
  186. package/src/voice/voice-activity-detector.ts +0 -90
@@ -1,572 +1,392 @@
1
1
  // Runtype Voice Provider
2
- // WebSocket implementation for Runtype's voice service
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
- import { VoiceActivityDetector } from "./voice-activity-detector";
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 audioContext: AudioContext | null = null;
17
- private w: any = typeof window !== "undefined" ? window : undefined;
18
- private mediaRecorder: MediaRecorder | null = null;
19
- private resultCallbacks: ((result: VoiceResult) => void)[] = [];
20
- private errorCallbacks: ((error: Error) => void)[] = [];
21
- private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
22
- private processingStartCallbacks: (() => void)[] = [];
23
- private audioChunks: Blob[] = [];
24
- private isProcessing = false;
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
- // Voice activity detection (silence auto-stop + barge-in speech detection)
28
- private vad = new VoiceActivityDetector();
29
- private mediaStream: MediaStream | null = null;
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
- // Cancellation / interruption support
32
- private currentAudio: HTMLAudioElement | null = null;
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
- // Streaming audio playback (PCM chunks)
38
- private playbackManager: AudioPlaybackManager | null = null;
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
- /** Returns the current interruption mode received from the server */
43
- getInterruptionMode(): "none" | "cancel" | "barge-in" {
44
- return this.interruptionMode;
45
- }
95
+ // --- VoiceProvider lifecycle ----------------------------------------------
46
96
 
47
- /** Returns true if the barge-in mic stream is alive (hot mic between turns) */
48
- isBargeInActive(): boolean {
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
- /** Tear down the barge-in mic pipeline "hang up" the always-on mic */
53
- async deactivateBargeIn(): Promise<void> {
54
- this.vad.stop();
55
- if (this.mediaStream) {
56
- this.mediaStream.getTracks().forEach((track) => track.stop());
57
- this.mediaStream = null;
58
- }
59
- if (this.audioContext) {
60
- await this.audioContext.close();
61
- this.audioContext = null;
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
- // Ensure we're running in a browser environment
71
- if (!this.w) {
72
- throw new Error("Window object not available");
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
- // Temporary workaround for TypeScript issues
76
- const w: any = this.w;
77
- if (!w || !w.location) {
78
- throw new Error("Window object or location not available");
79
- }
80
- const protocol = w.location.protocol === "https:" ? "wss:" : "ws:";
81
- const host = this.config?.host;
82
- const agentId = this.config?.agentId;
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
- if (!host) {
88
- throw new Error(
89
- "host must be provided in Runtype voice provider configuration",
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
- const wsUrl = `${protocol}//${host}/ws/agents/${agentId}/voice?token=${clientToken}`;
93
-
94
- this.ws = new WebSocket(wsUrl);
95
- this.setupWebSocketHandlers();
96
-
97
- // Wait for WebSocket to actually open before resolving
98
- const safeUrl = `${protocol}//${host}/ws/agents/${agentId}/voice?token=...`;
99
- const hint =
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
- // Send a ping immediately so the server replies with session_config
151
- // (which includes interruptionMode). This ensures the client knows
152
- // about barge-in mode before the first recording starts.
153
- this.sendHeartbeat();
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.ws = null;
156
- this.errorCallbacks.forEach((cb) => cb(error as Error));
157
- this.statusCallbacks.forEach((cb) => cb("error"));
199
+ this.cleanup();
200
+ this.emitError(error as Error);
201
+ this.emitStatus("error");
158
202
  throw error;
159
203
  }
160
204
  }
161
205
 
162
- private setupWebSocketHandlers() {
163
- if (!this.ws) return;
206
+ /** End the call (hang up). */
207
+ async stopListening(): Promise<void> {
208
+ this.cleanup();
209
+ this.emitStatus("idle");
210
+ }
164
211
 
165
- this.ws.onopen = () => {
166
- this.statusCallbacks.forEach((cb) => cb("connected"));
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
- this.ws.onclose = () => {
170
- this.statusCallbacks.forEach((cb) => cb("disconnected"));
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
- this.ws.onerror = (_error) => {
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
- // Receive binary frames for streaming audio (set binaryType to arraybuffer)
179
- this.ws.binaryType = "arraybuffer";
234
+ /** A continuous call is a permanent barge-in session. */
235
+ getInterruptionMode(): "none" | "cancel" | "barge-in" {
236
+ return "barge-in";
237
+ }
180
238
 
181
- this.ws.onmessage = (event) => {
182
- // Binary frame = raw PCM audio chunk for streaming playback
183
- if (event.data instanceof ArrayBuffer) {
184
- this.handleAudioChunk(new Uint8Array(event.data));
185
- return;
186
- }
239
+ /** True while the call (hot mic) is live. */
240
+ isBargeInActive(): boolean {
241
+ return this.callLive;
242
+ }
187
243
 
188
- // Text frame = JSON control message
189
- try {
190
- const message = JSON.parse(event.data);
191
- this.handleWebSocketMessage(message);
192
- } catch (error) {
193
- this.errorCallbacks.forEach((cb) =>
194
- cb(new Error("Message parsing failed")),
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
- private handleWebSocketMessage(message: any) {
201
- switch (message.type) {
202
- case "session_config":
203
- // Server sends voice settings on session init
204
- if (message.interruptionMode) {
205
- this.interruptionMode = message.interruptionMode;
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 "voice_response":
210
- // Deliver text result immediately
211
- this.isProcessing = false;
212
- this.resultCallbacks.forEach((cb) =>
213
- cb({
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
- // Guard: discard late audio_end from a cancelled request
239
- if (message.requestId && message.requestId !== this.currentRequestId) break;
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.isProcessing = false;
247
- this.statusCallbacks.forEach((cb) => cb("idle"));
317
+ this.emitStatus("listening");
248
318
  }
249
319
  break;
250
320
 
251
- case "cancelled":
252
- // Server acknowledged cancellation — discard any late-arriving responses
253
- this.isProcessing = false;
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.errorCallbacks.forEach((cb) => cb(new Error(message.error)));
258
- this.statusCallbacks.forEach((cb) => cb("error"));
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
- * Handle a binary audio chunk (raw PCM 24kHz 16-bit LE) for streaming playback.
270
- */
271
- private handleAudioChunk(pcmData: Uint8Array): void {
272
- if (pcmData.length === 0) return;
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.statusCallbacks.forEach((cb) => cb("speaking"));
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
- this.currentRequestId = null;
339
- this.isProcessing = false;
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
- async startListening() {
344
- try {
345
- if (this.isProcessing || this.isSpeaking) {
346
- // If interruption is enabled, cancel current playback and proceed
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
- async stopListening() {
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
- // Only tear down mic pipeline in non-barge-in modes
424
- if (this.interruptionMode !== "barge-in") {
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 (!this.audioContext && w) {
454
- this.audioContext = new (w.AudioContext || w.webkitAudioContext)();
370
+ if (this.captureContext) {
371
+ this.captureContext.close().catch(() => {});
372
+ this.captureContext = null;
455
373
  }
456
- if (!this.audioContext || !this.mediaStream) return;
457
-
458
- const audioContext = this.audioContext!;
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
- try {
497
- const base64Audio = await this.blobToBase64(audioBlob);
498
- const format = this.getFormatFromMimeType(audioBlob.type);
499
- const requestId = this.generateRequestId();
500
- this.currentRequestId = requestId;
501
-
502
- this.ws.send(
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
- private getFormatFromMimeType(mimeType: string): string {
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
- onProcessingStart(callback: () => void): void {
584
- this.processingStartCallbacks.push(callback);
403
+ onTranscript(
404
+ callback: (role: "user" | "assistant", text: string, isFinal: boolean) => void,
405
+ ): void {
406
+ this.transcriptCallbacks.push(callback);
585
407
  }
586
408
 
587
- async disconnect(): Promise<void> {
588
- // Stop any playing audio (batch)
589
- if (this.currentAudio) {
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
- // Force mic teardown (barge-in mode skips this in stopListening)
610
- if (this.mediaStream) {
611
- this.mediaStream.getTracks().forEach((track) => track.stop());
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
- if (this.ws) {
620
- try {
621
- this.ws.close();
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
- this.statusCallbacks.forEach((cb) => cb("disconnected"));
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
- // Heartbeat functionality
632
- sendHeartbeat() {
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
  }