@theoremjs/react 0.0.0-stage → 0.3.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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -2
  3. package/client/attachment-hover-preview.d.ts +11 -0
  4. package/client/attachment-hover-preview.js +36 -0
  5. package/client/audio-level.d.ts +5 -0
  6. package/client/audio-level.js +24 -0
  7. package/client/composer-attachments.d.ts +30 -0
  8. package/client/composer-attachments.js +33 -0
  9. package/client/composer-drawer.d.ts +19 -0
  10. package/client/composer-drawer.js +15 -0
  11. package/client/composer-hints.d.ts +25 -0
  12. package/client/composer-hints.js +20 -0
  13. package/client/composer-primary.d.ts +16 -0
  14. package/client/composer-primary.js +25 -0
  15. package/client/decision-transport.d.ts +48 -0
  16. package/client/decision-transport.js +102 -0
  17. package/client/decode-composer-draft.d.ts +11 -0
  18. package/client/decode-composer-draft.js +12 -0
  19. package/client/encode-composer-draft.d.ts +9 -0
  20. package/client/encode-composer-draft.js +34 -0
  21. package/client/encode-files.d.ts +18 -0
  22. package/client/encode-files.js +54 -0
  23. package/client/failure.d.ts +32 -0
  24. package/client/failure.js +34 -0
  25. package/client/generation-selection.d.ts +15 -0
  26. package/client/generation-selection.js +24 -0
  27. package/client/host-call.d.ts +51 -0
  28. package/client/host-call.js +42 -0
  29. package/client/host-transport.d.ts +68 -0
  30. package/client/host-transport.js +74 -0
  31. package/client/image-output.d.ts +6 -0
  32. package/client/image-output.js +13 -0
  33. package/client/index.d.ts +20 -0
  34. package/client/index.js +18 -0
  35. package/client/ink-waveform.d.ts +17 -0
  36. package/client/ink-waveform.js +69 -0
  37. package/client/live/apply-live-turn-tool-event.d.ts +14 -0
  38. package/client/live/apply-live-turn-tool-event.js +39 -0
  39. package/client/live/live-captions.d.ts +23 -0
  40. package/client/live/live-captions.js +70 -0
  41. package/client/live/live-mic-forward.d.ts +29 -0
  42. package/client/live/live-mic-forward.js +37 -0
  43. package/client/live/live-state.d.ts +11 -0
  44. package/client/live/live-state.js +20 -0
  45. package/client/live/live-tool.d.ts +11 -0
  46. package/client/live/live-tool.js +1 -0
  47. package/client/live/live-video.d.ts +10 -0
  48. package/client/live/live-video.js +71 -0
  49. package/client/live/run-live-tool-call.d.ts +17 -0
  50. package/client/live/run-live-tool-call.js +32 -0
  51. package/client/live-client.d.ts +141 -0
  52. package/client/live-client.js +638 -0
  53. package/client/live-errors.d.ts +2 -0
  54. package/client/live-errors.js +9 -0
  55. package/client/live-messages.d.ts +110 -0
  56. package/client/live-messages.js +67 -0
  57. package/client/mic-capture.d.ts +4 -0
  58. package/client/mic-capture.js +6 -0
  59. package/client/oauth-popup.d.ts +26 -0
  60. package/client/oauth-popup.js +30 -0
  61. package/client/pcm-downsample.d.ts +4 -0
  62. package/client/pcm-downsample.js +45 -0
  63. package/client/run-commit.d.ts +61 -0
  64. package/client/run-commit.js +124 -0
  65. package/client/run-session.d.ts +80 -0
  66. package/client/run-session.js +215 -0
  67. package/client/schema-fields.d.ts +63 -0
  68. package/client/schema-fields.js +178 -0
  69. package/client/shaped-data.d.ts +118 -0
  70. package/client/shaped-data.js +314 -0
  71. package/client/source-citations.d.ts +13 -0
  72. package/client/source-citations.js +56 -0
  73. package/client/state-fields.d.ts +16 -0
  74. package/client/state-fields.js +44 -0
  75. package/client/tool-result.d.ts +78 -0
  76. package/client/tool-result.js +275 -0
  77. package/client/tool-resume.d.ts +33 -0
  78. package/client/tool-resume.js +11 -0
  79. package/client/trace-feed.d.ts +13 -0
  80. package/client/trace-feed.js +18 -0
  81. package/client/trace-story.d.ts +163 -0
  82. package/client/trace-story.js +416 -0
  83. package/client/trace-view.d.ts +130 -0
  84. package/client/trace-view.js +293 -0
  85. package/client/transcript-groups.d.ts +114 -0
  86. package/client/transcript-groups.js +167 -0
  87. package/client/transcript-scroll.d.ts +7 -0
  88. package/client/transcript-scroll.js +6 -0
  89. package/client/transport.d.ts +176 -0
  90. package/client/transport.js +256 -0
  91. package/client/turn-client.d.ts +35 -0
  92. package/client/turn-client.js +93 -0
  93. package/client/voice-label.d.ts +2 -0
  94. package/client/voice-label.js +15 -0
  95. package/client/voice-recorder.d.ts +27 -0
  96. package/client/voice-recorder.js +165 -0
  97. package/client/walk-away.d.ts +34 -0
  98. package/client/walk-away.js +48 -0
  99. package/client/wire-line.d.ts +56 -0
  100. package/client/wire-line.js +90 -0
  101. package/client/worklets/mic-capture.js +16 -0
  102. package/components/InkWaveform.d.ts +10 -0
  103. package/components/InkWaveform.js +108 -0
  104. package/components/live/use-live-runner-controls.d.ts +48 -0
  105. package/components/live/use-live-runner-controls.js +100 -0
  106. package/components/live/use-live-runner-model.d.ts +45 -0
  107. package/components/live/use-live-runner-model.js +175 -0
  108. package/components/live/use-live-runner-ui.d.ts +65 -0
  109. package/components/live/use-live-runner-ui.js +159 -0
  110. package/components/live/use-live-session-client.d.ts +41 -0
  111. package/components/live/use-live-session-client.js +123 -0
  112. package/components/use-composer-voice.d.ts +28 -0
  113. package/components/use-composer-voice.js +90 -0
  114. package/components/use-editor-selection.d.ts +3 -0
  115. package/components/use-editor-selection.js +16 -0
  116. package/components/use-voice-playback.d.ts +16 -0
  117. package/components/use-voice-playback.js +68 -0
  118. package/hooks/use-described.d.ts +8 -0
  119. package/hooks/use-described.js +23 -0
  120. package/hooks/use-theorem-chat-actions.d.ts +75 -0
  121. package/hooks/use-theorem-chat-actions.js +265 -0
  122. package/hooks/use-theorem-chat-state.d.ts +66 -0
  123. package/hooks/use-theorem-chat-state.js +118 -0
  124. package/hooks/use-theorem-chat.d.ts +63 -0
  125. package/hooks/use-theorem-chat.js +340 -0
  126. package/hooks/use-theorem-decision.d.ts +23 -0
  127. package/hooks/use-theorem-decision.js +75 -0
  128. package/hooks/use-theorem-host.d.ts +44 -0
  129. package/hooks/use-theorem-host.js +117 -0
  130. package/hooks/use-theorem-interface.d.ts +19 -0
  131. package/hooks/use-theorem-interface.js +25 -0
  132. package/hooks/use-trace-records.d.ts +4 -0
  133. package/hooks/use-trace-records.js +7 -0
  134. package/index.d.ts +14 -0
  135. package/index.js +14 -0
  136. package/live.d.ts +8 -0
  137. package/live.js +10 -0
  138. package/package.json +73 -4
  139. package/server/credential-store.d.ts +24 -0
  140. package/server/credential-store.js +14 -0
  141. package/server/decision-handler.d.ts +34 -0
  142. package/server/decision-handler.js +55 -0
  143. package/server/handler.d.ts +114 -0
  144. package/server/handler.js +568 -0
  145. package/server/json-response.d.ts +2 -0
  146. package/server/json-response.js +7 -0
  147. package/server/mod.d.ts +16 -0
  148. package/server/mod.js +15 -0
  149. package/server/request-check.d.ts +16 -0
  150. package/server/request-check.js +34 -0
  151. package/server/session-store.d.ts +74 -0
  152. package/server/session-store.js +49 -0
  153. package/server/steer-inbox.d.ts +35 -0
  154. package/server/steer-inbox.js +52 -0
  155. package/server/walk-away.d.ts +22 -0
  156. package/server/walk-away.js +41 -0
  157. package/styles/ink-controls.css +134 -0
  158. package/ui/ChatComposerBar.d.ts +39 -0
  159. package/ui/ChatComposerBar.js +251 -0
  160. package/ui/ChatTranscript.d.ts +30 -0
  161. package/ui/ChatTranscript.js +474 -0
  162. package/ui/ConsoleFrame.d.ts +47 -0
  163. package/ui/ConsoleFrame.js +46 -0
  164. package/ui/DecisionAnswers.d.ts +15 -0
  165. package/ui/DecisionAnswers.js +102 -0
  166. package/ui/LiveRunner.d.ts +28 -0
  167. package/ui/LiveRunner.js +193 -0
  168. package/ui/SchemaFields.d.ts +10 -0
  169. package/ui/SchemaFields.js +122 -0
  170. package/ui/ShapedData.d.ts +11 -0
  171. package/ui/ShapedData.js +151 -0
  172. package/ui/SidePanel.d.ts +57 -0
  173. package/ui/SidePanel.js +50 -0
  174. package/ui/StateFields.d.ts +36 -0
  175. package/ui/StateFields.js +96 -0
  176. package/ui/TheoremChat.d.ts +71 -0
  177. package/ui/TheoremChat.js +146 -0
  178. package/ui/TheoremDecision.d.ts +38 -0
  179. package/ui/TheoremDecision.js +87 -0
  180. package/ui/TheoremHost.d.ts +38 -0
  181. package/ui/TheoremHost.js +150 -0
  182. package/ui/ToolGateCard.d.ts +28 -0
  183. package/ui/ToolGateCard.js +100 -0
  184. package/ui/ToolResult.d.ts +10 -0
  185. package/ui/ToolResult.js +60 -0
  186. package/ui/ToolResultCharts.d.ts +5 -0
  187. package/ui/ToolResultCharts.js +86 -0
  188. package/ui/TraceCharts.d.ts +13 -0
  189. package/ui/TraceCharts.js +235 -0
  190. package/ui/TraceGuardrails.d.ts +10 -0
  191. package/ui/TraceGuardrails.js +56 -0
  192. package/ui/TraceInspector.d.ts +6 -0
  193. package/ui/TraceInspector.js +189 -0
  194. package/ui/TraceInspectorPanel.d.ts +24 -0
  195. package/ui/TraceInspectorPanel.js +57 -0
  196. package/ui/TraceOverview.d.ts +34 -0
  197. package/ui/TraceOverview.js +96 -0
  198. package/ui/TraceSpanDetail.d.ts +10 -0
  199. package/ui/TraceSpanDetail.js +188 -0
  200. package/ui/TraceStory.d.ts +20 -0
  201. package/ui/TraceStory.js +107 -0
  202. package/ui/TraceValues.d.ts +36 -0
  203. package/ui/TraceValues.js +113 -0
  204. package/ui/TraceWaterfall.d.ts +13 -0
  205. package/ui/TraceWaterfall.js +132 -0
  206. package/ui/VoiceNote.d.ts +12 -0
  207. package/ui/VoiceNote.js +28 -0
  208. package/ui/built/theme.css +774 -0
  209. package/ui/built/theorem.d.ts +13 -0
  210. package/ui/built/theorem.js +631 -0
  211. package/ui/built/theorem.variants.d.ts +16 -0
  212. package/ui/disclosure-motion.d.ts +6 -0
  213. package/ui/disclosure-motion.js +163 -0
  214. package/ui/icons.d.ts +4 -0
  215. package/ui/icons.js +37 -0
  216. package/ui/index.d.ts +25 -0
  217. package/ui/index.js +29 -0
  218. package/ui/labels-provider.d.ts +16 -0
  219. package/ui/labels-provider.js +61 -0
  220. package/ui/labels.d.ts +1153 -0
  221. package/ui/labels.js +603 -0
  222. package/ui/theme.d.ts +10 -0
  223. package/ui/theme.js +13 -0
  224. package/ui/theorem-theme.d.ts +10 -0
  225. package/ui/theorem-theme.js +83 -0
  226. package/ui/trace-palette.d.ts +24 -0
  227. package/ui/trace-palette.js +24 -0
  228. package/ui/trace-patterns.d.ts +32 -0
  229. package/ui/trace-patterns.js +44 -0
  230. package/ui/transcript-copy-text.d.ts +4 -0
  231. package/ui/transcript-copy-text.js +39 -0
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Pure client-side SDK for THEOREM Gemini 3.1 Flash Live sessions over WebSocket relay.
3
+ *
4
+ * Handles:
5
+ * - Bidirectional WebSocket connection to `/api/live/relay`.
6
+ * - 16-bit 16kHz PCM microphone audio recording & streaming.
7
+ * - Gapless 24kHz PCM / WAV model voice playback scheduling.
8
+ * - Barge-in interruption cancellation (instant audio queue flush).
9
+ * - Quiet-mic gate while model audio plays (reduces false barge-in from speaker bleed).
10
+ * - UI tool execution and response routing.
11
+ *
12
+ * @module
13
+ */
14
+ import { describeError, TheoremError, } from '@theoremjs/agents';
15
+ import { base64ToBytes, bytesToBase64 } from '@theoremjs/agents/kernel';
16
+ import { float32Rms, float32RmsToLevel, timeDomainBytesToLevel } from "./audio-level.js";
17
+ import { isPermissionDeniedError } from "./live-errors.js";
18
+ import { applyLiveToolTurnEvent, liveTranscriptFromEvidence, shouldForwardMicFrame, } from "./live/live-mic-forward.js";
19
+ import { parseLiveServerEnvelope, } from "./live-messages.js";
20
+ import { MIC_CAPTURE_PROCESSOR, micCaptureWorkletUrl } from "./mic-capture.js";
21
+ import { downsampleAndConvertToInt16, pcm16BytesToFloat32 } from "./pcm-downsample.js";
22
+ import { hostError } from "./transport.js";
23
+ /** One message to the relay, in the shape a relay checks with `parseLiveClientMessage`. */
24
+ function clientMessage(message) {
25
+ return JSON.stringify(message);
26
+ }
27
+ function emptyInboundTurnAccum() {
28
+ return {
29
+ toolCalls: [],
30
+ mediaChunks: [],
31
+ cancelledToolIds: new Set(),
32
+ };
33
+ }
34
+ /**
35
+ * While the model is playing audio, mic frames below this RMS are not forwarded.
36
+ * Speaker bleed / keyboard / room noise sit under this; intentional barge-in speech
37
+ * sits above it. Gemini VAD only offers LOW|HIGH start sensitivity — this is the
38
+ * fine-grained "less choppy barge-in" knob.
39
+ */
40
+ const BARGE_IN_RMS_WHILE_SPEAKING = 0.05;
41
+ /** A caught value as an `Error`; one that is not keeps its text as `internal` detail. */
42
+ function asError(err) {
43
+ return err instanceof Error ? err : new TheoremError('internal', describeError(err), { cause: err });
44
+ }
45
+ function safeDisconnect(node) {
46
+ if (!node || typeof node.disconnect !== 'function')
47
+ return;
48
+ try {
49
+ node.disconnect();
50
+ }
51
+ catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+ function stopMediaTracks(stream) {
56
+ if (!stream || typeof stream.getTracks !== 'function')
57
+ return;
58
+ for (const track of stream.getTracks()) {
59
+ track.stop();
60
+ }
61
+ }
62
+ function checkMicFrameForward(args) {
63
+ const rms = float32Rms(args.inputFloat32);
64
+ const modelPlaying = args.hasPlaybackNodes || args.status === 'speaking';
65
+ return shouldForwardMicFrame({
66
+ isMuted: args.isMuted,
67
+ socketOpen: args.wsOpen,
68
+ modelPlaying,
69
+ rms,
70
+ bargeInRmsWhileSpeaking: BARGE_IN_RMS_WHILE_SPEAKING,
71
+ });
72
+ }
73
+ function resolveWorkingStatusTransition(currentStatus, serverWorking) {
74
+ if (currentStatus === 'listening' && serverWorking)
75
+ return 'working';
76
+ if (currentStatus === 'working' && !serverWorking)
77
+ return 'listening';
78
+ return null;
79
+ }
80
+ export class LiveSessionClient {
81
+ ws = null;
82
+ audioContext = null;
83
+ micStream = null;
84
+ micSource = null;
85
+ micWorklet = null;
86
+ micWorkletModuleLoaded = false;
87
+ playbackNodes = [];
88
+ playbackBus = null;
89
+ playbackAnalyser = null;
90
+ playbackMeterFrame = 0;
91
+ playbackMeterBuffer = null;
92
+ nextPlaybackTime = 0;
93
+ status = 'disconnected';
94
+ /** Server reported background work (reasoning / async tools) still in flight. */
95
+ serverWorking = false;
96
+ micActivating = false;
97
+ connectTimeout = null;
98
+ isMuted = false;
99
+ options;
100
+ /** Serialize control-plane handling (tools / status) without waiting on audio decode. */
101
+ inboundChain = Promise.resolve();
102
+ /** Ordered model-audio playback queue (separate so tools are not stuck behind decode). */
103
+ audioChain = Promise.resolve();
104
+ /**
105
+ * Tool calls, one at a time in the order the model made them. Apart from
106
+ * `inboundChain`: a call waits on the relay's reply, which arrives inbound.
107
+ */
108
+ toolChain = Promise.resolve();
109
+ /** Bumped on barge-in / cancel so stale audioChain work is skipped. */
110
+ audioEpoch = 0;
111
+ pendingExecuteResults = new Map();
112
+ constructor(options) {
113
+ this.options = options;
114
+ }
115
+ setStatus(newStatus) {
116
+ this.status = newStatus;
117
+ if (newStatus === 'listening' || newStatus === 'disconnected' || newStatus === 'error') {
118
+ this.clearConnectTimeout();
119
+ }
120
+ this.options.onStatusChange?.(newStatus);
121
+ }
122
+ setConnectPhase(phase) {
123
+ this.options.onConnectPhase?.(phase);
124
+ }
125
+ clearConnectTimeout() {
126
+ if (this.connectTimeout) {
127
+ clearTimeout(this.connectTimeout);
128
+ this.connectTimeout = null;
129
+ }
130
+ }
131
+ detachWebSocket() {
132
+ for (const [callId, pending] of this.pendingExecuteResults) {
133
+ // lexicon-exempt: internal diagnostic; the user reads error.network
134
+ pending.reject(new TheoremError('network', `live session closed before call ${callId} settled`));
135
+ }
136
+ this.pendingExecuteResults.clear();
137
+ if (!this.ws)
138
+ return;
139
+ this.ws.onopen = null;
140
+ this.ws.onmessage = null;
141
+ this.ws.onclose = null;
142
+ this.ws.onerror = null;
143
+ try {
144
+ this.ws.close();
145
+ }
146
+ catch {
147
+ /* ignore */
148
+ }
149
+ this.ws = null;
150
+ }
151
+ teardownConnection() {
152
+ this.clearConnectTimeout();
153
+ this.micActivating = false;
154
+ this.setConnectPhase(null);
155
+ this.detachWebSocket();
156
+ this.cleanupAudio();
157
+ }
158
+ /** End the session on `error`: a failed connect or a lost socket. */
159
+ failSession(error) {
160
+ if (this.status === 'error' || this.status === 'disconnected')
161
+ return;
162
+ this.teardownConnection();
163
+ this.options.onError?.(error);
164
+ this.setStatus('error');
165
+ }
166
+ createSocket(url) {
167
+ return this.options.createSocket?.() ?? new WebSocket(url);
168
+ }
169
+ // fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
170
+ async connect() {
171
+ this.teardownConnection();
172
+ this.setStatus('connecting');
173
+ this.setConnectPhase('socket');
174
+ try {
175
+ const AudioContextClass = globalThis.AudioContext;
176
+ this.audioContext = new AudioContextClass();
177
+ if (this.audioContext.state === 'suspended') {
178
+ await this.audioContext.resume();
179
+ }
180
+ const protocol = globalThis.location.protocol === 'https:' ? 'wss:' : 'ws:';
181
+ const profileParam = this.options.profile
182
+ ? `?profile=${encodeURIComponent(this.options.profile)}`
183
+ : '';
184
+ const defaultUrl = `${protocol}//${globalThis.location.host}/api/live/relay${profileParam}`;
185
+ const url = this.options.relayUrl || defaultUrl;
186
+ this.ws = this.createSocket(url);
187
+ this.ws.binaryType = 'arraybuffer';
188
+ const { openMessage } = this.options;
189
+ if (openMessage) {
190
+ this.ws.onopen = () => {
191
+ this.ws?.send(JSON.stringify(openMessage));
192
+ };
193
+ }
194
+ this.connectTimeout = setTimeout(() => {
195
+ if (this.status === 'connecting') {
196
+ // lexicon-exempt: internal diagnostic; the user reads error.timeout
197
+ this.failSession(new TheoremError('timeout', 'live connect timed out'));
198
+ }
199
+ }, 20_000);
200
+ this.ws.onmessage = (event) => {
201
+ this.enqueueServerMessage(event.data);
202
+ };
203
+ this.ws.onclose = () => {
204
+ if (this.status === 'connecting') {
205
+ // lexicon-exempt: internal diagnostic; the user reads error.network
206
+ this.failSession(new TheoremError('network', 'live socket closed before ready'));
207
+ return;
208
+ }
209
+ this.teardownConnection();
210
+ this.setStatus('disconnected');
211
+ };
212
+ this.ws.onerror = () => {
213
+ // lexicon-exempt: internal diagnostic; the user reads error.network
214
+ this.failSession(new TheoremError('network', 'live socket error'));
215
+ };
216
+ }
217
+ catch (err) {
218
+ this.failSession(new TheoremError('internal', describeError(err), { cause: err }));
219
+ }
220
+ }
221
+ async ensureAudioContext() {
222
+ if (!this.audioContext || this.audioContext.state === 'closed') {
223
+ const AudioContextClass = globalThis.AudioContext;
224
+ this.audioContext = new AudioContextClass();
225
+ if (this.audioContext.state === 'suspended') {
226
+ await this.audioContext.resume();
227
+ }
228
+ }
229
+ }
230
+ async activateMicrophone() {
231
+ if (this.micActivating || this.status !== 'connecting')
232
+ return;
233
+ this.micActivating = true;
234
+ this.setConnectPhase('microphone');
235
+ try {
236
+ await this.ensureAudioContext();
237
+ this.micStream = await navigator.mediaDevices.getUserMedia({
238
+ audio: {
239
+ channelCount: 1,
240
+ echoCancellation: true,
241
+ noiseSuppression: true,
242
+ autoGainControl: true,
243
+ },
244
+ });
245
+ await this.setupMicrophonePipeline();
246
+ this.setConnectPhase(null);
247
+ this.setStatus('listening');
248
+ }
249
+ catch (err) {
250
+ const denied = isPermissionDeniedError(err);
251
+ this.failSession(new TheoremError(denied ? 'auth' : 'internal', describeError(err), {
252
+ cause: err,
253
+ copy: { key: denied ? 'voice.permission' : 'voice.unavailable' },
254
+ }));
255
+ }
256
+ finally {
257
+ this.micActivating = false;
258
+ }
259
+ }
260
+ sendMicFrame(inputFloat32, sampleRate) {
261
+ const pcm16 = downsampleAndConvertToInt16(inputFloat32, sampleRate, 16000);
262
+ const base64 = bytesToBase64(new Uint8Array(pcm16.buffer));
263
+ const socket = this.ws;
264
+ if (socket?.readyState === WebSocket.OPEN) {
265
+ socket.send(clientMessage({ type: 'audio', data: base64 }));
266
+ }
267
+ }
268
+ reportMicVolume(inputFloat32) {
269
+ if (this.isMuted)
270
+ return;
271
+ this.options.onVolumeLevel?.(float32RmsToLevel(inputFloat32), true);
272
+ }
273
+ forwardMicBuffer(inputFloat32) {
274
+ this.reportMicVolume(inputFloat32);
275
+ const forward = checkMicFrameForward({
276
+ isMuted: this.isMuted,
277
+ wsOpen: this.ws?.readyState === WebSocket.OPEN,
278
+ hasPlaybackNodes: this.playbackNodes.length > 0,
279
+ status: this.status,
280
+ inputFloat32,
281
+ });
282
+ if (forward) {
283
+ const sampleRate = this.audioContext ? this.audioContext.sampleRate : 48000;
284
+ this.sendMicFrame(inputFloat32, sampleRate);
285
+ }
286
+ }
287
+ async setupMicrophonePipeline() {
288
+ if (!this.micStream || !this.audioContext)
289
+ return;
290
+ this.micSource = this.audioContext.createMediaStreamSource(this.micStream);
291
+ const silent = this.audioContext.createGain();
292
+ silent.gain.value = 0;
293
+ if (!this.micWorkletModuleLoaded) {
294
+ await this.audioContext.audioWorklet.addModule(micCaptureWorkletUrl());
295
+ this.micWorkletModuleLoaded = true;
296
+ }
297
+ this.micWorklet = new AudioWorkletNode(this.audioContext, MIC_CAPTURE_PROCESSOR);
298
+ this.micWorklet.port.onmessage = (event) => {
299
+ const inputFloat32 = new Float32Array(event.data);
300
+ this.forwardMicBuffer(inputFloat32);
301
+ };
302
+ this.micSource.connect(this.micWorklet);
303
+ this.micWorklet.connect(silent);
304
+ silent.connect(this.audioContext.destination);
305
+ }
306
+ ensurePlaybackBus() {
307
+ if (!this.audioContext || this.playbackBus)
308
+ return;
309
+ this.playbackBus = this.audioContext.createGain();
310
+ this.playbackAnalyser = this.audioContext.createAnalyser();
311
+ this.playbackAnalyser.fftSize = 512;
312
+ this.playbackAnalyser.smoothingTimeConstant = 0.35;
313
+ this.playbackBus.connect(this.playbackAnalyser);
314
+ this.playbackAnalyser.connect(this.audioContext.destination);
315
+ this.playbackMeterBuffer = new Uint8Array(this.playbackAnalyser.fftSize);
316
+ }
317
+ stopPlaybackMeter() {
318
+ if (this.playbackMeterFrame) {
319
+ cancelAnimationFrame(this.playbackMeterFrame);
320
+ this.playbackMeterFrame = 0;
321
+ }
322
+ this.options.onVolumeLevel?.(0, false);
323
+ }
324
+ startPlaybackMeter() {
325
+ if (this.playbackMeterFrame)
326
+ return;
327
+ const tick = () => {
328
+ if (this.playbackNodes.length === 0 || !this.playbackAnalyser || !this.playbackMeterBuffer) {
329
+ this.stopPlaybackMeter();
330
+ return;
331
+ }
332
+ this.playbackAnalyser.getByteTimeDomainData(this.playbackMeterBuffer);
333
+ this.options.onVolumeLevel?.(timeDomainBytesToLevel(this.playbackMeterBuffer), false);
334
+ this.playbackMeterFrame = requestAnimationFrame(tick);
335
+ };
336
+ this.playbackMeterFrame = requestAnimationFrame(tick);
337
+ }
338
+ async processServerEnvelope(payload) {
339
+ if (await this.tryHandleControlEnvelope(payload))
340
+ return;
341
+ if (payload.type !== 'events')
342
+ return;
343
+ const accum = this.collectInboundTurn(payload.events);
344
+ const runnableTools = accum.toolCalls.filter((call) => !accum.cancelledToolIds.has(call.id));
345
+ for (const call of runnableTools) {
346
+ this.toolChain = this.toolChain
347
+ .then(() => this.options.onToolCall(call.name, call.arguments, { callId: call.id,
348
+ }))
349
+ .catch((err) => {
350
+ this.options.onError?.(asError(err));
351
+ });
352
+ }
353
+ this.scheduleMediaChunks(accum.mediaChunks);
354
+ }
355
+ enqueueServerMessage(data) {
356
+ this.inboundChain = this.inboundChain
357
+ .then(async () => {
358
+ if (typeof data !== 'string')
359
+ return;
360
+ const payload = parseLiveServerEnvelope(data);
361
+ // An envelope the client can't use reaches onTurnEvent like any event; the call goes on.
362
+ if (payload.type === 'unsupported' || payload.type === 'malformed') {
363
+ this.options.onTurnEvent?.(payload);
364
+ }
365
+ else
366
+ await this.processServerEnvelope(payload);
367
+ })
368
+ .catch((err) => {
369
+ this.options.onError?.(asError(err));
370
+ });
371
+ }
372
+ async handleReadyEnvelope(payload) {
373
+ this.options.onSessionReady?.({
374
+ sessionId: payload.sessionId,
375
+ profile: payload.profile,
376
+ });
377
+ if (this.options.voiceIngress === false) {
378
+ this.setConnectPhase(null);
379
+ this.setStatus('listening');
380
+ }
381
+ else {
382
+ await this.activateMicrophone();
383
+ }
384
+ }
385
+ handleExecuteToolResultEnvelope(payload) {
386
+ const pending = this.pendingExecuteResults.get(payload.callId);
387
+ if (!pending)
388
+ return;
389
+ this.pendingExecuteResults.delete(payload.callId);
390
+ if (payload.status === 'refused') {
391
+ pending.reject(hostError(payload.body, 'request'));
392
+ }
393
+ else if (payload.status === 'gated') {
394
+ pending.resolve({ status: 'gated', gate: payload.gate });
395
+ }
396
+ else
397
+ pending.resolve({ status: 'settled' });
398
+ }
399
+ async tryHandleControlEnvelope(payload) {
400
+ if (payload.type === 'ready') {
401
+ await this.handleReadyEnvelope(payload);
402
+ return true;
403
+ }
404
+ if (payload.type === 'executeToolResult') {
405
+ this.handleExecuteToolResultEnvelope(payload);
406
+ return true;
407
+ }
408
+ if (payload.type === 'trace') {
409
+ this.options.onTrace?.(payload.record);
410
+ return true;
411
+ }
412
+ if (payload.type === 'error') {
413
+ // The relay's kind and wording when it sent them, else `unavailable`.
414
+ this.options.onError?.(hostError(payload, 'unavailable'));
415
+ this.setStatus('error');
416
+ return true;
417
+ }
418
+ return false;
419
+ }
420
+ collectInboundTurn(events) {
421
+ const accum = emptyInboundTurnAccum();
422
+ for (const event of events) {
423
+ this.processTurnEvent(event, accum);
424
+ }
425
+ return accum;
426
+ }
427
+ processTurnEvent(event, accum) {
428
+ this.options.onTurnEvent?.(event);
429
+ switch (event.type) {
430
+ case 'evidence':
431
+ this.handleEvidenceTurnEvent(event);
432
+ break;
433
+ case 'session':
434
+ this.handleSessionTurnEvent(event);
435
+ break;
436
+ case 'media':
437
+ this.collectMediaTurnEvent(event, accum);
438
+ break;
439
+ case 'tool':
440
+ applyLiveToolTurnEvent(event.tool, accum);
441
+ break;
442
+ case 'done':
443
+ this.handleDoneTurnEvent(event);
444
+ break;
445
+ }
446
+ }
447
+ handleEvidenceTurnEvent(event) {
448
+ const transcript = liveTranscriptFromEvidence(event);
449
+ if (transcript) {
450
+ this.options.onTranscript?.(transcript.text, transcript.isUser, { interim: transcript.interim,
451
+ });
452
+ }
453
+ }
454
+ handleSessionTurnEvent(event) {
455
+ if (this.notifySessionLifecycle(event.session))
456
+ return;
457
+ this.serverWorking = event.session.kind === 'working';
458
+ const nextStatus = resolveWorkingStatusTransition(this.status, this.serverWorking);
459
+ if (nextStatus)
460
+ this.setStatus(nextStatus);
461
+ }
462
+ /** Hand the session's closing and end to the host; `true` when it was one of them. */
463
+ notifySessionLifecycle(session) {
464
+ if (session.kind === 'closing_soon') {
465
+ this.options.onSessionClosing?.(session.timeLeftMs);
466
+ return true;
467
+ }
468
+ if (session.kind !== 'ended')
469
+ return false;
470
+ this.options.onSessionEnded?.(session);
471
+ return true;
472
+ }
473
+ /** Status to settle into once model speech stops. */
474
+ restingStatus() {
475
+ return this.serverWorking ? 'working' : 'listening';
476
+ }
477
+ collectMediaTurnEvent(event, accum) {
478
+ if (!event.media.data)
479
+ return;
480
+ this.setStatus('speaking');
481
+ accum.mediaChunks.push({
482
+ data: event.media.data,
483
+ mimeType: event.media.mimeType,
484
+ });
485
+ }
486
+ handleDoneTurnEvent(event) {
487
+ if (event.interrupted) {
488
+ this.cancelPlayback();
489
+ }
490
+ if (event.stop.kind !== 'generation_complete') {
491
+ this.serverWorking = false;
492
+ this.setStatus('listening');
493
+ }
494
+ }
495
+ scheduleMediaChunks(mediaChunks) {
496
+ if (mediaChunks.length === 0)
497
+ return;
498
+ const epoch = this.audioEpoch;
499
+ for (const chunk of mediaChunks) {
500
+ this.audioChain = this.audioChain
501
+ .then(async () => {
502
+ if (epoch !== this.audioEpoch)
503
+ return;
504
+ await this.enqueueAudioChunk(chunk.data, chunk.mimeType);
505
+ })
506
+ .catch((err) => {
507
+ this.options.onError?.(asError(err));
508
+ });
509
+ }
510
+ }
511
+ /** The relay runs `LiveSession.executeTool`; see `ExecuteToolOnRelay`. */
512
+ executeToolOnRelay(args) {
513
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
514
+ throw new TheoremError('request', 'live session is not connected'); // lexicon-exempt: internal diagnostic
515
+ }
516
+ const resultPromise = new Promise((resolve, reject) => {
517
+ this.pendingExecuteResults.set(args.callId, { resolve, reject });
518
+ });
519
+ this.ws.send(clientMessage({ type: 'executeTool', ...args }));
520
+ return resultPromise;
521
+ }
522
+ async enqueueAudioChunk(base64Data, mimeType) {
523
+ if (!this.audioContext)
524
+ return;
525
+ try {
526
+ this.ensurePlaybackBus();
527
+ if (!this.playbackBus)
528
+ return;
529
+ const bytes = base64ToBytes(base64Data);
530
+ let audioBuffer;
531
+ if (mimeType?.includes('wav')) {
532
+ const wavBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
533
+ audioBuffer = await this.audioContext.decodeAudioData(wavBuffer);
534
+ }
535
+ else {
536
+ // Raw PCM 24kHz 1-channel 16-bit LE
537
+ const float32 = pcm16BytesToFloat32(bytes);
538
+ audioBuffer = this.audioContext.createBuffer(1, float32.length, 24000);
539
+ audioBuffer.getChannelData(0).set(float32);
540
+ }
541
+ const source = this.audioContext.createBufferSource();
542
+ source.buffer = audioBuffer;
543
+ source.connect(this.playbackBus);
544
+ const now = this.audioContext.currentTime;
545
+ const startTime = Math.max(now, this.nextPlaybackTime);
546
+ source.start(startTime);
547
+ this.nextPlaybackTime = startTime + audioBuffer.duration;
548
+ this.playbackNodes.push(source);
549
+ this.startPlaybackMeter();
550
+ source.onended = () => {
551
+ const idx = this.playbackNodes.indexOf(source);
552
+ if (idx !== -1)
553
+ this.playbackNodes.splice(idx, 1);
554
+ if (this.playbackNodes.length === 0) {
555
+ this.stopPlaybackMeter();
556
+ if (this.status === 'speaking') {
557
+ this.setStatus(this.restingStatus());
558
+ }
559
+ }
560
+ };
561
+ }
562
+ catch (err) {
563
+ this.options.onError?.(asError(err));
564
+ }
565
+ }
566
+ cancelPlayback() {
567
+ this.audioEpoch += 1;
568
+ this.audioChain = Promise.resolve();
569
+ for (const node of this.playbackNodes) {
570
+ try {
571
+ node.stop();
572
+ node.disconnect();
573
+ }
574
+ catch {
575
+ /* ignore */
576
+ }
577
+ }
578
+ this.playbackNodes = [];
579
+ this.stopPlaybackMeter();
580
+ if (this.audioContext) {
581
+ this.nextPlaybackTime = this.audioContext.currentTime;
582
+ }
583
+ }
584
+ // fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
585
+ toggleMute() {
586
+ this.isMuted = !this.isMuted;
587
+ return this.isMuted;
588
+ }
589
+ // fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
590
+ sendText(text) {
591
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
592
+ this.ws.send(clientMessage({ type: 'text', text }));
593
+ }
594
+ }
595
+ // fallow-ignore-next-line unused-class-member -- called by hosts that tell the model where the visitor is
596
+ sendContext(text) {
597
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
598
+ this.ws.send(clientMessage({ type: 'context', text }));
599
+ }
600
+ }
601
+ // fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
602
+ sendVideo(data, mimeType = 'image/jpeg') {
603
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
604
+ this.ws.send(clientMessage({ type: 'video', data, mimeType }));
605
+ }
606
+ }
607
+ // fallow-ignore-next-line unused-class-member -- called from useLiveSessionClient teardown
608
+ disconnect() {
609
+ this.teardownConnection();
610
+ this.setStatus('disconnected');
611
+ }
612
+ cleanupAudio() {
613
+ this.cancelPlayback();
614
+ this.stopPlaybackMeter();
615
+ safeDisconnect(this.playbackBus);
616
+ this.playbackBus = null;
617
+ this.playbackAnalyser = null;
618
+ this.playbackMeterBuffer = null;
619
+ if (this.micWorklet) {
620
+ this.micWorklet.port.onmessage = null;
621
+ safeDisconnect(this.micWorklet);
622
+ this.micWorklet = null;
623
+ }
624
+ safeDisconnect(this.micSource);
625
+ this.micSource = null;
626
+ stopMediaTracks(this.micStream);
627
+ this.micStream = null;
628
+ if (this.audioContext && this.audioContext.state !== 'closed') {
629
+ try {
630
+ void this.audioContext.close();
631
+ }
632
+ catch {
633
+ /* ignore */
634
+ }
635
+ this.audioContext = null;
636
+ }
637
+ }
638
+ }
@@ -0,0 +1,2 @@
1
+ /** Whether a DOM/media error indicates the user denied microphone access. */
2
+ export declare function isPermissionDeniedError(err: unknown): boolean;
@@ -0,0 +1,9 @@
1
+ /** Whether a DOM/media error indicates the user denied microphone access. */
2
+ export function isPermissionDeniedError(err) {
3
+ if (!(err instanceof Error))
4
+ return false;
5
+ const name = 'name' in err && typeof err.name === 'string' ? err.name : '';
6
+ return (name === 'NotAllowedError' ||
7
+ name === 'PermissionDeniedError' ||
8
+ /permission|not allowed|denied/i.test(err.message));
9
+ }