@runtypelabs/persona 4.24.0 → 4.26.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 (237) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -0,0 +1,16 @@
1
+ {
2
+ "type": "session_config",
3
+ "interruptionMode": "barge-in",
4
+ "speechMode": "speech_to_speech",
5
+ "protocolVersion": "runtype-browser-v1",
6
+ "capabilities": ["client_delegation", "context", "delegation_update"],
7
+ "audio": {
8
+ "inputSampleRate": 16000,
9
+ "outputSampleRate": 24000,
10
+ "encoding": "pcm16"
11
+ },
12
+ "clientDelegation": true,
13
+ "contextFrames": true,
14
+ "followUpFrames": true,
15
+ "callId": "call_01JABCDEF"
16
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "type": "transcript_final",
3
+ "role": "assistant",
4
+ "text": "Today we have tomato soup and a grilled cheese.",
5
+ "turnId": "turn_2",
6
+ "executionId": "exec_1"
7
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "transcript_interim",
3
+ "text": "What's on the"
4
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "type": "transcript_partial",
3
+ "role": "assistant",
4
+ "text": "Today we have tomato soup.",
5
+ "turnId": "turn_2"
6
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "type": "transcript_update",
3
+ "role": "user",
4
+ "text": "What's on the menu today?",
5
+ "utteranceId": "utt_1",
6
+ "turnId": "utt_1",
7
+ "final": true,
8
+ "startMs": 1200,
9
+ "endMs": 2650
10
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "type": "warning",
3
+ "code": "UNKNOWN_FRAME",
4
+ "message": "Unknown browser voice message type: teleport."
5
+ }
@@ -91,6 +91,40 @@ describe("AudioPlaybackManager (PcmStreamPlayer surface)", () => {
91
91
  expect(started).toHaveBeenCalledTimes(1);
92
92
  });
93
93
 
94
+ it("continuous mode releases a held tail after a prebuffer of quiet, and stays usable", () => {
95
+ vi.useFakeTimers();
96
+ try {
97
+ const m = new AudioPlaybackManager(24000, { prebufferMs: 200 });
98
+ m.setContinuousMode(true);
99
+ m.enqueue(pcm(500)); // below the waterline and no markStreamEnd will ever come
100
+ vi.advanceTimersByTime(150);
101
+ m.enqueue(pcm(500)); // more input restarts the quiet window
102
+ vi.advanceTimersByTime(150);
103
+ expect(MockAudioContext.instances).toHaveLength(0);
104
+ vi.advanceTimersByTime(50);
105
+ const ctx = MockAudioContext.instances[0];
106
+ expect(ctx.sources).toHaveLength(2);
107
+
108
+ // The next reply still plays through the same, never-ended stream.
109
+ m.enqueue(pcm(100));
110
+ expect(ctx.sources).toHaveLength(3);
111
+ } finally {
112
+ vi.useRealTimers();
113
+ }
114
+ });
115
+
116
+ it("without continuous mode a short held tail waits for markStreamEnd", () => {
117
+ vi.useFakeTimers();
118
+ try {
119
+ const m = new AudioPlaybackManager(24000, { prebufferMs: 200 });
120
+ m.enqueue(pcm(500));
121
+ vi.advanceTimersByTime(1000);
122
+ expect(MockAudioContext.instances).toHaveLength(0);
123
+ } finally {
124
+ vi.useRealTimers();
125
+ }
126
+ });
127
+
94
128
  it("plays a reply shorter than the prebuffer (markStreamEnd flushes the gate)", () => {
95
129
  const m = new AudioPlaybackManager(24000, { prebufferMs: 200 });
96
130
  const finished = vi.fn();
@@ -156,4 +190,27 @@ describe("AudioPlaybackManager (PcmStreamPlayer surface)", () => {
156
190
  m.enqueue(pcm(100));
157
191
  expect(startedAgain).toHaveBeenCalledTimes(1);
158
192
  });
193
+
194
+ it("ignores onended from sources a flush stopped, so the next reply drains on its own audio", () => {
195
+ const m = new AudioPlaybackManager(24000);
196
+ m.enqueue(pcm(100));
197
+ m.enqueue(pcm(100));
198
+ const ctx = MockAudioContext.instances[0];
199
+ // Browsers fire onended for stopped sources after flush(); capture the
200
+ // handlers as the browser would have them queued.
201
+ const staleEnded = ctx.sources.map((s) => s.onended);
202
+ m.flush();
203
+ expect(ctx.sources.every((s) => s.onended === null)).toBe(true);
204
+
205
+ const finished = vi.fn();
206
+ m.onFinished(finished);
207
+ m.enqueue(pcm(100)); // the next reply
208
+ m.markStreamEnd();
209
+ for (const ended of staleEnded) ended?.();
210
+ expect(finished).not.toHaveBeenCalled();
211
+ expect(m.isPlaying()).toBe(true);
212
+
213
+ ctx.sources[2].onended?.();
214
+ expect(finished).toHaveBeenCalledTimes(1);
215
+ });
159
216
  });
