@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/constants/defaults.d.ts +6 -0
- package/dist/constants/defaults.js +69 -0
- package/dist/core/chat-manager.d.ts +17 -0
- package/dist/core/chat-manager.js +39 -0
- package/dist/core/config-manager.d.ts +14 -0
- package/dist/core/config-manager.js +75 -0
- package/dist/core/connection-manager.d.ts +68 -0
- package/dist/core/connection-manager.js +273 -0
- package/dist/core/connection-retry-handler.d.ts +11 -0
- package/dist/core/connection-retry-handler.js +33 -0
- package/dist/core/consent-manager.d.ts +15 -0
- package/dist/core/consent-manager.js +40 -0
- package/dist/core/conversation-persistence.d.ts +23 -0
- package/dist/core/conversation-persistence.js +109 -0
- package/dist/core/event-emitter.d.ts +7 -0
- package/dist/core/event-emitter.js +27 -0
- package/dist/core/greeting-gate.d.ts +16 -0
- package/dist/core/greeting-gate.js +53 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +7 -0
- package/dist/core/link-target.d.ts +15 -0
- package/dist/core/link-target.js +64 -0
- package/dist/core/local-storage-persistence.d.ts +11 -0
- package/dist/core/local-storage-persistence.js +111 -0
- package/dist/core/navigation-manager.d.ts +24 -0
- package/dist/core/navigation-manager.js +132 -0
- package/dist/core/popover-manager.d.ts +14 -0
- package/dist/core/popover-manager.js +36 -0
- package/dist/core/session-beacon.d.ts +11 -0
- package/dist/core/session-beacon.js +60 -0
- package/dist/core/text-connection-manager.d.ts +58 -0
- package/dist/core/text-connection-manager.js +200 -0
- package/dist/core/text-mode-manager.d.ts +50 -0
- package/dist/core/text-mode-manager.js +154 -0
- package/dist/core/text-types.d.ts +20 -0
- package/dist/core/text-types.js +1 -0
- package/dist/core/text-widget-controller.d.ts +47 -0
- package/dist/core/text-widget-controller.js +278 -0
- package/dist/core/theme-manager.d.ts +10 -0
- package/dist/core/theme-manager.js +25 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +1 -0
- package/dist/core/voice-state-manager.d.ts +85 -0
- package/dist/core/voice-state-manager.js +317 -0
- package/dist/core/widget-controller.d.ts +58 -0
- package/dist/core/widget-controller.js +533 -0
- package/dist/core/widget-core.d.ts +51 -0
- package/dist/core/widget-core.js +160 -0
- package/dist/google.d.ts +1 -0
- package/dist/google.js +12 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +40 -0
- package/dist/openai.d.ts +1 -0
- package/dist/openai.js +7 -0
- package/dist/services/api.d.ts +114 -0
- package/dist/services/api.js +336 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/page-interaction/constants.d.ts +1 -0
- package/dist/services/page-interaction/constants.js +1 -0
- package/dist/services/page-interaction/content-extractor.d.ts +2 -0
- package/dist/services/page-interaction/content-extractor.js +104 -0
- package/dist/services/page-interaction/deep-query.d.ts +8 -0
- package/dist/services/page-interaction/deep-query.js +44 -0
- package/dist/services/page-interaction/dom-executor.d.ts +7 -0
- package/dist/services/page-interaction/dom-executor.js +172 -0
- package/dist/services/page-interaction/dom-extractor.d.ts +8 -0
- package/dist/services/page-interaction/dom-extractor.js +252 -0
- package/dist/services/page-interaction/extension-bridge.d.ts +10 -0
- package/dist/services/page-interaction/extension-bridge.js +86 -0
- package/dist/services/page-interaction/iframe-navigation-manager.d.ts +28 -0
- package/dist/services/page-interaction/iframe-navigation-manager.js +154 -0
- package/dist/services/page-interaction/index.d.ts +5 -0
- package/dist/services/page-interaction/index.js +4 -0
- package/dist/services/page-interaction/page-interaction-handler.d.ts +21 -0
- package/dist/services/page-interaction/page-interaction-handler.js +129 -0
- package/dist/services/page-interaction/selector-generator.d.ts +1 -0
- package/dist/services/page-interaction/selector-generator.js +48 -0
- package/dist/services/page-interaction/types.d.ts +75 -0
- package/dist/services/page-interaction/types.js +1 -0
- package/dist/services/realtime/google/google-audio.d.ts +44 -0
- package/dist/services/realtime/google/google-audio.js +410 -0
- package/dist/services/realtime/google/google-provider.d.ts +47 -0
- package/dist/services/realtime/google/google-provider.js +465 -0
- package/dist/services/realtime/google/google-tool-mapper.d.ts +3 -0
- package/dist/services/realtime/google/google-tool-mapper.js +70 -0
- package/dist/services/realtime/google/index.d.ts +1 -0
- package/dist/services/realtime/google/index.js +1 -0
- package/dist/services/realtime/openai/index.d.ts +1 -0
- package/dist/services/realtime/openai/index.js +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.d.ts +1 -0
- package/dist/services/realtime/openai/openai-audio-monitor.js +55 -0
- package/dist/services/realtime/openai/openai-provider.d.ts +50 -0
- package/dist/services/realtime/openai/openai-provider.js +579 -0
- package/dist/services/realtime/openai/openai-tool-mapper.d.ts +15 -0
- package/dist/services/realtime/openai/openai-tool-mapper.js +14 -0
- package/dist/services/realtime/provider-factory.d.ts +4 -0
- package/dist/services/realtime/provider-factory.js +12 -0
- package/dist/services/realtime/types.d.ts +78 -0
- package/dist/services/realtime/types.js +1 -0
- package/dist/services/realtime/vertexai/index.d.ts +1 -0
- package/dist/services/realtime/vertexai/index.js +1 -0
- package/dist/services/realtime/vertexai/vertexai-provider.d.ts +53 -0
- package/dist/services/realtime/vertexai/vertexai-provider.js +561 -0
- package/dist/services/realtime/voice-session-tracker.d.ts +10 -0
- package/dist/services/realtime/voice-session-tracker.js +62 -0
- package/dist/services/signalr.d.ts +37 -0
- package/dist/services/signalr.js +145 -0
- package/dist/services/thinking-audio-pulse.d.ts +2 -0
- package/dist/services/thinking-audio-pulse.js +72 -0
- package/dist/services/thinking-audio.d.ts +2 -0
- package/dist/services/thinking-audio.js +105 -0
- package/dist/services/voice-health-watchdog.d.ts +42 -0
- package/dist/services/voice-health-watchdog.js +90 -0
- package/dist/services/webrtc-event-handler.d.ts +4 -0
- package/dist/services/webrtc-event-handler.js +42 -0
- package/dist/services/webrtc-greeting.d.ts +3 -0
- package/dist/services/webrtc-greeting.js +62 -0
- package/dist/services/webrtc-microphone.d.ts +15 -0
- package/dist/services/webrtc-microphone.js +64 -0
- package/dist/services/webrtc.d.ts +44 -0
- package/dist/services/webrtc.js +196 -0
- package/dist/types/index.d.ts +151 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/audio-context.d.ts +11 -0
- package/dist/utils/audio-context.js +33 -0
- package/dist/utils/connect-chime.d.ts +11 -0
- package/dist/utils/connect-chime.js +230 -0
- package/dist/utils/consent.d.ts +4 -0
- package/dist/utils/consent.js +30 -0
- package/dist/utils/language-detection.d.ts +1 -0
- package/dist/utils/language-detection.js +17 -0
- package/dist/utils/logger.d.ts +13 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/mic-level-analyser.d.ts +13 -0
- package/dist/utils/mic-level-analyser.js +50 -0
- package/dist/utils/proactive-events.d.ts +5 -0
- package/dist/utils/proactive-events.js +1 -0
- package/dist/utils/styles.d.ts +4 -0
- package/dist/utils/styles.js +35 -0
- package/dist/utils/theme-detection.d.ts +3 -0
- package/dist/utils/theme-detection.js +121 -0
- package/dist/utils/tool-status.d.ts +10 -0
- package/dist/utils/tool-status.js +61 -0
- package/dist/utils/ui-sounds.d.ts +3 -0
- package/dist/utils/ui-sounds.js +57 -0
- 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
|
+
}
|