refactor(voice): isolate gemini live provider core

This commit is contained in:
manjunath committed 2026-10-19 21:40:40 +05:30
commit 10916227cb
655 files changed
+45946 -26483

No files matched your search

@@ -0,0 +1,364 @@
import assert from 'node:assert/strict';
import { createVoiceSession } from '../voice/session.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createGeminiSession } from '../voice/geminiSession.js';
import { createNarrationScheduler } from '../voice/narration.js';
export const turnOfEventLoop = () =>
new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function ownedClock() {
let now = 0;
let nextId = 0;
const timers = new Map();
return {
timers,
now: () => now,
setTimer(fn, delay = 0) {
const id = ++nextId;
timers.set(id, { fn, at: now + Math.max(0, delay) });
return id;
},
clearTimer: (id) => timers.delete(id),
advance(ms) {
const until = now + ms;
let callbacks = 0;
for (;;) {
const due = [...timers]
.filter(([, timer]) => timer.at <= until)
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
if (!due) break;
assert.ok(
++callbacks <= 1000,
'fixture timer queue must remain bounded',
);
timers.delete(due[0]);
now = due[1].at;
due[1].fn();
}
now = until;
},
};
}
/**
* Real common session -> real Gemini adapter -> real action runner.
* Only devices, transport, passive UI and wall-clock facilities are fakes.
* The optional result gate delays return AFTER the actual action commits.
*/
export function nativeIntegrationFixture(t) {
const clock = ownedClock();
const events = [];
const sockets = [];
const tracks = [];
const audios = [];
const requests = [];
const runnerCalls = [];
const runnerJobs = [];
const resultGates = new Map();
const acquisitions = { microphone: 0, audio: 0, token: 0, socket: 0 };
const state = { height: 1000, renders: 0, zooms: [], earconStarts: 0 };
const position = { x: 6379137, y: 0, z: 0 };
const viewer = {
clock: { onTick: { addEventListener: () => () => {} } },
camera: {
positionWC: position,
positionCartographic: { height: state.height },
moveEnd: { addEventListener() {} },
cancelFlight() {},
zoomIn(metres) {
position.x -= metres;
state.height -= metres;
this.positionCartographic.height = state.height;
state.zooms.push({ direction: 'in', metres });
},
zoomOut(metres) {
position.x += metres;
state.height += metres;
this.positionCartographic.height = state.height;
state.zooms.push({ direction: 'out', metres });
},
},
scene: {
canvas: {
clientWidth: 800,
clientHeight: 600,
addEventListener() {},
removeEventListener() {},
},
requestRender() {
state.renders++;
},
},
};
const dataManager = { layers: new Map() };
const actualRunner = createGevActionRunner({
viewer,
dataManager,
styleManager: {},
});
const runner = (name, args, options) => {
runnerCalls.push({ name, args, options });
const job = (async () => {
// No fake action result: the camera and envelope come from production.
const result = await actualRunner(name, args, options);
const gate = resultGates.get(options.callId);
if (gate) {
gate.entered.resolve(result);
await gate.release.promise;
}
return result;
})();
runnerJobs.push(job);
return job;
};
class Socket {
constructor(url) {
acquisitions.socket++;
this.url = url;
this.readyState = 0;
this.bufferedAmount = 0;
this.sent = [];
sockets.push(this);
queueMicrotask(() => {
if (this.closed) return;
this.readyState = 1;
this.onopen?.();
});
}
send(raw) {
assert.equal(
this.readyState,
1,
'sending requires the owned open socket',
);
const message = JSON.parse(raw);
this.sent.push(message);
if (message.setup)
queueMicrotask(() => this.message({ setupComplete: {} }));
}
message(message) {
this.onmessage?.({ data: JSON.stringify(message) });
}
close() {
this.closed = true;
this.readyState = 3;
}
}
const session = createVoiceSession({
runner,
createAdapter: (hooks) =>
createGeminiSession({
...hooks,
ui: { root: { remove() {} } },
dataManager,
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
WebSocketClass: Socket,
getUserMedia: async () => {
acquisitions.microphone++;
const track = {
enabled: true,
readyState: 'live',
stopCalls: 0,
stop() {
this.stopCalls++;
this.readyState = 'ended';
},
};
tracks.push(track);
return { getTracks: () => [track], getAudioTracks: () => [track] };
},
fetchImpl: async (url, init) => {
acquisitions.token++;
requests.push({ url, init });
const manual = JSON.parse(init.body).inputMode === 'push-to-talk';
return Response.json({
token: 'auth_tokens/fixture-not-a-credential',
model: 'gemini-3.8-live',
websocketUrl:
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained',
config: {
generationConfig: { responseModalities: ['AUDIO'] },
...(manual
? {
realtimeInputConfig: {
automaticActivityDetection: { disabled: true },
},
}
: {}),
},
});
},
createInput: ({ readStream, operations }) => ({
...operations,
setMicrophoneEnabled(enabled) {
for (const track of readStream()?.getAudioTracks() || [])
track.enabled = enabled;
},
updateVoiceButtonLabel() {},
setVoiceSpeaker() {},
startVoiceVisualizer() {},
startAssistantVoiceVisualizer() {},
cancelPushToTalkHold() {},
stopVoiceVisualizer() {},
detachBindings() {},
bindPushToTalkShortcut() {},
resetSession() {
this.pushToTalkMode =
this.pushToTalkKeyHeld =
this.spaceKeyHeld =
false;
},
}),
createAudio: (callbacks) => {
acquisitions.audio++;
const audio = {
pending: false,
closed: false,
outputStream: {},
async initialize() {},
connectMicrophone(stream, enabled, metadata) {
this.stream = stream;
this.setMicrophoneEnabled(enabled, metadata);
},
setMicrophoneEnabled(enabled, metadata) {
this.enabled = enabled;
this.metadata = metadata;
},
play() {
this.pending = true;
},
clearPlayback() {
this.pending = false;
},
drain() {
this.pending = false;
callbacks.onDrain();
},
diagnostics: () => ({ outputChunks: 0 }),
async close() {
this.closed = true;
this.pending = false;
return true;
},
};
audios.push(audio);
return audio;
},
createNarration: (options) =>
createNarrationScheduler({
...options,
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
}),
createProgressEarcon: () => ({
available: () => true,
start() {
state.earconStarts++;
},
stop() {},
dispose() {},
}),
captureViewport: async () => null,
}),
});
session.subscribe((event) => events.push(event));
const controller = session.adapter.controller;
t.after(async () => {
session.destroy();
for (const gate of resultGates.values()) gate.release.resolve();
await Promise.allSettled(runnerJobs);
actualRunner.dispose();
assert.equal(clock.timers.size, 0, 'fixture must leave no owned timer');
assert.ok(audios.every((audio) => audio.closed));
assert.ok(tracks.every((track) => track.readyState === 'ended'));
assert.ok(sockets.every((socket) => socket.closed));
});
return {
session,
controller,
state,
events,
sockets,
tracks,
audios,
requests,
acquisitions,
runnerCalls,
clock,
holdResult(callId) {
const gate = { entered: deferred(), release: deferred() };
resultGates.set(callId, gate);
return { ready: gate.entered.promise, release: gate.release.resolve };
},
async start() {
await session.start();
assert.equal(session.state, 'listening');
assert.equal(controller.getDiagnostics().connected, true);
return sockets.at(-1);
},
activity(socket = sockets.at(-1)) {
socket.message({ voiceActivity: { type: 'ACTIVITY_START' } });
socket.message({ voiceActivity: { type: 'ACTIVITY_END' } });
},
tool(callId, socket = sockets.at(-1)) {
socket.message({
toolCall: {
functionCalls: [
{
id: callId,
name: 'adjust_camera_zoom',
args: { direction: 'in', amount: 'little' },
},
],
},
});
// A deterministic settlement boundary, not a fake action dispatcher.
return controller.toolQueue;
},
outputs(socket = sockets.at(-1)) {
return socket.sent.flatMap(
(message) => message.toolResponse?.functionResponses || [],
);
},
complete(socket = sockets.at(-1)) {
socket.message({ serverContent: { turnComplete: true } });
},
};
}
export function assertSuccessfulZoom(h, socket, callId, beforeHeight) {
assert.equal(h.state.height, beforeHeight * 0.75);
assert.ok(h.state.renders > 0);
const calls = h.events.filter(
(event) => event.type === 'action-call' && event.callId === callId,
);
const results = h.events.filter(
(event) => event.type === 'action-result' && event.callId === callId,
);
assert.equal(calls.length, 1);
assert.equal(results.length, 1);
assert.equal(calls[0].name, 'adjust_camera_zoom');
assert.deepEqual(calls[0].arguments, { direction: 'in', amount: 'little' });
assert.equal(results[0].result.ok, true);
assert.equal(results[0].result.action, 'adjust_camera_zoom');
assert.equal(results[0].result.beforeHeightM, Math.round(beforeHeight));
assert.equal(results[0].result.afterHeightM, Math.round(h.state.height));
const outputs = h.outputs(socket).filter((output) => output.id === callId);
assert.equal(outputs.length, 1);
assert.equal(outputs[0].name, 'adjust_camera_zoom');
assert.equal(outputs[0].response.ok, true);
assert.equal(outputs[0].response.action, 'adjust_camera_zoom');
assert.equal(outputs[0].response.afterHeightM, Math.round(h.state.height));
}
+251
View File
@@ -0,0 +1,251 @@
import { GeminiSessionController } from '../voice/geminiSession.js';
import { createNarrationScheduler } from '../voice/narration.js';
const endpoint =
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained';
const credential = {
token: 'auth_tokens/test',
model: 'gemini-3.8-live',
websocketUrl: endpoint,
config: { generationConfig: { responseModalities: ['AUDIO'] } },
};
const manualCredential = {
...credential,
config: {
...credential.config,
realtimeInputConfig: { automaticActivityDetection: { disabled: true } },
},
};
const tick = () => new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function harness(overrides = {}) {
const { autoSetup = true, ...controllerOverrides } = overrides;
const events = [];
const sockets = [];
const media = [];
const tracks = [];
const captures = [];
const calls = [];
const requests = [];
const state = { playing: 0, ducked: false, stopped: 0 };
let clock = 0;
let nextTimer = 0;
const timers = new Map();
const earcon = { playing: false, starts: 0, disposals: 0 };
const advance = (ms) => {
const target = clock + ms;
for (;;) {
const due = [...timers]
.filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!due) break;
timers.delete(due[0]);
clock = due[1].at;
due[1].fn();
}
clock = target;
};
let radioControl;
const radio = {
setVoiceDucked(value) {
state.ducked = value;
},
pause() {},
subscribePlaybackControls(callback) {
radioControl = callback;
return () => {
radioControl = null;
};
},
async playForVoice() {
state.playing++;
return true;
},
stopPlayback() {
state.stopped++;
},
};
class Socket {
constructor(url) {
this.url = url;
this.readyState = 0;
this.bufferedAmount = 0;
this.sent = [];
sockets.push(this);
queueMicrotask(() => {
this.readyState = 1;
this.onopen?.();
});
}
send(raw) {
const value = JSON.parse(raw);
this.sent.push(value);
if (value.setup && autoSetup)
queueMicrotask(() => this.message({ setupComplete: {} }));
}
message(value) {
this.onmessage?.({ data: JSON.stringify(value) });
}
error() {
this.onerror?.();
}
remoteClose() {
this.readyState = 3;
this.onclose?.();
}
close() {
this.readyState = 3;
this.closed = true;
}
}
const makeStream = () => {
const track = {
enabled: true,
readyState: 'live',
stop() {
this.readyState = 'ended';
},
};
tracks.push(track);
return { getTracks: () => [track], getAudioTracks: () => [track] };
};
const c = new GeminiSessionController({
emit: (event) => events.push(event),
runAction: async (name, args, options) => {
calls.push({ name, args, options });
return { ok: true, action: name };
},
ui: { root: { remove() {} } },
radioLayer: radio,
now: () => clock,
setTimer: (fn, ms) => {
const id = ++nextTimer;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimer: (id) => timers.delete(id),
WebSocketClass: Socket,
fetchImpl: async (_url, init) => {
requests.push(init);
return Response.json(
JSON.parse(init.body).inputMode === 'push-to-talk'
? manualCredential
: credential,
);
},
getUserMedia: async () => makeStream(),
createInput: ({ readStream, operations }) => ({
...operations,
setMicrophoneEnabled(enabled) {
for (const track of readStream()?.getAudioTracks() || [])
track.enabled = enabled;
},
updateVoiceButtonLabel() {},
setVoiceSpeaker() {},
startVoiceVisualizer() {},
startAssistantVoiceVisualizer() {},
cancelPushToTalkHold() {},
stopVoiceVisualizer() {},
detachBindings() {},
bindPushToTalkShortcut() {},
resetSession() {
this.pushToTalkMode = false;
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
},
}),
createNarration: (options) =>
createNarrationScheduler({
...options,
now: () => clock,
setTimer: (fn, ms) => {
const id = ++nextTimer;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimer: (id) => timers.delete(id),
}),
createProgressEarcon: ({ isMicrophoneMuted }) => ({
available: isMicrophoneMuted,
start() {
earcon.playing = true;
earcon.starts++;
},
stop() {
earcon.playing = false;
},
dispose() {
this.stop();
earcon.disposals++;
},
}),
createAudio: (callbacks) => {
const audio = {
callbacks,
pending: false,
closed: false,
played: [],
async initialize() {},
connectMicrophone(stream, enabled, metadata) {
media.push({ stream, enabled });
this.connected = true;
this.setMicrophoneEnabled(enabled, metadata);
},
setMicrophoneEnabled(value, metadata) {
if (!this.connected) return;
if (value && !this.enabled) {
this.metadata = metadata;
callbacks.onAudioStart(metadata);
}
if (!value && this.enabled) callbacks.onAudioEnd(this.metadata);
this.enabled = value;
},
play(data, mime) {
this.pending = true;
this.played.push({ data, mime });
},
drain() {
this.pending = false;
callbacks.onDrain();
},
clearPlayback() {
this.pending = false;
this.cleared = true;
},
diagnostics() {
return { outputChunks: this.played.length };
},
close() {
this.closed = true;
},
};
captures.push(audio);
return audio;
},
...controllerOverrides,
});
return {
c,
sockets,
tracks,
makeStream,
events,
media,
captures,
calls,
requests,
state,
advance,
timers,
earcon,
radioControl: (value) => radioControl?.(value),
};
}
export { harness, tick, deferred };