@@ -27,59 +27,63 @@ import type { PcmStreamPlayer } from "../types";
27
27
  * approximation of the worklet's audio-thread silence, not parity.
28
28
  */
29
29
  export class AudioPlaybackManager implements PcmStreamPlayer {
30
- private ctx: AudioContext | null = null;
31
- private nextStartTime = 0;
32
- private activeSources: AudioBufferSourceNode[] = [];
33
- private finishedCallbacks: (() => void)[] = [];
34
- private startedCallbacks: (() => void)[] = [];
35
- private playing = false;
36
- private streamEnded = false;
37
- private pendingCount = 0;
30
+ #ctx: AudioContext | null = null;
31
+ #nextStartTime = 0;
32
+ #activeSources: AudioBufferSourceNode[] = [];
33
+ #finishedCallbacks: (() => void)[] = [];
34
+ #startedCallbacks: (() => void)[] = [];
35
+ #playing = false;
36
+ #streamEnded = false;
37
+ #pendingCount = 0;
38
38
  // Fires once per playback session when the first sample is actually scheduled
39
39
  // (loading→playing). Cleared by flush(); a mid-reply underrun does not re-fire.
40
- private started = false;
40
+ #started = false;
41
41
  // Explicit user pause via pause(); kept separate from the AudioContext's
42
42
  // autoplay-policy suspension so ensureContext() doesn't auto-resume over it.
43
- private userPaused = false;
43
+ #userPaused = false;
44
44
 
45
45
  // Prebuffer gate: while `buffering`, incoming samples accumulate in
46
46
  // `pendingBuffers` until they reach `waterlineSamples`, then release into the
47
47
  // scheduler. With `waterlineSamples === 0` the gate is off (realtime default).
48
- private buffering: boolean;
49
- private pendingBuffers: Float32Array[] = [];
50
- private pendingSamples = 0;
48
+ #buffering: boolean;
49
+ #pendingBuffers: Float32Array[] = [];
50
+ #pendingSamples = 0;
51
+ // Continuous streams never call markStreamEnd: a held tail is released after one
52
+ // prebuffer's worth of quiet instead.
53
+ #continuous = false;
54
+ #tailTimer: ReturnType<typeof setTimeout> | undefined;
51
55
 
52
56
  // PCM format constants
53
- private readonly sampleRate: number;
54
- private readonly waterlineSamples: number;
57
+ readonly #sampleRate: number;
58
+ readonly #waterlineSamples: number;
55
59
 
56
60
  // Remainder byte from a previous chunk when the chunk had an odd byte count.
57
61
  // Network chunks don't respect 2-byte sample boundaries, so we carry over
58
62
  // the orphaned byte and prepend it to the next chunk.
59
- private remainder: Uint8Array | null = null;
63
+ #remainder: Uint8Array | null = null;
60
64
 
61
65
  constructor(sampleRate = 24000, options: { prebufferMs?: number } = {}) {
62
- this.sampleRate = sampleRate;
66
+ this.#sampleRate = sampleRate;
63
67
  const prebufferMs = Math.max(0, options.prebufferMs ?? 0);
64
- this.waterlineSamples = Math.round((sampleRate * prebufferMs) / 1000);
65
- this.buffering = this.waterlineSamples > 0;
68
+ this.#waterlineSamples = Math.round((sampleRate * prebufferMs) / 1000);
69
+ this.#buffering = this.#waterlineSamples > 0;
66
70
  }
67
71
 
68
72
  /**
69
73
  * Ensure AudioContext is created and running.
70
74
  * Must be called after a user gesture on iOS Safari.
71
75
  */
72
- private ensureContext(): AudioContext {
73
- if (!this.ctx) {
76
+ #ensureContext(): AudioContext {
77
+ if (!this.#ctx) {
74
78
  const w = typeof window !== "undefined" ? (window as any) : undefined;
75
79
  if (!w) throw new Error("AudioPlaybackManager requires a browser environment");
76
80
  const AudioCtx = w.AudioContext || w.webkitAudioContext;
77
- this.ctx = new AudioCtx({ sampleRate: this.sampleRate }) as AudioContext;
81
+ this.#ctx = new AudioCtx({ sampleRate: this.#sampleRate }) as AudioContext;
78
82
  }
79
- const ctx = this.ctx!;
83
+ const ctx = this.#ctx!;
80
84
  // Resume if suspended (autoplay policy) — but never override an explicit
81
85
  // user pause(): more audio may still stream in while paused.
82
- if (ctx.state === "suspended" && !this.userPaused) {
86
+ if (ctx.state === "suspended" && !this.#userPaused) {
83
87
  ctx.resume();
84
88
  }
85
89
  return ctx;
@@ -94,32 +98,39 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
94
98
 
95
99
  // Prepend any remainder byte from the previous chunk
96
100
  let data = pcmData;
97
- if (this.remainder) {
98
- const merged = new Uint8Array(this.remainder.length + pcmData.length);
99
- merged.set(this.remainder);
100
- merged.set(pcmData, this.remainder.length);
101
+ if (this.#remainder) {
102
+ const merged = new Uint8Array(this.#remainder.length + pcmData.length);
103
+ merged.set(this.#remainder);
104
+ merged.set(pcmData, this.#remainder.length);
101
105
  data = merged;
102
- this.remainder = null;
106
+ this.#remainder = null;
103
107
  }
104
108
 
105
109
  // If odd byte count, save the trailing byte for next chunk
106
110
  if (data.length % 2 !== 0) {
107
- this.remainder = new Uint8Array([data[data.length - 1]]);
111
+ this.#remainder = new Uint8Array([data[data.length - 1]]);
108
112
  data = data.subarray(0, data.length - 1);
109
113
  }
110
114
 
111
115
  if (data.length === 0) return;
112
116
 
113
- const float32 = this.pcmToFloat32(data);
117
+ const float32 = this.#pcmToFloat32(data);
114
118
  if (float32.length === 0) return;
115
119
 
116
- if (this.buffering) {
120
+ if (this.#buffering) {
117
121
  // Hold until the prebuffer waterline fills, then release as a batch.
118
- this.pendingBuffers.push(float32);
119
- this.pendingSamples += float32.length;
120
- if (this.pendingSamples >= this.waterlineSamples) this.releaseBuffer();
122
+ this.#pendingBuffers.push(float32);
123
+ this.#pendingSamples += float32.length;
124
+ clearTimeout(this.#tailTimer);
125
+ if (this.#pendingSamples >= this.#waterlineSamples) this.#releaseBuffer();
126
+ else if (this.#continuous) {
127
+ this.#tailTimer = setTimeout(
128
+ () => this.#releaseBuffer(),
129
+ (this.#waterlineSamples / this.#sampleRate) * 1000,
130
+ );
131
+ }
121
132
  } else {
122
- this.scheduleSamples(float32);
133
+ this.#scheduleSamples(float32);
123
134
  }
124
135
  }
125
136
 
@@ -130,16 +141,19 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
130
141
  markStreamEnd(): void {
131
142
  // A reply shorter than the prebuffer never reaches the waterline; release
132
143
  // whatever we held so it still plays.
133
- if (this.pendingBuffers.length > 0) this.releaseBuffer();
134
- this.streamEnded = true;
135
- this.checkFinished();
144
+ if (this.#pendingBuffers.length > 0) this.#releaseBuffer();
145
+ this.#streamEnded = true;
146
+ this.#checkFinished();
136
147
  }
137
148
 
138
149
  /**
139
150
  * Immediately stop all playback and discard queued audio.
140
151
  */
141
152
  flush(): void {
142
- for (const source of this.activeSources) {
153
+ for (const source of this.#activeSources) {
154
+ // A stopped source still fires `onended` later; detach it so a flushed
155
+ // batch can't decrement the next reply's pendingCount and drain it early.
156
+ source.onended = null;
143
157
  try {
144
158
  source.stop();
145
159
  source.disconnect();
@@ -147,33 +161,39 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
147
161
  // Ignore errors from already-stopped sources
148
162
  }
149
163
  }
150
- this.activeSources = [];
151
- this.pendingCount = 0;
152
- this.nextStartTime = 0;
153
- this.playing = false;
154
- this.streamEnded = false;
155
- this.finishedCallbacks = [];
156
- this.startedCallbacks = [];
157
- this.remainder = null;
164
+ this.#activeSources = [];
165
+ this.#pendingCount = 0;
166
+ this.#nextStartTime = 0;
167
+ this.#playing = false;
168
+ this.#streamEnded = false;
169
+ this.#finishedCallbacks = [];
170
+ this.#startedCallbacks = [];
171
+ this.#remainder = null;
158
172
  // Reset the prebuffer gate and the started latch for the next reply.
159
- this.pendingBuffers = [];
160
- this.pendingSamples = 0;
161
- this.buffering = this.waterlineSamples > 0;
162
- this.started = false;
173
+ this.#pendingBuffers = [];
174
+ this.#pendingSamples = 0;
175
+ this.#buffering = this.#waterlineSamples > 0;
176
+ this.#started = false;
177
+ clearTimeout(this.#tailTimer);
178
+ }
179
+
180
+ /** See {@link VoicePlaybackEngine.setContinuousMode}. */
181
+ setContinuousMode(enabled: boolean): void {
182
+ this.#continuous = enabled;
163
183
  }
164
184
 
165
185
  /**
166
186
  * Whether audio is currently playing or queued.
167
187
  */
168
188
  isPlaying(): boolean {
169
- return this.playing;
189
+ return this.#playing;
170
190
  }
171
191
 
172
192
  /**
173
193
  * Register a callback for when all queued audio finishes playing.
174
194
  */
175
195
  onFinished(callback: () => void): void {
176
- this.finishedCallbacks.push(callback);
196
+ this.#finishedCallbacks.push(callback);
177
197
  }
178
198
 
179
199
  /**
@@ -182,7 +202,7 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
182
202
  * mid-reply underrun re-buffer does not re-fire it.
183
203
  */
184
204
  onStarted(callback: () => void): void {
185
- this.startedCallbacks.push(callback);
205
+ this.#startedCallbacks.push(callback);
186
206
  }
187
207
 
188
208
  /**
@@ -190,14 +210,14 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
190
210
  * freezes in place and {@link resume} continues exactly where it left off.
191
211
  */
192
212
  pause(): void {
193
- this.userPaused = true;
194
- if (this.ctx && this.ctx.state === "running") void this.ctx.suspend();
213
+ this.#userPaused = true;
214
+ if (this.#ctx && this.#ctx.state === "running") void this.#ctx.suspend();
195
215
  }
196
216
 
197
217
  /** Resume playback after {@link pause}. */
198
218
  resume(): void {
199
- this.userPaused = false;
200
- if (this.ctx && this.ctx.state === "suspended") void this.ctx.resume();
219
+ this.#userPaused = false;
220
+ if (this.#ctx && this.#ctx.state === "suspended") void this.#ctx.resume();
201
221
  }
202
222
 
203
223
  /**
@@ -205,27 +225,27 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
205
225
  */
206
226
  async destroy(): Promise<void> {
207
227
  this.flush();
208
- if (this.ctx) {
209
- await this.ctx.close();
210
- this.ctx = null;
228
+ if (this.#ctx) {
229
+ await this.#ctx.close();
230
+ this.#ctx = null;
211
231
  }
212
232
  }
213
233
 
214
234
  /** Release held prebuffer samples into the scheduler in arrival order. */
215
- private releaseBuffer(): void {
216
- this.buffering = false;
217
- const held = this.pendingBuffers;
218
- this.pendingBuffers = [];
219
- this.pendingSamples = 0;
220
- for (const samples of held) this.scheduleSamples(samples);
235
+ #releaseBuffer(): void {
236
+ this.#buffering = false;
237
+ const held = this.#pendingBuffers;
238
+ this.#pendingBuffers = [];
239
+ this.#pendingSamples = 0;
240
+ for (const samples of held) this.#scheduleSamples(samples);
221
241
  }
222
242
 
223
243
  /** Schedule one Float32 sample block for gap-free playback. */
224
- private scheduleSamples(float32: Float32Array): void {
244
+ #scheduleSamples(float32: Float32Array): void {
225
245
  if (float32.length === 0) return;
226
- const ctx = this.ensureContext();
246
+ const ctx = this.#ensureContext();
227
247
 
228
- const buffer = ctx.createBuffer(1, float32.length, this.sampleRate);
248
+ const buffer = ctx.createBuffer(1, float32.length, this.#sampleRate);
229
249
  buffer.getChannelData(0).set(float32);
230
250
 
231
251
  const source = ctx.createBufferSource();
@@ -233,52 +253,54 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
233
253
  source.connect(ctx.destination);
234
254
 
235
255
  const now = ctx.currentTime;
236
- if (this.nextStartTime === 0) {
256
+ if (this.#nextStartTime === 0) {
237
257
  // Fresh start (first audio of this session, or after flush).
238
- this.nextStartTime = now;
239
- } else if (this.nextStartTime < now) {
258
+ this.#nextStartTime = now;
259
+ } else if (this.#nextStartTime < now) {
240
260
  // Underrun: the playhead caught up to the queue. Snap to now (a small,
241
261
  // unavoidable gap) and, when a prebuffer is configured, re-enter buffering
242
262
  // so subsequent chunks re-accumulate before scheduling — collapsing a
243
263
  // train of clicks into a single rebuffer.
244
- this.nextStartTime = now;
245
- if (this.waterlineSamples > 0) this.buffering = true;
264
+ this.#nextStartTime = now;
265
+ if (this.#waterlineSamples > 0) this.#buffering = true;
246
266
  }
247
- source.start(this.nextStartTime);
248
- this.nextStartTime += buffer.duration;
267
+ source.start(this.#nextStartTime);
268
+ this.#nextStartTime += buffer.duration;
249
269
 
250
- this.activeSources.push(source);
251
- this.pendingCount++;
252
- this.playing = true;
270
+ this.#activeSources.push(source);
271
+ this.#pendingCount++;
272
+ this.#playing = true;
253
273
 
254
- if (!this.started) {
255
- this.started = true;
256
- const cbs = this.startedCallbacks.slice();
257
- this.startedCallbacks = [];
274
+ if (!this.#started) {
275
+ this.#started = true;
276
+ const cbs = this.#startedCallbacks.slice();
277
+ this.#startedCallbacks = [];
258
278
  for (const cb of cbs) cb();
259
279
  }
260
280
 
261
281
  source.onended = () => {
262
- const idx = this.activeSources.indexOf(source);
263
- if (idx !== -1) this.activeSources.splice(idx, 1);
264
- this.pendingCount--;
265
- this.checkFinished();
282
+ const idx = this.#activeSources.indexOf(source);
283
+ // Not active: removed by flush(), so it no longer counts toward a drain.
284
+ if (idx === -1) return;
285
+ this.#activeSources.splice(idx, 1);
286
+ this.#pendingCount--;
287
+ this.#checkFinished();
266
288
  };
267
289
  }
268
290
 
269
- private checkFinished(): void {
291
+ #checkFinished(): void {
270
292
  // Fire once the stream has ended and nothing is scheduled or held. No
271
293
  // `playing` precondition: an empty reply (markStreamEnd with no audio) must
272
294
  // still resolve to idle, matching the worklet's immediate 'drained'.
273
295
  if (
274
- this.streamEnded &&
275
- this.pendingCount <= 0 &&
276
- this.pendingBuffers.length === 0
296
+ this.#streamEnded &&
297
+ this.#pendingCount <= 0 &&
298
+ this.#pendingBuffers.length === 0
277
299
  ) {
278
- this.playing = false;
279
- this.streamEnded = false;
280
- const cbs = this.finishedCallbacks.slice();
281
- this.finishedCallbacks = [];
300
+ this.#playing = false;
301
+ this.#streamEnded = false;
302
+ const cbs = this.#finishedCallbacks.slice();
303
+ this.#finishedCallbacks = [];
282
304
  for (const cb of cbs) cb();
283
305
  }
284
306
  }
@@ -286,7 +308,7 @@ export class AudioPlaybackManager implements PcmStreamPlayer {
286
308
  /**
287
309
  * Convert 16-bit signed LE PCM to Float32 samples in [-1, 1].
288
310
  */
289
- private pcmToFloat32(pcmData: Uint8Array): Float32Array {
311
+ #pcmToFloat32(pcmData: Uint8Array): Float32Array {
290
312
  // 2 bytes per sample (16-bit)
291
313
  const numSamples = Math.floor(pcmData.length / 2);
292
314
  const float32 = new Float32Array(numSamples);
@@ -5,43 +5,43 @@ import type { VoiceProvider, VoiceResult, VoiceStatus, VoiceConfig } from '../ty
5
5
 
6
6
  export class BrowserVoiceProvider implements VoiceProvider {
7
7
  type: 'browser' = 'browser';
8
- private recognition: any = null;
9
- private resultCallbacks: ((result: VoiceResult) => void)[] = [];
10
- private errorCallbacks: ((error: Error) => void)[] = [];
11
- private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
12
- private isListening = false;
13
- private w: any = typeof window !== 'undefined' ? window : undefined;
8
+ #recognition: any = null;
9
+ #resultCallbacks: ((result: VoiceResult) => void)[] = [];
10
+ #errorCallbacks: ((error: Error) => void)[] = [];
11
+ #statusCallbacks: ((status: VoiceStatus) => void)[] = [];
12
+ #isListening = false;
13
+ #w: any = typeof window !== 'undefined' ? window : undefined;
14
14
 
15
15
  constructor(private config: VoiceConfig['browser'] = {}) {}
16
16
 
17
17
  async connect() {
18
18
  // Browser provider doesn't need connection
19
- this.statusCallbacks.forEach(cb => cb('connected'));
19
+ this.#statusCallbacks.forEach(cb => cb('connected'));
20
20
  }
21
21
 
22
22
  async startListening() {
23
23
  try {
24
- if (this.isListening) {
24
+ if (this.#isListening) {
25
25
  throw new Error('Already listening');
26
26
  }
27
27
 
28
- if (!this.w) {
28
+ if (!this.#w) {
29
29
  throw new Error('Window object not available');
30
30
  }
31
31
 
32
32
  // @ts-ignore - Browser SpeechRecognition API
33
- const SpeechRecognition = this.w!.SpeechRecognition || this.w!.webkitSpeechRecognition;
33
+ const SpeechRecognition = this.#w!.SpeechRecognition || this.#w!.webkitSpeechRecognition;
34
34
 
35
35
  if (!SpeechRecognition) {
36
36
  throw new Error('Browser speech recognition not supported');
37
37
  }
38
38
 
39
- this.recognition = new SpeechRecognition();
40
- this.recognition.lang = this.config?.language || 'en-US';
41
- this.recognition.continuous = this.config?.continuous || false;
42
- this.recognition.interimResults = true;
39
+ this.#recognition = new SpeechRecognition();
40
+ this.#recognition.lang = this.config?.language || 'en-US';
41
+ this.#recognition.continuous = this.config?.continuous || false;
42
+ this.#recognition.interimResults = true;
43
43
 
44
- this.recognition.onresult = (event: any) => {
44
+ this.#recognition.onresult = (event: any) => {
45
45
  const transcript = Array.from(event.results)
46
46
  .map((result: any) => result[0])
47
47
  .map((result: any) => result.transcript)
@@ -49,7 +49,7 @@ export class BrowserVoiceProvider implements VoiceProvider {
49
49
 
50
50
  const isFinal = event.results[event.results.length - 1].isFinal;
51
51
 
52
- this.resultCallbacks.forEach(cb => cb({
52
+ this.#resultCallbacks.forEach(cb => cb({
53
53
  text: transcript,
54
54
  confidence: isFinal ? 0.8 : 0.5,
55
55
  provider: 'browser'
@@ -60,55 +60,55 @@ export class BrowserVoiceProvider implements VoiceProvider {
60
60
  }
61
61
  };
62
62
 
63
- this.recognition.onerror = (event: any) => {
64
- this.errorCallbacks.forEach(cb => cb(new Error(event.error)));
65
- this.statusCallbacks.forEach(cb => cb('error'));
63
+ this.#recognition.onerror = (event: any) => {
64
+ this.#errorCallbacks.forEach(cb => cb(new Error(event.error)));
65
+ this.#statusCallbacks.forEach(cb => cb('error'));
66
66
  };
67
67
 
68
- this.recognition.onstart = () => {
69
- this.isListening = true;
70
- this.statusCallbacks.forEach(cb => cb('listening'));
68
+ this.#recognition.onstart = () => {
69
+ this.#isListening = true;
70
+ this.#statusCallbacks.forEach(cb => cb('listening'));
71
71
  };
72
72
 
73
- this.recognition.onend = () => {
74
- this.isListening = false;
75
- this.statusCallbacks.forEach(cb => cb('idle'));
73
+ this.#recognition.onend = () => {
74
+ this.#isListening = false;
75
+ this.#statusCallbacks.forEach(cb => cb('idle'));
76
76
  };
77
77
 
78
- this.recognition.start();
78
+ this.#recognition.start();
79
79
 
80
80
  } catch (error) {
81
- this.errorCallbacks.forEach(cb => cb(error as Error));
82
- this.statusCallbacks.forEach(cb => cb('error'));
81
+ this.#errorCallbacks.forEach(cb => cb(error as Error));
82
+ this.#statusCallbacks.forEach(cb => cb('error'));
83
83
  throw error;
84
84
  }
85
85
  }
86
86
 
87
87
  async stopListening() {
88
- if (this.recognition) {
89
- this.recognition.stop();
90
- this.recognition = null;
88
+ if (this.#recognition) {
89
+ this.#recognition.stop();
90
+ this.#recognition = null;
91
91
  }
92
92
 
93
- this.isListening = false;
94
- this.statusCallbacks.forEach(cb => cb('idle'));
93
+ this.#isListening = false;
94
+ this.#statusCallbacks.forEach(cb => cb('idle'));
95
95
  }
96
96
 
97
97
  onResult(callback: (result: VoiceResult) => void): void {
98
- this.resultCallbacks.push(callback);
98
+ this.#resultCallbacks.push(callback);
99
99
  }
100
100
 
101
101
  onError(callback: (error: Error) => void): void {
102
- this.errorCallbacks.push(callback);
102
+ this.#errorCallbacks.push(callback);
103
103
  }
104
104
 
105
105
  onStatusChange(callback: (status: VoiceStatus) => void): void {
106
- this.statusCallbacks.push(callback);
106
+ this.#statusCallbacks.push(callback);
107
107
  }
108
108
 
109
109
  async disconnect(): Promise<void> {
110
110
  await this.stopListening();
111
- this.statusCallbacks.forEach(cb => cb('disconnected'));
111
+ this.#statusCallbacks.forEach(cb => cb('disconnected'));
112
112
  }
113
113
 
114
114
  // Check if browser supports speech recognition