Files
gods-eye-view/src/voice/geminiSession.js
T
manjunath22466 b2fb4a261e feat(camera): add globe rotation (#1225)
Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards.
2026-10-23 14:51:42 -07:00

2558 lines
87 KiB
JavaScript

import { t, translateKnown } from '../i18n/index.js';
import { msg } from '../i18n/marker.js';
import { createGeminiAudio } from './geminiAudio.js';
import { RealtimeInput } from './realtimeInput.js';
import { RealtimeRadio } from './realtimeRadio.js';
import { captureViewportImage } from './realtimeViewport.js';
import { createNarrationScheduler } from './narration.js';
import { createEarcon } from './earcon.js';
import { narrationLabel } from './speech.js';
import { waitForSignal } from '../sources/requestDeadline.js';
import { readResponseJsonCapped } from '../sources/httpBody.js';
import { readLayerLifecycleSummary } from './layerSummary.js';
import {
POINTER_OPEN_MIC_RECENT_MS,
pointerAnnouncement,
pointerTurnContext,
pointerTurnEvent,
} from './pointerContext.js';
import {
hasStructuredViewIdentity,
shouldSendViewportImage,
shouldStopVoiceAfterRadioTool,
responseInstructionForToolResult,
isGlobeViewRefusal,
} from './realtimeProtocol.js';
const SOCKET_PATH =
/^\/ws\/google\.ai\.generativelanguage\.v1(?:alpha|beta)\.GenerativeService\.BidiGenerateContentConstrained$/;
const MAX_BUFFERED_BYTES = 256 * 1024;
const MAX_CONTROL_BYTES = 1024 * 1024;
const MAX_CONTROL_MESSAGES = 64;
const CONTROL_PRESSURE_TIMEOUT_MS = 5_000;
const CONTROL_POLL_MS = 25;
const MAX_GO_AWAY_MS = 10 * 60 * 1000;
const MAX_MESSAGE_LENGTH = 3 * 1024 * 1024;
const MAX_TOOL_CALLS = 16;
const MAX_SEEN_CALLS = 4096;
const MAX_CALL_ID_LENGTH = 1024;
const MAX_MAP_EVENTS = 16;
const MAX_MAP_CONTEXT_LENGTH = 16000;
/** Only an ephemeral token can be attached to the fixed Google Live endpoint. */
export function geminiSocketUrl({ token, websocketUrl }) {
if (
typeof token !== 'string' ||
!token.startsWith('auth_tokens/') ||
token.length > 8192
)
throw new Error(
msg('Gemini token response did not include an ephemeral credential'),
);
let url;
try {
url = new URL(websocketUrl);
} catch {
throw new Error(msg('Invalid Gemini connection endpoint'));
}
if (
url.protocol !== 'wss:' ||
url.hostname !== 'generativelanguage.googleapis.com' ||
url.port ||
url.username ||
url.password ||
url.search ||
url.hash ||
!SOCKET_PATH.test(url.pathname)
)
throw new Error(msg('Invalid Gemini connection endpoint'));
url.searchParams.set('access_token', token);
return url.href;
}
// Known local-server failures are translated; unexpected diagnostics stay intact.
function tokenErrorMessage(error) {
switch (error) {
case msg('Gemini voice could not connect. Try again.'):
case msg('Gemini voice requires a same-origin app request'):
case msg('Add a Gemini API key in Provider Settings'):
case msg('The Gemini Live model configuration is invalid'):
case msg('Gemini voice is starting too often. Try again shortly.'):
case msg('Gemini quota is unavailable. Try again later.'):
case msg('Gemini access was refused. Check the API key and model access.'):
case msg('Gemini Live is unavailable for the configured model.'):
case msg('Token request is too large'):
case msg('Use an empty JSON token request'):
case msg('Method not allowed'):
return t(error);
default:
return error;
}
}
function validCallId(id) {
return (
typeof id === 'string' &&
id.length > 0 &&
id.length <= MAX_CALL_ID_LENGTH &&
id.trim().length > 0 &&
!/[\u0000-\u001f\u007f]/u.test(id)
);
}
function cancelled() {
return new DOMException('Voice operation cancelled', 'AbortError');
}
// Match the existing OpenAI/Radio classification; transport ownership stays local.
function classifyGeminiTool(name, args = {}) {
const radioControl = name === 'control_radio';
const radioVisibility =
name === 'set_layer_visibility' && args.layerId === 'radio';
const radioAction = radioControl
? String(args.action || '').toLowerCase()
: null;
const authorityDomain =
radioVisibility || ['enable', 'disable'].includes(radioAction)
? 'visibility'
: radioAction === 'status'
? 'query'
: radioControl
? 'playback'
: null;
const stronger =
(radioControl && ['disable', 'pause', 'stop'].includes(radioAction)) ||
(radioVisibility && args.enabled === false);
return {
radioControl,
radioVisibility,
authorityDomain,
stronger,
abortScope:
radioAction === 'disable' || radioVisibility ? 'all' : 'playback',
};
}
/** Gemini owns its socket/media; the common session owns action authorization. */
export class GeminiSessionController {
constructor({
emit,
runAction,
signal,
ui,
radioLayer = null,
dataManager = null,
pointer = null,
tokenEndpoint = '/api/gemini/token',
startupTimeoutMs = 30_000,
inputBoundaryTimeoutMs = 5_000,
fetchImpl = (...args) => fetch(...args),
WebSocketClass = globalThis.WebSocket,
getUserMedia = (settings) => navigator.mediaDevices.getUserMedia(settings),
createAudio = createGeminiAudio,
createInput = (settings) => new RealtimeInput(settings),
createNarration = createNarrationScheduler,
createProgressEarcon = createEarcon,
captureViewport = captureViewportImage,
now = () => Date.now(),
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
Object.assign(this, {
emit,
runAction,
signal,
ui,
tokenEndpoint,
startupTimeoutMs,
inputBoundaryTimeoutMs,
fetchImpl,
WebSocketClass,
getUserMedia,
createAudio,
captureViewport,
pointer,
radioLayer,
dataManager,
now,
// Browser timer functions reject the controller as their receiver.
setTimer: (...args) => setTimer(...args),
clearTimer: (...args) => clearTimer(...args),
});
this.status = 'idle';
this.model = null;
this.epoch = 0;
this.turn = 0;
this.globeToolsBlocked = false;
this.globeResetFailed = false;
this.inputGeneration = 0;
this.turnAbort = new AbortController();
this.disposed = false;
this.socket = null;
this.channel = null;
this.stream = null;
this.audio = null;
this.abort = null;
this.tools = new Map();
this.radioPlaybackCalls = new WeakMap();
this.seenCalls = new Set();
this.cancelledCalls = new Set();
this.toolQueue = Promise.resolve();
this.pendingTools = 0;
this.modelComplete = false;
this.pendingCompletionStatus = null;
this.postToolOutput = false;
this.stopAfterReply = false;
this.modelSpoke = false;
this.transcripts = { assistant: '' };
this.pendingMapEvents = [];
this.userTurnPending = false;
// Live output has no response ID. A locally retired reply keeps ownership
// of incoming output until its ordered server turnComplete boundary.
this.replyPending = false;
// A reply Gemini started without a request; it gets no tool authority.
this.unrequestedReply = false;
this.automaticTurnOpen = false;
this.automaticActivityOwner = null;
this.retiredAutomaticActivityOwner = null;
this.automaticActivitySequence = 0;
this.retiredReplyPending = false;
this.deferredInput = null;
this.retiredBoundaryWait = null;
this.forwardedActivityOpen = false;
this.forwardedCaptureId = null;
this.captureGeneration = 0;
this.captureEnabled = false;
this.pttPlaybackSuppressed = false;
this.microphoneRequested = false;
this.captureOwner = null;
this.userActivityOpen = false;
this.pointerSentKey = null;
this.pointerAnnounced = null;
this.pendingPointerContext = null;
this.typedIntent = null;
this.toolSequence = 0;
this.radioStrongerCommit = null;
this.intentSequence = 0;
this.intentItemId = null;
this.intentResponseId = null;
this.intentCaptureId = null;
this.controlQueue = [];
this.controlBytes = 0;
this.controlTimer = null;
this.goAway = null;
this.droppedAudioChunks = 0;
this.deferredAudioDrops = { nonPttOwner: 0, closedActivity: 0 };
this.droppedImages = 0;
this.lastClose = null;
this.lastError = null;
this.completedTurns = 0;
this.lastAudio = null;
this.lastViewportAt = -Infinity;
this.input = createInput({
readUi: () => ui,
readStream: () => this.stream,
readStatus: () => this.status,
operations: {
isActive: () => this.isActive(),
start: (settings) => this.start(settings),
setStatus: (...args) => this.setStatus(...args),
pauseRadioForVoice: () => this.radio.pauseRadioForVoice(),
bargeIn: () => this.claimPttPlayback(),
holdPointer: () => {
this.heldPointerSnapshot = this.pointer?.hold('keydown');
},
releasePointerHold: () => this.pointer?.releaseHold(),
beginPointerTurn: (at) =>
this.input.pushToTalkMode && at === 'keydown'
? this.heldPointerSnapshot
: this.beginPointerTurn(at),
mayClaimSpeaker: () => this.radio.mayVoiceClaimSpeaker(),
},
});
const setMicrophoneEnabled = this.input.setMicrophoneEnabled.bind(
this.input,
);
this.applyMicrophoneEnabled = (enabled) => {
enabled = Boolean(enabled) && !this.goAway;
setMicrophoneEnabled(enabled);
if (enabled && !this.captureEnabled) {
// The physical gate owns this generation, independently of tool turns.
this.captureOwner = {
captureId: ++this.captureGeneration,
context: null,
snapshot: this.heldPointerSnapshot,
retired: false,
admitted: false,
settled: false,
};
}
this.captureEnabled = enabled;
const metadata = this.captureOwner
? { captureId: this.captureOwner.captureId }
: null;
this.audio?.setMicrophoneEnabled(enabled, metadata);
if (enabled && this.channel?.readyState === 'open') {
if (this.input.pushToTalkMode) this.radio.cancelRadioHandoff();
else this.noteUserActivity();
}
};
this.input.setMicrophoneEnabled = (enabled) => {
this.microphoneRequested = Boolean(enabled) && !this.goAway;
this.applyMicrophoneEnabled(this.microphoneRequested);
};
this.radio = new RealtimeRadio({
readRadioLayer: () => radioLayer,
readDataManager: () => dataManager,
readChannel: () => this.channel,
readUserTurnPending: () => this.userTurnPending,
readSessionId: () => `gemini-${this.epoch}`,
operations: {
abortTools: () => this.abortTools(),
isActive: () => this.isActive(),
stop: (settings) => this.stop(settings),
setStatus: (...args) => this.setStatus(...args),
// The shared Radio owner already surfaces a failed start in the UI.
queueResponseCreate: () => {},
debugLog: () => {},
},
});
this.earcon = createProgressEarcon({
isMicrophoneMuted: () =>
Boolean(
this.isActive() &&
this.channel?.readyState === 'open' &&
this.input.pushToTalkMode &&
!this.input.pushToTalkKeyHeld &&
this.stream &&
this.stream.getAudioTracks().every((track) => !track.enabled) &&
!this.audio?.pending &&
this.radio.mayVoiceClaimSpeaker(),
),
});
this.narration = createNarration({
// Live clientContent interrupts generation, even with turnComplete:false.
// Reuse shared progress timing and muted-PTT earcons without injecting a
// second conversation turn. The model's own preamble supplies speech.
speak: () => false,
earcon: this.earcon,
canSpeak: () => false,
});
this.radio.observe();
this.lifetimeAbort = () => this.stop({ removeUi: true });
signal?.addEventListener('abort', this.lifetimeAbort, { once: true });
}
get spaceKeyHeld() {
return this.input.spaceKeyHeld;
}
isActive() {
return !['idle', 'error'].includes(this.status);
}
owns(epoch) {
return (
!this.disposed && epoch === this.epoch && !this.abort?.signal.aborted
);
}
setStatus(status, detail, recovery) {
this.status = status;
if (status === 'listening' && this.input.pushToTalkMode)
detail = this.input.pushToTalkKeyHeld
? msg('Release Space to send')
: msg('Hold Space to talk');
const event = {
type: 'state',
state: status,
detail: translateKnown(detail),
};
if (recovery) event.recovery = translateKnown(recovery);
this.emit(event);
this.input.updateVoiceButtonLabel();
if (['idle', 'connecting', 'error'].includes(status))
this.input.setVoiceSpeaker('idle');
}
send(
message,
{
beforeSetup = false,
captureId = null,
onSent = null,
durable = false,
} = {},
) {
if (
this.socket?.readyState !== 1 ||
(!beforeSetup && this.channel?.readyState !== 'open')
)
return false;
const body = JSON.stringify(message);
const bytes = new TextEncoder().encode(body).byteLength;
if (bytes > MAX_MESSAGE_LENGTH)
throw new Error(msg('Voice command context is too large'));
const optional = Boolean(
message.realtimeInput?.audio || message.realtimeInput?.video,
);
if (optional) {
this.pruneControls();
if (
this.goAway ||
this.controlQueue.length ||
this.socket.bufferedAmount + bytes > MAX_BUFFERED_BYTES
) {
if (message.realtimeInput.audio) this.droppedAudioChunks++;
else this.droppedImages++;
return false;
}
this.socket.send(body);
onSent?.();
return true;
}
this.pruneControls();
const entry = {
epoch: this.epoch,
turn: this.turn,
captureId,
durable,
message,
body,
bytes,
onSent,
deadline: this.now() + CONTROL_PRESSURE_TIMEOUT_MS,
};
if (!this.controlQueue.length && this.canSendControl(entry)) {
this.writeControl(entry);
return true;
}
if (
bytes > MAX_CONTROL_BYTES ||
this.controlBytes + bytes > MAX_CONTROL_BYTES ||
this.controlQueue.length >= MAX_CONTROL_MESSAGES
)
throw new Error(
msg(
'Gemini reliable transport exceeded its queue limit. Start voice again.',
),
);
this.controlQueue.push(entry);
this.controlBytes += bytes;
this.scheduleControls();
return true;
}
controlCurrent(entry) {
if (!this.owns(entry.epoch)) return false;
if (entry.durable) return true;
const input = entry.message.realtimeInput;
if (
entry.message.toolResponse?.functionResponses?.some((call) =>
this.cancelledCalls.has(call.id),
)
)
return false;
if (input?.activityEnd)
return (
(this.forwardedActivityOpen &&
this.forwardedCaptureId === entry.captureId) ||
(this.acceptsCapture({ captureId: entry.captureId }) &&
this.controlQueue.some(
(pending) =>
pending.message.realtimeInput?.activityStart &&
pending.captureId === entry.captureId &&
this.controlCurrent(pending),
))
);
if (entry.captureId !== null)
return this.acceptsCapture({ captureId: entry.captureId });
return entry.turn === this.turn;
}
pruneControls() {
this.controlQueue = this.controlQueue.filter((entry) =>
this.controlCurrent(entry),
);
this.controlBytes = this.controlQueue.reduce(
(total, entry) => total + entry.bytes,
0,
);
if (!this.controlQueue.length) {
if (this.controlTimer !== null) this.clearTimer(this.controlTimer);
this.controlTimer = null;
}
}
canSendControl(entry) {
return (
entry.bytes <= MAX_CONTROL_BYTES &&
(this.socket.bufferedAmount === 0 ||
this.socket.bufferedAmount + entry.bytes <= MAX_BUFFERED_BYTES)
);
}
writeControl(entry) {
this.socket.send(entry.body);
if (!this.controlCurrent(entry)) return;
const input = entry.message.realtimeInput;
// Only markers actually written to the socket own its physical activity.
if (input?.activityStart) {
this.forwardedActivityOpen = true;
this.forwardedCaptureId = entry.captureId;
this.beginSpeechIntent(entry.captureId);
if (!this.controlCurrent(entry)) return;
}
if (input?.activityEnd) {
this.forwardedActivityOpen = false;
this.forwardedCaptureId = null;
if (this.intentCaptureId === entry.captureId) this.bindSpeechIntent();
}
entry.onSent?.();
}
scheduleControls() {
if (this.controlTimer !== null || !this.controlQueue.length) return;
const epoch = this.epoch;
this.controlTimer = this.setTimer(
() => {
if (!this.owns(epoch)) return;
this.controlTimer = null;
try {
this.flushControls();
} catch (error) {
this.fail(error);
}
},
Math.min(
CONTROL_POLL_MS,
Math.max(0, this.controlQueue[0].deadline - this.now()),
),
);
this.controlTimer?.unref?.();
}
flushControls() {
this.pruneControls();
while (this.controlQueue.length) {
const entry = this.controlQueue[0];
if (this.now() >= entry.deadline)
throw new Error(
msg('Gemini reliable transport did not drain. Start voice again.'),
);
if (!this.canSendControl(entry)) break;
this.controlQueue.shift();
this.controlBytes -= entry.bytes;
if (this.controlCurrent(entry)) this.writeControl(entry);
}
this.scheduleControls();
if (!this.controlQueue.length) this.finishPlayback();
}
clearControls() {
if (this.controlTimer !== null) this.clearTimer(this.controlTimer);
this.controlTimer = null;
this.controlQueue = [];
this.controlBytes = 0;
}
async requestToken(signal, pushToTalk = false) {
signal.throwIfAborted();
let acquiredResponse;
const acquisition = Promise.resolve(
this.fetchImpl(this.tokenEndpoint, {
method: 'POST',
cache: 'no-store',
redirect: 'error',
signal,
headers: { 'Content-Type': 'application/json' },
body: pushToTalk ? '{"inputMode":"push-to-talk"}' : '{}',
}),
);
void acquisition.then(
(response) => {
acquiredResponse = response;
if (signal.aborted) void response.body?.cancel().catch(() => {});
},
() => {},
);
try {
const response = await waitForSignal(acquisition, signal);
signal.throwIfAborted();
const data = await readResponseJsonCapped(response, 256 * 1024, signal);
signal.throwIfAborted();
if (!response.ok)
throw new Error(
typeof data?.error === 'string'
? tokenErrorMessage(data.error)
: t('Gemini token request failed (HTTP {status})', {
status: response.status,
}),
);
if (
typeof data?.model !== 'string' ||
!/^gemini-[a-z0-9.-]+$/.test(data.model)
)
throw new Error(
msg('Gemini token response did not include a valid model'),
);
if (
!data.config ||
typeof data.config !== 'object' ||
Array.isArray(data.config)
)
throw new Error(
msg('Gemini token response did not include session settings'),
);
const manual =
data.config.realtimeInputConfig?.automaticActivityDetection?.disabled;
if (
pushToTalk ? manual !== true : manual !== undefined && manual !== false
)
throw new Error(
msg('Gemini session settings do not match the input mode'),
);
const url = geminiSocketUrl(data);
return { ...data, url };
} finally {
if (signal.aborted) void acquiredResponse?.body?.cancel().catch(() => {});
}
}
async start({ pushToTalk = false } = {}) {
if (this.disposed || this.isActive() || this.signal?.aborted) return;
const held = pushToTalk && this.input.pushToTalkKeyHeld;
const spaceHeld = this.input.spaceKeyHeld;
this.stop({ preserveStatus: true });
this.input.pushToTalkMode = pushToTalk;
this.input.pushToTalkKeyHeld = held;
this.input.spaceKeyHeld = spaceHeld;
this.abort = new AbortController();
const epoch = this.epoch;
const permissionSignal = AbortSignal.any([
this.abort.signal,
...(this.signal ? [this.signal] : []),
]);
let signal = permissionSignal;
this.lastError = null;
this.lastAudio = null;
this.completedTurns = 0;
let acquiredStream = null;
let releasedStream = null;
const releaseStream = (stream) => {
if (!stream || stream === releasedStream) return;
releasedStream = stream;
stream.getTracks().forEach((track) => track.stop());
};
try {
this.setStatus('connecting', msg('Requesting microphone'));
if (!this.owns(epoch)) return;
signal.throwIfAborted();
this.radio.pauseRadioForVoice();
if (!this.owns(epoch)) return;
// The worklet serializes start, PCM and flushed end notifications. A
// keydown must not send a new start ahead of the previous queued end.
let activityCaptureId = null;
const audio = this.createAudio({
pushToTalk,
onAudioStart: (metadata) => {
if (
!this.owns(epoch) ||
this.goAway ||
!this.acceptsCapture(metadata)
)
return;
if (!pushToTalk || activityCaptureId === metadata.captureId) return;
this.captureOwner.admitted = true;
this.globeToolsBlocked = false;
this.globeResetFailed = false;
if (
!this.interrupt('push-to-talk', {
keepPlayback: !this.pttPlaybackSuppressed,
})
)
return;
if (!this.owns(epoch) || !this.acceptsCapture(metadata)) return;
const activityGeneration = this.inputGeneration;
const activityCurrent = () =>
this.owns(epoch) &&
this.inputGeneration === activityGeneration &&
this.acceptsCapture(metadata);
this.beginPointerTurn(
'speech_start',
this.captureOwner.snapshot,
activityCurrent,
);
if (!activityCurrent()) return;
const context = this.pendingPointerContext;
this.captureOwner.context = context;
if (!this.sendPointerContext(context, metadata)) {
if (this.owns(epoch))
this.fail(
new Error(
msg('Gemini could not submit this input. Start voice again.'),
),
);
return;
}
if (!activityCurrent()) return;
const accepted = this.sendUserInput(
{ realtimeInput: { activityStart: {} } },
metadata,
);
if (!activityCurrent()) return;
// Quiet context and an empty hold have no response to retire later.
if (accepted) activityCaptureId = metadata.captureId;
else if (this.owns(epoch))
this.fail(
new Error(
msg('Gemini could not submit this input. Start voice again.'),
),
);
},
onAudio: (chunk, metadata) => {
if (!this.owns(epoch) || !this.acceptsCapture(metadata)) return;
if (!pushToTalk || activityCaptureId === metadata.captureId) {
const sent = this.sendUserInput(
{
realtimeInput: { audio: chunk },
},
metadata,
);
if (sent && pushToTalk) this.replyPending = true;
}
},
onAudioEnd: (metadata) => {
if (!this.owns(epoch) || !this.validCaptureMetadata(metadata)) return;
if (pushToTalk) {
if (this.captureOwner?.captureId === metadata.captureId)
this.captureOwner.settled = true;
if (activityCaptureId !== metadata.captureId) {
this.finishPlayback();
return;
}
this.sendUserInput(
{ realtimeInput: { activityEnd: {} } },
metadata,
);
if (this.acceptsCapture(metadata)) this.narration.userTurnEnded();
activityCaptureId = null;
} else if (this.acceptsCapture(metadata)) {
this.send({ realtimeInput: { audioStreamEnd: true } });
}
},
onAudioDiscard: (metadata) => {
if (
!this.owns(epoch) ||
!pushToTalk ||
!this.validCaptureMetadata(metadata) ||
this.captureOwner?.captureId !== metadata.captureId
)
return;
this.captureOwner.settled = true;
if (!this.acceptsCapture(metadata)) {
// New typed input owns completion; its retired capture cannot
// revoke that request or leave its presentation waiting.
this.finishPlayback();
return;
}
if (
activityCaptureId === metadata.captureId ||
this.forwardedCaptureId === metadata.captureId ||
this.deferredInput?.captureId === metadata.captureId
)
return;
// No provider turn was submitted or retired. Keep an older request's
// tool/completion ownership; the physical claim still revokes Radio.
this.radio.cancelRadioHandoff();
if (!this.audio?.pending) this.input.setVoiceSpeaker('idle');
this.finishPlayback();
},
onDrain: () => {
if (this.owns(epoch)) {
this.narration.quiet();
this.finishPlayback();
}
},
onError: (error) => {
if (this.owns(epoch)) this.fail(error);
},
});
if (!this.owns(epoch) || signal.aborted) {
void Promise.resolve(audio.close()).catch(() => {});
return;
}
this.audio = audio;
// Invoke resume in the initiating gesture, but do not mint the short-lived
// admission credential while an uncancellable permission prompt is open.
const initialized = Promise.resolve(audio.initialize(permissionSignal));
void initialized.catch(() => {});
if (!this.owns(epoch)) return;
signal.throwIfAborted();
const mediaRequest = Promise.resolve(
this.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
}),
);
// Permission prompts cannot be cancelled. A later grant still belongs
// to this attempt and must release its own tracks after stop/timeout.
void mediaRequest.then(
(stream) => {
if (!this.owns(epoch) || signal.aborted) releaseStream(stream);
},
() => {},
);
acquiredStream = await waitForSignal(mediaRequest, signal);
if (!this.owns(epoch)) {
releaseStream(acquiredStream);
return;
}
signal.throwIfAborted();
this.stream = acquiredStream;
acquiredStream.getAudioTracks().forEach((track) => {
track.enabled = false;
});
signal = AbortSignal.any([
permissionSignal,
AbortSignal.timeout(this.startupTimeoutMs),
]);
this.setStatus('connecting', msg('Connecting to Gemini'));
if (!this.owns(epoch)) return;
signal.throwIfAborted();
const startup = Promise.all([
initialized,
this.requestToken(signal, pushToTalk),
]);
void startup.catch(() => {});
const [, credential] = await waitForSignal(startup, signal);
if (!this.owns(epoch)) return;
this.model = credential.model;
await this.connect(credential, epoch, signal);
if (!this.owns(epoch)) return;
signal.throwIfAborted();
const enabled =
!this.input.pushToTalkMode || this.input.pushToTalkKeyHeld;
this.input.setMicrophoneEnabled(enabled);
audio.connectMicrophone(
acquiredStream,
enabled,
this.captureOwner ? { captureId: this.captureOwner.captureId } : null,
);
this.input.startVoiceVisualizer(acquiredStream);
this.input.startAssistantVoiceVisualizer(audio.outputStream);
this.setStatus('listening', msg('Ask or command'));
} catch (error) {
if (acquiredStream && acquiredStream !== this.stream)
releaseStream(acquiredStream);
if (this.owns(epoch)) this.fail(error);
}
}
connect(credential, epoch, signal) {
return new Promise((resolve, reject) => {
signal.throwIfAborted();
if (!this.owns(epoch)) return reject(cancelled());
const socket = new this.WebSocketClass(credential.url);
socket.binaryType = 'arraybuffer';
this.socket = socket;
const channel = { readyState: 'connecting' };
this.channel = channel;
let connected = false;
const current = () => this.owns(epoch) && this.socket === socket;
const abort = () => {
signal.removeEventListener('abort', abort);
reject(signal.reason || cancelled());
};
signal.addEventListener('abort', abort, { once: true });
socket.onopen = () => {
if (!current()) return;
try {
this.send(
{
setup: {
...credential.config,
model: `models/${credential.model}`,
},
},
{ beforeSetup: true },
);
} catch (error) {
reject(error);
}
};
socket.onmessage = ({ data }) => {
if (!current()) return;
try {
const text =
typeof data === 'string' ? data : new TextDecoder().decode(data);
if (text.length > MAX_MESSAGE_LENGTH)
throw new Error(msg('Gemini returned an oversized message'));
const message = JSON.parse(text);
if (message.setupComplete) {
if (connected) return;
connected = true;
channel.readyState = 'open';
signal.removeEventListener('abort', abort);
resolve();
}
this.handleMessage(message, epoch);
} catch (error) {
if (!connected) reject(error);
else if (current()) this.fail(error);
}
};
socket.onerror = () => {
const error = new Error(
msg(
'Gemini Live connection failed. Check network access, then start voice again.',
),
);
if (!current()) return;
if (!connected) reject(error);
else this.fail(error);
};
socket.onclose = (event) => {
if (!current()) return;
const code =
Number.isInteger(event?.code) &&
event.code >= 1000 &&
event.code <= 4999
? event.code
: null;
this.lastClose = {
code,
category:
code === 1000
? 'normal'
: code === 1008
? 'policy'
: code === 1011
? 'server'
: 'connection',
};
const error = new Error(
connected
? msg(
'Gemini Live connection ended. Start voice again for a new conversation.',
)
: msg(
'Gemini Live could not start. Start voice again to request a fresh session.',
),
);
error.recovery = connected
? msg('Start voice again for a fresh token and a new conversation.')
: msg('Start voice again to request a fresh session credential.');
if (!connected) reject(error);
else this.fail(error);
};
});
}
fail(error, recovery = error?.recovery) {
const detail =
error?.name === 'NotAllowedError'
? msg(
'Microphone permission was denied. Allow microphone access and try again.',
)
: error?.name === 'TimeoutError'
? msg('Gemini connection timed out. Try again.')
: error?.message || msg('Gemini voice could not start');
this.lastError = detail;
this.stop({ preserveStatus: true });
this.setStatus('error', detail, recovery);
}
abortTools() {
const resources = this.turnAbort;
const tools = [...this.tools.values()];
this.turnAbort = new AbortController();
this.turn++;
this.typedIntent = null;
this.radioStrongerCommit = null;
this.pruneControls();
this.tools.clear();
this.radio?.clearTools();
this.pendingTools = 0;
this.toolQueue = Promise.resolve();
this.narration?.cancel();
resources.abort();
for (const entry of tools) entry.abort();
}
noteUserActivity() {
this.narration?.cancel();
this.modelSpoke = false;
this.userTurnPending = true;
this.radio.cancelRadioHandoff();
}
/** A physical hold silences playback; only admitted speech retires a turn. */
claimPttPlayback() {
if (
this.channel?.readyState !== 'open' ||
this.goAway ||
!this.radio.mayVoiceClaimSpeaker()
)
return false;
this.pttPlaybackSuppressed = true;
this.audio?.clearPlayback();
this.input.setVoiceSpeaker('user');
return true;
}
/** A claimed Space hold stops local playback before its audio reaches Live. */
bargeIn() {
if (
this.channel?.readyState !== 'open' ||
this.goAway ||
!this.radio.mayVoiceClaimSpeaker()
)
return false;
return this.interrupt('push-to-talk');
}
interrupt(
reason = 'user-speech',
{
keepPlayback = false,
preserveAdvisoryCaption = false,
serverInterrupted = false,
} = {},
) {
if (this.goAway && !serverInterrupted) return false;
const epoch = this.epoch;
const generation = ++this.inputGeneration;
const channel = this.channel;
const owned = () =>
this.owns(epoch) &&
this.inputGeneration === generation &&
this.channel === channel;
this.pttPlaybackSuppressed = false;
// Arm only for an outstanding reply: an idle hold/first text command has
// no old boundary to wait for. This also covers generation before the
// first output packet, because input submission marks replyPending.
const deferred = this.deferredInput;
const deferInput =
deferred || (this.retiredReplyPending && this.replyPending);
if (this.replyPending) {
if (!this.retiredReplyPending)
this.retiredAutomaticActivityOwner = this.automaticActivityOwner;
this.retiredReplyPending = true;
}
this.replyPending = false;
this.unrequestedReply = false;
if (['push-to-talk', 'user-text'].includes(reason)) {
this.automaticTurnOpen = false;
this.automaticActivityOwner = null;
}
this.clearDeferredInput();
this.abortTools();
if (!owned()) return false;
this.intentItemId = null;
this.intentResponseId = null;
this.intentCaptureId = null;
if (deferInput) {
// Only one successor may reach Live before the previous boundary. A
// newer local turn replaces unsent input and waits to interrupt that
// successor, rather than creating indistinguishable overlapping replies.
const owner = {
epoch: this.epoch,
turn: this.turn,
messages: [],
bytes: 0,
activityOpen: false,
kind: reason,
context: null,
deadline:
deferred?.deadline ||
this.retiredBoundaryWait?.deadline ||
this.now() + this.inputBoundaryTimeoutMs,
};
this.clearRetiredBoundaryWait();
this.deferredInput = owner;
owner.timer = this.setTimer(
() => {
if (this.deferredInput === owner && this.owns(owner.epoch))
this.fail(
new Error(
msg(
'Gemini did not finish the interrupted reply. Start voice again.',
),
),
);
},
Math.max(0, owner.deadline - this.now()),
);
owner.timer.unref?.();
} else this.boundRetiredReply();
this.noteUserActivity();
if (!owned()) return false;
if (!keepPlayback) this.audio?.clearPlayback();
if (!owned()) return false;
this.modelComplete = false;
this.pendingCompletionStatus = null;
this.postToolOutput = false;
this.stopAfterReply = false;
this.transcripts.assistant = '';
this.emit({
type: 'interruption',
reason,
...(preserveAdvisoryCaption ? { preserveAdvisoryCaption: true } : {}),
});
if (!owned()) return false;
this.input.setVoiceSpeaker(this.goAway ? 'idle' : 'user');
if (!owned()) return false;
this.setStatus(
'listening',
this.goAway
? msg('Gemini session expiring; finishing the current reply')
: msg('Ask or command'),
);
return owned();
}
discardRetiredReply(message, epoch) {
if (!this.owns(epoch) || !this.retiredReplyPending) return false;
const content = message.serverContent;
// Retired output proves the old reply is still running; only silence
// after it counts toward the first retirement's recovery bound.
if (
this.retiredBoundaryWait?.extendable &&
!content?.turnComplete &&
(content?.modelTurn ||
content?.outputTranscription ||
content?.generationComplete ||
message.toolCall)
) {
this.clearRetiredBoundaryWait();
this.boundRetiredReply();
}
const retiredIds = (message.toolCall?.functionCalls || [])
.map((call) => call?.id)
.filter(validCallId);
this.rememberCallIds(retiredIds);
for (const id of retiredIds) this.cancelledCalls.add(id);
// Gemini documents interrupted -> turnComplete. Discard all retired
// output, including content coalesced with either marker. A natural
// completion racing the local claim also closes the old reply safely.
if (content?.turnComplete) {
this.clearRetiredBoundaryWait();
this.retiredReplyPending = false;
if (
this.typedIntent?.retiredAutomaticPredecessor ===
this.retiredAutomaticActivityOwner
)
this.typedIntent.retiredAutomaticPredecessor = null;
// A true replyPending now belongs to a successor submitted while
// this older reply was retired. Its boundary cannot revoke that input.
if (this.automaticActivityOwner === this.retiredAutomaticActivityOwner) {
this.automaticTurnOpen = false;
this.automaticActivityOwner = null;
}
this.retiredAutomaticActivityOwner = null;
if (this.goAway) {
this.clearDeferredInput();
if (
this.goAway.supersededSuccessor ||
(!this.replyPending &&
!this.automaticTurnOpen &&
!this.pendingTools &&
!this.audio?.pending &&
!this.controlQueue.length)
)
this.finishGoAway();
} else this.flushDeferredInput();
}
return true;
}
handleMessage(message, epoch = this.epoch) {
if (!this.owns(epoch)) return;
const initialGeneration = this.inputGeneration;
const initialTurn = this.turn;
const initialCurrent = () =>
this.owns(epoch) &&
this.inputGeneration === initialGeneration &&
this.turn === initialTurn;
if (this.goAway && this.now() >= this.goAway.deadline) {
this.finishGoAway();
return;
}
if (message.error) {
this.fail(
new Error(
msg(
'Gemini rejected the voice session. Start voice again to request a fresh session.',
),
),
msg(
'Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.',
),
);
return;
}
if (message.goAway) {
this.beginGoAway(message.goAway.timeLeft, epoch);
if (!initialCurrent()) return;
}
let prunedCancelledControls = false;
for (const id of message.toolCallCancellation?.ids || []) {
const action = this.tools.get(id);
const results = [
this.radio.pendingRadioPlaybackResult,
this.radio.radioHandoffInFlightResult,
].filter((result) => {
const owner = result && this.radioPlaybackCalls.get(result);
return (
owner?.id === id && owner.epoch === epoch && owner.turn === this.turn
);
});
const queuedResponse = this.controlQueue.some((entry) =>
entry.message.toolResponse?.functionResponses?.some(
(call) => call.id === id,
),
);
// A queued response still owns transport work until it reaches the wire.
if (!action && !results.length && !queuedResponse) continue;
this.cancelledCalls.add(id);
action?.abort();
if (!initialCurrent()) return;
for (const result of results) this.radio.cancelPlaybackResult(result);
if (!initialCurrent()) return;
const queuedControls = this.controlQueue.length;
this.pruneControls();
prunedCancelledControls ||= this.controlQueue.length < queuedControls;
}
if (prunedCancelledControls) {
// Removing the last queued response can release an already complete turn.
this.finishPlayback();
// Completion observers may stop this session or accept a successor input.
if (!initialCurrent()) return;
}
const content = message.serverContent;
let receivedReplyPending = this.replyPending || this.retiredReplyPending;
const settleFencedBoundary = () => {
// Preserve this packet's old terminal without publishing its caption,
// audio or tools into the new input. A terminal already processed below
// must never be reused to close a successor retired by a final observer.
if (receivedReplyPending && content?.turnComplete)
this.discardRetiredReply(message, epoch);
};
// A server acknowledgment of a local interruption belongs to the retired
// reply; it must not revoke the successor's pointer, timing or turn token.
if (content?.interrupted) {
const newAutomaticActivity =
!this.input.pushToTalkMode &&
!this.goAway &&
message.voiceActivity?.type === 'ACTIVITY_START' &&
!this.userActivityOpen;
const predecessor = this.typedIntent?.automaticPredecessor;
if (
!this.retiredReplyPending &&
!newAutomaticActivity &&
predecessor?.epoch === epoch &&
this.currentTypedInput()
) {
// Automatic VAD may not have produced output before text superseded
// it. This specific local predecessor owns one interruption boundary;
// the acknowledgment is not another user input. Do not infer this
// from output silence alone, or wait for a boundary until it arrives.
this.retiredAutomaticActivityOwner = predecessor;
this.typedIntent.retiredAutomaticPredecessor = predecessor;
this.typedIntent.automaticPredecessor = null;
} else if (!this.retiredReplyPending) {
if (
!this.interrupt('user-speech', {
preserveAdvisoryCaption: true,
serverInterrupted: true,
})
) {
settleFencedBoundary();
return;
}
}
this.retiredReplyPending = true;
receivedReplyPending = true;
this.boundRetiredReply();
}
// Manual PTT owns its onset locally. Automatic speech takes one snapshot
// per activity, regardless of duplicate/late transcription notifications.
if (!this.input.pushToTalkMode && !this.goAway) {
if (
message.voiceActivity?.type === 'ACTIVITY_START' &&
!this.userActivityOpen
) {
this.userActivityOpen = true;
const typedPredecessor = this.typedIntent?.retiredAutomaticPredecessor;
if (
this.retiredReplyPending &&
typedPredecessor &&
typedPredecessor === this.retiredAutomaticActivityOwner &&
this.currentTypedInput()
) {
// This precise wait still belongs to the automatic predecessor, not
// the submitted text. New speech revokes that text without inventing
// a second retirement/deferred input or renewing the existing bound.
this.replyPending = false;
if (!this.interrupt()) return;
} else if (!this.retiredReplyPending && !this.interrupt()) return;
this.globeToolsBlocked = false;
this.globeResetFailed = false;
// Local activity ownership is only for expiry liveness, not tools.
// Assign after retiring observed output so its boundary cannot clear
// a newer detected activity that has not received a reply yet.
this.automaticActivityOwner = {
epoch,
sequence: ++this.automaticActivitySequence,
};
this.automaticTurnOpen = true;
const activityGeneration = this.inputGeneration;
const activityCurrent = () =>
this.owns(epoch) && this.inputGeneration === activityGeneration;
this.beginSpeechIntent(null);
if (!activityCurrent()) return;
this.beginPointerTurn('speech_start', undefined, activityCurrent);
if (!activityCurrent()) return;
const context = this.pendingPointerContext;
if (
context &&
this.send({
realtimeInput: { text: JSON.stringify(context.item) },
}) &&
activityCurrent()
)
this.consumePointerContext(context);
} else if (
message.voiceActivity?.type === 'ACTIVITY_END' &&
this.userActivityOpen
) {
this.userActivityOpen = false;
this.bindSpeechIntent();
this.narration.userTurnEnded();
}
}
// Native activity above may intentionally advance the local input. From
// here, observers must not let the rest of this packet reach a successor.
const messageGeneration = this.inputGeneration;
const messageTurn = this.turn;
const messageCurrent = () =>
this.owns(epoch) &&
this.inputGeneration === messageGeneration &&
this.turn === messageTurn;
// Native Live captions are unordered advisory segments. Never attach them
// to a command token or use them to grant/revoke a tool's lifetime.
if (content?.inputTranscription?.text) {
this.transcript('user', content.inputTranscription);
if (!messageCurrent()) {
settleFencedBoundary();
return;
}
}
if (this.discardRetiredReply(message, epoch)) return;
// Push-to-talk owns every input boundary locally: a call that arrives with
// no input, reply or tool result outstanding was not requested, and nor is
// anything else in the reply it starts. Open-mic turns start on Gemini's
// server-side activity detection, which reports no boundary to gate on.
const unrequested =
this.input.pushToTalkMode &&
!this.replyPending &&
!this.userTurnPending &&
!this.pendingTools;
let toolCalls = message.toolCall?.functionCalls || [];
if (toolCalls.length && (unrequested || this.unrequestedReply)) {
this.refuseUnrequestedTools(toolCalls);
toolCalls = [];
}
// In push-to-talk, a caption alone after its turn ended still belongs to
// that turn. It must not open a reply the next input would retire,
// discarding that input's own tool calls while it waits for a boundary
// that never comes. Open mic cannot tell it from a new reply's first
// caption, so there it still opens one.
const lateCaption =
this.input.pushToTalkMode &&
!this.replyPending &&
!this.userTurnPending &&
!this.automaticTurnOpen &&
!this.pendingTools &&
!content?.modelTurn &&
!content?.generationComplete &&
!toolCalls.length;
if (
content?.modelTurn ||
(content?.outputTranscription && !lateCaption) ||
content?.generationComplete ||
toolCalls.length
) {
// Output with nothing outstanding starts a reply nobody requested; a
// later call in it is refused like one that arrived first.
if (unrequested) this.unrequestedReply = true;
// Once successor output is observed, an unattributed later interruption
// cannot still claim the unobserved automatic predecessor's boundary.
if (this.typedIntent) {
this.typedIntent.automaticPredecessor = null;
this.typedIntent.retiredAutomaticPredecessor = null;
}
this.replyPending = true;
receivedReplyPending = true;
}
if (content?.outputTranscription?.text) {
this.userTurnPending = false;
this.postToolOutput = true;
this.transcript('assistant', content.outputTranscription);
if (!messageCurrent()) {
settleFencedBoundary();
return;
}
}
for (const part of (content?.interrupted
? []
: content?.modelTurn?.parts) || []) {
if (part.thought) continue;
if (part.inlineData?.data) {
this.userTurnPending = false;
this.modelComplete = false;
this.postToolOutput = true;
if (!this.pttPlaybackSuppressed) {
this.input.setVoiceSpeaker('ai');
if (!messageCurrent()) {
settleFencedBoundary();
return;
}
this.audio?.play(part.inlineData.data, part.inlineData.mimeType);
if (!messageCurrent()) {
settleFencedBoundary();
return;
}
this.modelSpoke = true;
this.narration.audible();
if (!messageCurrent()) {
settleFencedBoundary();
return;
}
}
}
}
if (toolCalls.length) this.enqueueTools(toolCalls, epoch);
if (content?.turnComplete) {
if (this.typedIntent) {
this.typedIntent.automaticPredecessor = null;
this.typedIntent.retiredAutomaticPredecessor = null;
}
this.pttPlaybackSuppressed = false;
// A native terminal boundary also completes a silent user turn. Output,
// pending tools and queued controls still gate presentation completion.
this.userTurnPending = false;
this.replyPending = false;
this.unrequestedReply = false;
this.automaticTurnOpen = false;
this.automaticActivityOwner = null;
this.retiredAutomaticActivityOwner = null;
this.modelComplete = true;
this.pendingCompletionStatus = content.interrupted
? 'interrupted'
: 'completed';
this.completedTurns++;
// Native input captions have no response identity. A model completion
// cannot finalize them or join speech from either side of an interruption.
if (this.transcripts.assistant) {
this.emit({
type: 'transcript',
role: 'assistant',
text: this.transcripts.assistant,
final: true,
responseId: this.intentResponseId,
});
if (!messageCurrent()) return;
this.transcripts.assistant = '';
}
this.finishPlayback();
}
}
transcript(role, value) {
if (role === 'assistant')
this.transcripts.assistant = (
this.transcripts.assistant + value.text
).slice(-16000);
this.emit({
type: 'transcript',
role,
text: value.text,
final: false,
responseId: role === 'user' ? null : this.intentResponseId,
...(role === 'user' ? { attribution: 'unverified', delta: true } : {}),
});
}
/**
* Answer unrequested calls without running them: every declaration is
* BLOCKING, so Live waits for a result. The reply stays tracked, so the next
* input retires it and its late boundary cannot close the user's turn.
*/
refuseUnrequestedTools(calls) {
const refused = new Map();
for (const call of calls)
if (
validCallId(call?.id) &&
typeof call.name === 'string' &&
!this.seenCalls.has(call.id)
)
refused.set(call.id, call.name);
this.rememberCallIds([...refused.keys()]);
if (!refused.size) return;
this.unrequestedReply = true;
this.replyPending = true;
this.send({
toolResponse: {
functionResponses: [...refused].map(([id, name]) => ({
id,
name,
response: {
ok: false,
action: name,
changed: false,
error: 'No user request is active.',
responseGuidance:
'Do not call another tool or claim an action ran. Wait for the user.',
},
})),
},
});
}
rememberCallIds(ids) {
const freshIds = new Set(ids.filter((id) => !this.seenCalls.has(id)));
// Forgetting an old ID would let its replay acquire a new turn's authority.
// Reserve the entire batch before changing policy or queuing any action.
if (this.seenCalls.size + freshIds.size > MAX_SEEN_CALLS)
throw new Error(
msg(
'Gemini action history is full. Start voice again for a fresh session.',
),
);
for (const id of freshIds) this.seenCalls.add(id);
}
enqueueTools(calls, epoch) {
if (
calls.length > MAX_TOOL_CALLS ||
this.pendingTools + calls.length > MAX_TOOL_CALLS
)
throw new Error(msg('Gemini requested too many simultaneous actions'));
const turn = this.turn;
this.userTurnPending = false;
this.modelComplete = false;
// Admit each valid native call ID once per connection.
const batchIds = new Set();
const acceptedCalls = calls.filter((call) => {
if (
!validCallId(call?.id) ||
typeof call.name !== 'string' ||
this.seenCalls.has(call.id) ||
batchIds.has(call.id)
)
return false;
batchIds.add(call.id);
return true;
});
this.rememberCallIds(acceptedCalls.map((call) => call.id));
for (const call of acceptedCalls) {
this.pendingTools++;
const action = new AbortController();
action.call = { id: call.id, name: call.name };
action.sequence = ++this.toolSequence;
this.tools.set(call.id, action);
const priorTools = this.toolQueue;
const args =
call.args && typeof call.args === 'object' && !Array.isArray(call.args)
? call.args
: {};
// Stronger Radio controls must reach their shared reservation/result
// owner while an older selection is awaiting a service. Keep ordinary
// siblings ordered, and retain every in-flight call in the drain promise.
// executeTool still checks currentness before any
// reservation; only a successful stronger result revokes older work.
const { stronger } = classifyGeminiTool(call.name, args);
const execution = (stronger ? Promise.resolve() : priorTools)
.then(async () => {
if (!this.owns(epoch) || turn !== this.turn) return;
if (this.cancelledCalls.has(call.id)) {
if (call.name === 'zoom_to_globe') this.globeResetFailed = true;
if (this.tools.get(call.id) === action) this.tools.delete(call.id);
this.pendingTools--;
if (!this.pendingTools) {
this.setStatus('listening', msg('Ask or command'));
if (this.owns(epoch) && turn === this.turn) this.finishPlayback();
}
return;
}
await this.executeTool(call, epoch, turn, action);
})
.catch((error) => {
if (this.owns(epoch) && turn === this.turn) this.fail(error);
});
this.toolQueue = stronger
? Promise.all([priorTools, execution]).then(() => {})
: execution;
}
}
async executeTool(call, epoch, turn, action) {
const args =
call.args && typeof call.args === 'object' && !Array.isArray(call.args)
? call.args
: {};
const deadline = AbortSignal.timeout(45_000);
const expire = () => action.abort(deadline.reason);
deadline.addEventListener('abort', expire, { once: true });
const toolSignal = AbortSignal.any([action.signal, this.turnAbort.signal]);
const {
radioControl,
radioVisibility,
authorityDomain: authority,
stronger,
abortScope,
} = classifyGeminiTool(call.name, args);
let reservation = null;
let handoffEpoch = this.radio.radioHandoffEpoch;
const current = () =>
this.owns(epoch) && this.turn === turn && !toolSignal.aborted;
const actionCurrent = () =>
current() &&
!this.radioSuperseded(action, authority, epoch, turn) &&
(authority !== 'playback' ||
handoffEpoch === this.radio.radioHandoffEpoch);
let result;
let skippedGlobeRotation = false;
let blockedGlobeTool = false;
try {
if (!actionCurrent()) throw cancelled();
// Live has no response-scoped tool_choice. Recheck here so queued siblings
// cannot reset after a rotation refusal.
if (this.globeToolsBlocked) {
blockedGlobeTool = true;
throw new Error(
'Wait for fresh user input before calling another tool.',
);
}
if (
this.globeResetFailed &&
call.name === 'move_camera' &&
String(args.motion || '').toLowerCase() === 'rotate_globe'
) {
skippedGlobeRotation = true;
throw new Error(
'Not run: the Globe View reset/rotation sequence failed or was cancelled.',
);
}
if (stronger)
reservation = this.radio.reserveRadioToolHandoff({ abortScope });
handoffEpoch = this.radio.radioHandoffEpoch;
if (!actionCurrent()) throw cancelled();
if (authority)
this.radio.registerTool(action, {
responseId: String(turn),
authorityDomain: authority,
});
this.setStatus('executing', msg('Running command'));
// A common state observer may synchronously stop/replace the session.
if (!actionCurrent()) throw cancelled();
this.radio.pauseRadioForVoice();
if (!actionCurrent()) throw cancelled();
if (this.modelSpoke) this.narration.preamble();
this.narration.toolStarted(call.id, {
name: call.name,
label: narrationLabel(call.name, args),
narrate: !radioControl && !radioVisibility,
});
if (!actionCurrent()) throw cancelled();
const execution = Promise.resolve(
this.runAction(call.name, args, {
signal: toolSignal,
callId: call.id,
progress: (update) => {
if (actionCurrent()) this.narration.progress(call.id, update);
},
isCurrent: actionCurrent,
}),
);
// action-call observers can abort before waitForSignal receives the
// already-started operation; retain its rejection handler in that case.
void execution.catch(() => {});
result = await waitForSignal(execution, toolSignal);
if (!actionCurrent()) throw cancelled();
if (result?.ok && result.radioPlaybackRequested) {
const playbackResult = { ...result };
this.radioPlaybackCalls.set(playbackResult, {
id: call.id,
epoch,
turn,
});
this.radio.setPendingPlayback(playbackResult);
}
if (
result?.action === 'get_entity_context' &&
shouldSendViewportImage(result.scene?.basemap?.viewScale) &&
!hasStructuredViewIdentity(result) &&
this.now() - this.lastViewportAt >= 1000
) {
const capture = Promise.resolve(this.captureViewport());
void capture.catch(() => {});
const image = await waitForSignal(capture, toolSignal);
if (actionCurrent() && image?.startsWith('data:image/jpeg;base64,')) {
this.lastViewportAt = this.now();
this.send({
realtimeInput: {
video: {
mimeType: 'image/jpeg',
data: image.slice(image.indexOf(',') + 1),
},
},
});
}
}
} catch (error) {
result = {
ok: false,
...(skippedGlobeRotation
? { action: call.name, reason: 'globe-reset-failed', skipped: true }
: {
tool: call.name,
...(blockedGlobeTool ? { skipped: true } : {}),
}),
...(radioVisibility ? { layerId: 'radio' } : {}),
error:
error?.name === 'AbortError'
? 'Action cancelled by a newer request'
: error?.message || 'GEV command failed',
};
} finally {
deadline.removeEventListener('abort', expire);
if (this.tools.get(call.id) === action) this.tools.delete(call.id);
this.radio.releaseTool(action);
if (this.owns(epoch) && turn === this.turn)
this.narration.toolFinished(call.id);
}
if (!this.owns(epoch) || turn !== this.turn) return;
this.pendingTools--;
// Successful stronger ownership commits before the result is serialized.
// A failed reservation stays held until after its failure reaches transport.
if (reservation && result?.ok) {
this.radio.settleRadioToolHandoffReservation(reservation, {
commit: true,
responseId: String(turn),
});
reservation = null;
this.noteRadioStrongerCommit(action, abortScope, turn);
}
if (!this.owns(epoch) || turn !== this.turn) return;
if (
call.name === 'zoom_to_globe' &&
(result?.ok !== true || result.cancelled)
)
this.globeResetFailed = true;
const playbackObsolete =
authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch;
if (radioControl || radioVisibility) {
const state = this.radioLayer?.getUIState?.() || {};
const lifecycle = readLayerLifecycleSummary(this.dataManager, 'radio', {
fallbackEnabled: state.enabled ?? result?.enabled,
});
result = {
...result,
...lifecycle,
audioState: state.audioState || result?.audioState || 'stopped',
...(result?.radioAction === 'pause' && lifecycle.enabled === false
? { changed: false }
: {}),
...(playbackObsolete && result?.ok && result.radioPlaybackRequested
? { radioPlaybackRequested: false, radioPlaybackSuppressed: true }
: {}),
};
}
if (
!this.cancelledCalls.has(call.id) &&
shouldStopVoiceAfterRadioTool(result) &&
!result.radioPlaybackRequested &&
!result.radioPlaybackSuppressed
)
this.stopAfterReply = true;
// Native call cancellation suppresses its output, without canceling siblings.
if (!this.cancelledCalls.has(call.id)) {
if (isGlobeViewRefusal(result) && !skippedGlobeRotation)
this.globeToolsBlocked = true;
this.postToolOutput = false;
this.modelComplete = false;
this.replyPending = true;
const mapSnapshot = [...this.pendingMapEvents];
const mapEvents = mapSnapshot.map((text) => JSON.parse(text));
const response = {
...(result ?? { ok: false, error: 'Command returned no result' }),
responseGuidance: blockedGlobeTool
? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.'
: responseInstructionForToolResult(result),
};
this.send(
{
toolResponse: {
functionResponses: [
{
id: call.id,
name: call.name,
response: mapEvents.length
? { ...response, mapEvents }
: response,
},
],
},
},
{ onSent: () => this.consumeMapEvents(mapSnapshot) },
);
}
if (!this.owns(epoch) || turn !== this.turn) return;
if (reservation)
this.radio.settleRadioToolHandoffReservation(reservation, {
commit: false,
responseId: String(turn),
});
if (this.owns(epoch) && turn === this.turn && !this.pendingTools) {
this.setStatus('listening', msg('Ask or command'));
if (this.owns(epoch) && turn === this.turn) this.finishPlayback();
}
}
finishPlayback() {
if (this.goAway && this.now() >= this.goAway.deadline) {
this.finishGoAway();
return;
}
if (
!this.isActive() ||
!this.modelComplete ||
this.userTurnPending ||
this.pendingTools ||
(this.input.pushToTalkMode &&
this.captureOwner &&
!this.captureOwner.retired &&
!this.captureOwner.admitted &&
!this.captureOwner.settled) ||
this.audio?.pending ||
this.controlQueue.length
)
return;
const completionStatus = this.pendingCompletionStatus;
this.pendingCompletionStatus = null;
this.narration.cancel();
if (completionStatus) {
const responseId = this.intentResponseId;
const completionEpoch = this.epoch;
const completionTurn = this.turn;
this.emit({
type: 'completion',
status: completionStatus,
responseId,
spoken: Boolean(this.modelSpoke),
audioDrained: true,
});
// Observers may synchronously accept new input or replace the session.
// Old completion must not clear its metadata or overwrite its status.
if (this.epoch !== completionEpoch || this.turn !== completionTurn)
return;
// Caption completion does not create an input or tool authority boundary.
this.clearSettledSpeechIntent();
}
// A completion observer may replace this provider immediately. Do not let
// the retired session update UI or start delayed Radio work afterward.
if (!this.isActive()) return;
this.input.setVoiceSpeaker('idle');
this.setStatus('listening', msg('Ask or command'));
if (this.stopAfterReply && this.postToolOutput) {
this.stop({ preserveRadioPlayback: true });
return;
}
if (this.goAway) {
this.finishGoAway();
return;
}
if (this.postToolOutput && this.radio.pendingRadioPlaybackResult)
void this.radio.startPendingRadioHandoff();
}
sendTextCommand(text) {
const clean = String(text || '').trim();
if (!clean || this.goAway) return false;
if (clean.length > 16000)
throw new Error(msg('Voice text command is too long'));
if (this.channel?.readyState !== 'open')
throw new Error(msg('Gemini voice is not connected'));
if (this.input.pushToTalkMode && this.captureOwner)
this.captureOwner.retired = true;
const automaticPredecessor =
this.automaticTurnOpen &&
!this.replyPending &&
!this.retiredReplyPending &&
!this.deferredInput
? this.automaticActivityOwner
: null;
if (!this.interrupt('user-text')) return false;
const epoch = this.epoch;
const turn = this.turn;
const generation = this.inputGeneration;
const owned = () =>
this.owns(epoch) &&
this.turn === turn &&
this.inputGeneration === generation;
// Typed intent supersedes the held speech even if its worklet end is late.
// Close the forwarded manual activity before submitting the new text.
if (this.input.pushToTalkMode && this.forwardedActivityOpen)
this.send(
{ realtimeInput: { activityEnd: {} } },
{ captureId: this.forwardedCaptureId },
);
if (!owned()) return false;
const typedIntent = {
epoch: this.epoch,
turn: this.turn,
text: clean,
responseId: `gemini-response-${this.epoch}-${++this.intentSequence}`,
automaticPredecessor,
retiredAutomaticPredecessor: null,
};
// Keep local presentation ownership while reliable transport drains.
this.typedIntent = typedIntent;
this.beginPointerTurn('text', undefined, owned);
if (!owned()) return false;
const context = this.pendingPointerContext;
const mapSnapshot = [...this.pendingMapEvents];
const parts = context ? [{ text: JSON.stringify(context.item) }] : [];
if (this.pendingMapEvents.length)
parts.push({
text: JSON.stringify({
type: 'map_events',
events: mapSnapshot.map((value) => JSON.parse(value)),
}),
});
parts.push({ text: clean });
const sent = this.sendUserInput(
{
clientContent: { turns: [{ role: 'user', parts }], turnComplete: true },
},
undefined,
{
onSent: () => {
if (!owned() || this.typedIntent !== typedIntent) return;
this.globeToolsBlocked = false;
this.globeResetFailed = false;
this.replyPending = true;
this.bindTextIntent(typedIntent);
if (!owned() || this.typedIntent !== typedIntent) return;
this.consumePointerContext(context);
this.consumeMapEvents(mapSnapshot);
},
},
);
if (sent && owned()) {
this.narration.userTurnEnded();
}
return sent && owned();
}
consumeMapEvents(snapshot) {
// Queue acceptance is not delivery. A cancelled envelope retains passive
// context for the next genuine turn, and new events cannot be erased.
if (snapshot.every((text, index) => this.pendingMapEvents[index] === text))
this.pendingMapEvents.splice(0, snapshot.length);
}
validCaptureMetadata(metadata) {
return Number.isSafeInteger(metadata?.captureId) && metadata.captureId > 0;
}
acceptsCapture(metadata) {
return (
this.validCaptureMetadata(metadata) &&
metadata.captureId === this.captureOwner?.captureId &&
!this.captureOwner.retired
);
}
sendUserInput(message, metadata, { onSent = null } = {}) {
const owner = this.deferredInput;
const input = message.realtimeInput;
const captureContext = message.clientContent?.turnComplete === false;
// A superseded start cannot read the current pointer or acquire its queue.
// Only an end for the actually forwarded old activity may close that wire.
if (
captureContext ||
input?.activityStart ||
input?.activityEnd ||
input?.audio
) {
if (!this.validCaptureMetadata(metadata)) return false;
if (!this.acceptsCapture(metadata)) {
return input?.activityEnd &&
this.forwardedActivityOpen &&
this.forwardedCaptureId === metadata.captureId
? this.send(message, { captureId: metadata.captureId, onSent })
: false;
}
}
if (
input?.activityEnd &&
!this.forwardedActivityOpen &&
!this.controlQueue.some(
(entry) =>
entry.captureId === metadata?.captureId &&
entry.message.realtimeInput?.activityStart,
) &&
!owner?.activityOpen
)
return true;
if (!owner)
return this.send(message, {
captureId: metadata?.captureId ?? null,
onSent,
});
if (!this.owns(owner.epoch) || owner.turn !== this.turn) return false;
// Worklet callbacks keep the previous end ahead of the new start. After
// replacing unsent input, that old end/PCM has no new gesture to belong to.
if (input?.activityEnd && !owner.activityOpen)
return this.forwardedActivityOpen
? this.send(message, { captureId: this.forwardedCaptureId, onSent })
: true;
if (
input?.audio &&
(!owner.activityOpen || owner.kind !== 'push-to-talk')
) {
// These frames cannot be replayed under the deferred explicit owner.
// Preserve that discard policy while making the loss observable.
this.droppedAudioChunks++;
this.deferredAudioDrops[
owner.kind !== 'push-to-talk' ? 'nonPttOwner' : 'closedActivity'
]++;
return true;
}
const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
if (
owner.bytes + bytes > MAX_BUFFERED_BYTES ||
this.now() >= owner.deadline
) {
this.fail(
new Error(
msg(
'Gemini interrupted input exceeded its buffer or wait limit. Start voice again.',
),
),
);
return false;
}
if (input?.activityStart) {
owner.activityOpen = true;
owner.captureId = metadata?.captureId;
}
if (input?.activityEnd) owner.activityOpen = false;
owner.messages.push(message);
if (onSent) {
owner.onSent ||= new Map();
owner.onSent.set(message, onSent);
}
owner.bytes += bytes;
return true;
}
clearDeferredInput() {
if (this.deferredInput) this.clearTimer(this.deferredInput.timer);
this.deferredInput = null;
}
clearRetiredBoundaryWait() {
if (this.retiredBoundaryWait)
this.clearTimer(this.retiredBoundaryWait.timer);
this.retiredBoundaryWait = null;
}
waitForRetiredBoundary(owner) {
this.clearRetiredBoundaryWait();
const wait = { epoch: owner.epoch, deadline: owner.deadline };
this.retiredBoundaryWait = wait;
wait.timer = this.setTimer(
() => {
if (
this.retiredBoundaryWait === wait &&
this.owns(wait.epoch) &&
this.retiredReplyPending
)
this.fail(
new Error(
msg(
'Gemini did not finish the interrupted reply. Start voice again.',
),
),
);
},
Math.max(0, wait.deadline - this.now()),
);
wait.timer.unref?.();
}
// A first retirement has no deferred owner, yet everything Live sends is
// discarded until the old boundary. Never leave that wait unbounded.
boundRetiredReply() {
if (
!this.retiredReplyPending ||
this.deferredInput ||
this.retiredBoundaryWait
)
return;
this.waitForRetiredBoundary({
epoch: this.epoch,
deadline: this.now() + this.inputBoundaryTimeoutMs,
});
this.retiredBoundaryWait.extendable = true;
}
flushDeferredInput() {
const owner = this.deferredInput;
if (!owner) return;
this.clearDeferredInput();
const generation = this.inputGeneration;
const current = () =>
this.owns(owner.epoch) &&
owner.turn === this.turn &&
generation === this.inputGeneration;
if (!current()) return;
if (this.now() >= owner.deadline) {
this.fail(
new Error(
msg(
'Gemini did not finish the interrupted reply. Start voice again.',
),
),
);
return;
}
// The already submitted successor is now the one being retired. Send the
// newest start/text immediately to trigger its own provider boundary.
this.retiredReplyPending = true;
// The forwarded predecessor here is explicit PTT/text, not a later VAD
// activity that may have arrived while its boundary was pending.
this.retiredAutomaticActivityOwner = null;
// Flushing (even an empty capture queue) must not remove the absolute
// recovery bound while the forwarded predecessor still owns output.
this.waitForRetiredBoundary(owner);
this.replyPending = false;
for (const message of owner.messages) {
if (!current()) return;
const onSent = owner.onSent?.get(message);
this.send(message, {
captureId: owner.captureId ?? null,
onSent: () => {
if (!current()) return;
onSent?.();
if (!current()) return;
if (
(message.clientContent &&
message.clientContent.turnComplete !== false) ||
message.realtimeInput?.audio ||
message.realtimeInput?.text
)
this.replyPending = true;
},
});
if (!current()) return;
}
if (current() && owner.context && !this.controlQueue.length)
this.consumePointerContext(owner.context);
}
/** Use the common pointer owner; held speech context precedes audio quietly. */
beginPointerTurn(at, capturedSnapshot = undefined, isCurrent = () => true) {
if (!this.pointer || !isCurrent()) return null;
// The delayed manual speech notification still refers to the released hold.
if (
capturedSnapshot === undefined &&
at === 'speech_start' &&
this.input.pushToTalkMode &&
!this.pointer.isHeld?.()
)
return this.pointer.activeSnapshot?.() || null;
const gesture =
at === 'keydown' || (at === 'speech_start' && this.input.pushToTalkMode);
let snapshot = null;
try {
snapshot = this.pointer.beginTurn(
at,
capturedSnapshot !== undefined
? { capturedSnapshot }
: gesture
? {}
: { recentMs: POINTER_OPEN_MIC_RECENT_MS },
);
} catch {
snapshot = null;
}
if (!isCurrent()) return null;
const event = pointerTurnEvent(snapshot, gesture);
this.pointerAnnounced = event.announced;
this.emit({ type: 'pointer', pointer: event.pointer });
if (!isCurrent()) return null;
this.pendingPointerContext = pointerTurnContext(snapshot, {
at,
gesture,
sentKey: this.pointerSentKey,
});
return snapshot;
}
/** Reveal an implicit referent only after the shared action runner uses it. */
announcePointer() {
const snapshot = this.pointer?.activeSnapshot?.() || null;
const pointer = pointerAnnouncement(snapshot, this.pointerAnnounced);
if (!pointer) return false;
this.pointerAnnounced = snapshot;
this.emit({ type: 'pointer', pointer });
return true;
}
/** Pointing accompanies speech without asking Live to answer the context. */
sendPointerContext(context, metadata) {
if (!context) return true;
const message = {
clientContent: {
turns: [
{ role: 'user', parts: [{ text: JSON.stringify(context.item) }] },
],
turnComplete: false,
},
};
// Context has the same physical owner and queue as its PTT start/PCM/end.
const deferred = Boolean(this.deferredInput);
const accepted = this.sendUserInput(message, metadata, {
onSent: () => this.consumePointerContext(context),
});
if (accepted && deferred) this.consumePointerContext(context);
return accepted;
}
consumePointerContext(context) {
if (!context) return;
if (this.deferredInput) {
this.deferredInput.context = context;
if (this.pendingPointerContext === context)
this.pendingPointerContext = null;
return;
}
this.pointerSentKey = context.key;
if (this.pendingPointerContext === context)
this.pendingPointerContext = null;
}
// Any clientContent message can interrupt Live generation, including one
// with turnComplete:false. Passive map results wait for an explicit turn
// or a tool response; they must never cut off spoken output or start speech.
notifyMapEvent(event) {
if (this.goAway || this.channel?.readyState !== 'open') return false;
let text;
try {
text = JSON.stringify(event);
} catch {
return false;
}
if (!text || text.length > MAX_MAP_CONTEXT_LENGTH) return false;
this.pendingMapEvents.push(text);
while (
this.pendingMapEvents.length > MAX_MAP_EVENTS ||
this.pendingMapEvents.reduce((sum, value) => sum + value.length, 0) >
MAX_MAP_CONTEXT_LENGTH
)
this.pendingMapEvents.shift();
return true;
}
beginSpeechIntent(captureId) {
if (this.retiredReplyPending && this.currentTypedInput()) return;
// Local onset labels serve timing/UI only. They do not assert native
// transcript ownership or mint a completed-speech authorization token.
this.clearSettledSpeechIntent();
// interrupt() revokes old input when a new local turn actually starts.
// Onsets racing a retired reply must not erase its typed successor.
this.intentItemId = `gemini-input-${this.epoch}-${++this.intentSequence}`;
this.intentResponseId = null;
this.intentCaptureId = captureId;
// A physical speech onset clears display-only recent captions, including
// when retirement already fenced the reply and no second interrupt runs.
this.emit({ type: 'transcript', role: 'user', text: '', final: false });
}
bindSpeechIntent() {
if (!this.intentItemId || this.intentResponseId) return;
this.intentResponseId = `gemini-response-${this.epoch}-${this.intentSequence}`;
}
currentTypedInput(turn = this.turn) {
const input = this.typedIntent;
return input?.epoch === this.epoch && input.turn === turn ? input : null;
}
// Stronger Radio controls skip the ordered queue. An older Radio call still
// waiting behind a sibling must not start after a successful stronger
// result has revoked it; later calls in the same turn remain the model's.
noteRadioStrongerCommit(action, abortScope, turn) {
let commit = this.radioStrongerCommit;
if (commit?.epoch !== this.epoch || commit.turn !== turn)
commit = this.radioStrongerCommit = {
epoch: this.epoch,
turn,
all: 0,
playback: 0,
};
const scope = abortScope === 'all' ? 'all' : 'playback';
commit[scope] = Math.max(commit[scope], action.sequence);
}
radioSuperseded(action, authority, epoch, turn) {
const commit = this.radioStrongerCommit;
// Read-only status queries stay current, as for the shared sibling abort.
if (
!authority ||
authority === 'query' ||
commit?.epoch !== epoch ||
commit.turn !== turn
)
return false;
return (
action.sequence < commit.all ||
(authority === 'playback' && action.sequence < commit.playback)
);
}
bindTextIntent(intent) {
if (
intent !== this.typedIntent ||
intent.epoch !== this.epoch ||
intent.turn !== this.turn
)
return;
this.clearSettledSpeechIntent();
this.intentResponseId = intent.responseId;
this.emit({
type: 'transcript',
role: 'user',
text: intent.text,
final: true,
});
}
clearSettledSpeechIntent() {
this.intentResponseId = null;
this.intentItemId = null;
this.intentCaptureId = null;
}
beginGoAway(timeLeft, epoch) {
const duration =
typeof timeLeft === 'string' &&
/^(?:\d{1,6})(?:\.\d{1,9})?s$/.test(timeLeft)
? Number(timeLeft.slice(0, -1)) * 1000
: 0;
const milliseconds = Math.min(
MAX_GO_AWAY_MS,
Number.isFinite(duration) ? duration : 0,
);
const deadline = this.now() + milliseconds;
if (this.goAway && this.goAway.deadline <= deadline) return;
const supersededSuccessor =
Boolean(this.deferredInput) || Boolean(this.goAway?.supersededSuccessor);
if (this.goAway) this.clearTimer(this.goAway.timer);
const owner = {
epoch,
turn: this.turn,
deadline,
timer: null,
// Dropping the newest unsent input must not make its superseded,
// already forwarded predecessor current at the retired boundary.
supersededSuccessor,
};
this.goAway = owner;
this.clearDeferredInput();
owner.timer = this.setTimer(() => {
if (this.goAway === owner && this.owns(epoch)) this.finishGoAway();
}, milliseconds);
owner.timer?.unref?.();
this.input.setMicrophoneEnabled(false);
this.radio.cancelRadioHandoff();
this.setStatus(
this.status,
msg('Gemini session expiring; finishing the current reply'),
);
if (
milliseconds === 0 ||
(!this.replyPending &&
!this.automaticTurnOpen &&
!this.pendingTools &&
!this.audio?.pending &&
!this.controlQueue.length)
)
this.finishGoAway();
else this.finishPlayback();
}
finishGoAway() {
if (!this.goAway) return;
this.fail(
new Error(
msg(
'Gemini Live connection is expiring. Start voice again for a new conversation.',
),
),
msg('Start voice again for a fresh token and a new conversation.'),
);
}
getDiagnostics() {
return {
provider: 'gemini',
status: this.status,
model: this.model,
connected: this.channel?.readyState === 'open',
microphoneActive: Boolean(
this.stream
?.getAudioTracks()
.some((track) => track.enabled && track.readyState !== 'ended'),
),
pendingTools: this.pendingTools,
narration: this.narration.state(),
completedTurns: this.completedTurns,
audio: this.audio?.diagnostics() || this.lastAudio,
error: this.lastError,
close: this.lastClose,
droppedAudioChunks: this.droppedAudioChunks,
deferredAudioDrops: { ...this.deferredAudioDrops },
droppedImages: this.droppedImages,
queuedControls: this.controlQueue.length,
expiring: Boolean(this.goAway),
};
}
stop({
removeUi = false,
preserveStatus = false,
preserveRadioPlayback = false,
} = {}) {
if (this.disposed) return;
this.epoch++;
this.inputGeneration++;
this.clearSettledSpeechIntent();
this.clearControls();
if (this.goAway) this.clearTimer(this.goAway.timer);
this.goAway = null;
this.abort?.abort();
this.abort = null;
this.abortTools();
this.earcon.dispose();
this.radio.invalidateHandoff();
this.radio.stopHandoff({ preserveRadioPlayback });
this.radio.clearPendingPlayback();
if (this.channel) this.channel.readyState = 'closed';
this.channel = null;
const socket = this.socket;
this.socket = null;
if (socket) {
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
socket.close();
}
if (this.audio) {
const lastAudio = {
...this.audio.diagnostics(),
queuedSeconds: 0,
resourcesReleased: true,
contextState: 'closing',
};
this.lastAudio = lastAudio;
void Promise.resolve(this.audio.close()).then(
(closed) => {
lastAudio.contextState = closed === false ? 'close-failed' : 'closed';
},
() => {
lastAudio.contextState = 'close-failed';
},
);
}
this.audio = null;
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
this.input.cancelPushToTalkHold();
this.input.stopVoiceVisualizer();
this.pointer?.clear({ keepHold: this.input.pushToTalkKeyHeld });
if (!this.input.pushToTalkKeyHeld) this.heldPointerSnapshot = undefined;
this.input.resetSession();
this.radio.setRadioVoiceDucking(false);
this.modelComplete = false;
this.pendingCompletionStatus = null;
this.postToolOutput = false;
this.modelSpoke = false;
this.stopAfterReply = false;
this.seenCalls.clear();
this.cancelledCalls.clear();
this.pendingMapEvents = [];
this.userTurnPending = false;
this.globeToolsBlocked = false;
this.globeResetFailed = false;
this.replyPending = false;
this.unrequestedReply = false;
this.automaticTurnOpen = false;
this.automaticActivityOwner = null;
this.retiredAutomaticActivityOwner = null;
this.automaticActivitySequence = 0;
this.retiredReplyPending = false;
this.clearDeferredInput();
this.clearRetiredBoundaryWait();
this.forwardedActivityOpen = false;
this.forwardedCaptureId = null;
this.captureEnabled = false;
this.pttPlaybackSuppressed = false;
this.microphoneRequested = false;
this.captureOwner = null;
this.userActivityOpen = false;
this.pointerSentKey = null;
this.pointerAnnounced = null;
this.pendingPointerContext = null;
this.transcripts = { assistant: '' };
if (removeUi) {
this.disposed = true;
this.status = 'idle';
this.input.detachBindings();
this.radio.detachObservers();
this.signal?.removeEventListener('abort', this.lifetimeAbort);
this.ui?.root?.remove();
this.emit({ type: 'disposed' });
} else if (!preserveStatus) this.setStatus('idle', msg('Voice off'));
}
}
export function createGeminiSession(options) {
const controller = new GeminiSessionController(options);
return {
controller,
capabilities: {
costControls: false,
pushToTalk: true,
advisoryLimit: t(
'Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.',
),
},
start: (settings) => controller.start(settings),
getStartOptions: () => ({
pushToTalk: Boolean(controller.input.pushToTalkMode),
}),
stop: (settings) => controller.stop(settings),
sendText: (text) => controller.sendTextCommand(text),
sendMapEvent: (event) => controller.notifyMapEvent(event),
ignoreButtonClick: () => Boolean(controller.spaceKeyHeld),
bindControls: () => controller.input.bindPushToTalkShortcut(),
};
}