@assistant-ui/react 0.15.32 → 0.15.33
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/dist/adapters/GeminiLiveVoiceAdapter.d.ts +26 -0
- package/dist/adapters/GeminiLiveVoiceAdapter.d.ts.map +1 -0
- package/dist/adapters/GeminiLiveVoiceAdapter.js +346 -0
- package/dist/adapters/GeminiLiveVoiceAdapter.js.map +1 -0
- package/dist/adapters/gemini-live/audio.d.ts +27 -0
- package/dist/adapters/gemini-live/audio.d.ts.map +1 -0
- package/dist/adapters/gemini-live/audio.js +291 -0
- package/dist/adapters/gemini-live/audio.js.map +1 -0
- package/dist/context/stores/ThreadViewport.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/internal.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +1 -1
- package/dist/mcp-apps/app-frame.js +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +1 -1
- package/dist/sandbox-host/SandboxHost.js +1 -1
- package/dist/unstable/useComposerInputHistory.js +1 -1
- package/package.json +6 -6
- package/src/adapters/GeminiLiveVoiceAdapter.test.ts +992 -0
- package/src/adapters/GeminiLiveVoiceAdapter.ts +454 -0
- package/src/adapters/gemini-live/audio.test.ts +880 -0
- package/src/adapters/gemini-live/audio.ts +333 -0
- package/src/index.test.ts +34 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bytesToBase64,
|
|
3
|
+
raceWithAbortSignal,
|
|
4
|
+
} from "@assistant-ui/core/internal";
|
|
5
|
+
|
|
6
|
+
export const encodePcm16 = (samples: Float32Array): string => {
|
|
7
|
+
const bytes = new Uint8Array(samples.length * 2);
|
|
8
|
+
const view = new DataView(bytes.buffer);
|
|
9
|
+
for (let i = 0; i < samples.length; i++) {
|
|
10
|
+
const s = Math.max(-1, Math.min(1, samples[i]!));
|
|
11
|
+
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
|
12
|
+
}
|
|
13
|
+
return bytesToBase64(bytes);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const decodePcm16 = (base64: string): Float32Array<ArrayBuffer> => {
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const samples = new Float32Array(new ArrayBuffer((binary.length >> 1) * 4));
|
|
19
|
+
const view = new DataView(new ArrayBuffer(samples.length * 2));
|
|
20
|
+
for (let i = 0; i < samples.length * 2; i++)
|
|
21
|
+
view.setUint8(i, binary.charCodeAt(i));
|
|
22
|
+
for (let i = 0; i < samples.length; i++)
|
|
23
|
+
samples[i] = view.getInt16(i * 2, true) / 0x8000;
|
|
24
|
+
return samples;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const readLevel = (analyser: AnalyserNode): number => {
|
|
28
|
+
const data = new Uint8Array(analyser.fftSize);
|
|
29
|
+
analyser.getByteTimeDomainData(data);
|
|
30
|
+
let sum = 0;
|
|
31
|
+
for (const v of data) {
|
|
32
|
+
const centered = (v - 128) / 128;
|
|
33
|
+
sum += centered * centered;
|
|
34
|
+
}
|
|
35
|
+
return Math.min(1, Math.sqrt(sum / data.length) * 2);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type MicrophoneCapture = {
|
|
39
|
+
setMuted: (muted: boolean) => void;
|
|
40
|
+
level: () => number;
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const createLowPass = (ratio: number) => {
|
|
45
|
+
const omega = (Math.PI * 0.9) / ratio;
|
|
46
|
+
const cosine = Math.cos(omega);
|
|
47
|
+
const sine = Math.sin(omega);
|
|
48
|
+
const sections = [0.5176380902, 0.7071067812, 1.9318516526].map((q) => {
|
|
49
|
+
const alpha = sine / (2 * q);
|
|
50
|
+
const a0 = 1 + alpha;
|
|
51
|
+
const b = (1 - cosine) / (2 * a0);
|
|
52
|
+
return { b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 };
|
|
53
|
+
});
|
|
54
|
+
return (samples: Float32Array) => {
|
|
55
|
+
for (let i = 0; i < samples.length; i++) {
|
|
56
|
+
let x = samples[i]!;
|
|
57
|
+
for (const s of sections) {
|
|
58
|
+
const y = s.b * x + s.z1;
|
|
59
|
+
s.z1 = 2 * s.b * x - s.a1 * y + s.z2;
|
|
60
|
+
s.z2 = s.b * x - s.a2 * y;
|
|
61
|
+
x = y;
|
|
62
|
+
}
|
|
63
|
+
samples[i] = x;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const WORKLET_SOURCE = `
|
|
69
|
+
class PcmCapture extends AudioWorkletProcessor {
|
|
70
|
+
constructor(options) {
|
|
71
|
+
super();
|
|
72
|
+
this.ratio = sampleRate / options.processorOptions.targetRate;
|
|
73
|
+
this.chunk = Math.round(options.processorOptions.targetRate / 50);
|
|
74
|
+
this.position = 0;
|
|
75
|
+
this.last = 0;
|
|
76
|
+
this.pending = [];
|
|
77
|
+
this.sections = [];
|
|
78
|
+
if (this.ratio > 1) {
|
|
79
|
+
const omega = (Math.PI * 0.9) / this.ratio;
|
|
80
|
+
const cosine = Math.cos(omega);
|
|
81
|
+
const sine = Math.sin(omega);
|
|
82
|
+
for (const q of [0.5176380902, 0.7071067812, 1.9318516526]) {
|
|
83
|
+
const alpha = sine / (2 * q);
|
|
84
|
+
const a0 = 1 + alpha;
|
|
85
|
+
const b = (1 - cosine) / (2 * a0);
|
|
86
|
+
this.sections.push({ b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
process(inputs) {
|
|
91
|
+
const channels = inputs[0];
|
|
92
|
+
if (!channels || channels.length === 0 || channels[0].length === 0) return true;
|
|
93
|
+
const input = channels[0].slice();
|
|
94
|
+
for (let c = 1; c < channels.length; c++)
|
|
95
|
+
for (let i = 0; i < input.length; i++) input[i] += channels[c][i];
|
|
96
|
+
if (channels.length > 1)
|
|
97
|
+
for (let i = 0; i < input.length; i++) input[i] /= channels.length;
|
|
98
|
+
for (let i = 0; i < input.length; i++) {
|
|
99
|
+
let x = input[i];
|
|
100
|
+
for (const s of this.sections) {
|
|
101
|
+
const y = s.b * x + s.z1;
|
|
102
|
+
s.z1 = 2 * s.b * x - s.a1 * y + s.z2;
|
|
103
|
+
s.z2 = s.b * x - s.a2 * y;
|
|
104
|
+
x = y;
|
|
105
|
+
}
|
|
106
|
+
input[i] = x;
|
|
107
|
+
}
|
|
108
|
+
while (this.position < input.length - 1) {
|
|
109
|
+
const index = Math.floor(this.position);
|
|
110
|
+
const prev = index < 0 ? this.last : input[index];
|
|
111
|
+
const next = input[index + 1];
|
|
112
|
+
this.pending.push(prev + (next - prev) * (this.position - index));
|
|
113
|
+
this.position += this.ratio;
|
|
114
|
+
}
|
|
115
|
+
this.position -= input.length;
|
|
116
|
+
this.last = input[input.length - 1];
|
|
117
|
+
if (this.pending.length >= this.chunk) {
|
|
118
|
+
this.port.postMessage(Float32Array.from(this.pending));
|
|
119
|
+
this.pending = [];
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
registerProcessor("pcm-capture", PcmCapture);
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
export const createResampler = (ratio: number) => {
|
|
128
|
+
let position = 0;
|
|
129
|
+
let last = 0;
|
|
130
|
+
const lowPass = ratio > 1 ? createLowPass(ratio) : undefined;
|
|
131
|
+
return (input: Float32Array): Float32Array => {
|
|
132
|
+
if (input.length === 0) return new Float32Array(0);
|
|
133
|
+
const samples = lowPass ? input.slice() : input;
|
|
134
|
+
lowPass?.(samples);
|
|
135
|
+
const out: number[] = [];
|
|
136
|
+
while (position < samples.length - 1) {
|
|
137
|
+
const index = Math.floor(position);
|
|
138
|
+
const prev = index < 0 ? last : samples[index]!;
|
|
139
|
+
const next = samples[index + 1]!;
|
|
140
|
+
out.push(prev + (next - prev) * (position - index));
|
|
141
|
+
position += ratio;
|
|
142
|
+
}
|
|
143
|
+
position -= samples.length;
|
|
144
|
+
last = samples[samples.length - 1]!;
|
|
145
|
+
return Float32Array.from(out);
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const openMicrophone = async (options: {
|
|
150
|
+
sampleRate: number;
|
|
151
|
+
onChunk: (base64: string) => void;
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}): Promise<MicrophoneCapture> => {
|
|
154
|
+
let stream: MediaStream | undefined;
|
|
155
|
+
const stopTracks = () => {
|
|
156
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
157
|
+
stream = undefined;
|
|
158
|
+
};
|
|
159
|
+
const throwIfAborted = () => {
|
|
160
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
161
|
+
};
|
|
162
|
+
let context: AudioContext | undefined;
|
|
163
|
+
try {
|
|
164
|
+
const openedStream = await raceWithAbortSignal(options.signal, async () => {
|
|
165
|
+
const acquired = await navigator.mediaDevices.getUserMedia({
|
|
166
|
+
audio: true,
|
|
167
|
+
});
|
|
168
|
+
stream = acquired;
|
|
169
|
+
if (options.signal?.aborted) {
|
|
170
|
+
stopTracks();
|
|
171
|
+
throw options.signal.reason;
|
|
172
|
+
}
|
|
173
|
+
return acquired;
|
|
174
|
+
});
|
|
175
|
+
throwIfAborted();
|
|
176
|
+
context = new AudioContext();
|
|
177
|
+
if (context.state === "suspended")
|
|
178
|
+
await raceWithAbortSignal(options.signal, () => context!.resume());
|
|
179
|
+
throwIfAborted();
|
|
180
|
+
const source = context.createMediaStreamSource(openedStream);
|
|
181
|
+
const analyser = context.createAnalyser();
|
|
182
|
+
analyser.fftSize = 256;
|
|
183
|
+
source.connect(analyser);
|
|
184
|
+
|
|
185
|
+
let muted = false;
|
|
186
|
+
let closed = false;
|
|
187
|
+
let closing: Promise<void> | undefined;
|
|
188
|
+
const push = (samples: Float32Array) => {
|
|
189
|
+
if (!muted && !closed && samples.length)
|
|
190
|
+
options.onChunk(encodePcm16(samples));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let node: AudioNode | undefined;
|
|
194
|
+
let detach = () => {};
|
|
195
|
+
if (context.audioWorklet) {
|
|
196
|
+
const url = URL.createObjectURL(
|
|
197
|
+
new Blob([WORKLET_SOURCE], { type: "text/javascript" }),
|
|
198
|
+
);
|
|
199
|
+
try {
|
|
200
|
+
await raceWithAbortSignal(options.signal, () =>
|
|
201
|
+
context!.audioWorklet.addModule(url),
|
|
202
|
+
);
|
|
203
|
+
throwIfAborted();
|
|
204
|
+
const worklet = new AudioWorkletNode(context, "pcm-capture", {
|
|
205
|
+
processorOptions: { targetRate: options.sampleRate },
|
|
206
|
+
});
|
|
207
|
+
worklet.port.onmessage = (event) => push(event.data as Float32Array);
|
|
208
|
+
detach = () => {
|
|
209
|
+
worklet.port.onmessage = null;
|
|
210
|
+
};
|
|
211
|
+
node = worklet;
|
|
212
|
+
} catch {
|
|
213
|
+
// A CSP can block blob worklets even when AudioWorklet is available.
|
|
214
|
+
throwIfAborted();
|
|
215
|
+
} finally {
|
|
216
|
+
URL.revokeObjectURL(url);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!node) {
|
|
220
|
+
const resample = createResampler(context.sampleRate / options.sampleRate);
|
|
221
|
+
const processor = context.createScriptProcessor(1024, 1, 1);
|
|
222
|
+
processor.onaudioprocess = (event) =>
|
|
223
|
+
push(resample(event.inputBuffer.getChannelData(0)));
|
|
224
|
+
detach = () => {
|
|
225
|
+
processor.onaudioprocess = null;
|
|
226
|
+
};
|
|
227
|
+
node = processor;
|
|
228
|
+
}
|
|
229
|
+
analyser.connect(node);
|
|
230
|
+
node.connect(context.destination);
|
|
231
|
+
const opened = context;
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
setMuted: (value) => {
|
|
235
|
+
muted = value;
|
|
236
|
+
openedStream.getAudioTracks().forEach((track) => {
|
|
237
|
+
track.enabled = !value;
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
level: () => readLevel(analyser),
|
|
241
|
+
close: () => {
|
|
242
|
+
if (closing) return closing;
|
|
243
|
+
closed = true;
|
|
244
|
+
detach();
|
|
245
|
+
node.disconnect();
|
|
246
|
+
source.disconnect();
|
|
247
|
+
stopTracks();
|
|
248
|
+
return (closing = opened.close());
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
} catch (error) {
|
|
252
|
+
stopTracks();
|
|
253
|
+
await context?.close().catch(() => {});
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export type PcmPlayer = {
|
|
259
|
+
enqueue: (base64: string) => void;
|
|
260
|
+
clear: () => void;
|
|
261
|
+
isPlaying: () => boolean;
|
|
262
|
+
level: () => number;
|
|
263
|
+
close: () => Promise<void>;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const IDLE_GRACE_MS = 100;
|
|
267
|
+
|
|
268
|
+
export const createPcmPlayer = (options: {
|
|
269
|
+
sampleRate: number;
|
|
270
|
+
onIdle?: () => void;
|
|
271
|
+
}): PcmPlayer => {
|
|
272
|
+
const context = new AudioContext({ sampleRate: options.sampleRate });
|
|
273
|
+
const analyser = context.createAnalyser();
|
|
274
|
+
analyser.fftSize = 256;
|
|
275
|
+
analyser.connect(context.destination);
|
|
276
|
+
const sources = new Set<AudioBufferSourceNode>();
|
|
277
|
+
let cursor = 0;
|
|
278
|
+
let closing: Promise<void> | undefined;
|
|
279
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
280
|
+
const scheduleIdle = () => {
|
|
281
|
+
clearTimeout(idleTimer);
|
|
282
|
+
idleTimer = setTimeout(() => {
|
|
283
|
+
if (sources.size === 0) options.onIdle?.();
|
|
284
|
+
}, IDLE_GRACE_MS);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
enqueue: (base64) => {
|
|
289
|
+
const samples = decodePcm16(base64);
|
|
290
|
+
if (samples.length === 0) return;
|
|
291
|
+
const buffer = context.createBuffer(
|
|
292
|
+
1,
|
|
293
|
+
samples.length,
|
|
294
|
+
options.sampleRate,
|
|
295
|
+
);
|
|
296
|
+
buffer.copyToChannel(samples, 0);
|
|
297
|
+
const source = context.createBufferSource();
|
|
298
|
+
source.buffer = buffer;
|
|
299
|
+
source.connect(analyser);
|
|
300
|
+
const start = Math.max(context.currentTime, cursor);
|
|
301
|
+
source.start(start);
|
|
302
|
+
cursor = start + buffer.duration;
|
|
303
|
+
sources.add(source);
|
|
304
|
+
source.onended = () => {
|
|
305
|
+
sources.delete(source);
|
|
306
|
+
if (sources.size === 0) scheduleIdle();
|
|
307
|
+
};
|
|
308
|
+
clearTimeout(idleTimer);
|
|
309
|
+
if (context.state === "suspended") void context.resume().catch(() => {});
|
|
310
|
+
},
|
|
311
|
+
clear: () => {
|
|
312
|
+
clearTimeout(idleTimer);
|
|
313
|
+
for (const source of sources) {
|
|
314
|
+
source.onended = null;
|
|
315
|
+
source.stop();
|
|
316
|
+
}
|
|
317
|
+
sources.clear();
|
|
318
|
+
cursor = 0;
|
|
319
|
+
},
|
|
320
|
+
isPlaying: () => sources.size > 0,
|
|
321
|
+
level: () => readLevel(analyser),
|
|
322
|
+
close: () => {
|
|
323
|
+
if (closing) return closing;
|
|
324
|
+
clearTimeout(idleTimer);
|
|
325
|
+
for (const source of sources) {
|
|
326
|
+
source.onended = null;
|
|
327
|
+
source.stop();
|
|
328
|
+
}
|
|
329
|
+
sources.clear();
|
|
330
|
+
return (closing = context.close());
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_DIR = fileURLToPath(new URL("..", import.meta.url));
|
|
7
|
+
|
|
8
|
+
// The built entry imports React through @assistant-ui/tap/react-shim. Vite
|
|
9
|
+
// dev pre-bundles that shim with esbuild, whose CommonJS interop hands it
|
|
10
|
+
// React's default export only; the loader below recreates that for the shim.
|
|
11
|
+
const script = `
|
|
12
|
+
import { registerHooks } from "node:module";
|
|
13
|
+
registerHooks({
|
|
14
|
+
resolve(specifier, context, nextResolve) {
|
|
15
|
+
const resolved = nextResolve(specifier, context);
|
|
16
|
+
if (specifier !== "react" || !context.parentURL?.endsWith("/react-shim/index.js")) return resolved;
|
|
17
|
+
const source = "import React from " + JSON.stringify(resolved.url) + "; export default React;";
|
|
18
|
+
return { url: "data:text/javascript," + encodeURIComponent(source), shortCircuit: true };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const entry = await import("@assistant-ui/react");
|
|
22
|
+
console.log(typeof entry.ComposerPrimitive.Root, typeof entry.CloudRendererHost);
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
it("loads the public entry when the react-shim only sees React's default export", () => {
|
|
26
|
+
const result = spawnSync(
|
|
27
|
+
process.execPath,
|
|
28
|
+
[...process.execArgv, "--input-type=module", "--eval", script],
|
|
29
|
+
{ cwd: PACKAGE_DIR, encoding: "utf8" },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result.status, result.stderr).toBe(0);
|
|
33
|
+
expect(result.stdout.trim()).toBe("object function");
|
|
34
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -69,6 +69,10 @@ export {
|
|
|
69
69
|
OpenAIRealtimeVoiceAdapter,
|
|
70
70
|
type OpenAIRealtimeVoiceAdapterOptions,
|
|
71
71
|
} from "./adapters/OpenAIRealtimeVoiceAdapter";
|
|
72
|
+
export {
|
|
73
|
+
GeminiLiveVoiceAdapter,
|
|
74
|
+
type GeminiLiveVoiceAdapterOptions,
|
|
75
|
+
} from "./adapters/GeminiLiveVoiceAdapter";
|
|
72
76
|
export type {
|
|
73
77
|
VoiceSessionControls,
|
|
74
78
|
VoiceSessionHelpers,
|