Files
gods-eye-view/src/testSupport/geminiNativeHarness.mjs
T

252 lines
6.4 KiB
JavaScript

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 };