@nextpa/chat-core 0.1.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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/constants/defaults.d.ts +6 -0
  4. package/dist/constants/defaults.js +69 -0
  5. package/dist/core/chat-manager.d.ts +17 -0
  6. package/dist/core/chat-manager.js +39 -0
  7. package/dist/core/config-manager.d.ts +14 -0
  8. package/dist/core/config-manager.js +75 -0
  9. package/dist/core/connection-manager.d.ts +68 -0
  10. package/dist/core/connection-manager.js +273 -0
  11. package/dist/core/connection-retry-handler.d.ts +11 -0
  12. package/dist/core/connection-retry-handler.js +33 -0
  13. package/dist/core/consent-manager.d.ts +15 -0
  14. package/dist/core/consent-manager.js +40 -0
  15. package/dist/core/conversation-persistence.d.ts +23 -0
  16. package/dist/core/conversation-persistence.js +109 -0
  17. package/dist/core/event-emitter.d.ts +7 -0
  18. package/dist/core/event-emitter.js +27 -0
  19. package/dist/core/greeting-gate.d.ts +16 -0
  20. package/dist/core/greeting-gate.js +53 -0
  21. package/dist/core/index.d.ts +9 -0
  22. package/dist/core/index.js +7 -0
  23. package/dist/core/link-target.d.ts +15 -0
  24. package/dist/core/link-target.js +64 -0
  25. package/dist/core/local-storage-persistence.d.ts +11 -0
  26. package/dist/core/local-storage-persistence.js +111 -0
  27. package/dist/core/navigation-manager.d.ts +24 -0
  28. package/dist/core/navigation-manager.js +132 -0
  29. package/dist/core/popover-manager.d.ts +14 -0
  30. package/dist/core/popover-manager.js +36 -0
  31. package/dist/core/session-beacon.d.ts +11 -0
  32. package/dist/core/session-beacon.js +60 -0
  33. package/dist/core/text-connection-manager.d.ts +58 -0
  34. package/dist/core/text-connection-manager.js +200 -0
  35. package/dist/core/text-mode-manager.d.ts +50 -0
  36. package/dist/core/text-mode-manager.js +154 -0
  37. package/dist/core/text-types.d.ts +20 -0
  38. package/dist/core/text-types.js +1 -0
  39. package/dist/core/text-widget-controller.d.ts +47 -0
  40. package/dist/core/text-widget-controller.js +278 -0
  41. package/dist/core/theme-manager.d.ts +10 -0
  42. package/dist/core/theme-manager.js +25 -0
  43. package/dist/core/types.d.ts +50 -0
  44. package/dist/core/types.js +1 -0
  45. package/dist/core/voice-state-manager.d.ts +85 -0
  46. package/dist/core/voice-state-manager.js +317 -0
  47. package/dist/core/widget-controller.d.ts +58 -0
  48. package/dist/core/widget-controller.js +533 -0
  49. package/dist/core/widget-core.d.ts +51 -0
  50. package/dist/core/widget-core.js +160 -0
  51. package/dist/google.d.ts +1 -0
  52. package/dist/google.js +12 -0
  53. package/dist/index.d.ts +37 -0
  54. package/dist/index.js +40 -0
  55. package/dist/openai.d.ts +1 -0
  56. package/dist/openai.js +7 -0
  57. package/dist/services/api.d.ts +114 -0
  58. package/dist/services/api.js +336 -0
  59. package/dist/services/index.d.ts +3 -0
  60. package/dist/services/index.js +3 -0
  61. package/dist/services/page-interaction/constants.d.ts +1 -0
  62. package/dist/services/page-interaction/constants.js +1 -0
  63. package/dist/services/page-interaction/content-extractor.d.ts +2 -0
  64. package/dist/services/page-interaction/content-extractor.js +104 -0
  65. package/dist/services/page-interaction/deep-query.d.ts +8 -0
  66. package/dist/services/page-interaction/deep-query.js +44 -0
  67. package/dist/services/page-interaction/dom-executor.d.ts +7 -0
  68. package/dist/services/page-interaction/dom-executor.js +172 -0
  69. package/dist/services/page-interaction/dom-extractor.d.ts +8 -0
  70. package/dist/services/page-interaction/dom-extractor.js +252 -0
  71. package/dist/services/page-interaction/extension-bridge.d.ts +10 -0
  72. package/dist/services/page-interaction/extension-bridge.js +86 -0
  73. package/dist/services/page-interaction/iframe-navigation-manager.d.ts +28 -0
  74. package/dist/services/page-interaction/iframe-navigation-manager.js +154 -0
  75. package/dist/services/page-interaction/index.d.ts +5 -0
  76. package/dist/services/page-interaction/index.js +4 -0
  77. package/dist/services/page-interaction/page-interaction-handler.d.ts +21 -0
  78. package/dist/services/page-interaction/page-interaction-handler.js +129 -0
  79. package/dist/services/page-interaction/selector-generator.d.ts +1 -0
  80. package/dist/services/page-interaction/selector-generator.js +48 -0
  81. package/dist/services/page-interaction/types.d.ts +75 -0
  82. package/dist/services/page-interaction/types.js +1 -0
  83. package/dist/services/realtime/google/google-audio.d.ts +44 -0
  84. package/dist/services/realtime/google/google-audio.js +410 -0
  85. package/dist/services/realtime/google/google-provider.d.ts +47 -0
  86. package/dist/services/realtime/google/google-provider.js +465 -0
  87. package/dist/services/realtime/google/google-tool-mapper.d.ts +3 -0
  88. package/dist/services/realtime/google/google-tool-mapper.js +70 -0
  89. package/dist/services/realtime/google/index.d.ts +1 -0
  90. package/dist/services/realtime/google/index.js +1 -0
  91. package/dist/services/realtime/openai/index.d.ts +1 -0
  92. package/dist/services/realtime/openai/index.js +1 -0
  93. package/dist/services/realtime/openai/openai-audio-monitor.d.ts +1 -0
  94. package/dist/services/realtime/openai/openai-audio-monitor.js +55 -0
  95. package/dist/services/realtime/openai/openai-provider.d.ts +50 -0
  96. package/dist/services/realtime/openai/openai-provider.js +579 -0
  97. package/dist/services/realtime/openai/openai-tool-mapper.d.ts +15 -0
  98. package/dist/services/realtime/openai/openai-tool-mapper.js +14 -0
  99. package/dist/services/realtime/provider-factory.d.ts +4 -0
  100. package/dist/services/realtime/provider-factory.js +12 -0
  101. package/dist/services/realtime/types.d.ts +78 -0
  102. package/dist/services/realtime/types.js +1 -0
  103. package/dist/services/realtime/vertexai/index.d.ts +1 -0
  104. package/dist/services/realtime/vertexai/index.js +1 -0
  105. package/dist/services/realtime/vertexai/vertexai-provider.d.ts +53 -0
  106. package/dist/services/realtime/vertexai/vertexai-provider.js +561 -0
  107. package/dist/services/realtime/voice-session-tracker.d.ts +10 -0
  108. package/dist/services/realtime/voice-session-tracker.js +62 -0
  109. package/dist/services/signalr.d.ts +37 -0
  110. package/dist/services/signalr.js +145 -0
  111. package/dist/services/thinking-audio-pulse.d.ts +2 -0
  112. package/dist/services/thinking-audio-pulse.js +72 -0
  113. package/dist/services/thinking-audio.d.ts +2 -0
  114. package/dist/services/thinking-audio.js +105 -0
  115. package/dist/services/voice-health-watchdog.d.ts +42 -0
  116. package/dist/services/voice-health-watchdog.js +90 -0
  117. package/dist/services/webrtc-event-handler.d.ts +4 -0
  118. package/dist/services/webrtc-event-handler.js +42 -0
  119. package/dist/services/webrtc-greeting.d.ts +3 -0
  120. package/dist/services/webrtc-greeting.js +62 -0
  121. package/dist/services/webrtc-microphone.d.ts +15 -0
  122. package/dist/services/webrtc-microphone.js +64 -0
  123. package/dist/services/webrtc.d.ts +44 -0
  124. package/dist/services/webrtc.js +196 -0
  125. package/dist/types/index.d.ts +151 -0
  126. package/dist/types/index.js +1 -0
  127. package/dist/utils/audio-context.d.ts +11 -0
  128. package/dist/utils/audio-context.js +33 -0
  129. package/dist/utils/connect-chime.d.ts +11 -0
  130. package/dist/utils/connect-chime.js +230 -0
  131. package/dist/utils/consent.d.ts +4 -0
  132. package/dist/utils/consent.js +30 -0
  133. package/dist/utils/language-detection.d.ts +1 -0
  134. package/dist/utils/language-detection.js +17 -0
  135. package/dist/utils/logger.d.ts +13 -0
  136. package/dist/utils/logger.js +40 -0
  137. package/dist/utils/mic-level-analyser.d.ts +13 -0
  138. package/dist/utils/mic-level-analyser.js +50 -0
  139. package/dist/utils/proactive-events.d.ts +5 -0
  140. package/dist/utils/proactive-events.js +1 -0
  141. package/dist/utils/styles.d.ts +4 -0
  142. package/dist/utils/styles.js +35 -0
  143. package/dist/utils/theme-detection.d.ts +3 -0
  144. package/dist/utils/theme-detection.js +121 -0
  145. package/dist/utils/tool-status.d.ts +10 -0
  146. package/dist/utils/tool-status.js +61 -0
  147. package/dist/utils/ui-sounds.d.ts +3 -0
  148. package/dist/utils/ui-sounds.js +57 -0
  149. package/package.json +56 -0
