@memberjunction/ai-realtime-client 6.2.0-edge.3 → 6.2.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -29
- package/dist/audio/audioMeter.d.ts +71 -0
- package/dist/audio/audioMeter.d.ts.map +1 -1
- package/dist/audio/audioMeter.js +83 -0
- package/dist/audio/audioMeter.js.map +1 -1
- package/dist/audio/mediaElementAudioRouter.d.ts +41 -0
- package/dist/audio/mediaElementAudioRouter.d.ts.map +1 -0
- package/dist/audio/mediaElementAudioRouter.js +82 -0
- package/dist/audio/mediaElementAudioRouter.js.map +1 -0
- package/dist/audio/micCapture.d.ts +12 -3
- package/dist/audio/micCapture.d.ts.map +1 -1
- package/dist/audio/micCapture.js +15 -1
- package/dist/audio/micCapture.js.map +1 -1
- package/dist/audio/pcmPlayback.d.ts +72 -4
- package/dist/audio/pcmPlayback.d.ts.map +1 -1
- package/dist/audio/pcmPlayback.js +101 -1
- package/dist/audio/pcmPlayback.js.map +1 -1
- package/dist/drivers/assemblyAIRealtimeClient.d.ts +2 -0
- package/dist/drivers/assemblyAIRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/assemblyAIRealtimeClient.js +14 -0
- package/dist/drivers/assemblyAIRealtimeClient.js.map +1 -1
- package/dist/drivers/elevenLabsRealtimeClient.d.ts +2 -0
- package/dist/drivers/elevenLabsRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/elevenLabsRealtimeClient.js +14 -0
- package/dist/drivers/elevenLabsRealtimeClient.js.map +1 -1
- package/dist/drivers/geminiAvatarOutput.d.ts +132 -0
- package/dist/drivers/geminiAvatarOutput.d.ts.map +1 -0
- package/dist/drivers/geminiAvatarOutput.js +310 -0
- package/dist/drivers/geminiAvatarOutput.js.map +1 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts +38 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts.map +1 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.js +99 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.js.map +1 -0
- package/dist/drivers/geminiRealtimeClient.d.ts +325 -50
- package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/geminiRealtimeClient.js +782 -184
- package/dist/drivers/geminiRealtimeClient.js.map +1 -1
- package/dist/drivers/openAILiveClient.d.ts +5 -8
- package/dist/drivers/openAILiveClient.d.ts.map +1 -1
- package/dist/drivers/openAILiveClient.js +19 -32
- package/dist/drivers/openAILiveClient.js.map +1 -1
- package/dist/drivers/openAIRealtimeClient.d.ts +13 -26
- package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/openAIRealtimeClient.js +28 -55
- package/dist/drivers/openAIRealtimeClient.js.map +1 -1
- package/dist/drivers/xaiRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/xaiRealtimeClient.js +19 -14
- package/dist/drivers/xaiRealtimeClient.js.map +1 -1
- package/dist/generic/baseRealtimeClient.d.ts +183 -29
- package/dist/generic/baseRealtimeClient.d.ts.map +1 -1
- package/dist/generic/baseRealtimeClient.js +246 -14
- package/dist/generic/baseRealtimeClient.js.map +1 -1
- package/dist/generic/openAIProtocolClient.d.ts +2 -0
- package/dist/generic/openAIProtocolClient.d.ts.map +1 -1
- package/dist/generic/openAIProtocolClient.js +17 -0
- package/dist/generic/openAIProtocolClient.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/media/attachVideoSource.d.ts +23 -0
- package/dist/media/attachVideoSource.d.ts.map +1 -0
- package/dist/media/attachVideoSource.js +49 -0
- package/dist/media/attachVideoSource.js.map +1 -0
- package/dist/media/channelVideoSource.d.ts +110 -14
- package/dist/media/channelVideoSource.d.ts.map +1 -1
- package/dist/media/channelVideoSource.js +183 -18
- package/dist/media/channelVideoSource.js.map +1 -1
- package/dist/media/decoders/frameScheduler.d.ts +119 -0
- package/dist/media/decoders/frameScheduler.d.ts.map +1 -0
- package/dist/media/decoders/frameScheduler.js +238 -0
- package/dist/media/decoders/frameScheduler.js.map +1 -0
- package/dist/media/decoders/imageFrameDecoder.d.ts +58 -0
- package/dist/media/decoders/imageFrameDecoder.d.ts.map +1 -0
- package/dist/media/decoders/imageFrameDecoder.js +110 -0
- package/dist/media/decoders/imageFrameDecoder.js.map +1 -0
- package/dist/media/decoders/mseFmp4Decoder.d.ts +130 -0
- package/dist/media/decoders/mseFmp4Decoder.d.ts.map +1 -0
- package/dist/media/decoders/mseFmp4Decoder.js +402 -0
- package/dist/media/decoders/mseFmp4Decoder.js.map +1 -0
- package/dist/media/decoders/streamCanvas.d.ts +46 -0
- package/dist/media/decoders/streamCanvas.d.ts.map +1 -0
- package/dist/media/decoders/streamCanvas.js +163 -0
- package/dist/media/decoders/streamCanvas.js.map +1 -0
- package/dist/media/decoders/videoMimeTypes.d.ts +29 -0
- package/dist/media/decoders/videoMimeTypes.d.ts.map +1 -0
- package/dist/media/decoders/videoMimeTypes.js +50 -0
- package/dist/media/decoders/videoMimeTypes.js.map +1 -0
- package/dist/media/decoders/webCodecsChunkDecoder.d.ts +99 -0
- package/dist/media/decoders/webCodecsChunkDecoder.d.ts.map +1 -0
- package/dist/media/decoders/webCodecsChunkDecoder.js +265 -0
- package/dist/media/decoders/webCodecsChunkDecoder.js.map +1 -0
- package/dist/media/displayCapture.d.ts +128 -0
- package/dist/media/displayCapture.d.ts.map +1 -0
- package/dist/media/displayCapture.js +245 -0
- package/dist/media/displayCapture.js.map +1 -0
- package/dist/media/frameCapture.d.ts +25 -8
- package/dist/media/frameCapture.d.ts.map +1 -1
- package/dist/media/frameCapture.js +42 -72
- package/dist/media/frameCapture.js.map +1 -1
- package/dist/media/frameSampler.d.ts +94 -0
- package/dist/media/frameSampler.d.ts.map +1 -0
- package/dist/media/frameSampler.js +134 -0
- package/dist/media/frameSampler.js.map +1 -0
- package/dist/media/index.d.ts +32 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +32 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/localMediaController.d.ts +116 -0
- package/dist/media/localMediaController.d.ts.map +1 -0
- package/dist/media/localMediaController.js +281 -0
- package/dist/media/localMediaController.js.map +1 -0
- package/dist/media/mediaLayout.d.ts +93 -0
- package/dist/media/mediaLayout.d.ts.map +1 -0
- package/dist/media/mediaLayout.js +187 -0
- package/dist/media/mediaLayout.js.map +1 -0
- package/dist/media/mediaPreview.d.ts +94 -0
- package/dist/media/mediaPreview.d.ts.map +1 -0
- package/dist/media/mediaPreview.js +154 -0
- package/dist/media/mediaPreview.js.map +1 -0
- package/dist/media/mediaStage.d.ts +99 -0
- package/dist/media/mediaStage.d.ts.map +1 -0
- package/dist/media/mediaStage.js +171 -0
- package/dist/media/mediaStage.js.map +1 -0
- package/dist/media/model.d.ts +154 -0
- package/dist/media/model.d.ts.map +1 -0
- package/dist/media/model.js +9 -0
- package/dist/media/model.js.map +1 -0
- package/dist/media/playbackClock.d.ts +20 -0
- package/dist/media/playbackClock.d.ts.map +1 -0
- package/dist/media/playbackClock.js +13 -0
- package/dist/media/playbackClock.js.map +1 -0
- package/dist/media/videoFrameDecoder.d.ts +86 -0
- package/dist/media/videoFrameDecoder.d.ts.map +1 -0
- package/dist/media/videoFrameDecoder.js +2 -0
- package/dist/media/videoFrameDecoder.js.map +1 -0
- package/dist/media/videoFrameDecoderRegistry.d.ts +40 -0
- package/dist/media/videoFrameDecoderRegistry.d.ts.map +1 -0
- package/dist/media/videoFrameDecoderRegistry.js +69 -0
- package/dist/media/videoFrameDecoderRegistry.js.map +1 -0
- package/dist/media/videoPacing.d.ts +32 -0
- package/dist/media/videoPacing.d.ts.map +1 -0
- package/dist/media/videoPacing.js +37 -0
- package/dist/media/videoPacing.js.map +1 -0
- package/dist/media/videoPlayout.d.ts +168 -0
- package/dist/media/videoPlayout.d.ts.map +1 -0
- package/dist/media/videoPlayout.js +217 -0
- package/dist/media/videoPlayout.js.map +1 -0
- package/dist/media/videoSourceArbiter.d.ts +250 -0
- package/dist/media/videoSourceArbiter.d.ts.map +1 -0
- package/dist/media/videoSourceArbiter.js +423 -0
- package/dist/media/videoSourceArbiter.js.map +1 -0
- package/dist/testing/conformanceAssertions.d.ts +25 -0
- package/dist/testing/conformanceAssertions.d.ts.map +1 -0
- package/dist/testing/conformanceAssertions.js +64 -0
- package/dist/testing/conformanceAssertions.js.map +1 -0
- package/dist/testing/conformanceFixtures.d.ts +76 -0
- package/dist/testing/conformanceFixtures.d.ts.map +1 -0
- package/dist/testing/conformanceFixtures.js +169 -0
- package/dist/testing/conformanceFixtures.js.map +1 -0
- package/dist/testing/conformanceGates.d.ts +32 -0
- package/dist/testing/conformanceGates.d.ts.map +1 -0
- package/dist/testing/conformanceGates.js +35 -0
- package/dist/testing/conformanceGates.js.map +1 -0
- package/dist/testing/conformanceMicrophone.d.ts +10 -0
- package/dist/testing/conformanceMicrophone.d.ts.map +1 -0
- package/dist/testing/conformanceMicrophone.js +71 -0
- package/dist/testing/conformanceMicrophone.js.map +1 -0
- package/dist/testing/conformanceTimeline.d.ts +128 -0
- package/dist/testing/conformanceTimeline.d.ts.map +1 -0
- package/dist/testing/conformanceTimeline.js +32 -0
- package/dist/testing/conformanceTimeline.js.map +1 -0
- package/dist/testing/conformanceVideoStream.d.ts +29 -0
- package/dist/testing/conformanceVideoStream.d.ts.map +1 -0
- package/dist/testing/conformanceVideoStream.js +39 -0
- package/dist/testing/conformanceVideoStream.js.map +1 -0
- package/dist/testing/index.d.ts +29 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +28 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/realtimeVideoConformance.d.ts +23 -0
- package/dist/testing/realtimeVideoConformance.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformance.js +139 -0
- package/dist/testing/realtimeVideoConformance.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceChecks.js +164 -0
- package/dist/testing/realtimeVideoConformanceChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.js +63 -0
- package/dist/testing/realtimeVideoConformanceFrameChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceSession.d.ts +108 -0
- package/dist/testing/realtimeVideoConformanceSession.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceSession.js +270 -0
- package/dist/testing/realtimeVideoConformanceSession.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts +4 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.js +248 -0
- package/dist/testing/realtimeVideoConformanceTurnChecks.js.map +1 -0
- package/dist/testing/realtimeVideoConformanceTypes.d.ts +155 -0
- package/dist/testing/realtimeVideoConformanceTypes.d.ts.map +1 -0
- package/dist/testing/realtimeVideoConformanceTypes.js +2 -0
- package/dist/testing/realtimeVideoConformanceTypes.js.map +1 -0
- package/dist/testing/recordingPlayout.d.ts +84 -0
- package/dist/testing/recordingPlayout.d.ts.map +1 -0
- package/dist/testing/recordingPlayout.js +120 -0
- package/dist/testing/recordingPlayout.js.map +1 -0
- package/package.json +29 -4
|
@@ -6,14 +6,18 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
};
|
|
7
7
|
var GeminiRealtimeClient_1;
|
|
8
8
|
import { RegisterClass } from '@memberjunction/global';
|
|
9
|
-
import { RealtimeDiagLog, RealtimeToolBatchBarrier, ExtractToolSchedulingHint, } from '@memberjunction/ai';
|
|
10
|
-
import { GoogleGenAI, FunctionResponseScheduling, } from '@google/genai';
|
|
11
|
-
import { BaseRealtimeClient, REQUESTED_TRACKS_SESSION_KEY } from '../generic/baseRealtimeClient.js';
|
|
9
|
+
import { RealtimeDiagLog, IsPcmAudioMimeType, ParseDurationToMs, RealtimeConnectionSetup, RealtimeSessionResumption, RealtimeToolBatchBarrier, ExtractToolSchedulingHint, REALTIME_RELAY_TIME_LIMIT_CLOSE_CODE, } from '@memberjunction/ai';
|
|
10
|
+
import { GoogleGenAI, FunctionResponseScheduling, Modality, } from '@google/genai';
|
|
11
|
+
import { BaseRealtimeClient, REALTIME_CLIENT_TIME_LIMIT_ERROR_CODE, REQUESTED_TRACKS_SESSION_KEY, } from '../generic/baseRealtimeClient.js';
|
|
12
12
|
import { Base64ToArrayBuffer } from '../audio/pcmUtils.js';
|
|
13
13
|
import { RealtimePcmPlayback } from '../audio/pcmPlayback.js';
|
|
14
14
|
import { RealtimeAudioMeter } from '../audio/audioMeter.js';
|
|
15
15
|
import { CreatePcmMicCapture } from '../audio/micCapture.js';
|
|
16
|
-
import {
|
|
16
|
+
import { FrameSampler } from '../media/frameSampler.js';
|
|
17
|
+
import { DEFAULT_INBOUND_VIDEO_RATE, MinVideoFrameSpacingMs } from '../media/videoPacing.js';
|
|
18
|
+
import { VideoSourceArbiter } from '../media/videoSourceArbiter.js';
|
|
19
|
+
import { GEMINI_AVATAR_MP4_TYPE, VideoPlayout } from '../media/videoPlayout.js';
|
|
20
|
+
import { GeminiAvatarOutput } from './geminiAvatarOutput.js';
|
|
17
21
|
// ── Audio constants (Gemini Live wire formats) ─────────────────────────────────
|
|
18
22
|
/** Gemini Live expects client audio as 16-bit signed PCM, 16 kHz, mono. */
|
|
19
23
|
const GEMINI_INPUT_SAMPLE_RATE = 16000;
|
|
@@ -21,6 +25,17 @@ const GEMINI_INPUT_SAMPLE_RATE = 16000;
|
|
|
21
25
|
const GEMINI_INPUT_AUDIO_MIME_TYPE = 'audio/pcm;rate=16000';
|
|
22
26
|
/** Gemini Live emits model audio as 16-bit signed PCM, 24 kHz, mono. */
|
|
23
27
|
const GEMINI_OUTPUT_SAMPLE_RATE = 24000;
|
|
28
|
+
/** How much microphone audio is held while the session moves to a new connection; older audio is dropped first. */
|
|
29
|
+
const HELD_MIC_AUDIO_SECONDS = 2;
|
|
30
|
+
/** {@link HELD_MIC_AUDIO_SECONDS} of 16 kHz, 16-bit mono PCM, as the base64 characters the chunks travel in. */
|
|
31
|
+
const MAX_HELD_MIC_AUDIO_BASE64_CHARS = Math.ceil((GEMINI_INPUT_SAMPLE_RATE * 2 * HELD_MIC_AUDIO_SECONDS * 4) / 3);
|
|
32
|
+
/** The detail field each Gemini `usageMetadata` modality's token count goes into; other modalities are not kept. */
|
|
33
|
+
const GEMINI_MODALITY_TOKEN_FIELDS = {
|
|
34
|
+
TEXT: 'TextTokens',
|
|
35
|
+
AUDIO: 'AudioTokens',
|
|
36
|
+
IMAGE: 'ImageTokens',
|
|
37
|
+
VIDEO: 'VideoTokens',
|
|
38
|
+
};
|
|
24
39
|
// ── Legacy video-capability fallback ───────────────────────────────────────────
|
|
25
40
|
//
|
|
26
41
|
// Video capability and its rate ceiling are per-model data, minted from the provider's profile
|
|
@@ -33,6 +48,8 @@ const GEMINI_OUTPUT_SAMPLE_RATE = 24000;
|
|
|
33
48
|
const LEGACY_VIDEO_MODEL_PREFIX = 'gemini-3.8-live';
|
|
34
49
|
/** The frame-rate ceiling this client hardcoded before `MaxInboundVideoRate` was minted. */
|
|
35
50
|
const LEGACY_VIDEO_MODEL_RATE = 1;
|
|
51
|
+
/** The arbiter source id of the camera stream passed to `Connect`. */
|
|
52
|
+
const CONNECT_CAMERA_SOURCE_ID = 'connect-camera';
|
|
36
53
|
// ── Production playback engine ─────────────────────────────────────────────────
|
|
37
54
|
/**
|
|
38
55
|
* Web Audio playout scheduler for Gemini's 24 kHz PCM16 model audio.
|
|
@@ -62,21 +79,36 @@ export class GeminiPcmPlayback extends RealtimePcmPlayback {
|
|
|
62
79
|
* ({@link createMicCapture} seam) and streamed with `sendRealtimeInput`.
|
|
63
80
|
* - **Audio out**: model PCM16 @ 24 kHz chunks scheduled through {@link GeminiPcmPlayback}
|
|
64
81
|
* ({@link createPlayback} seam); {@link IsAudioPlaying} is computed from the playout clock.
|
|
82
|
+
* - **Avatar out**: when the minted config grants an avatar and the host shows it (the outbound
|
|
83
|
+
* video track is live), fragmented MP4 parts play through a {@link VideoPlayout}
|
|
84
|
+
* ({@link CreateVideoPlayout} seam) handed to the host at connect, whose audio routes into the
|
|
85
|
+
* PCM playback's graph; PCM follows the avatar's voice rule (see `GeminiAvatarOutput`). A host
|
|
86
|
+
* that shows no avatar gets an audio-only session.
|
|
65
87
|
* - **Event translation** (Gemini → contract): `inputTranscription` → User deltas/finals,
|
|
66
88
|
* `outputTranscription` → Assistant deltas/finals (accumulated like the OpenAI driver),
|
|
67
89
|
* `toolCall.functionCalls` → {@link OnToolCall} (callID→name cached for
|
|
68
90
|
* {@link SendToolResult}), `interrupted` → playback flush + `'listening'`,
|
|
69
91
|
* `turnComplete` → busy cleared + queued sends flushed, `usageMetadata` → {@link OnUsage}
|
|
70
|
-
* (per-turn prompt/response token deltas — see {@link handleUsageMetadata}).
|
|
92
|
+
* (per-turn prompt/response token deltas with their modality split — see {@link handleUsageMetadata}).
|
|
93
|
+
* An avatar's generated video seconds go to {@link OnUsage} in updates of their own
|
|
94
|
+
* (`OutputTokenDetails.VideoSeconds`) at each turn boundary.
|
|
71
95
|
* - **Busy mapping**: Gemini has no `response.created` frame, so `IsBusy` is set EAGERLY when
|
|
72
96
|
* this client triggers a response (text / narration / tool result) and on the first model
|
|
73
97
|
* output of a turn (audio part or output-transcription delta); cleared on `turnComplete`,
|
|
74
98
|
* and on a `toolCall` frame (the model has yielded the floor pending the tool result — so a
|
|
75
|
-
* slow `turnComplete` can never deadlock the queued result).
|
|
99
|
+
* slow `turnComplete` can never deadlock the queued result). In an avatar session, video sets
|
|
100
|
+
* it only while the model's answer is under way: Vertex AI streams the avatar's video between
|
|
101
|
+
* answers too, and that idle video plays without marking the agent speaking or busy.
|
|
76
102
|
* - **Collision safety**: ANY `sendClientContent` interrupts in-flight Gemini generation (per
|
|
77
103
|
* the Live API contract), so text / narration / context-note / tool-result sends issued
|
|
78
104
|
* while a turn is in flight are queued and flushed in order on `turnComplete` (the flush
|
|
79
105
|
* stops at the first send that starts a new response).
|
|
106
|
+
* - **Moving to a new connection**: Google ends a connection after about 10 minutes, announcing
|
|
107
|
+
* it with `goAway`, and the session resumes on a new one with Google's handle
|
|
108
|
+
* ({@link RealtimeSessionResumption}). A planned move waits until no turn is in progress
|
|
109
|
+
* ({@link IsTurnOpen}). A connection is put to use only once Google confirms its setup
|
|
110
|
+
* (`setupComplete`): input that reaches it earlier is dropped. During a move, sends queue and
|
|
111
|
+
* the latest 2 s of mic audio are held; both go out on the new connection once it is ready.
|
|
80
112
|
* - **Open-turn commit**: context notes ride as `turnComplete: false` client content, which
|
|
81
113
|
* tells the Live API MORE INPUT IS COMING — the server holds ALL generation (including the
|
|
82
114
|
* normally-automatic continuation after a tool response) until a `turnComplete: true`
|
|
@@ -105,13 +137,49 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
105
137
|
this.micStream = null;
|
|
106
138
|
this.cameraStream = null;
|
|
107
139
|
this.micCapture = null;
|
|
108
|
-
this.
|
|
140
|
+
this.cameraSampler = null;
|
|
109
141
|
this.playback = null;
|
|
142
|
+
/**
|
|
143
|
+
* The avatar half of the session: created at connect when the server granted an avatar and the host shows it (the
|
|
144
|
+
* outbound video track is live). `null` otherwise, and the session then plays model output as it always has.
|
|
145
|
+
*/
|
|
146
|
+
this.avatarOutput = null;
|
|
110
147
|
this.firstVideoSendTimestamp = 0;
|
|
111
148
|
this.lastVideoSendTimestamp = 0;
|
|
112
149
|
this.videoFramesSent = 0;
|
|
113
|
-
|
|
114
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Moves the session to a new connection with Google's resumption handle: when Google
|
|
152
|
+
* announces the connection is ending (`goAway`, with the time left; on Vertex AI it came about
|
|
153
|
+
* 9 minutes into a connection, with 30 s left) and after an unexpected drop. A planned move
|
|
154
|
+
* waits until no turn is in progress ({@link IsTurnOpen}). Created per {@link Connect}.
|
|
155
|
+
*/
|
|
156
|
+
this.resumption = null;
|
|
157
|
+
/**
|
|
158
|
+
* Whether input can go out: the connection in use has confirmed its setup (`setupComplete`) and
|
|
159
|
+
* no move to a new connection is in progress. Google drops input that reaches a connection
|
|
160
|
+
* before its setup completes, and a connection being replaced resumes from a handle issued
|
|
161
|
+
* before the move, so what it receives never reaches the new one. While `false`, sends wait in
|
|
162
|
+
* {@link queuedSends} and microphone audio in {@link heldMicAudio}.
|
|
163
|
+
*/
|
|
164
|
+
this.connectionReady = false;
|
|
165
|
+
/** Microphone audio captured during a move, sent on the new connection once it is ready. */
|
|
166
|
+
this.heldMicAudio = [];
|
|
167
|
+
/** Base64 characters in {@link heldMicAudio}, kept under {@link MAX_HELD_MIC_AUDIO_BASE64_CHARS}. */
|
|
168
|
+
this.heldMicAudioChars = 0;
|
|
169
|
+
/** See {@link IsTurnOpen}. */
|
|
170
|
+
this.turnOpen = false;
|
|
171
|
+
/** A turn's `turnComplete` came while a tool call was pending: the turn ends once the last pending call's result goes out. */
|
|
172
|
+
this.turnEndAwaitsToolResults = false;
|
|
173
|
+
/** Model, config and token of the current connection; a resume reuses them with the new handle. */
|
|
174
|
+
this.connectTarget = null;
|
|
175
|
+
/** Last connection number handed out by {@link openConnection}. */
|
|
176
|
+
this.issuedConnections = 0;
|
|
177
|
+
/**
|
|
178
|
+
* Number of the connection in use. Callbacks from any other connection (one that was
|
|
179
|
+
* replaced, closed, or is still opening) are ignored, so a replaced socket's close can't end
|
|
180
|
+
* the session that replaced it. `0` while no connection is in use.
|
|
181
|
+
*/
|
|
182
|
+
this.currentConnection = 0;
|
|
115
183
|
// ── Model capability & profile state ───────────────────────────────────────
|
|
116
184
|
this.idleSignal = 'turnComplete';
|
|
117
185
|
this.supportsScheduling = true;
|
|
@@ -126,6 +194,8 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
126
194
|
this.pendingUserText = '';
|
|
127
195
|
/** Accumulates in-flight thought text deltas until finalized on turn completion. */
|
|
128
196
|
this.pendingThoughtText = '';
|
|
197
|
+
/** MIME types of model output this session dropped, so each is reported once. */
|
|
198
|
+
this.droppedOutputTypes = new Set();
|
|
129
199
|
/** True while a model turn is in flight; gates (queues) client-triggered sends. */
|
|
130
200
|
this.responseActive = false;
|
|
131
201
|
/** The kind of the turn currently in flight; stamped at send time, reset on turnComplete. */
|
|
@@ -159,7 +229,19 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
159
229
|
static { this.ASSISTANT_SAFETY_BACKSTOP_MS = 15000; }
|
|
160
230
|
/** Returns the latest session resumption handle reported by the server, if any. */
|
|
161
231
|
get ResumptionHandle() {
|
|
162
|
-
return this.
|
|
232
|
+
return this.resumption?.Handle ?? null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether a turn is in progress. A turn opens at its first sign: the user's first transcribed words, a turn this
|
|
236
|
+
* client sends (typed text, a spoken update), or the model's output (a transcription delta, a thought, a tool call,
|
|
237
|
+
* an audio part, which in an avatar session must play as the voice, or, on an `interactionStatus` model, the
|
|
238
|
+
* `IN_PROGRESS` status; the video an avatar streams between turns never opens one). It closes at the turn's
|
|
239
|
+
* `turnComplete` (the true idle signal on an `interactionStatus` model) once no tool call is pending, or when the last
|
|
240
|
+
* pending call's result goes out after that; and at a resume. The session moves to a new connection only between
|
|
241
|
+
* turns.
|
|
242
|
+
*/
|
|
243
|
+
get IsTurnOpen() {
|
|
244
|
+
return this.turnOpen;
|
|
163
245
|
}
|
|
164
246
|
/** Returns the count of video frames successfully sent over the established video track. */
|
|
165
247
|
get VideoFramesSent() {
|
|
@@ -184,20 +266,60 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
184
266
|
return !this.supportsBlocking;
|
|
185
267
|
}
|
|
186
268
|
// ── BaseRealtimeClient: connection lifecycle ───────────────────────────────
|
|
187
|
-
/**
|
|
188
|
-
* Opens the client-direct Gemini Live session: creates the playout engine, connects with
|
|
189
|
-
* the ephemeral token + the server-built `SessionConfig` (`{ model, config }` — the same
|
|
190
|
-
* values the server LOCKED into the token, so tampering is ignored by the API), then wires
|
|
191
|
-
* the mic-capture worklet. Reports `'listening'` once audio is flowing.
|
|
192
|
-
*/
|
|
193
269
|
/**
|
|
194
270
|
* Opens the client-direct Gemini Live session: creates the playout engine, connects with
|
|
195
271
|
* the ephemeral token + the server-built `SessionConfig` (`{ model, config }` — the same
|
|
196
272
|
* values the server LOCKED into the token, so tampering is ignored by the API), negotiates
|
|
197
273
|
* tracks, then wires the mic-capture worklet and optional video capture.
|
|
198
|
-
* Reports `'
|
|
274
|
+
* Reports `'connected'` once Google confirms the session's setup (`setupComplete`): input
|
|
275
|
+
* sent before that is dropped, so nothing goes out on the connection, and the microphone
|
|
276
|
+
* does not start, until then. Reports `'listening'` once audio is flowing.
|
|
277
|
+
*
|
|
278
|
+
* When the server granted an avatar (the minted `avatar` block) and the host shows it (the
|
|
279
|
+
* outbound video track is live), the avatar's video goes to the host once the connection is
|
|
280
|
+
* open. When the host doesn't show it, the session connects audio only.
|
|
281
|
+
*
|
|
282
|
+
* @throws When the connection closes or fails before Google confirms its setup, or no
|
|
283
|
+
* confirmation comes in time (see `RealtimeConnectionSetup`).
|
|
199
284
|
*/
|
|
200
285
|
async Connect(config, micStream, cameraStream) {
|
|
286
|
+
this.AssertTransportSupported(config);
|
|
287
|
+
this.resetForConnect(micStream, cameraStream);
|
|
288
|
+
this.setState('connecting');
|
|
289
|
+
const session = this.parseSessionConfig(config);
|
|
290
|
+
this.idleSignal = session.idleSignal;
|
|
291
|
+
this.supportsScheduling = session.supportsScheduling;
|
|
292
|
+
this.supportsBlocking = session.supportsBlocking;
|
|
293
|
+
this.negotiateSessionTracks(session);
|
|
294
|
+
const playback = this.createPlayback();
|
|
295
|
+
this.playback = playback;
|
|
296
|
+
// The agent voice plays through Web Audio only; publish it so a host recorder can mix
|
|
297
|
+
// it in (issue #5153). Null for playbacks with no output stream (fakes, no WebAudio).
|
|
298
|
+
this.publishRemoteMediaStream(playback.GetOutputStream?.() ?? null);
|
|
299
|
+
const liveConfig = this.prepareAvatar(session, playback);
|
|
300
|
+
this.resumption?.Dispose();
|
|
301
|
+
this.resumption = this.createResumption();
|
|
302
|
+
const target = {
|
|
303
|
+
Model: session.model,
|
|
304
|
+
Config: liveConfig,
|
|
305
|
+
EphemeralToken: config.EphemeralToken,
|
|
306
|
+
Transport: config.Transport,
|
|
307
|
+
RelayUrl: config.RelayUrl,
|
|
308
|
+
};
|
|
309
|
+
this.connectTarget = target;
|
|
310
|
+
if (!(await this.openFirstConnection(target))) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.setState('connected');
|
|
314
|
+
// Handed over now rather than at the first part, so the avatar's tile is up before the agent speaks.
|
|
315
|
+
if (this.avatarOutput) {
|
|
316
|
+
this.emitRemoteVideo(this.avatarOutput.Source);
|
|
317
|
+
}
|
|
318
|
+
await this.startLocalCapture(micStream);
|
|
319
|
+
this.setState('listening');
|
|
320
|
+
}
|
|
321
|
+
/** Takes the streams for a new connect and clears what an earlier one left behind. */
|
|
322
|
+
resetForConnect(micStream, cameraStream) {
|
|
201
323
|
this.micStream = micStream;
|
|
202
324
|
this.cameraStream = cameraStream ?? null;
|
|
203
325
|
this.clearSafetyBackstop();
|
|
@@ -205,18 +327,53 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
205
327
|
this.firstVideoSendTimestamp = 0;
|
|
206
328
|
this.lastVideoSendTimestamp = 0;
|
|
207
329
|
this.videoFramesSent = 0;
|
|
208
|
-
this.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
330
|
+
this.resetConnectionState();
|
|
331
|
+
this.disposeAvatarOutput();
|
|
332
|
+
}
|
|
333
|
+
/** No connection ready, no audio held, no turn open: the state of a session before its first connection. */
|
|
334
|
+
resetConnectionState() {
|
|
335
|
+
this.connectionReady = false;
|
|
336
|
+
this.heldMicAudio = [];
|
|
337
|
+
this.heldMicAudioChars = 0;
|
|
338
|
+
this.turnOpen = false;
|
|
339
|
+
this.turnEndAwaitsToolResults = false;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Opens the session's first connection and puts it to use once Google confirms its setup.
|
|
343
|
+
*
|
|
344
|
+
* @returns `false` when the consumer disconnected while the setup was in flight; the connection is then closed, and a
|
|
345
|
+
* setup failure after the disconnect is not reported.
|
|
346
|
+
*/
|
|
347
|
+
async openFirstConnection(target) {
|
|
348
|
+
const opened = await this.openConnection(target);
|
|
349
|
+
try {
|
|
350
|
+
await GeminiRealtimeClient_1.awaitSetup(opened);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (this.connectTarget !== target) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
if (this.connectTarget !== target) {
|
|
359
|
+
GeminiRealtimeClient_1.closeQuietly(opened.Session);
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
this.useConnection(opened);
|
|
363
|
+
this.connectionReady = true;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Negotiates tracks. Video capability and its frame-rate ceiling are PER-MODEL DATA, minted
|
|
368
|
+
* from the provider's profile table (GeminiLiveModelProfile.SupportsInboundVideo /
|
|
369
|
+
* .MaxInboundVideoRate) and carried in the session config. Deriving either from the model
|
|
370
|
+
* id would put a second answer to the same question in a second place: the two agreed only
|
|
371
|
+
* because the model names happened to line up, and the next model to break that pattern
|
|
372
|
+
* would diverge silently. A granted avatar adds an outbound video track when this browser
|
|
373
|
+
* can play it.
|
|
374
|
+
*/
|
|
375
|
+
negotiateSessionTracks(session) {
|
|
376
|
+
const isVideoModel = session.supportsInboundVideo ?? session.model.toLowerCase().startsWith(LEGACY_VIDEO_MODEL_PREFIX);
|
|
220
377
|
const supportedTracks = [
|
|
221
378
|
{ Modality: 'audio', Direction: 'inbound' },
|
|
222
379
|
{ Modality: 'audio', Direction: 'outbound' },
|
|
@@ -228,38 +385,110 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
228
385
|
Encoding: 'image/jpeg',
|
|
229
386
|
// The model's own ceiling. ResolveRequestedTracks takes the more restrictive of
|
|
230
387
|
// this and what the session requested, so this is what bounds the live track.
|
|
231
|
-
Rate: maxInboundVideoRate ?? LEGACY_VIDEO_MODEL_RATE,
|
|
388
|
+
Rate: session.maxInboundVideoRate ?? LEGACY_VIDEO_MODEL_RATE,
|
|
232
389
|
UsageBasis: ['tokens', 'frames'],
|
|
233
390
|
RequiresConsent: true,
|
|
234
391
|
});
|
|
235
392
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
393
|
+
const avatarTrack = session.avatar ? GeminiRealtimeClient_1.avatarTrackFor(session.avatar) : null;
|
|
394
|
+
if (avatarTrack) {
|
|
395
|
+
supportedTracks.push(avatarTrack);
|
|
396
|
+
}
|
|
397
|
+
// How many inbound video streams the model takes, minted from the profile like the rate. A mint
|
|
398
|
+
// that predates the field falls back to one stream for a video model (every model that had video
|
|
399
|
+
// took exactly one) and none otherwise.
|
|
400
|
+
this.negotiateTracks(session.requestedTracks, supportedTracks, isVideoModel ? (session.maxInboundVideoStreams ?? 1) : 0);
|
|
401
|
+
}
|
|
402
|
+
/** The outbound video track a granted avatar offers, or `null` when this browser can't play its type through MSE. */
|
|
403
|
+
static avatarTrackFor(grant) {
|
|
404
|
+
const encoding = grant.Encoding ?? GEMINI_AVATAR_MP4_TYPE;
|
|
405
|
+
if (!VideoPlayout.IsSupported(encoding)) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
return { Modality: 'video', Direction: 'outbound', Encoding: encoding, UsageBasis: ['seconds'] };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Sets up the avatar the server granted, when the host shows it (the outbound video track is
|
|
412
|
+
* live). Otherwise the session runs audio only: the config sent at connect asks for audio and
|
|
413
|
+
* no avatar, and one line says why. Returns the config to connect with.
|
|
414
|
+
*/
|
|
415
|
+
prepareAvatar(session, playback) {
|
|
416
|
+
const grant = session.avatar;
|
|
417
|
+
if (!grant) {
|
|
418
|
+
return session.liveConfig;
|
|
419
|
+
}
|
|
420
|
+
if (!this.IsTrackEstablished('video', 'outbound')) {
|
|
421
|
+
console.warn(this.avatarDowngradeMessage(session.liveConfig, grant));
|
|
422
|
+
return GeminiRealtimeClient_1.withoutAvatar(session.liveConfig);
|
|
423
|
+
}
|
|
424
|
+
const playout = this.CreateVideoPlayout({
|
|
425
|
+
MimeType: grant.Encoding ?? GEMINI_AVATAR_MP4_TYPE,
|
|
426
|
+
CarriesVoice: grant.AudioMuxed,
|
|
427
|
+
// The avatar's voice plays through the PCM engine's graph, so the meter and the recording carry it.
|
|
428
|
+
OnElementAttached: (element) => playback.ConnectMediaElement?.(element),
|
|
429
|
+
});
|
|
430
|
+
this.avatarOutput = new GeminiAvatarOutput(playout, playback, grant);
|
|
431
|
+
return session.liveConfig;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The one line for a granted avatar this session won't show, in the server driver's format: `browser` when the host
|
|
435
|
+
* asked for the agent's video and this browser can't play it, `host` when the host asked for none.
|
|
436
|
+
*/
|
|
437
|
+
avatarDowngradeMessage(config, grant) {
|
|
438
|
+
const requested = this.AllTracks.some((t) => t.Descriptor.Direction === 'outbound' && String(t.Descriptor.Modality).trim().toLowerCase() === 'video');
|
|
439
|
+
const why = requested ? `this browser cannot play ${grant.Encoding ?? GEMINI_AVATAR_MP4_TYPE}` : 'the host shows no agent video';
|
|
440
|
+
const reason = requested ? 'browser' : 'host';
|
|
441
|
+
return `[GeminiRealtimeClient] Avatar "${config.avatarConfig?.avatarName ?? ''}" not used: ${why}. The call is audio only. Reason: ${reason}.`;
|
|
442
|
+
}
|
|
443
|
+
/** The connect config of a session whose avatar won't show: audio out, and no avatar. */
|
|
444
|
+
static withoutAvatar(config) {
|
|
445
|
+
const { avatarConfig: _avatar, ...rest } = config;
|
|
446
|
+
return { ...rest, responseModalities: [Modality.AUDIO] };
|
|
447
|
+
}
|
|
448
|
+
/** Starts the microphone capture, the camera passed to `Connect` when inbound video is live, and both audio meters. */
|
|
449
|
+
async startLocalCapture(micStream) {
|
|
249
450
|
this.micCapture = await this.createMicCapture(micStream, (base64Pcm16) => this.sendMicChunk(base64Pcm16));
|
|
250
|
-
// Start camera capture if inbound video is established and cameraStream provided
|
|
251
451
|
if (this.cameraStream && this.IsTrackEstablished('video', 'inbound')) {
|
|
252
|
-
this.
|
|
253
|
-
Rate: 1,
|
|
254
|
-
OnFrame: (frame) => this.SendVideoFrame(frame.data, frame.mimeType),
|
|
255
|
-
});
|
|
452
|
+
this.cameraSampler = this.startConnectCamera(this.cameraStream);
|
|
256
453
|
}
|
|
257
454
|
// Audio-activity capability (base obligation #9): agent side taps the playout
|
|
258
455
|
// engine's master gain; user side meters the mic stream. Null-safe — test fakes /
|
|
259
456
|
// no-WebAudio environments simply leave the session un-metered.
|
|
260
457
|
this.attachOutputAudioMeter(this.playback?.CreateMeter?.() ?? null);
|
|
261
458
|
this.attachInputAudioMeter(RealtimeAudioMeter.ForMicStream(micStream));
|
|
262
|
-
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Stops the avatar's video and releases its element; the session then has no avatar. The video it generated and
|
|
462
|
+
* did not yet report is emitted first.
|
|
463
|
+
*/
|
|
464
|
+
disposeAvatarOutput() {
|
|
465
|
+
this.emitAvatarVideoSeconds();
|
|
466
|
+
this.avatarOutput?.Dispose();
|
|
467
|
+
this.avatarOutput = null;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Samples the camera passed to {@link Connect} at the rate the video track negotiated and feeds it to
|
|
471
|
+
* the session's {@link VideoSourceArbiter} as a `'camera'` source. The arbiter stays the only writer of
|
|
472
|
+
* inbound video, and it tells the model when it switches to the camera.
|
|
473
|
+
*/
|
|
474
|
+
startConnectCamera(stream) {
|
|
475
|
+
const arbiter = VideoSourceArbiter.ForSink(this);
|
|
476
|
+
arbiter.RegisterSource({ SourceID: CONNECT_CAMERA_SOURCE_ID, Label: 'Camera', Kind: 'camera' });
|
|
477
|
+
const sampler = new FrameSampler(stream, {
|
|
478
|
+
Rate: this.InboundVideoRate ?? DEFAULT_INBOUND_VIDEO_RATE,
|
|
479
|
+
OnFrame: (frame) => arbiter.PushFrame(CONNECT_CAMERA_SOURCE_ID, frame.Data, frame.MimeType),
|
|
480
|
+
});
|
|
481
|
+
sampler.Start();
|
|
482
|
+
return sampler;
|
|
483
|
+
}
|
|
484
|
+
/** Stops sampling the `Connect` camera and removes it from the arbiter. Does not stop its tracks. */
|
|
485
|
+
stopConnectCamera() {
|
|
486
|
+
if (!this.cameraSampler) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
this.cameraSampler.Stop();
|
|
490
|
+
this.cameraSampler = null;
|
|
491
|
+
VideoSourceArbiter.ForSink(this).UnregisterSource(CONNECT_CAMERA_SOURCE_ID);
|
|
263
492
|
}
|
|
264
493
|
/**
|
|
265
494
|
* Tears down the session, mic capture, mic tracks, camera capture, and playout engine,
|
|
@@ -272,15 +501,21 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
272
501
|
this.toolBatchBarrier.Clear();
|
|
273
502
|
this.micStream?.getTracks().forEach((track) => track.stop());
|
|
274
503
|
this.micStream = null;
|
|
275
|
-
this.
|
|
276
|
-
this.cameraCapture = null;
|
|
504
|
+
this.stopConnectCamera();
|
|
277
505
|
this.cameraStream?.getTracks().forEach((track) => track.stop());
|
|
278
506
|
this.cameraStream = null;
|
|
279
507
|
this.micCapture?.Stop();
|
|
280
508
|
this.micCapture = null;
|
|
509
|
+
this.disposeAvatarOutput();
|
|
281
510
|
this.playback?.Close();
|
|
282
511
|
this.playback = null;
|
|
283
|
-
|
|
512
|
+
// Stop resuming before the socket closes, and drop events still in flight from it.
|
|
513
|
+
this.resumption?.Dispose();
|
|
514
|
+
this.resumption = null;
|
|
515
|
+
this.connectTarget = null;
|
|
516
|
+
this.currentConnection = 0;
|
|
517
|
+
this.resetConnectionState();
|
|
518
|
+
this.clearRemoteMediaStream();
|
|
284
519
|
this.firstVideoSendTimestamp = 0;
|
|
285
520
|
this.lastVideoSendTimestamp = 0;
|
|
286
521
|
this.videoFramesSent = 0;
|
|
@@ -323,25 +558,32 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
323
558
|
/**
|
|
324
559
|
* Streams one base64 image frame over the established inbound video track.
|
|
325
560
|
*
|
|
326
|
-
* Enforces a
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
561
|
+
* Enforces a minimum inter-frame spacing as a backstop with deliberate jitter headroom for the
|
|
562
|
+
* upstream pacers (the source arbiter, `ChannelInboundVideoBridge`, `frameCapture`, channel-level
|
|
563
|
+
* gates such as `OnScreencastFrame`). The spacing is derived from the rate the track NEGOTIATED
|
|
564
|
+
* ({@link MinVideoFrameSpacingMs} of {@link BaseRealtimeClient.InboundVideoRate}), so it follows the
|
|
565
|
+
* model's profile rather than a constant: at the 1 fps every Live model accepts today it is the
|
|
566
|
+
* 750 ms gate this driver has always applied, and a faster model needs no change here. Upstream
|
|
567
|
+
* generators are the primary enforcers of the nominal rate; this gate absorbs event-loop and async
|
|
568
|
+
* dispatch jitter without dropping intended frames, while preventing an unpaced caller from bursting.
|
|
569
|
+
*
|
|
570
|
+
* Gemini accepts ONE inbound video stream, so `sourceId` is ignored: choosing which source feeds that
|
|
571
|
+
* stream is the arbiter's job, and by the time a frame arrives here the choice is made.
|
|
332
572
|
*
|
|
333
573
|
* If inbound video is not established, returns `false` without error or frame sends (fallback).
|
|
574
|
+
* A frame that comes while no connection is ready (before Google confirms the setup, or while
|
|
575
|
+
* the session moves to a new connection) is dropped too: the source sends the next one at the track's rate.
|
|
334
576
|
*
|
|
335
|
-
* @returns `true` if the frame was dispatched to the session; `false` if dropped (throttled
|
|
336
|
-
* or
|
|
577
|
+
* @returns `true` if the frame was dispatched to the session; `false` if dropped (throttled,
|
|
578
|
+
* track unestablished, or no connection ready).
|
|
337
579
|
*/
|
|
338
|
-
SendVideoFrame(base64Image, mimeType = 'image/jpeg') {
|
|
339
|
-
if (!this.IsTrackEstablished('video', 'inbound')) {
|
|
580
|
+
SendVideoFrame(base64Image, mimeType = 'image/jpeg', _sourceId) {
|
|
581
|
+
if (!this.IsTrackEstablished('video', 'inbound') || !this.connectionReady) {
|
|
340
582
|
return false;
|
|
341
583
|
}
|
|
342
584
|
const now = Date.now();
|
|
343
|
-
if (this.lastVideoSendTimestamp > 0 && now - this.lastVideoSendTimestamp <
|
|
344
|
-
return false; // Throttled:
|
|
585
|
+
if (this.lastVideoSendTimestamp > 0 && now - this.lastVideoSendTimestamp < MinVideoFrameSpacingMs(this.InboundVideoRate)) {
|
|
586
|
+
return false; // Throttled: jitter-headroom backstop for upstream pacers (Reviewer Items 25, 30, 33)
|
|
345
587
|
}
|
|
346
588
|
this.lastVideoSendTimestamp = now;
|
|
347
589
|
if (this.firstVideoSendTimestamp === 0) {
|
|
@@ -364,7 +606,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
364
606
|
* by a cancel). Server-side, the next client content sent naturally interrupts any
|
|
365
607
|
* residual generation per the Live API contract. The interrupted turn's accumulated
|
|
366
608
|
* transcript is kept — the provider's trailing frames finalize what WAS spoken. No-op
|
|
367
|
-
* when nothing is active.
|
|
609
|
+
* when nothing is active. An avatar's video stops with the voice.
|
|
368
610
|
*/
|
|
369
611
|
CancelActiveResponse() {
|
|
370
612
|
if (!this.session) {
|
|
@@ -375,6 +617,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
375
617
|
}
|
|
376
618
|
this.clearSafetyBackstop();
|
|
377
619
|
this.playback?.Flush();
|
|
620
|
+
this.avatarOutput?.Cancel();
|
|
378
621
|
this.responseActive = false;
|
|
379
622
|
this.interactionInProgress = false;
|
|
380
623
|
this.activeResponseKind = 'normal';
|
|
@@ -435,12 +678,9 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
435
678
|
return;
|
|
436
679
|
}
|
|
437
680
|
const name = this.pendingToolCallNames.get(callID) ?? '';
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
else {
|
|
442
|
-
this.enqueueOrRun(() => this.sendToolResponseTurn(callID, name, outputJson));
|
|
443
|
-
}
|
|
681
|
+
// Non-blocking results go out while the model talks; blocking ones wait for the turn in flight. Both wait while
|
|
682
|
+
// the session moves to a new connection.
|
|
683
|
+
this.enqueueOrRun(() => this.sendToolResponseTurn(callID, name, outputJson), !this.isNonBlocking);
|
|
444
684
|
}
|
|
445
685
|
/**
|
|
446
686
|
* Mutes / unmutes by toggling the mic tracks' `enabled` flag: the capture pipeline stays
|
|
@@ -454,6 +694,15 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
454
694
|
track.enabled = !muted;
|
|
455
695
|
}
|
|
456
696
|
}
|
|
697
|
+
/** Rebinds the PCM capture and the input meter to the stream's current track (obligation #10); the socket stays open. */
|
|
698
|
+
async ReplaceMicrophone(micStream) {
|
|
699
|
+
if (!this.micCapture) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.micCapture.Rebind(micStream);
|
|
703
|
+
this.micStream = micStream;
|
|
704
|
+
this.attachInputAudioMeter(RealtimeAudioMeter.ForMicStream(micStream));
|
|
705
|
+
}
|
|
457
706
|
/** @inheritdoc */
|
|
458
707
|
get IsBusy() {
|
|
459
708
|
if (this.isNonBlocking) {
|
|
@@ -468,28 +717,46 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
468
717
|
*
|
|
469
718
|
* Computed directly from the playout engine's playhead clock — this client OWNS the output
|
|
470
719
|
* buffer (no WebRTC playback events exist on Gemini), so "audibly playing" is precisely
|
|
471
|
-
* "scheduled audio extends beyond the audio context's current time".
|
|
720
|
+
* "scheduled audio extends beyond the audio context's current time". In an avatar session
|
|
721
|
+
* the avatar's video counts too, while some of an answer's media is still ahead of its
|
|
722
|
+
* playhead: its MP4 usually carries the voice. The idle video that follows an answer does
|
|
723
|
+
* not count.
|
|
472
724
|
*/
|
|
473
725
|
get IsAudioPlaying() {
|
|
474
|
-
return this.playback?.IsPlaying ?? false;
|
|
726
|
+
return (this.playback?.IsPlaying ?? false) || (this.avatarOutput?.IsPlaying ?? false);
|
|
475
727
|
}
|
|
476
728
|
// ── Overridable creation seams (tests inject fakes — no network / audio) ──
|
|
477
729
|
/**
|
|
478
730
|
* Creation seam for the Gemini Live session. Production constructs a `GoogleGenAI` client
|
|
479
731
|
* with the **ephemeral token as the API key** on the `v1alpha` API version (the only
|
|
480
732
|
* version that accepts `auth_tokens`), then opens `live.connect` with the server-built
|
|
481
|
-
* model + config.
|
|
733
|
+
* model + config. Rejects if the socket errors or closes before Gemini acknowledges the setup.
|
|
734
|
+
* Unit tests override this to return an in-memory fake.
|
|
482
735
|
*/
|
|
483
736
|
async connectLiveSession(args) {
|
|
484
737
|
const ai = new GoogleGenAI({ apiKey: args.EphemeralToken, httpOptions: { apiVersion: 'v1alpha' } });
|
|
485
|
-
return ai.live.connect({
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
738
|
+
return this.connectUntilSetup(args, (callbacks) => ai.live.connect({ model: args.Model, config: args.Config, callbacks }));
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Opens a Live connection with `connect`, which passes the given callbacks to `live.connect`, and rejects if the
|
|
742
|
+
* socket errors or closes before Gemini acknowledges the setup. `live.connect` (@google/genai 2.11+) resolves only on
|
|
743
|
+
* the server's setupComplete frame and never rejects when the socket errors or closes first (a rejected token, model
|
|
744
|
+
* or setup field), so without this the connect waits forever. A promise settles once, so callbacks after a successful
|
|
745
|
+
* connect only reach the handlers. Every {@link connectLiveSession} override should open through this.
|
|
746
|
+
*/
|
|
747
|
+
connectUntilSetup(args, connect) {
|
|
748
|
+
return new Promise((resolve, reject) => {
|
|
749
|
+
connect({
|
|
489
750
|
onmessage: args.OnMessage,
|
|
490
|
-
onerror:
|
|
491
|
-
|
|
492
|
-
|
|
751
|
+
onerror: (event) => {
|
|
752
|
+
reject(new Error(`Gemini Live connection failed: ${event?.message ?? 'websocket error'}`));
|
|
753
|
+
args.OnError(event);
|
|
754
|
+
},
|
|
755
|
+
onclose: (event) => {
|
|
756
|
+
reject(new Error(`Gemini Live closed during setup (code ${event?.code ?? 'unknown'}${event?.reason ? `: ${event.reason}` : ''})`));
|
|
757
|
+
args.OnClose(event);
|
|
758
|
+
},
|
|
759
|
+
}).then(resolve, reject);
|
|
493
760
|
});
|
|
494
761
|
}
|
|
495
762
|
/**
|
|
@@ -506,6 +773,17 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
506
773
|
createPlayback() {
|
|
507
774
|
return new GeminiPcmPlayback();
|
|
508
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Creation seam for the avatar's video player, called at connect when the server granted an
|
|
778
|
+
* avatar and the host shows it. Production returns {@link VideoPlayout} (Media Source
|
|
779
|
+
* Extensions); unit tests return a fake.
|
|
780
|
+
*
|
|
781
|
+
* @param options The type the grant names, whether the video carries the voice, and the hook
|
|
782
|
+
* that routes the element's audio into the PCM playback's Web Audio graph.
|
|
783
|
+
*/
|
|
784
|
+
CreateVideoPlayout(options) {
|
|
785
|
+
return new VideoPlayout(options);
|
|
786
|
+
}
|
|
509
787
|
// ── Connection internals ───────────────────────────────────────────────────
|
|
510
788
|
/**
|
|
511
789
|
* Extracts the model + Live connect config from the server-minted `SessionConfig`
|
|
@@ -527,36 +805,69 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
527
805
|
const supportsInboundVideo = typeof sessionConfig['supportsInboundVideo'] === 'boolean' ? sessionConfig['supportsInboundVideo'] : undefined;
|
|
528
806
|
const rawMaxVideoRate = sessionConfig['maxInboundVideoRate'];
|
|
529
807
|
const maxInboundVideoRate = typeof rawMaxVideoRate === 'number' && rawMaxVideoRate > 0 ? rawMaxVideoRate : undefined;
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
808
|
+
const rawMaxStreams = sessionConfig['maxInboundVideoStreams'];
|
|
809
|
+
const maxInboundVideoStreams = typeof rawMaxStreams === 'number' && Number.isInteger(rawMaxStreams) && rawMaxStreams >= 0 ? rawMaxStreams : undefined;
|
|
810
|
+
return {
|
|
811
|
+
model, liveConfig, idleSignal, supportsScheduling, supportsBlocking, supportsInboundVideo, maxInboundVideoRate,
|
|
812
|
+
maxInboundVideoStreams,
|
|
813
|
+
requestedTracks: GeminiRealtimeClient_1.readRequestedTracks(sessionConfig[REQUESTED_TRACKS_SESSION_KEY]),
|
|
814
|
+
avatar: GeminiRealtimeClient_1.readAvatarGrant(sessionConfig['avatar']),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/** The tracks the runtime asks the session for (`requestedTracks`); `undefined` when it sent no list. */
|
|
818
|
+
static readRequestedTracks(raw) {
|
|
819
|
+
if (!Array.isArray(raw)) {
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
const list = [];
|
|
823
|
+
for (const item of raw) {
|
|
824
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
825
|
+
const modality = typeof item['Modality'] === 'string' ? item['Modality'] : undefined;
|
|
826
|
+
const direction = item['Direction'];
|
|
827
|
+
if (modality && (direction === 'inbound' || direction === 'outbound')) {
|
|
828
|
+
list.push({
|
|
829
|
+
Modality: modality,
|
|
830
|
+
Direction: direction,
|
|
831
|
+
Encoding: typeof item['Encoding'] === 'string' ? item['Encoding'] : undefined,
|
|
832
|
+
Rate: typeof item['Rate'] === 'number' ? item['Rate'] : undefined,
|
|
833
|
+
RequiresConsent: typeof item['RequiresConsent'] === 'boolean' ? item['RequiresConsent'] : undefined,
|
|
834
|
+
SourceID: typeof item['SourceID'] === 'string' ? item['SourceID'] : undefined,
|
|
835
|
+
Label: typeof item['Label'] === 'string' ? item['Label'] : undefined,
|
|
836
|
+
});
|
|
547
837
|
}
|
|
548
838
|
}
|
|
549
|
-
requestedTracks = list;
|
|
550
839
|
}
|
|
551
|
-
return
|
|
840
|
+
return list;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* The avatar the server granted: the minted `avatar` block (`{ output: true, encoding, audioMuxed }`), which the
|
|
844
|
+
* server writes only when the session renders one. `audioMuxed` defaults to true, as the server's profile does.
|
|
845
|
+
*/
|
|
846
|
+
static readAvatarGrant(raw) {
|
|
847
|
+
const block = GeminiRealtimeClient_1.readObject(raw);
|
|
848
|
+
if (block?.['output'] !== true) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
return { Encoding: GeminiRealtimeClient_1.readString(block['encoding']) ?? null, AudioMuxed: block['audioMuxed'] !== false };
|
|
552
852
|
}
|
|
553
|
-
/**
|
|
853
|
+
/**
|
|
854
|
+
* Streams one base64 PCM16 mic chunk to the model (no-op once the session is gone, closed, or in error). During a
|
|
855
|
+
* move to a new connection the chunk is held instead, and goes out on the new connection once it is ready.
|
|
856
|
+
*/
|
|
554
857
|
sendMicChunk(base64Pcm16) {
|
|
555
858
|
if (!this.session || this.currentState === 'closed' || this.currentState === 'error') {
|
|
556
859
|
return;
|
|
557
860
|
}
|
|
861
|
+
if (!this.connectionReady) {
|
|
862
|
+
this.holdMicChunk(base64Pcm16);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
this.sendAudio(this.session, base64Pcm16);
|
|
866
|
+
}
|
|
867
|
+
/** Sends one base64 PCM16 mic chunk on `session`; a failed send is logged, not thrown. */
|
|
868
|
+
sendAudio(session, base64Pcm16) {
|
|
558
869
|
try {
|
|
559
|
-
|
|
870
|
+
session.sendRealtimeInput({
|
|
560
871
|
audio: { data: base64Pcm16, mimeType: GEMINI_INPUT_AUDIO_MIME_TYPE },
|
|
561
872
|
});
|
|
562
873
|
}
|
|
@@ -564,21 +875,69 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
564
875
|
RealtimeDiagLog(`[GeminiRealtimeClient] sendMicChunk failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
565
876
|
}
|
|
566
877
|
}
|
|
567
|
-
/**
|
|
878
|
+
/** Holds a mic chunk during a move, keeping the latest {@link HELD_MIC_AUDIO_SECONDS} seconds; older audio goes first. */
|
|
879
|
+
holdMicChunk(base64Pcm16) {
|
|
880
|
+
this.heldMicAudio.push(base64Pcm16);
|
|
881
|
+
this.heldMicAudioChars += base64Pcm16.length;
|
|
882
|
+
while (this.heldMicAudioChars > MAX_HELD_MIC_AUDIO_BASE64_CHARS && this.heldMicAudio.length > 1) {
|
|
883
|
+
this.heldMicAudioChars -= this.heldMicAudio.shift()?.length ?? 0;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/** Sends the mic audio held during a move on the connection now in use, oldest first. */
|
|
887
|
+
sendHeldMicAudio() {
|
|
888
|
+
const held = this.heldMicAudio;
|
|
889
|
+
this.heldMicAudio = [];
|
|
890
|
+
this.heldMicAudioChars = 0;
|
|
891
|
+
if (!this.session) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
for (const chunk of held) {
|
|
895
|
+
this.sendAudio(this.session, chunk);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Surfaces a fatal websocket error and marks the session unusable. When the session can be
|
|
900
|
+
* resumed, the error is only logged: a websocket `error` is always followed by a `close`,
|
|
901
|
+
* and {@link handleTransportClose} resumes from there.
|
|
902
|
+
*/
|
|
568
903
|
handleTransportError(event) {
|
|
569
|
-
const detail =
|
|
904
|
+
const detail = GeminiRealtimeClient_1.transportErrorDetail(event);
|
|
570
905
|
RealtimeDiagLog(`[GeminiRealtimeClient] Transport error: ${detail}`);
|
|
906
|
+
if (this.resumption?.Handle) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
571
909
|
this.emitError({ Message: `Gemini Live transport error: ${detail}`, Fatal: true });
|
|
572
910
|
this.setState('error');
|
|
573
911
|
}
|
|
574
|
-
/**
|
|
912
|
+
/**
|
|
913
|
+
* Handles a close of the current connection that the consumer did not ask for. MJAPI's relay
|
|
914
|
+
* closing at the session's time limit ends the call with a `time-limit` error. Otherwise, when
|
|
915
|
+
* Google issued a resumption handle, the session reconnects with it; else the close ends the
|
|
916
|
+
* session, as an error when it was abnormal.
|
|
917
|
+
*/
|
|
575
918
|
handleTransportClose(event) {
|
|
576
919
|
const code = event?.code;
|
|
577
920
|
const reason = event?.reason;
|
|
578
921
|
const wasClean = event?.wasClean;
|
|
579
922
|
RealtimeDiagLog(`[GeminiRealtimeClient] Transport closed: code=${code} reason=${reason} wasClean=${wasClean}`);
|
|
923
|
+
if (this.currentState === 'error' || this.currentState === 'closed') {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (code === REALTIME_RELAY_TIME_LIMIT_CLOSE_CODE) {
|
|
927
|
+
// The relay session reached its time limit: it is gone, so a resume could only fail. End with its own code.
|
|
928
|
+
this.emitError({
|
|
929
|
+
Message: `Gemini Live session reached its time limit (${code}): ${reason || 'no reason given'}`,
|
|
930
|
+
Code: REALTIME_CLIENT_TIME_LIMIT_ERROR_CODE,
|
|
931
|
+
Fatal: true,
|
|
932
|
+
});
|
|
933
|
+
this.setState('error');
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (this.resumption?.ConnectionLost()) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
580
939
|
const isAbnormal = (code !== undefined && code !== 0 && code !== 1000 && code !== 1005) || (wasClean === false && code !== 1000 && code !== 0 && code !== 1005 && code !== undefined);
|
|
581
|
-
if (isAbnormal
|
|
940
|
+
if (isAbnormal) {
|
|
582
941
|
this.emitError({
|
|
583
942
|
Message: `Gemini Live connection closed (${code}): ${reason || 'unexpected disconnect'}`,
|
|
584
943
|
Fatal: true,
|
|
@@ -586,9 +945,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
586
945
|
this.setState('error');
|
|
587
946
|
return;
|
|
588
947
|
}
|
|
589
|
-
|
|
590
|
-
this.setState('closed');
|
|
591
|
-
}
|
|
948
|
+
this.setState('closed');
|
|
592
949
|
}
|
|
593
950
|
// ── Inbound message translation ────────────────────────────────────────────
|
|
594
951
|
/**
|
|
@@ -597,20 +954,14 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
597
954
|
*/
|
|
598
955
|
handleServerMessage(message) {
|
|
599
956
|
this.checkInteractionStatus(message);
|
|
600
|
-
// Session continuity:
|
|
957
|
+
// Session continuity (F7). `resumable: false` (mid-turn, mid-tool-call) arrives with no
|
|
958
|
+
// handle; an update without the flag is treated as resumable.
|
|
601
959
|
if (message.sessionResumptionUpdate) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
605
|
-
else if (message.sessionResumptionUpdate.newHandle) {
|
|
606
|
-
this.resumptionHandle = message.sessionResumptionUpdate.newHandle;
|
|
607
|
-
}
|
|
960
|
+
const update = message.sessionResumptionUpdate;
|
|
961
|
+
this.resumption?.RecordHandle(update.newHandle, update.resumable !== false);
|
|
608
962
|
}
|
|
609
|
-
// Server approaching timeout / abort: reconnect seamlessly using resumption handle (F7)
|
|
610
963
|
if (message.goAway) {
|
|
611
|
-
|
|
612
|
-
void this.resumeSession(this.resumptionHandle);
|
|
613
|
-
}
|
|
964
|
+
this.resumption?.ConnectionEnding(ParseDurationToMs(message.goAway.timeLeft));
|
|
614
965
|
}
|
|
615
966
|
if (message.serverContent) {
|
|
616
967
|
this.handleServerContent(message.serverContent);
|
|
@@ -623,33 +974,151 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
623
974
|
}
|
|
624
975
|
}
|
|
625
976
|
/**
|
|
626
|
-
*
|
|
977
|
+
* Opens a replacement connection that resumes the session from `handle`, switches to it once
|
|
978
|
+
* Google confirms its setup, and closes the old one. Until then the old connection stays in
|
|
979
|
+
* use, so what it still sends (the rest of a turn the deadline cut, its usage) is handled.
|
|
980
|
+
* Mic capture and playout carry over untouched. Rejects when the connection can't be opened or
|
|
981
|
+
* its setup fails, so {@link RealtimeSessionResumption} can retry.
|
|
982
|
+
*
|
|
983
|
+
* The resume reuses the session's ephemeral token, which Google accepts until the token's
|
|
984
|
+
* `expireTime` (the server driver mints 30 minutes). After that every attempt fails and the
|
|
985
|
+
* session ends with a fatal error.
|
|
986
|
+
*
|
|
987
|
+
* @param handle The resumption handle Google issued.
|
|
988
|
+
* @param attempt Marks the attempt abandoned after a timeout or a consumer close; a
|
|
989
|
+
* connection that opens after that is closed instead of used.
|
|
627
990
|
*/
|
|
628
|
-
async resumeSession(handle) {
|
|
629
|
-
|
|
991
|
+
async resumeSession(handle, attempt) {
|
|
992
|
+
const target = this.connectTarget;
|
|
993
|
+
if (!target) {
|
|
994
|
+
throw new Error('there is no Gemini Live session to resume');
|
|
995
|
+
}
|
|
996
|
+
const resumeTarget = {
|
|
997
|
+
...target,
|
|
998
|
+
Config: { ...target.Config, sessionResumption: { ...target.Config.sessionResumption, handle } },
|
|
999
|
+
};
|
|
1000
|
+
const opened = await this.openConnection(resumeTarget);
|
|
1001
|
+
await GeminiRealtimeClient_1.awaitSetup(opened);
|
|
1002
|
+
if (attempt.Abandoned || this.connectTarget !== target) {
|
|
1003
|
+
GeminiRealtimeClient_1.closeQuietly(opened.Session);
|
|
630
1004
|
return;
|
|
631
1005
|
}
|
|
1006
|
+
const previous = this.session;
|
|
1007
|
+
this.useConnection(opened);
|
|
1008
|
+
this.connectTarget = resumeTarget;
|
|
1009
|
+
if (previous) {
|
|
1010
|
+
GeminiRealtimeClient_1.closeQuietly(previous);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Opens a connection through {@link connectLiveSession}. Its callbacks act only while it is the
|
|
1015
|
+
* connection in use, so events from one that was replaced, closed, or is still opening are
|
|
1016
|
+
* dropped; its `setupComplete`, and a close or error before it, settle its {@link GeminiOpenedConnection.Setup}
|
|
1017
|
+
* whether or not it is in use yet. It becomes the connection in use through {@link useConnection},
|
|
1018
|
+
* which lets the old connection keep delivering events while a planned move is in progress.
|
|
1019
|
+
*/
|
|
1020
|
+
async openConnection(target) {
|
|
1021
|
+
const number = ++this.issuedConnections;
|
|
1022
|
+
const isCurrent = () => number === this.currentConnection;
|
|
1023
|
+
const setup = new RealtimeConnectionSetup();
|
|
1024
|
+
const session = await this.connectLiveSession({
|
|
1025
|
+
...target,
|
|
1026
|
+
OnMessage: (message) => {
|
|
1027
|
+
if (message.setupComplete) {
|
|
1028
|
+
setup.Confirm();
|
|
1029
|
+
}
|
|
1030
|
+
if (isCurrent()) {
|
|
1031
|
+
this.handleServerMessage(message);
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
OnError: (event) => {
|
|
1035
|
+
setup.Fail(new Error(`Gemini Live connection failed before confirming its setup: ${GeminiRealtimeClient_1.transportErrorDetail(event)}`));
|
|
1036
|
+
if (isCurrent()) {
|
|
1037
|
+
this.handleTransportError(event);
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
OnClose: (event) => {
|
|
1041
|
+
setup.Fail(new Error(`Gemini Live closed the connection before confirming its setup (${event?.code}): ${event?.reason || 'no reason given'}`));
|
|
1042
|
+
if (isCurrent()) {
|
|
1043
|
+
this.handleTransportClose(event);
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
return { Session: session, ConnectionNumber: number, Setup: setup };
|
|
1048
|
+
}
|
|
1049
|
+
/** Waits for Google to confirm a new connection's setup. On failure the connection is closed and the error rethrown. */
|
|
1050
|
+
static async awaitSetup(opened) {
|
|
632
1051
|
try {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1052
|
+
await opened.Setup.Wait();
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
GeminiRealtimeClient_1.closeQuietly(opened.Session);
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/** What a websocket error event says went wrong. */
|
|
1060
|
+
static transportErrorDetail(event) {
|
|
1061
|
+
return event.message || (event.error instanceof Error ? event.error.message : String(event.error ?? 'unknown'));
|
|
1062
|
+
}
|
|
1063
|
+
/** Makes an opened connection the one in use. */
|
|
1064
|
+
useConnection(opened) {
|
|
1065
|
+
this.session = opened.Session;
|
|
1066
|
+
this.currentConnection = opened.ConnectionNumber;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Builds the resumption helper for a newly connected session; see {@link resumption}. From the
|
|
1070
|
+
* moment a move starts, input waits for the new connection ({@link connectionReady}).
|
|
1071
|
+
*/
|
|
1072
|
+
createResumption() {
|
|
1073
|
+
return new RealtimeSessionResumption({
|
|
1074
|
+
Reconnect: (handle, attempt) => this.resumeSession(handle, attempt),
|
|
1075
|
+
OnReconnecting: (reason) => {
|
|
1076
|
+
RealtimeDiagLog(`[GeminiRealtimeClient] Resuming the session on a new connection (${reason})`);
|
|
1077
|
+
this.connectionReady = false;
|
|
1078
|
+
this.setState('connecting');
|
|
1079
|
+
},
|
|
1080
|
+
OnReconnected: () => this.handleResumed(),
|
|
1081
|
+
OnReconnectFailed: (error) => this.handleResumeFailed(error),
|
|
1082
|
+
Log: (message) => RealtimeDiagLog(message),
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* The session continues on a new connection, whose setup Google has confirmed. A turn cut off
|
|
1087
|
+
* by a drop or the deadline never completes there, so the turn state is reset (its partial
|
|
1088
|
+
* transcripts are emitted as final). The mic audio held during the move goes out, then the
|
|
1089
|
+
* sends queued behind it. An avatar's video plays out what arrived of that turn and holds its
|
|
1090
|
+
* last frame.
|
|
1091
|
+
*/
|
|
1092
|
+
handleResumed() {
|
|
1093
|
+
this.avatarOutput?.Resumed();
|
|
1094
|
+
this.emitAvatarVideoSeconds();
|
|
1095
|
+
this.finalizeUserTranscript();
|
|
1096
|
+
this.finalizeAssistantTranscript();
|
|
1097
|
+
this.responseActive = false;
|
|
1098
|
+
this.interactionInProgress = false;
|
|
1099
|
+
this.activeResponseKind = 'normal';
|
|
1100
|
+
this.clearSafetyBackstop();
|
|
1101
|
+
this.endTurn();
|
|
1102
|
+
this.connectionReady = true;
|
|
1103
|
+
this.setState('listening');
|
|
1104
|
+
this.sendHeldMicAudio();
|
|
1105
|
+
this.flushQueuedSends();
|
|
1106
|
+
}
|
|
1107
|
+
/** Every resume attempt failed: the session ends with a fatal error. */
|
|
1108
|
+
handleResumeFailed(error) {
|
|
1109
|
+
if (this.currentState === 'closed' || this.currentState === 'error') {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.emitError({ Message: `Gemini Live connection was lost and could not be resumed: ${error.message}`, Fatal: true });
|
|
1113
|
+
this.setState('error');
|
|
1114
|
+
}
|
|
1115
|
+
/** Closes a socket the session no longer uses; it may already be closed. */
|
|
1116
|
+
static closeQuietly(session) {
|
|
1117
|
+
try {
|
|
1118
|
+
session.close();
|
|
650
1119
|
}
|
|
651
1120
|
catch (err) {
|
|
652
|
-
RealtimeDiagLog(`[GeminiRealtimeClient]
|
|
1121
|
+
RealtimeDiagLog(`[GeminiRealtimeClient] Closing a replaced connection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
653
1122
|
}
|
|
654
1123
|
}
|
|
655
1124
|
/**
|
|
@@ -671,6 +1140,8 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
671
1140
|
this.interactionInProgress = true;
|
|
672
1141
|
this.responseActive = true;
|
|
673
1142
|
if (this.idleSignal === 'interactionStatus') {
|
|
1143
|
+
// The turn ends at the true IDLE (handleIdleTerminal), so the status that opens it counts only here.
|
|
1144
|
+
this.markTurnOpen();
|
|
674
1145
|
this.scheduleSafetyBackstop();
|
|
675
1146
|
}
|
|
676
1147
|
}
|
|
@@ -695,24 +1166,9 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
695
1166
|
* the server-bridged `GeminiRealtime` driver forwards the same payload to `IRealtimeSession.OnUsage`.
|
|
696
1167
|
*/
|
|
697
1168
|
handleUsageMetadata(usageMetadata) {
|
|
698
|
-
let inputDetails;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (typeof detail.tokenCount === 'number') {
|
|
702
|
-
inputDetails = inputDetails ?? {};
|
|
703
|
-
const mod = String(detail.modality ?? '').toUpperCase();
|
|
704
|
-
if (mod === 'AUDIO') {
|
|
705
|
-
inputDetails.AudioTokens = (inputDetails.AudioTokens ?? 0) + detail.tokenCount;
|
|
706
|
-
}
|
|
707
|
-
else if (mod === 'TEXT') {
|
|
708
|
-
inputDetails.TextTokens = (inputDetails.TextTokens ?? 0) + detail.tokenCount;
|
|
709
|
-
}
|
|
710
|
-
else if (mod === 'IMAGE') {
|
|
711
|
-
inputDetails.ImageTokens = (inputDetails.ImageTokens ?? 0) + detail.tokenCount;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
1169
|
+
let inputDetails = GeminiRealtimeClient_1.modalityTokens(usageMetadata.promptTokensDetails);
|
|
1170
|
+
// The response side: TEXT and AUDIO, and VIDEO for a generated avatar (when Google reports it).
|
|
1171
|
+
const outputDetails = GeminiRealtimeClient_1.modalityTokens(usageMetadata.responseTokensDetails);
|
|
716
1172
|
/**
|
|
717
1173
|
* Cost Attribution Note (F6 & Reviewer Item 29):
|
|
718
1174
|
* Inbound video frames are sent as individual JPEG images (V5) and billed on the video pricing tier
|
|
@@ -730,14 +1186,13 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
730
1186
|
* and provider-received ImageTokens immediately surfaces frame drops or network throttling in production.
|
|
731
1187
|
*/
|
|
732
1188
|
if (this.videoFramesSent > 0) {
|
|
733
|
-
inputDetails = inputDetails
|
|
734
|
-
inputDetails.VideoFrames = this.videoFramesSent;
|
|
735
|
-
inputDetails.VideoSeconds = this.VideoSeconds;
|
|
1189
|
+
inputDetails = { ...inputDetails, VideoFrames: this.videoFramesSent, VideoSeconds: this.VideoSeconds };
|
|
736
1190
|
}
|
|
737
1191
|
this.emitUsage({
|
|
738
1192
|
InputTokens: typeof usageMetadata.promptTokenCount === 'number' ? usageMetadata.promptTokenCount : undefined,
|
|
739
1193
|
OutputTokens: typeof usageMetadata.responseTokenCount === 'number' ? usageMetadata.responseTokenCount : undefined,
|
|
740
1194
|
...(inputDetails ? { InputTokenDetails: inputDetails } : {}),
|
|
1195
|
+
...(outputDetails ? { OutputTokenDetails: outputDetails } : {}),
|
|
741
1196
|
...(this.videoFramesSent > 0 ? {
|
|
742
1197
|
VideoFrames: this.videoFramesSent,
|
|
743
1198
|
VideoSeconds: this.VideoSeconds,
|
|
@@ -745,13 +1200,53 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
745
1200
|
Raw: usageMetadata,
|
|
746
1201
|
});
|
|
747
1202
|
}
|
|
748
|
-
/**
|
|
1203
|
+
/**
|
|
1204
|
+
* Gemini's per-modality token counts (`promptTokensDetails` or `responseTokensDetails`) as a detail block: TEXT,
|
|
1205
|
+
* AUDIO, IMAGE and VIDEO. `undefined` when Gemini sent no count for a modality it names.
|
|
1206
|
+
*/
|
|
1207
|
+
static modalityTokens(details) {
|
|
1208
|
+
if (!Array.isArray(details)) {
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
let block;
|
|
1212
|
+
for (const detail of details) {
|
|
1213
|
+
const field = GEMINI_MODALITY_TOKEN_FIELDS[String(detail.modality ?? '').toUpperCase()];
|
|
1214
|
+
if (field && typeof detail.tokenCount === 'number') {
|
|
1215
|
+
block = block ?? {};
|
|
1216
|
+
block[field] = (block[field] ?? 0) + detail.tokenCount;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return block;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Emits the avatar video generated since the last emission as an update of its own
|
|
1223
|
+
* (`OutputTokenDetails.VideoSeconds`, an amount): called where a turn's counting ends (generation complete,
|
|
1224
|
+
* turn complete, resumed; an interrupted turn ends at its turn complete) and before the avatar output is
|
|
1225
|
+
* disposed, so every second is emitted once, whenever Google's `usageMetadata` arrives.
|
|
1226
|
+
*/
|
|
1227
|
+
emitAvatarVideoSeconds() {
|
|
1228
|
+
const seconds = this.avatarOutput?.TakeVideoSeconds() ?? 0;
|
|
1229
|
+
if (seconds > 0) {
|
|
1230
|
+
this.emitUsage({ OutputTokenDetails: { VideoSeconds: seconds } });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Translates one {@link LiveServerContent} frame in provider-documented signal order. What the frame carries opens
|
|
1235
|
+
* the turn before its `turnComplete` ends it.
|
|
1236
|
+
*/
|
|
749
1237
|
handleServerContent(content) {
|
|
1238
|
+
if (this.contentOpensTurn(content)) {
|
|
1239
|
+
this.markTurnOpen();
|
|
1240
|
+
}
|
|
1241
|
+
if (this.avatarOutput && GeminiRealtimeClient_1.hasText(content.outputTranscription)) {
|
|
1242
|
+
// The model speaks: the avatar's video is its answer's from here, this message's parts included.
|
|
1243
|
+
this.avatarOutput.AnswerStarted();
|
|
1244
|
+
}
|
|
750
1245
|
if (content.interrupted) {
|
|
751
1246
|
this.handleInterruption();
|
|
752
1247
|
}
|
|
753
1248
|
if (content.modelTurn) {
|
|
754
|
-
this.
|
|
1249
|
+
this.handleModelMedia(content.modelTurn);
|
|
755
1250
|
this.handleModelThoughts(content.modelTurn);
|
|
756
1251
|
}
|
|
757
1252
|
if (content.inputTranscription) {
|
|
@@ -773,8 +1268,12 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
773
1268
|
*
|
|
774
1269
|
* Per Reviewer Item 20: Draining the queue happens on turnComplete or true IDLE, not prematurely
|
|
775
1270
|
* on generationComplete. We set responseActive = false so busy state reflects token completion.
|
|
1271
|
+
* An avatar's video gets its end of turn here: playback runs to the true end and holds the
|
|
1272
|
+
* last frame.
|
|
776
1273
|
*/
|
|
777
1274
|
handleGenerationComplete() {
|
|
1275
|
+
this.avatarOutput?.GenerationComplete();
|
|
1276
|
+
this.emitAvatarVideoSeconds();
|
|
778
1277
|
if (this.idleSignal === 'turnComplete') {
|
|
779
1278
|
this.responseActive = false;
|
|
780
1279
|
}
|
|
@@ -785,16 +1284,23 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
785
1284
|
* client's audio queue), surface the TRUE barge-in to the host (Gemini only emits
|
|
786
1285
|
* `interrupted` when user input actually cut off in-flight generation — no extra gating
|
|
787
1286
|
* needed), and give the floor back. The interrupted turn's accumulated transcript is
|
|
788
|
-
* kept — the following `turnComplete` finalizes what WAS spoken.
|
|
1287
|
+
* kept — the following `turnComplete` finalizes what WAS spoken. In an avatar session the
|
|
1288
|
+
* video stops too, and the turn's late parts are dropped until its `turnComplete`.
|
|
789
1289
|
*/
|
|
790
1290
|
handleInterruption() {
|
|
791
1291
|
this.playback?.Flush();
|
|
1292
|
+
this.avatarOutput?.Interrupted();
|
|
792
1293
|
this.finalizeThoughtTranscript();
|
|
793
1294
|
this.emitInterruption();
|
|
794
1295
|
this.setState('listening');
|
|
795
1296
|
}
|
|
796
|
-
/**
|
|
797
|
-
|
|
1297
|
+
/**
|
|
1298
|
+
* Plays inline model parts. In an avatar session the avatar output routes each part to the
|
|
1299
|
+
* video, to the PCM playback or nowhere; only the answer's parts mark generation started
|
|
1300
|
+
* (and the turn open): the idle video between answers plays without making the agent
|
|
1301
|
+
* speaking or busy. Otherwise base64 PCM16 @ 24 kHz parts go to the playout queue.
|
|
1302
|
+
*/
|
|
1303
|
+
handleModelMedia(modelTurn) {
|
|
798
1304
|
if (!modelTurn.parts) {
|
|
799
1305
|
return;
|
|
800
1306
|
}
|
|
@@ -802,13 +1308,35 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
802
1308
|
if (part.thought) {
|
|
803
1309
|
continue; // Thoughts are reasoning summaries, never spoken audio
|
|
804
1310
|
}
|
|
805
|
-
const
|
|
806
|
-
if (data) {
|
|
807
|
-
|
|
808
|
-
|
|
1311
|
+
const inline = part.inlineData;
|
|
1312
|
+
if (!inline?.data) {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (this.avatarOutput) {
|
|
1316
|
+
if (this.avatarOutput.Accept(inline.mimeType, Base64ToArrayBuffer(inline.data)) === 'answer') {
|
|
1317
|
+
this.markTurnOpen();
|
|
1318
|
+
this.markGenerationStarted();
|
|
1319
|
+
}
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
// A part that names a non-PCM type (e.g. video/mp4 avatar frames) must never reach PCM
|
|
1323
|
+
// playback, where it would play as noise. A part with no type plays, as it always has.
|
|
1324
|
+
if (inline.mimeType && !IsPcmAudioMimeType(inline.mimeType)) {
|
|
1325
|
+
this.reportDroppedOutput(inline.mimeType);
|
|
1326
|
+
continue;
|
|
809
1327
|
}
|
|
1328
|
+
this.markGenerationStarted();
|
|
1329
|
+
this.playback?.Enqueue(Base64ToArrayBuffer(inline.data));
|
|
810
1330
|
}
|
|
811
1331
|
}
|
|
1332
|
+
/** Reports each MIME type of dropped model output once per session, not once per part. */
|
|
1333
|
+
reportDroppedOutput(mimeType) {
|
|
1334
|
+
if (this.droppedOutputTypes.has(mimeType)) {
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
this.droppedOutputTypes.add(mimeType);
|
|
1338
|
+
console.warn(`[GeminiRealtimeClient] Dropped model output of type ${mimeType}: only PCM audio is played on this session.`);
|
|
1339
|
+
}
|
|
812
1340
|
/**
|
|
813
1341
|
* Extracts thought parts (`part.thought === true`) from model turns and emits them
|
|
814
1342
|
* as narration transcript deltas (`Kind: 'narration'`). Thought summaries are reasoning
|
|
@@ -882,6 +1410,8 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
882
1410
|
if (!functionCalls || functionCalls.length === 0) {
|
|
883
1411
|
return;
|
|
884
1412
|
}
|
|
1413
|
+
// The answer stops at the call: the avatar's video until the model speaks again is idle.
|
|
1414
|
+
this.avatarOutput?.ToolCalled();
|
|
885
1415
|
if (!this.isNonBlocking) {
|
|
886
1416
|
if (this.currentState === 'speaking') {
|
|
887
1417
|
this.currentState = 'connected';
|
|
@@ -895,21 +1425,27 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
895
1425
|
this.toolBatchBarrier.TrackPendingCall(callID, () => {
|
|
896
1426
|
this.handleToolBatchTimeout();
|
|
897
1427
|
});
|
|
1428
|
+
// A pending call keeps its turn open until its result goes out (see IsTurnOpen).
|
|
1429
|
+
this.markTurnOpen();
|
|
898
1430
|
this.emitToolCall({ CallID: callID, ToolName: toolName, ArgumentsJson: JSON.stringify(call.args ?? {}) });
|
|
899
1431
|
}
|
|
900
1432
|
}
|
|
901
1433
|
/**
|
|
902
1434
|
* Turn boundary: finalize any un-finished assistant transcript.
|
|
903
|
-
* Under 'turnComplete' idle signal,
|
|
904
|
-
* drain queued sends, and return the floor to the user.
|
|
1435
|
+
* Under 'turnComplete' idle signal, end the turn (see {@link IsTurnOpen}), release the busy
|
|
1436
|
+
* lock, reset response kind, drain queued sends, and return the floor to the user.
|
|
905
1437
|
* Under 'interactionStatus' (Extended Thinking), turnComplete does NOT indicate idle:
|
|
906
|
-
* background reasoning or async tool calls may still be in flight, so the busy lock
|
|
1438
|
+
* background reasoning or async tool calls may still be in flight, so the turn, the busy lock
|
|
907
1439
|
* and queued sends remain held until the true IDLE signal lands.
|
|
908
1440
|
*/
|
|
909
1441
|
handleTurnComplete() {
|
|
1442
|
+
this.avatarOutput?.TurnComplete();
|
|
1443
|
+
this.emitAvatarVideoSeconds();
|
|
910
1444
|
this.finalizeAssistantTranscript();
|
|
911
1445
|
this.finalizeThoughtTranscript();
|
|
912
1446
|
if (this.idleSignal === 'turnComplete') {
|
|
1447
|
+
// Before the drain: a queued send that starts a new turn opens it again.
|
|
1448
|
+
this.endTurnUnlessToolCallPending();
|
|
913
1449
|
this.responseActive = false;
|
|
914
1450
|
this.activeResponseKind = 'normal';
|
|
915
1451
|
this.openClientTurn = false; // the completed generation consumed any open client content
|
|
@@ -925,11 +1461,12 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
925
1461
|
}
|
|
926
1462
|
/**
|
|
927
1463
|
* Reached when the server emits true IDLE (interactionStatus) or the safety backstop fires.
|
|
928
|
-
*
|
|
929
|
-
* drains queued sends, and returns the floor.
|
|
1464
|
+
* Ends the turn (see {@link IsTurnOpen}), releases busy state, commits any deferred open client
|
|
1465
|
+
* turns without cutting off generation, drains queued sends, and returns the floor.
|
|
930
1466
|
*/
|
|
931
1467
|
handleIdleTerminal() {
|
|
932
1468
|
this.clearSafetyBackstop();
|
|
1469
|
+
this.endTurnUnlessToolCallPending();
|
|
933
1470
|
this.interactionInProgress = false;
|
|
934
1471
|
this.responseActive = false;
|
|
935
1472
|
this.activeResponseKind = 'normal';
|
|
@@ -944,9 +1481,9 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
944
1481
|
}
|
|
945
1482
|
}
|
|
946
1483
|
/**
|
|
947
|
-
* First model output of a turn (audio part
|
|
948
|
-
* over (finalize their pending transcript), the model is busy,
|
|
949
|
-
* imminently `'speaking'`.
|
|
1484
|
+
* First model output of a turn (audio part, transcription delta, or a part of an avatar's
|
|
1485
|
+
* answer): the user's turn is over (finalize their pending transcript), the model is busy,
|
|
1486
|
+
* and the client is audibly / imminently `'speaking'`.
|
|
950
1487
|
*/
|
|
951
1488
|
markGenerationStarted() {
|
|
952
1489
|
this.finalizeUserTranscript();
|
|
@@ -983,15 +1520,73 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
983
1520
|
this.emitTranscript({ Role: 'Assistant', Text: text, IsFinal: true, Kind: 'narration', IsThought: true });
|
|
984
1521
|
}
|
|
985
1522
|
}
|
|
1523
|
+
// ── Turn tracking (see IsTurnOpen) ─────────────────────────────────────────
|
|
1524
|
+
/**
|
|
1525
|
+
* Whether a server content message is a sign of a turn: the user's or the model's transcribed
|
|
1526
|
+
* words, a thought, or (in a session without an avatar) the model's audio. An avatar's video
|
|
1527
|
+
* never is: Vertex AI streams it between turns too.
|
|
1528
|
+
*/
|
|
1529
|
+
contentOpensTurn(content) {
|
|
1530
|
+
if (GeminiRealtimeClient_1.hasText(content.inputTranscription) || GeminiRealtimeClient_1.hasText(content.outputTranscription)) {
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
return (content.modelTurn?.parts ?? []).some((part) => this.partOpensTurn(part));
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Whether a model part is a sign of a turn: a thought, or audio (as this client plays it) in a session without an
|
|
1537
|
+
* avatar. In an avatar session, a voice part opens the turn once it plays ({@link handleModelMedia}).
|
|
1538
|
+
*/
|
|
1539
|
+
partOpensTurn(part) {
|
|
1540
|
+
if (part.thought) {
|
|
1541
|
+
return !!part.text;
|
|
1542
|
+
}
|
|
1543
|
+
const inline = part.inlineData;
|
|
1544
|
+
return !this.avatarOutput && !!inline?.data && (!inline.mimeType || IsPcmAudioMimeType(inline.mimeType));
|
|
1545
|
+
}
|
|
1546
|
+
/** Whether a transcription frame carries words (whitespace alone does not count). */
|
|
1547
|
+
static hasText(transcription) {
|
|
1548
|
+
return (transcription?.text?.trim().length ?? 0) > 0;
|
|
1549
|
+
}
|
|
1550
|
+
/** A turn started or continued: a planned move waits until it ends. */
|
|
1551
|
+
markTurnOpen() {
|
|
1552
|
+
this.turnOpen = true;
|
|
1553
|
+
this.turnEndAwaitsToolResults = false;
|
|
1554
|
+
this.resumption?.TurnStarted();
|
|
1555
|
+
}
|
|
1556
|
+
/** The turn's `turnComplete` (or true idle): it ends now, or once the results of the tool calls still pending go out. */
|
|
1557
|
+
endTurnUnlessToolCallPending() {
|
|
1558
|
+
if (this.pendingToolCallNames.size > 0) {
|
|
1559
|
+
this.turnEndAwaitsToolResults = this.turnOpen;
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
this.endTurn();
|
|
1563
|
+
}
|
|
1564
|
+
/** A tool result went out: a turn whose `turnComplete` waited on the pending calls ends once none is left. */
|
|
1565
|
+
endTurnIfToolResultsIn() {
|
|
1566
|
+
if (this.turnEndAwaitsToolResults && this.pendingToolCallNames.size === 0) {
|
|
1567
|
+
this.endTurn();
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** The turn is over: a planned move happens at the next resumable handle Google issues. */
|
|
1571
|
+
endTurn() {
|
|
1572
|
+
this.turnOpen = false;
|
|
1573
|
+
this.turnEndAwaitsToolResults = false;
|
|
1574
|
+
this.resumption?.TurnEnded();
|
|
1575
|
+
}
|
|
986
1576
|
// ── Collision-safe send machinery ──────────────────────────────────────────
|
|
987
1577
|
/**
|
|
988
1578
|
* Runs a send immediately when no turn is in flight; otherwise queues it for the next
|
|
989
1579
|
* `turnComplete`. This is Gemini's equivalent of the OpenAI driver's
|
|
990
1580
|
* queue-behind-active-response rule — stricter here because ANY client content interrupts
|
|
991
|
-
* in-flight generation on the Live API.
|
|
1581
|
+
* in-flight generation on the Live API. Every send also waits while no connection is ready
|
|
1582
|
+
* ({@link connectionReady}): during a move it goes out on the new connection.
|
|
1583
|
+
*
|
|
1584
|
+
* @param send The send.
|
|
1585
|
+
* @param waitsForTurn Whether the send waits for a turn in flight (`false` for a non-blocking
|
|
1586
|
+
* tool result, which goes out while the model talks).
|
|
992
1587
|
*/
|
|
993
|
-
enqueueOrRun(send) {
|
|
994
|
-
if (this.responseActive) {
|
|
1588
|
+
enqueueOrRun(send, waitsForTurn = true) {
|
|
1589
|
+
if (!this.connectionReady || (waitsForTurn && this.responseActive)) {
|
|
995
1590
|
this.queuedSends.push(send);
|
|
996
1591
|
return;
|
|
997
1592
|
}
|
|
@@ -999,10 +1594,11 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
999
1594
|
}
|
|
1000
1595
|
/**
|
|
1001
1596
|
* Drains queued sends in order on a turn boundary, stopping as soon as one starts a new
|
|
1002
|
-
* turn (sets {@link responseActive}) — the rest wait for that turn's completion.
|
|
1597
|
+
* turn (sets {@link responseActive}) — the rest wait for that turn's completion. Nothing
|
|
1598
|
+
* drains while no connection is ready: {@link handleResumed} drains onto the new one.
|
|
1003
1599
|
*/
|
|
1004
1600
|
flushQueuedSends() {
|
|
1005
|
-
while (!this.responseActive && this.queuedSends.length > 0) {
|
|
1601
|
+
while (!this.responseActive && this.connectionReady && this.queuedSends.length > 0) {
|
|
1006
1602
|
const send = this.queuedSends.shift();
|
|
1007
1603
|
send?.();
|
|
1008
1604
|
}
|
|
@@ -1027,6 +1623,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
1027
1623
|
// `openClientTurn` is left as-is — the model `turnComplete` that follows clears it.
|
|
1028
1624
|
session.sendRealtimeInput({ text });
|
|
1029
1625
|
this.responseActive = true;
|
|
1626
|
+
this.markTurnOpen();
|
|
1030
1627
|
if (this.idleSignal === 'interactionStatus') {
|
|
1031
1628
|
this.interactionInProgress = true;
|
|
1032
1629
|
this.scheduleSafetyBackstop();
|
|
@@ -1101,6 +1698,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
1101
1698
|
this.setState('speaking');
|
|
1102
1699
|
}
|
|
1103
1700
|
this.pendingToolCallNames.delete(callID);
|
|
1701
|
+
this.endTurnIfToolResultsIn();
|
|
1104
1702
|
}
|
|
1105
1703
|
/**
|
|
1106
1704
|
* Parses a JSON-stringified tool result into the structured object Gemini's
|