@@ -0,0 +1,410 @@
1
+ import { logger } from '../../../utils/logger';
2
+ const CAPTURE_SAMPLE_RATE = 16000;
3
+ const PLAYBACK_SAMPLE_RATE = 24000;
4
+ const PLAYBACK_BUFFER_SIZE = 7680;
5
+ const INITIAL_BUFFER_TIME = 0.1;
6
+ const SCHEDULE_AHEAD_TIME = 0.2;
7
+ const FADE_OUT_DURATION = 0.1;
8
+ const FADE_OUT_RESET_DELAY_MS = 200;
9
+ const SPEECH_RMS_THRESHOLD = 1500;
10
+ const SPEECH_START_DEBOUNCE_MS = 150;
11
+ const SPEECH_END_DEBOUNCE_MS = 1500;
12
+ export function resolveWorkletUrl() {
13
+ const loaderBase = globalThis.__nextpaWidgetBaseUrl;
14
+ if (loaderBase) {
15
+ return loaderBase + 'audio-capture-worklet.js';
16
+ }
17
+ // Resolve against the page origin instead of import.meta.url. In the
18
+ // embedded widget the chunk loads from a CDN-style absolute URL and the
19
+ // module-URL fallback works; in the chat-app the Angular esbuild dev
20
+ // server can hand back module URLs the URL constructor refuses to use
21
+ // as a base, which surfaces as `AbortError: Unable to load a worklet's
22
+ // module.` at AudioWorklet.addModule(). The worklet is shipped from the
23
+ // same origin as the app shell, so window.location.origin is correct
24
+ // for both dev and prod builds.
25
+ if (globalThis.window?.location?.origin) {
26
+ return `${globalThis.window.location.origin}/audio-capture-worklet.js`;
27
+ }
28
+ return new URL('/audio-capture-worklet.js', import.meta.url).href;
29
+ }
30
+ export class GoogleAudioHandler {
31
+ captureContext = null;
32
+ mediaStream = null;
33
+ workletNode = null;
34
+ playbackContext = null;
35
+ gainNode = null;
36
+ aecStreamDestination = null;
37
+ loopbackSource = null;
38
+ loopbackSink = null;
39
+ playbackElement = null;
40
+ scheduledTime = 0;
41
+ audioQueue = [];
42
+ isMuted = false;
43
+ isPlaying = false;
44
+ capturePaused = false;
45
+ speechDetected = false;
46
+ silenceTimerId = null;
47
+ speechStartTimerId = null;
48
+ consecutiveSpeechFrames = 0;
49
+ onAudioData = null;
50
+ onSpeechStarted = null;
51
+ onSpeechEnded = null;
52
+ onAudioLevel = null;
53
+ get isPlaybackActive() {
54
+ if (this.audioQueue.length > 0 || this.isPlaying)
55
+ return true;
56
+ if (!this.playbackContext)
57
+ return false;
58
+ return this.scheduledTime > this.playbackContext.currentTime;
59
+ }
60
+ async startCapture() {
61
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
62
+ audio: {
63
+ sampleRate: CAPTURE_SAMPLE_RATE,
64
+ channelCount: 1,
65
+ echoCancellation: true,
66
+ noiseSuppression: true,
67
+ autoGainControl: true,
68
+ },
69
+ });
70
+ this.captureContext = new AudioContext({ sampleRate: CAPTURE_SAMPLE_RATE });
71
+ // Browsers (Chrome especially) hand back AudioContexts in the
72
+ // "suspended" state until an explicit resume from a user-gesture
73
+ // promise chain. Without this the worklet receives no audio frames
74
+ // and the remote side hears silence.
75
+ const stateBeforeResume = this.captureContext.state;
76
+ if (this.captureContext.state === 'suspended') {
77
+ await this.captureContext.resume();
78
+ }
79
+ logger.debug('[GoogleAudio] Capture context state:', stateBeforeResume, '→', this.captureContext.state, 'sampleRate:', this.captureContext.sampleRate);
80
+ const source = this.captureContext.createMediaStreamSource(this.mediaStream);
81
+ await this.captureContext.audioWorklet.addModule(resolveWorkletUrl());
82
+ this.workletNode = new AudioWorkletNode(this.captureContext, 'audio-capture-processor');
83
+ let frameCount = 0;
84
+ this.workletNode.port.onmessage = (event) => {
85
+ const int16Buffer = new Int16Array(event.data.buffer);
86
+ this.detectSpeech(int16Buffer);
87
+ // First frame and every 100th frame: log a heartbeat so we can
88
+ // see in the console whether mic data is actually flowing and why
89
+ // it would be dropped (muted / paused / no callback wired up).
90
+ frameCount++;
91
+ if (frameCount === 1 || frameCount % 100 === 0) {
92
+ logger.debug('[GoogleAudio] Mic frame', frameCount, 'len:', int16Buffer.length, 'isMuted:', this.isMuted, 'capturePaused:', this.capturePaused, 'onAudioData:', this.onAudioData ? 'wired' : 'NULL');
93
+ }
94
+ if (this.isMuted || this.capturePaused || !this.onAudioData) {
95
+ return;
96
+ }
97
+ this.onAudioData(arrayBufferToBase64(int16Buffer.buffer));
98
+ };
99
+ source.connect(this.workletNode);
100
+ this.workletNode.connect(this.captureContext.destination);
101
+ await this.setupPlaybackChain();
102
+ }
103
+ playAudio(base64Pcm) {
104
+ if (!this.playbackContext || !this.gainNode)
105
+ return;
106
+ if (this.playbackContext.state === 'suspended') {
107
+ void this.playbackContext.resume();
108
+ }
109
+ void this.playbackElement?.play().catch(() => undefined);
110
+ const pcmBytes = base64ToUint8Array(base64Pcm);
111
+ const float32 = pcm16ToFloat32(pcmBytes);
112
+ this.enqueueAudio(float32);
113
+ }
114
+ async setupPlaybackChain() {
115
+ if (this.playbackContext)
116
+ return;
117
+ this.playbackContext = new AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE });
118
+ // Same suspended-context trap as the capture side: created after the
119
+ // user-gesture chain yielded, the context queues the greeting audio
120
+ // without ever playing it — the assistant appears mute until a reset.
121
+ const playbackStateBefore = this.playbackContext.state;
122
+ if (this.playbackContext.state === 'suspended') {
123
+ await this.playbackContext.resume();
124
+ }
125
+ logger.debug('[GoogleAudio] Playback context state:', playbackStateBefore, '→', this.playbackContext.state);
126
+ this.gainNode = this.playbackContext.createGain();
127
+ this.scheduledTime = 0;
128
+ // Route playback through a local WebRTC loopback into a hidden <audio>
129
+ // element instead of straight to AudioContext.destination. The mic is
130
+ // captured with echoCancellation:true, but the browser's AEC only
131
+ // subtracts audio it sees on its WebRTC render path — Web-Audio output
132
+ // to destination is invisible to it. On speakerphone the mic then picks
133
+ // up the assistant's own voice and Gemini's server VAD interrupts the
134
+ // model mid-sentence. Feeding playback through a PeerConnection makes it
135
+ // a far-end reference the AEC can cancel. Falls back to direct output
136
+ // when WebRTC is unavailable.
137
+ await this.tryRouteThroughAecLoopback();
138
+ this.connectGainOutput();
139
+ }
140
+ connectGainOutput() {
141
+ if (!this.playbackContext || !this.gainNode)
142
+ return;
143
+ if (this.aecStreamDestination) {
144
+ this.gainNode.connect(this.aecStreamDestination);
145
+ }
146
+ else {
147
+ this.gainNode.connect(this.playbackContext.destination);
148
+ }
149
+ }
150
+ async tryRouteThroughAecLoopback() {
151
+ if (typeof RTCPeerConnection === 'undefined' || typeof document === 'undefined'
152
+ || !this.playbackContext) {
153
+ return;
154
+ }
155
+ try {
156
+ const streamDestination = this.playbackContext.createMediaStreamDestination();
157
+ const source = new RTCPeerConnection();
158
+ const sink = new RTCPeerConnection();
159
+ source.addEventListener('icecandidate', (e) => {
160
+ if (e.candidate)
161
+ void sink.addIceCandidate(e.candidate);
162
+ });
163
+ sink.addEventListener('icecandidate', (e) => {
164
+ if (e.candidate)
165
+ void source.addIceCandidate(e.candidate);
166
+ });
167
+ const loopbackStream = new MediaStream();
168
+ sink.addEventListener('track', (e) => loopbackStream.addTrack(e.track));
169
+ for (const track of streamDestination.stream.getTracks()) {
170
+ source.addTrack(track, streamDestination.stream);
171
+ }
172
+ const offer = await source.createOffer();
173
+ await source.setLocalDescription(offer);
174
+ await sink.setRemoteDescription(offer);
175
+ const answer = await sink.createAnswer();
176
+ await sink.setLocalDescription(answer);
177
+ await source.setRemoteDescription(answer);
178
+ const element = document.createElement('audio');
179
+ element.autoplay = true;
180
+ element.setAttribute('playsinline', '');
181
+ element.style.display = 'none';
182
+ element.srcObject = loopbackStream;
183
+ document.body.appendChild(element);
184
+ await element.play().catch(() => undefined);
185
+ this.aecStreamDestination = streamDestination;
186
+ this.loopbackSource = source;
187
+ this.loopbackSink = sink;
188
+ this.playbackElement = element;
189
+ logger.debug('[GoogleAudio] Playback routed through AEC loopback');
190
+ }
191
+ catch (err) {
192
+ logger.debug('[GoogleAudio] AEC loopback unavailable, using direct output:', err);
193
+ this.teardownAecLoopback();
194
+ }
195
+ }
196
+ teardownAecLoopback() {
197
+ this.loopbackSource?.close();
198
+ this.loopbackSink?.close();
199
+ this.loopbackSource = null;
200
+ this.loopbackSink = null;
201
+ if (this.playbackElement) {
202
+ this.playbackElement.srcObject = null;
203
+ this.playbackElement.remove();
204
+ this.playbackElement = null;
205
+ }
206
+ this.aecStreamDestination = null;
207
+ }
208
+ enqueueAudio(samples) {
209
+ for (let offset = 0; offset < samples.length; offset += PLAYBACK_BUFFER_SIZE) {
210
+ const end = Math.min(offset + PLAYBACK_BUFFER_SIZE, samples.length);
211
+ this.audioQueue.push(samples.subarray(offset, end));
212
+ }
213
+ if (!this.isPlaying) {
214
+ this.isPlaying = true;
215
+ this.schedulePlayback();
216
+ }
217
+ }
218
+ schedulePlayback() {
219
+ if (!this.playbackContext || !this.gainNode) {
220
+ return;
221
+ }
222
+ const currentTime = this.playbackContext.currentTime;
223
+ if (this.scheduledTime < currentTime) {
224
+ this.scheduledTime = currentTime + INITIAL_BUFFER_TIME;
225
+ }
226
+ while (this.audioQueue.length > 0 && this.scheduledTime < currentTime + SCHEDULE_AHEAD_TIME) {
227
+ const chunk = this.audioQueue.shift();
228
+ const buffer = this.playbackContext.createBuffer(1, chunk.length, PLAYBACK_SAMPLE_RATE);
229
+ buffer.getChannelData(0).set(chunk);
230
+ const source = this.playbackContext.createBufferSource();
231
+ source.buffer = buffer;
232
+ source.connect(this.gainNode);
233
+ source.start(this.scheduledTime);
234
+ this.scheduledTime += buffer.duration;
235
+ }
236
+ if (this.audioQueue.length > 0) {
237
+ const remainingTime = Math.max(0, (this.scheduledTime - currentTime - SCHEDULE_AHEAD_TIME) * 1000);
238
+ setTimeout(() => this.schedulePlayback(), Math.min(remainingTime, 100));
239
+ }
240
+ else {
241
+ this.isPlaying = false;
242
+ }
243
+ }
244
+ resume() {
245
+ if (this.captureContext?.state === 'suspended') {
246
+ void this.captureContext.resume().catch(() => undefined);
247
+ }
248
+ if (this.playbackContext?.state === 'suspended') {
249
+ void this.playbackContext.resume().catch(() => undefined);
250
+ }
251
+ void this.playbackElement?.play().catch(() => undefined);
252
+ }
253
+ pauseCapture() {
254
+ this.capturePaused = true;
255
+ }
256
+ resumeCapture() {
257
+ this.capturePaused = false;
258
+ }
259
+ setMuted(muted) {
260
+ this.isMuted = muted;
261
+ if (this.mediaStream) {
262
+ for (const track of this.mediaStream.getAudioTracks()) {
263
+ track.enabled = !muted;
264
+ }
265
+ }
266
+ }
267
+ stop() {
268
+ this.workletNode?.disconnect();
269
+ this.workletNode = null;
270
+ if (this.mediaStream) {
271
+ for (const track of this.mediaStream.getTracks()) {
272
+ track.stop();
273
+ }
274
+ }
275
+ this.captureContext?.close();
276
+ this.playbackContext?.close();
277
+ this.teardownAecLoopback();
278
+ this.mediaStream = null;
279
+ this.captureContext = null;
280
+ this.playbackContext = null;
281
+ this.gainNode = null;
282
+ this.scheduledTime = 0;
283
+ this.audioQueue = [];
284
+ this.isPlaying = false;
285
+ this.capturePaused = false;
286
+ this.speechDetected = false;
287
+ this.consecutiveSpeechFrames = 0;
288
+ if (this.silenceTimerId) {
289
+ clearTimeout(this.silenceTimerId);
290
+ this.silenceTimerId = null;
291
+ }
292
+ if (this.speechStartTimerId) {
293
+ clearTimeout(this.speechStartTimerId);
294
+ this.speechStartTimerId = null;
295
+ }
296
+ }
297
+ detectSpeech(samples) {
298
+ if (this.isMuted)
299
+ return;
300
+ if (!this.onSpeechStarted && !this.onSpeechEnded && !this.onAudioLevel)
301
+ return;
302
+ let sumSquares = 0;
303
+ for (const sample of samples) {
304
+ sumSquares += sample * sample;
305
+ }
306
+ const rms = Math.sqrt(sumSquares / samples.length);
307
+ // Int16 PCM range — normalise into 0..1 against the codec full scale so
308
+ // the dBFS mapping downstream in voice-state-manager is meaningful.
309
+ this.onAudioLevel?.(Math.min(1, rms / 32768));
310
+ if (rms > SPEECH_RMS_THRESHOLD) {
311
+ this.handleSpeechFrame();
312
+ }
313
+ else {
314
+ this.handleSilenceFrame();
315
+ }
316
+ }
317
+ handleSpeechFrame() {
318
+ if (this.silenceTimerId) {
319
+ clearTimeout(this.silenceTimerId);
320
+ this.silenceTimerId = null;
321
+ }
322
+ if (this.speechDetected)
323
+ return;
324
+ this.consecutiveSpeechFrames++;
325
+ this.speechStartTimerId ??= setTimeout(() => {
326
+ this.speechStartTimerId = null;
327
+ if (this.consecutiveSpeechFrames > 0 && !this.speechDetected) {
328
+ this.speechDetected = true;
329
+ this.onSpeechStarted?.();
330
+ }
331
+ }, SPEECH_START_DEBOUNCE_MS);
332
+ }
333
+ handleSilenceFrame() {
334
+ this.consecutiveSpeechFrames = 0;
335
+ if (this.speechStartTimerId) {
336
+ clearTimeout(this.speechStartTimerId);
337
+ this.speechStartTimerId = null;
338
+ }
339
+ if (this.speechDetected && !this.silenceTimerId) {
340
+ this.silenceTimerId = setTimeout(() => {
341
+ this.silenceTimerId = null;
342
+ this.speechDetected = false;
343
+ this.onSpeechEnded?.();
344
+ }, SPEECH_END_DEBOUNCE_MS);
345
+ }
346
+ }
347
+ async restartCapture() {
348
+ const wasMuted = this.isMuted;
349
+ const callback = this.onAudioData;
350
+ this.workletNode?.disconnect();
351
+ this.workletNode = null;
352
+ if (this.mediaStream) {
353
+ for (const track of this.mediaStream.getTracks()) {
354
+ track.stop();
355
+ }
356
+ }
357
+ this.captureContext?.close();
358
+ this.mediaStream = null;
359
+ this.captureContext = null;
360
+ this.onAudioData = callback;
361
+ await this.startCapture();
362
+ this.isMuted = wasMuted;
363
+ const activeStream = this.mediaStream;
364
+ if (activeStream) {
365
+ for (const track of activeStream.getAudioTracks()) {
366
+ track.enabled = !wasMuted;
367
+ }
368
+ }
369
+ }
370
+ clearPlaybackQueue() {
371
+ this.audioQueue = [];
372
+ this.isPlaying = false;
373
+ if (this.playbackContext && this.gainNode) {
374
+ const currentTime = this.playbackContext.currentTime;
375
+ this.gainNode.gain.linearRampToValueAtTime(0, currentTime + FADE_OUT_DURATION);
376
+ setTimeout(() => {
377
+ if (this.playbackContext && this.gainNode) {
378
+ this.gainNode.disconnect();
379
+ this.gainNode = this.playbackContext.createGain();
380
+ this.connectGainOutput();
381
+ this.scheduledTime = 0;
382
+ }
383
+ }, FADE_OUT_RESET_DELAY_MS);
384
+ }
385
+ }
386
+ }
387
+ function arrayBufferToBase64(buffer) {
388
+ const bytes = new Uint8Array(buffer);
389
+ let binary = '';
390
+ for (const byte of bytes) {
391
+ binary += String.fromCodePoint(byte);
392
+ }
393
+ return btoa(binary);
394
+ }
395
+ function base64ToUint8Array(base64) {
396
+ const binary = atob(base64);
397
+ const bytes = new Uint8Array(binary.length);
398
+ for (let i = 0; i < binary.length; i++) {
399
+ bytes[i] = binary.codePointAt(i) ?? 0;
400
+ }
401
+ return bytes;
402
+ }
403
+ function pcm16ToFloat32(pcmBytes) {
404
+ const dataView = new DataView(pcmBytes.buffer);
405
+ const float32 = new Float32Array(pcmBytes.length / 2);
406
+ for (let i = 0; i < float32.length; i++) {
407
+ float32[i] = dataView.getInt16(i * 2, true) / 32768;
408
+ }
409
+ return float32;
410
+ }
@@ -0,0 +1,47 @@
1
+ import type { RealtimeProviderClient, RealtimeProviderConnectConfig, ProviderEventMap } from '../types';
2
+ export declare class GoogleRealtimeProvider implements RealtimeProviderClient {
3
+ private session;
4
+ private readonly audioHandler;
5
+ private readonly eventHandlers;
6
+ private _status;
7
+ private readonly pendingFunctionCalls;
8
+ private awaitingToolResponse;
9
+ private isSetupComplete;
10
+ private modelResponding;
11
+ private userSpeaking;
12
+ private pendingCommands;
13
+ private outputTranscriptBuffer;
14
+ private inputTranscriptBuffer;
15
+ private lastCompletedToolCalls;
16
+ constructor();
17
+ get status(): 'disconnected' | 'connecting' | 'connected';
18
+ connect(config: RealtimeProviderConnectConfig): Promise<void>;
19
+ close(): void;
20
+ resumeAudio(): void;
21
+ mute(muted: boolean): void;
22
+ interrupt(): void;
23
+ requestResponse(): void;
24
+ sendToolResult(callId: string, name: string, output: string): void;
25
+ sendText(text: string): void;
26
+ waitForAudioPlaybackEnd(timeoutMs?: number): Promise<string>;
27
+ refreshAudioDevice(): Promise<void>;
28
+ on<K extends keyof ProviderEventMap>(event: K, handler: ProviderEventMap[K]): () => void;
29
+ off<K extends keyof ProviderEventMap>(event: K, handler: ProviderEventMap[K]): void;
30
+ private emit;
31
+ private resetSessionState;
32
+ private executeRequestResponse;
33
+ private executeSendToolResult;
34
+ private finalizeInputTranscript;
35
+ private handleServerMessage;
36
+ private extractAndEmitUsage;
37
+ private flushPendingCommands;
38
+ private handleVoiceActivity;
39
+ private handleServerContent;
40
+ private static readonly THINKING_RENDER_DELAY_MS;
41
+ private deferAssistantTranscript;
42
+ private ensureSpeechStopped;
43
+ private handleModelTurn;
44
+ private handleTurnComplete;
45
+ private handleInterrupted;
46
+ private handleToolCall;
47
+ }