@memberjunction/ai-realtime-client 6.2.0-edge.3 → 6.2.0-edge.4
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 +107 -0
- package/dist/drivers/geminiEnterpriseRealtimeClient.js.map +1 -0
- package/dist/drivers/geminiRealtimeClient.d.ts +314 -48
- package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -1
- package/dist/drivers/geminiRealtimeClient.js +759 -176
- 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/generic/baseRealtimeClient.d.ts +173 -29
- package/dist/generic/baseRealtimeClient.d.ts.map +1 -1
- package/dist/generic/baseRealtimeClient.js +233 -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 +14 -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,10 +717,13 @@ 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
|
/**
|
|
@@ -506,6 +758,17 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
506
758
|
createPlayback() {
|
|
507
759
|
return new GeminiPcmPlayback();
|
|
508
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Creation seam for the avatar's video player, called at connect when the server granted an
|
|
763
|
+
* avatar and the host shows it. Production returns {@link VideoPlayout} (Media Source
|
|
764
|
+
* Extensions); unit tests return a fake.
|
|
765
|
+
*
|
|
766
|
+
* @param options The type the grant names, whether the video carries the voice, and the hook
|
|
767
|
+
* that routes the element's audio into the PCM playback's Web Audio graph.
|
|
768
|
+
*/
|
|
769
|
+
CreateVideoPlayout(options) {
|
|
770
|
+
return new VideoPlayout(options);
|
|
771
|
+
}
|
|
509
772
|
// ── Connection internals ───────────────────────────────────────────────────
|
|
510
773
|
/**
|
|
511
774
|
* Extracts the model + Live connect config from the server-minted `SessionConfig`
|
|
@@ -527,36 +790,69 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
527
790
|
const supportsInboundVideo = typeof sessionConfig['supportsInboundVideo'] === 'boolean' ? sessionConfig['supportsInboundVideo'] : undefined;
|
|
528
791
|
const rawMaxVideoRate = sessionConfig['maxInboundVideoRate'];
|
|
529
792
|
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
|
-
|
|
793
|
+
const rawMaxStreams = sessionConfig['maxInboundVideoStreams'];
|
|
794
|
+
const maxInboundVideoStreams = typeof rawMaxStreams === 'number' && Number.isInteger(rawMaxStreams) && rawMaxStreams >= 0 ? rawMaxStreams : undefined;
|
|
795
|
+
return {
|
|
796
|
+
model, liveConfig, idleSignal, supportsScheduling, supportsBlocking, supportsInboundVideo, maxInboundVideoRate,
|
|
797
|
+
maxInboundVideoStreams,
|
|
798
|
+
requestedTracks: GeminiRealtimeClient_1.readRequestedTracks(sessionConfig[REQUESTED_TRACKS_SESSION_KEY]),
|
|
799
|
+
avatar: GeminiRealtimeClient_1.readAvatarGrant(sessionConfig['avatar']),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** The tracks the runtime asks the session for (`requestedTracks`); `undefined` when it sent no list. */
|
|
803
|
+
static readRequestedTracks(raw) {
|
|
804
|
+
if (!Array.isArray(raw)) {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
const list = [];
|
|
808
|
+
for (const item of raw) {
|
|
809
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
810
|
+
const modality = typeof item['Modality'] === 'string' ? item['Modality'] : undefined;
|
|
811
|
+
const direction = item['Direction'];
|
|
812
|
+
if (modality && (direction === 'inbound' || direction === 'outbound')) {
|
|
813
|
+
list.push({
|
|
814
|
+
Modality: modality,
|
|
815
|
+
Direction: direction,
|
|
816
|
+
Encoding: typeof item['Encoding'] === 'string' ? item['Encoding'] : undefined,
|
|
817
|
+
Rate: typeof item['Rate'] === 'number' ? item['Rate'] : undefined,
|
|
818
|
+
RequiresConsent: typeof item['RequiresConsent'] === 'boolean' ? item['RequiresConsent'] : undefined,
|
|
819
|
+
SourceID: typeof item['SourceID'] === 'string' ? item['SourceID'] : undefined,
|
|
820
|
+
Label: typeof item['Label'] === 'string' ? item['Label'] : undefined,
|
|
821
|
+
});
|
|
547
822
|
}
|
|
548
823
|
}
|
|
549
|
-
requestedTracks = list;
|
|
550
824
|
}
|
|
551
|
-
return
|
|
825
|
+
return list;
|
|
552
826
|
}
|
|
553
|
-
/**
|
|
827
|
+
/**
|
|
828
|
+
* The avatar the server granted: the minted `avatar` block (`{ output: true, encoding, audioMuxed }`), which the
|
|
829
|
+
* server writes only when the session renders one. `audioMuxed` defaults to true, as the server's profile does.
|
|
830
|
+
*/
|
|
831
|
+
static readAvatarGrant(raw) {
|
|
832
|
+
const block = GeminiRealtimeClient_1.readObject(raw);
|
|
833
|
+
if (block?.['output'] !== true) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
return { Encoding: GeminiRealtimeClient_1.readString(block['encoding']) ?? null, AudioMuxed: block['audioMuxed'] !== false };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Streams one base64 PCM16 mic chunk to the model (no-op once the session is gone, closed, or in error). During a
|
|
840
|
+
* move to a new connection the chunk is held instead, and goes out on the new connection once it is ready.
|
|
841
|
+
*/
|
|
554
842
|
sendMicChunk(base64Pcm16) {
|
|
555
843
|
if (!this.session || this.currentState === 'closed' || this.currentState === 'error') {
|
|
556
844
|
return;
|
|
557
845
|
}
|
|
846
|
+
if (!this.connectionReady) {
|
|
847
|
+
this.holdMicChunk(base64Pcm16);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
this.sendAudio(this.session, base64Pcm16);
|
|
851
|
+
}
|
|
852
|
+
/** Sends one base64 PCM16 mic chunk on `session`; a failed send is logged, not thrown. */
|
|
853
|
+
sendAudio(session, base64Pcm16) {
|
|
558
854
|
try {
|
|
559
|
-
|
|
855
|
+
session.sendRealtimeInput({
|
|
560
856
|
audio: { data: base64Pcm16, mimeType: GEMINI_INPUT_AUDIO_MIME_TYPE },
|
|
561
857
|
});
|
|
562
858
|
}
|
|
@@ -564,21 +860,69 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
564
860
|
RealtimeDiagLog(`[GeminiRealtimeClient] sendMicChunk failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
565
861
|
}
|
|
566
862
|
}
|
|
567
|
-
/**
|
|
863
|
+
/** Holds a mic chunk during a move, keeping the latest {@link HELD_MIC_AUDIO_SECONDS} seconds; older audio goes first. */
|
|
864
|
+
holdMicChunk(base64Pcm16) {
|
|
865
|
+
this.heldMicAudio.push(base64Pcm16);
|
|
866
|
+
this.heldMicAudioChars += base64Pcm16.length;
|
|
867
|
+
while (this.heldMicAudioChars > MAX_HELD_MIC_AUDIO_BASE64_CHARS && this.heldMicAudio.length > 1) {
|
|
868
|
+
this.heldMicAudioChars -= this.heldMicAudio.shift()?.length ?? 0;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/** Sends the mic audio held during a move on the connection now in use, oldest first. */
|
|
872
|
+
sendHeldMicAudio() {
|
|
873
|
+
const held = this.heldMicAudio;
|
|
874
|
+
this.heldMicAudio = [];
|
|
875
|
+
this.heldMicAudioChars = 0;
|
|
876
|
+
if (!this.session) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
for (const chunk of held) {
|
|
880
|
+
this.sendAudio(this.session, chunk);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Surfaces a fatal websocket error and marks the session unusable. When the session can be
|
|
885
|
+
* resumed, the error is only logged: a websocket `error` is always followed by a `close`,
|
|
886
|
+
* and {@link handleTransportClose} resumes from there.
|
|
887
|
+
*/
|
|
568
888
|
handleTransportError(event) {
|
|
569
|
-
const detail =
|
|
889
|
+
const detail = GeminiRealtimeClient_1.transportErrorDetail(event);
|
|
570
890
|
RealtimeDiagLog(`[GeminiRealtimeClient] Transport error: ${detail}`);
|
|
891
|
+
if (this.resumption?.Handle) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
571
894
|
this.emitError({ Message: `Gemini Live transport error: ${detail}`, Fatal: true });
|
|
572
895
|
this.setState('error');
|
|
573
896
|
}
|
|
574
|
-
/**
|
|
897
|
+
/**
|
|
898
|
+
* Handles a close of the current connection that the consumer did not ask for. MJAPI's relay
|
|
899
|
+
* closing at the session's time limit ends the call with a `time-limit` error. Otherwise, when
|
|
900
|
+
* Google issued a resumption handle, the session reconnects with it; else the close ends the
|
|
901
|
+
* session, as an error when it was abnormal.
|
|
902
|
+
*/
|
|
575
903
|
handleTransportClose(event) {
|
|
576
904
|
const code = event?.code;
|
|
577
905
|
const reason = event?.reason;
|
|
578
906
|
const wasClean = event?.wasClean;
|
|
579
907
|
RealtimeDiagLog(`[GeminiRealtimeClient] Transport closed: code=${code} reason=${reason} wasClean=${wasClean}`);
|
|
908
|
+
if (this.currentState === 'error' || this.currentState === 'closed') {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (code === REALTIME_RELAY_TIME_LIMIT_CLOSE_CODE) {
|
|
912
|
+
// The relay session reached its time limit: it is gone, so a resume could only fail. End with its own code.
|
|
913
|
+
this.emitError({
|
|
914
|
+
Message: `Gemini Live session reached its time limit (${code}): ${reason || 'no reason given'}`,
|
|
915
|
+
Code: REALTIME_CLIENT_TIME_LIMIT_ERROR_CODE,
|
|
916
|
+
Fatal: true,
|
|
917
|
+
});
|
|
918
|
+
this.setState('error');
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (this.resumption?.ConnectionLost()) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
580
924
|
const isAbnormal = (code !== undefined && code !== 0 && code !== 1000 && code !== 1005) || (wasClean === false && code !== 1000 && code !== 0 && code !== 1005 && code !== undefined);
|
|
581
|
-
if (isAbnormal
|
|
925
|
+
if (isAbnormal) {
|
|
582
926
|
this.emitError({
|
|
583
927
|
Message: `Gemini Live connection closed (${code}): ${reason || 'unexpected disconnect'}`,
|
|
584
928
|
Fatal: true,
|
|
@@ -586,9 +930,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
586
930
|
this.setState('error');
|
|
587
931
|
return;
|
|
588
932
|
}
|
|
589
|
-
|
|
590
|
-
this.setState('closed');
|
|
591
|
-
}
|
|
933
|
+
this.setState('closed');
|
|
592
934
|
}
|
|
593
935
|
// ── Inbound message translation ────────────────────────────────────────────
|
|
594
936
|
/**
|
|
@@ -597,20 +939,14 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
597
939
|
*/
|
|
598
940
|
handleServerMessage(message) {
|
|
599
941
|
this.checkInteractionStatus(message);
|
|
600
|
-
// Session continuity:
|
|
942
|
+
// Session continuity (F7). `resumable: false` (mid-turn, mid-tool-call) arrives with no
|
|
943
|
+
// handle; an update without the flag is treated as resumable.
|
|
601
944
|
if (message.sessionResumptionUpdate) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
605
|
-
else if (message.sessionResumptionUpdate.newHandle) {
|
|
606
|
-
this.resumptionHandle = message.sessionResumptionUpdate.newHandle;
|
|
607
|
-
}
|
|
945
|
+
const update = message.sessionResumptionUpdate;
|
|
946
|
+
this.resumption?.RecordHandle(update.newHandle, update.resumable !== false);
|
|
608
947
|
}
|
|
609
|
-
// Server approaching timeout / abort: reconnect seamlessly using resumption handle (F7)
|
|
610
948
|
if (message.goAway) {
|
|
611
|
-
|
|
612
|
-
void this.resumeSession(this.resumptionHandle);
|
|
613
|
-
}
|
|
949
|
+
this.resumption?.ConnectionEnding(ParseDurationToMs(message.goAway.timeLeft));
|
|
614
950
|
}
|
|
615
951
|
if (message.serverContent) {
|
|
616
952
|
this.handleServerContent(message.serverContent);
|
|
@@ -623,33 +959,151 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
623
959
|
}
|
|
624
960
|
}
|
|
625
961
|
/**
|
|
626
|
-
*
|
|
962
|
+
* Opens a replacement connection that resumes the session from `handle`, switches to it once
|
|
963
|
+
* Google confirms its setup, and closes the old one. Until then the old connection stays in
|
|
964
|
+
* use, so what it still sends (the rest of a turn the deadline cut, its usage) is handled.
|
|
965
|
+
* Mic capture and playout carry over untouched. Rejects when the connection can't be opened or
|
|
966
|
+
* its setup fails, so {@link RealtimeSessionResumption} can retry.
|
|
967
|
+
*
|
|
968
|
+
* The resume reuses the session's ephemeral token, which Google accepts until the token's
|
|
969
|
+
* `expireTime` (the server driver mints 30 minutes). After that every attempt fails and the
|
|
970
|
+
* session ends with a fatal error.
|
|
971
|
+
*
|
|
972
|
+
* @param handle The resumption handle Google issued.
|
|
973
|
+
* @param attempt Marks the attempt abandoned after a timeout or a consumer close; a
|
|
974
|
+
* connection that opens after that is closed instead of used.
|
|
627
975
|
*/
|
|
628
|
-
async resumeSession(handle) {
|
|
629
|
-
|
|
976
|
+
async resumeSession(handle, attempt) {
|
|
977
|
+
const target = this.connectTarget;
|
|
978
|
+
if (!target) {
|
|
979
|
+
throw new Error('there is no Gemini Live session to resume');
|
|
980
|
+
}
|
|
981
|
+
const resumeTarget = {
|
|
982
|
+
...target,
|
|
983
|
+
Config: { ...target.Config, sessionResumption: { ...target.Config.sessionResumption, handle } },
|
|
984
|
+
};
|
|
985
|
+
const opened = await this.openConnection(resumeTarget);
|
|
986
|
+
await GeminiRealtimeClient_1.awaitSetup(opened);
|
|
987
|
+
if (attempt.Abandoned || this.connectTarget !== target) {
|
|
988
|
+
GeminiRealtimeClient_1.closeQuietly(opened.Session);
|
|
630
989
|
return;
|
|
631
990
|
}
|
|
991
|
+
const previous = this.session;
|
|
992
|
+
this.useConnection(opened);
|
|
993
|
+
this.connectTarget = resumeTarget;
|
|
994
|
+
if (previous) {
|
|
995
|
+
GeminiRealtimeClient_1.closeQuietly(previous);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Opens a connection through {@link connectLiveSession}. Its callbacks act only while it is the
|
|
1000
|
+
* connection in use, so events from one that was replaced, closed, or is still opening are
|
|
1001
|
+
* dropped; its `setupComplete`, and a close or error before it, settle its {@link GeminiOpenedConnection.Setup}
|
|
1002
|
+
* whether or not it is in use yet. It becomes the connection in use through {@link useConnection},
|
|
1003
|
+
* which lets the old connection keep delivering events while a planned move is in progress.
|
|
1004
|
+
*/
|
|
1005
|
+
async openConnection(target) {
|
|
1006
|
+
const number = ++this.issuedConnections;
|
|
1007
|
+
const isCurrent = () => number === this.currentConnection;
|
|
1008
|
+
const setup = new RealtimeConnectionSetup();
|
|
1009
|
+
const session = await this.connectLiveSession({
|
|
1010
|
+
...target,
|
|
1011
|
+
OnMessage: (message) => {
|
|
1012
|
+
if (message.setupComplete) {
|
|
1013
|
+
setup.Confirm();
|
|
1014
|
+
}
|
|
1015
|
+
if (isCurrent()) {
|
|
1016
|
+
this.handleServerMessage(message);
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
OnError: (event) => {
|
|
1020
|
+
setup.Fail(new Error(`Gemini Live connection failed before confirming its setup: ${GeminiRealtimeClient_1.transportErrorDetail(event)}`));
|
|
1021
|
+
if (isCurrent()) {
|
|
1022
|
+
this.handleTransportError(event);
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
OnClose: (event) => {
|
|
1026
|
+
setup.Fail(new Error(`Gemini Live closed the connection before confirming its setup (${event?.code}): ${event?.reason || 'no reason given'}`));
|
|
1027
|
+
if (isCurrent()) {
|
|
1028
|
+
this.handleTransportClose(event);
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
return { Session: session, ConnectionNumber: number, Setup: setup };
|
|
1033
|
+
}
|
|
1034
|
+
/** Waits for Google to confirm a new connection's setup. On failure the connection is closed and the error rethrown. */
|
|
1035
|
+
static async awaitSetup(opened) {
|
|
632
1036
|
try {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1037
|
+
await opened.Setup.Wait();
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
GeminiRealtimeClient_1.closeQuietly(opened.Session);
|
|
1041
|
+
throw error;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/** What a websocket error event says went wrong. */
|
|
1045
|
+
static transportErrorDetail(event) {
|
|
1046
|
+
return event.message || (event.error instanceof Error ? event.error.message : String(event.error ?? 'unknown'));
|
|
1047
|
+
}
|
|
1048
|
+
/** Makes an opened connection the one in use. */
|
|
1049
|
+
useConnection(opened) {
|
|
1050
|
+
this.session = opened.Session;
|
|
1051
|
+
this.currentConnection = opened.ConnectionNumber;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Builds the resumption helper for a newly connected session; see {@link resumption}. From the
|
|
1055
|
+
* moment a move starts, input waits for the new connection ({@link connectionReady}).
|
|
1056
|
+
*/
|
|
1057
|
+
createResumption() {
|
|
1058
|
+
return new RealtimeSessionResumption({
|
|
1059
|
+
Reconnect: (handle, attempt) => this.resumeSession(handle, attempt),
|
|
1060
|
+
OnReconnecting: (reason) => {
|
|
1061
|
+
RealtimeDiagLog(`[GeminiRealtimeClient] Resuming the session on a new connection (${reason})`);
|
|
1062
|
+
this.connectionReady = false;
|
|
1063
|
+
this.setState('connecting');
|
|
1064
|
+
},
|
|
1065
|
+
OnReconnected: () => this.handleResumed(),
|
|
1066
|
+
OnReconnectFailed: (error) => this.handleResumeFailed(error),
|
|
1067
|
+
Log: (message) => RealtimeDiagLog(message),
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* The session continues on a new connection, whose setup Google has confirmed. A turn cut off
|
|
1072
|
+
* by a drop or the deadline never completes there, so the turn state is reset (its partial
|
|
1073
|
+
* transcripts are emitted as final). The mic audio held during the move goes out, then the
|
|
1074
|
+
* sends queued behind it. An avatar's video plays out what arrived of that turn and holds its
|
|
1075
|
+
* last frame.
|
|
1076
|
+
*/
|
|
1077
|
+
handleResumed() {
|
|
1078
|
+
this.avatarOutput?.Resumed();
|
|
1079
|
+
this.emitAvatarVideoSeconds();
|
|
1080
|
+
this.finalizeUserTranscript();
|
|
1081
|
+
this.finalizeAssistantTranscript();
|
|
1082
|
+
this.responseActive = false;
|
|
1083
|
+
this.interactionInProgress = false;
|
|
1084
|
+
this.activeResponseKind = 'normal';
|
|
1085
|
+
this.clearSafetyBackstop();
|
|
1086
|
+
this.endTurn();
|
|
1087
|
+
this.connectionReady = true;
|
|
1088
|
+
this.setState('listening');
|
|
1089
|
+
this.sendHeldMicAudio();
|
|
1090
|
+
this.flushQueuedSends();
|
|
1091
|
+
}
|
|
1092
|
+
/** Every resume attempt failed: the session ends with a fatal error. */
|
|
1093
|
+
handleResumeFailed(error) {
|
|
1094
|
+
if (this.currentState === 'closed' || this.currentState === 'error') {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.emitError({ Message: `Gemini Live connection was lost and could not be resumed: ${error.message}`, Fatal: true });
|
|
1098
|
+
this.setState('error');
|
|
1099
|
+
}
|
|
1100
|
+
/** Closes a socket the session no longer uses; it may already be closed. */
|
|
1101
|
+
static closeQuietly(session) {
|
|
1102
|
+
try {
|
|
1103
|
+
session.close();
|
|
650
1104
|
}
|
|
651
1105
|
catch (err) {
|
|
652
|
-
RealtimeDiagLog(`[GeminiRealtimeClient]
|
|
1106
|
+
RealtimeDiagLog(`[GeminiRealtimeClient] Closing a replaced connection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
653
1107
|
}
|
|
654
1108
|
}
|
|
655
1109
|
/**
|
|
@@ -671,6 +1125,8 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
671
1125
|
this.interactionInProgress = true;
|
|
672
1126
|
this.responseActive = true;
|
|
673
1127
|
if (this.idleSignal === 'interactionStatus') {
|
|
1128
|
+
// The turn ends at the true IDLE (handleIdleTerminal), so the status that opens it counts only here.
|
|
1129
|
+
this.markTurnOpen();
|
|
674
1130
|
this.scheduleSafetyBackstop();
|
|
675
1131
|
}
|
|
676
1132
|
}
|
|
@@ -695,24 +1151,9 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
695
1151
|
* the server-bridged `GeminiRealtime` driver forwards the same payload to `IRealtimeSession.OnUsage`.
|
|
696
1152
|
*/
|
|
697
1153
|
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
|
-
}
|
|
1154
|
+
let inputDetails = GeminiRealtimeClient_1.modalityTokens(usageMetadata.promptTokensDetails);
|
|
1155
|
+
// The response side: TEXT and AUDIO, and VIDEO for a generated avatar (when Google reports it).
|
|
1156
|
+
const outputDetails = GeminiRealtimeClient_1.modalityTokens(usageMetadata.responseTokensDetails);
|
|
716
1157
|
/**
|
|
717
1158
|
* Cost Attribution Note (F6 & Reviewer Item 29):
|
|
718
1159
|
* Inbound video frames are sent as individual JPEG images (V5) and billed on the video pricing tier
|
|
@@ -730,14 +1171,13 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
730
1171
|
* and provider-received ImageTokens immediately surfaces frame drops or network throttling in production.
|
|
731
1172
|
*/
|
|
732
1173
|
if (this.videoFramesSent > 0) {
|
|
733
|
-
inputDetails = inputDetails
|
|
734
|
-
inputDetails.VideoFrames = this.videoFramesSent;
|
|
735
|
-
inputDetails.VideoSeconds = this.VideoSeconds;
|
|
1174
|
+
inputDetails = { ...inputDetails, VideoFrames: this.videoFramesSent, VideoSeconds: this.VideoSeconds };
|
|
736
1175
|
}
|
|
737
1176
|
this.emitUsage({
|
|
738
1177
|
InputTokens: typeof usageMetadata.promptTokenCount === 'number' ? usageMetadata.promptTokenCount : undefined,
|
|
739
1178
|
OutputTokens: typeof usageMetadata.responseTokenCount === 'number' ? usageMetadata.responseTokenCount : undefined,
|
|
740
1179
|
...(inputDetails ? { InputTokenDetails: inputDetails } : {}),
|
|
1180
|
+
...(outputDetails ? { OutputTokenDetails: outputDetails } : {}),
|
|
741
1181
|
...(this.videoFramesSent > 0 ? {
|
|
742
1182
|
VideoFrames: this.videoFramesSent,
|
|
743
1183
|
VideoSeconds: this.VideoSeconds,
|
|
@@ -745,13 +1185,53 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
745
1185
|
Raw: usageMetadata,
|
|
746
1186
|
});
|
|
747
1187
|
}
|
|
748
|
-
/**
|
|
1188
|
+
/**
|
|
1189
|
+
* Gemini's per-modality token counts (`promptTokensDetails` or `responseTokensDetails`) as a detail block: TEXT,
|
|
1190
|
+
* AUDIO, IMAGE and VIDEO. `undefined` when Gemini sent no count for a modality it names.
|
|
1191
|
+
*/
|
|
1192
|
+
static modalityTokens(details) {
|
|
1193
|
+
if (!Array.isArray(details)) {
|
|
1194
|
+
return undefined;
|
|
1195
|
+
}
|
|
1196
|
+
let block;
|
|
1197
|
+
for (const detail of details) {
|
|
1198
|
+
const field = GEMINI_MODALITY_TOKEN_FIELDS[String(detail.modality ?? '').toUpperCase()];
|
|
1199
|
+
if (field && typeof detail.tokenCount === 'number') {
|
|
1200
|
+
block = block ?? {};
|
|
1201
|
+
block[field] = (block[field] ?? 0) + detail.tokenCount;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return block;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Emits the avatar video generated since the last emission as an update of its own
|
|
1208
|
+
* (`OutputTokenDetails.VideoSeconds`, an amount): called where a turn's counting ends (generation complete,
|
|
1209
|
+
* turn complete, resumed; an interrupted turn ends at its turn complete) and before the avatar output is
|
|
1210
|
+
* disposed, so every second is emitted once, whenever Google's `usageMetadata` arrives.
|
|
1211
|
+
*/
|
|
1212
|
+
emitAvatarVideoSeconds() {
|
|
1213
|
+
const seconds = this.avatarOutput?.TakeVideoSeconds() ?? 0;
|
|
1214
|
+
if (seconds > 0) {
|
|
1215
|
+
this.emitUsage({ OutputTokenDetails: { VideoSeconds: seconds } });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Translates one {@link LiveServerContent} frame in provider-documented signal order. What the frame carries opens
|
|
1220
|
+
* the turn before its `turnComplete` ends it.
|
|
1221
|
+
*/
|
|
749
1222
|
handleServerContent(content) {
|
|
1223
|
+
if (this.contentOpensTurn(content)) {
|
|
1224
|
+
this.markTurnOpen();
|
|
1225
|
+
}
|
|
1226
|
+
if (this.avatarOutput && GeminiRealtimeClient_1.hasText(content.outputTranscription)) {
|
|
1227
|
+
// The model speaks: the avatar's video is its answer's from here, this message's parts included.
|
|
1228
|
+
this.avatarOutput.AnswerStarted();
|
|
1229
|
+
}
|
|
750
1230
|
if (content.interrupted) {
|
|
751
1231
|
this.handleInterruption();
|
|
752
1232
|
}
|
|
753
1233
|
if (content.modelTurn) {
|
|
754
|
-
this.
|
|
1234
|
+
this.handleModelMedia(content.modelTurn);
|
|
755
1235
|
this.handleModelThoughts(content.modelTurn);
|
|
756
1236
|
}
|
|
757
1237
|
if (content.inputTranscription) {
|
|
@@ -773,8 +1253,12 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
773
1253
|
*
|
|
774
1254
|
* Per Reviewer Item 20: Draining the queue happens on turnComplete or true IDLE, not prematurely
|
|
775
1255
|
* on generationComplete. We set responseActive = false so busy state reflects token completion.
|
|
1256
|
+
* An avatar's video gets its end of turn here: playback runs to the true end and holds the
|
|
1257
|
+
* last frame.
|
|
776
1258
|
*/
|
|
777
1259
|
handleGenerationComplete() {
|
|
1260
|
+
this.avatarOutput?.GenerationComplete();
|
|
1261
|
+
this.emitAvatarVideoSeconds();
|
|
778
1262
|
if (this.idleSignal === 'turnComplete') {
|
|
779
1263
|
this.responseActive = false;
|
|
780
1264
|
}
|
|
@@ -785,16 +1269,23 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
785
1269
|
* client's audio queue), surface the TRUE barge-in to the host (Gemini only emits
|
|
786
1270
|
* `interrupted` when user input actually cut off in-flight generation — no extra gating
|
|
787
1271
|
* needed), and give the floor back. The interrupted turn's accumulated transcript is
|
|
788
|
-
* kept — the following `turnComplete` finalizes what WAS spoken.
|
|
1272
|
+
* kept — the following `turnComplete` finalizes what WAS spoken. In an avatar session the
|
|
1273
|
+
* video stops too, and the turn's late parts are dropped until its `turnComplete`.
|
|
789
1274
|
*/
|
|
790
1275
|
handleInterruption() {
|
|
791
1276
|
this.playback?.Flush();
|
|
1277
|
+
this.avatarOutput?.Interrupted();
|
|
792
1278
|
this.finalizeThoughtTranscript();
|
|
793
1279
|
this.emitInterruption();
|
|
794
1280
|
this.setState('listening');
|
|
795
1281
|
}
|
|
796
|
-
/**
|
|
797
|
-
|
|
1282
|
+
/**
|
|
1283
|
+
* Plays inline model parts. In an avatar session the avatar output routes each part to the
|
|
1284
|
+
* video, to the PCM playback or nowhere; only the answer's parts mark generation started
|
|
1285
|
+
* (and the turn open): the idle video between answers plays without making the agent
|
|
1286
|
+
* speaking or busy. Otherwise base64 PCM16 @ 24 kHz parts go to the playout queue.
|
|
1287
|
+
*/
|
|
1288
|
+
handleModelMedia(modelTurn) {
|
|
798
1289
|
if (!modelTurn.parts) {
|
|
799
1290
|
return;
|
|
800
1291
|
}
|
|
@@ -802,13 +1293,35 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
802
1293
|
if (part.thought) {
|
|
803
1294
|
continue; // Thoughts are reasoning summaries, never spoken audio
|
|
804
1295
|
}
|
|
805
|
-
const
|
|
806
|
-
if (data) {
|
|
807
|
-
|
|
808
|
-
|
|
1296
|
+
const inline = part.inlineData;
|
|
1297
|
+
if (!inline?.data) {
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (this.avatarOutput) {
|
|
1301
|
+
if (this.avatarOutput.Accept(inline.mimeType, Base64ToArrayBuffer(inline.data)) === 'answer') {
|
|
1302
|
+
this.markTurnOpen();
|
|
1303
|
+
this.markGenerationStarted();
|
|
1304
|
+
}
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
// A part that names a non-PCM type (e.g. video/mp4 avatar frames) must never reach PCM
|
|
1308
|
+
// playback, where it would play as noise. A part with no type plays, as it always has.
|
|
1309
|
+
if (inline.mimeType && !IsPcmAudioMimeType(inline.mimeType)) {
|
|
1310
|
+
this.reportDroppedOutput(inline.mimeType);
|
|
1311
|
+
continue;
|
|
809
1312
|
}
|
|
1313
|
+
this.markGenerationStarted();
|
|
1314
|
+
this.playback?.Enqueue(Base64ToArrayBuffer(inline.data));
|
|
810
1315
|
}
|
|
811
1316
|
}
|
|
1317
|
+
/** Reports each MIME type of dropped model output once per session, not once per part. */
|
|
1318
|
+
reportDroppedOutput(mimeType) {
|
|
1319
|
+
if (this.droppedOutputTypes.has(mimeType)) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
this.droppedOutputTypes.add(mimeType);
|
|
1323
|
+
console.warn(`[GeminiRealtimeClient] Dropped model output of type ${mimeType}: only PCM audio is played on this session.`);
|
|
1324
|
+
}
|
|
812
1325
|
/**
|
|
813
1326
|
* Extracts thought parts (`part.thought === true`) from model turns and emits them
|
|
814
1327
|
* as narration transcript deltas (`Kind: 'narration'`). Thought summaries are reasoning
|
|
@@ -882,6 +1395,8 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
882
1395
|
if (!functionCalls || functionCalls.length === 0) {
|
|
883
1396
|
return;
|
|
884
1397
|
}
|
|
1398
|
+
// The answer stops at the call: the avatar's video until the model speaks again is idle.
|
|
1399
|
+
this.avatarOutput?.ToolCalled();
|
|
885
1400
|
if (!this.isNonBlocking) {
|
|
886
1401
|
if (this.currentState === 'speaking') {
|
|
887
1402
|
this.currentState = 'connected';
|
|
@@ -895,21 +1410,27 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
895
1410
|
this.toolBatchBarrier.TrackPendingCall(callID, () => {
|
|
896
1411
|
this.handleToolBatchTimeout();
|
|
897
1412
|
});
|
|
1413
|
+
// A pending call keeps its turn open until its result goes out (see IsTurnOpen).
|
|
1414
|
+
this.markTurnOpen();
|
|
898
1415
|
this.emitToolCall({ CallID: callID, ToolName: toolName, ArgumentsJson: JSON.stringify(call.args ?? {}) });
|
|
899
1416
|
}
|
|
900
1417
|
}
|
|
901
1418
|
/**
|
|
902
1419
|
* Turn boundary: finalize any un-finished assistant transcript.
|
|
903
|
-
* Under 'turnComplete' idle signal,
|
|
904
|
-
* drain queued sends, and return the floor to the user.
|
|
1420
|
+
* Under 'turnComplete' idle signal, end the turn (see {@link IsTurnOpen}), release the busy
|
|
1421
|
+
* lock, reset response kind, drain queued sends, and return the floor to the user.
|
|
905
1422
|
* 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
|
|
1423
|
+
* background reasoning or async tool calls may still be in flight, so the turn, the busy lock
|
|
907
1424
|
* and queued sends remain held until the true IDLE signal lands.
|
|
908
1425
|
*/
|
|
909
1426
|
handleTurnComplete() {
|
|
1427
|
+
this.avatarOutput?.TurnComplete();
|
|
1428
|
+
this.emitAvatarVideoSeconds();
|
|
910
1429
|
this.finalizeAssistantTranscript();
|
|
911
1430
|
this.finalizeThoughtTranscript();
|
|
912
1431
|
if (this.idleSignal === 'turnComplete') {
|
|
1432
|
+
// Before the drain: a queued send that starts a new turn opens it again.
|
|
1433
|
+
this.endTurnUnlessToolCallPending();
|
|
913
1434
|
this.responseActive = false;
|
|
914
1435
|
this.activeResponseKind = 'normal';
|
|
915
1436
|
this.openClientTurn = false; // the completed generation consumed any open client content
|
|
@@ -925,11 +1446,12 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
925
1446
|
}
|
|
926
1447
|
/**
|
|
927
1448
|
* Reached when the server emits true IDLE (interactionStatus) or the safety backstop fires.
|
|
928
|
-
*
|
|
929
|
-
* drains queued sends, and returns the floor.
|
|
1449
|
+
* Ends the turn (see {@link IsTurnOpen}), releases busy state, commits any deferred open client
|
|
1450
|
+
* turns without cutting off generation, drains queued sends, and returns the floor.
|
|
930
1451
|
*/
|
|
931
1452
|
handleIdleTerminal() {
|
|
932
1453
|
this.clearSafetyBackstop();
|
|
1454
|
+
this.endTurnUnlessToolCallPending();
|
|
933
1455
|
this.interactionInProgress = false;
|
|
934
1456
|
this.responseActive = false;
|
|
935
1457
|
this.activeResponseKind = 'normal';
|
|
@@ -944,9 +1466,9 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
944
1466
|
}
|
|
945
1467
|
}
|
|
946
1468
|
/**
|
|
947
|
-
* First model output of a turn (audio part
|
|
948
|
-
* over (finalize their pending transcript), the model is busy,
|
|
949
|
-
* imminently `'speaking'`.
|
|
1469
|
+
* First model output of a turn (audio part, transcription delta, or a part of an avatar's
|
|
1470
|
+
* answer): the user's turn is over (finalize their pending transcript), the model is busy,
|
|
1471
|
+
* and the client is audibly / imminently `'speaking'`.
|
|
950
1472
|
*/
|
|
951
1473
|
markGenerationStarted() {
|
|
952
1474
|
this.finalizeUserTranscript();
|
|
@@ -983,15 +1505,73 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
983
1505
|
this.emitTranscript({ Role: 'Assistant', Text: text, IsFinal: true, Kind: 'narration', IsThought: true });
|
|
984
1506
|
}
|
|
985
1507
|
}
|
|
1508
|
+
// ── Turn tracking (see IsTurnOpen) ─────────────────────────────────────────
|
|
1509
|
+
/**
|
|
1510
|
+
* Whether a server content message is a sign of a turn: the user's or the model's transcribed
|
|
1511
|
+
* words, a thought, or (in a session without an avatar) the model's audio. An avatar's video
|
|
1512
|
+
* never is: Vertex AI streams it between turns too.
|
|
1513
|
+
*/
|
|
1514
|
+
contentOpensTurn(content) {
|
|
1515
|
+
if (GeminiRealtimeClient_1.hasText(content.inputTranscription) || GeminiRealtimeClient_1.hasText(content.outputTranscription)) {
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
return (content.modelTurn?.parts ?? []).some((part) => this.partOpensTurn(part));
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Whether a model part is a sign of a turn: a thought, or audio (as this client plays it) in a session without an
|
|
1522
|
+
* avatar. In an avatar session, a voice part opens the turn once it plays ({@link handleModelMedia}).
|
|
1523
|
+
*/
|
|
1524
|
+
partOpensTurn(part) {
|
|
1525
|
+
if (part.thought) {
|
|
1526
|
+
return !!part.text;
|
|
1527
|
+
}
|
|
1528
|
+
const inline = part.inlineData;
|
|
1529
|
+
return !this.avatarOutput && !!inline?.data && (!inline.mimeType || IsPcmAudioMimeType(inline.mimeType));
|
|
1530
|
+
}
|
|
1531
|
+
/** Whether a transcription frame carries words (whitespace alone does not count). */
|
|
1532
|
+
static hasText(transcription) {
|
|
1533
|
+
return (transcription?.text?.trim().length ?? 0) > 0;
|
|
1534
|
+
}
|
|
1535
|
+
/** A turn started or continued: a planned move waits until it ends. */
|
|
1536
|
+
markTurnOpen() {
|
|
1537
|
+
this.turnOpen = true;
|
|
1538
|
+
this.turnEndAwaitsToolResults = false;
|
|
1539
|
+
this.resumption?.TurnStarted();
|
|
1540
|
+
}
|
|
1541
|
+
/** The turn's `turnComplete` (or true idle): it ends now, or once the results of the tool calls still pending go out. */
|
|
1542
|
+
endTurnUnlessToolCallPending() {
|
|
1543
|
+
if (this.pendingToolCallNames.size > 0) {
|
|
1544
|
+
this.turnEndAwaitsToolResults = this.turnOpen;
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
this.endTurn();
|
|
1548
|
+
}
|
|
1549
|
+
/** A tool result went out: a turn whose `turnComplete` waited on the pending calls ends once none is left. */
|
|
1550
|
+
endTurnIfToolResultsIn() {
|
|
1551
|
+
if (this.turnEndAwaitsToolResults && this.pendingToolCallNames.size === 0) {
|
|
1552
|
+
this.endTurn();
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/** The turn is over: a planned move happens at the next resumable handle Google issues. */
|
|
1556
|
+
endTurn() {
|
|
1557
|
+
this.turnOpen = false;
|
|
1558
|
+
this.turnEndAwaitsToolResults = false;
|
|
1559
|
+
this.resumption?.TurnEnded();
|
|
1560
|
+
}
|
|
986
1561
|
// ── Collision-safe send machinery ──────────────────────────────────────────
|
|
987
1562
|
/**
|
|
988
1563
|
* Runs a send immediately when no turn is in flight; otherwise queues it for the next
|
|
989
1564
|
* `turnComplete`. This is Gemini's equivalent of the OpenAI driver's
|
|
990
1565
|
* queue-behind-active-response rule — stricter here because ANY client content interrupts
|
|
991
|
-
* in-flight generation on the Live API.
|
|
1566
|
+
* in-flight generation on the Live API. Every send also waits while no connection is ready
|
|
1567
|
+
* ({@link connectionReady}): during a move it goes out on the new connection.
|
|
1568
|
+
*
|
|
1569
|
+
* @param send The send.
|
|
1570
|
+
* @param waitsForTurn Whether the send waits for a turn in flight (`false` for a non-blocking
|
|
1571
|
+
* tool result, which goes out while the model talks).
|
|
992
1572
|
*/
|
|
993
|
-
enqueueOrRun(send) {
|
|
994
|
-
if (this.responseActive) {
|
|
1573
|
+
enqueueOrRun(send, waitsForTurn = true) {
|
|
1574
|
+
if (!this.connectionReady || (waitsForTurn && this.responseActive)) {
|
|
995
1575
|
this.queuedSends.push(send);
|
|
996
1576
|
return;
|
|
997
1577
|
}
|
|
@@ -999,10 +1579,11 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
999
1579
|
}
|
|
1000
1580
|
/**
|
|
1001
1581
|
* 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.
|
|
1582
|
+
* turn (sets {@link responseActive}) — the rest wait for that turn's completion. Nothing
|
|
1583
|
+
* drains while no connection is ready: {@link handleResumed} drains onto the new one.
|
|
1003
1584
|
*/
|
|
1004
1585
|
flushQueuedSends() {
|
|
1005
|
-
while (!this.responseActive && this.queuedSends.length > 0) {
|
|
1586
|
+
while (!this.responseActive && this.connectionReady && this.queuedSends.length > 0) {
|
|
1006
1587
|
const send = this.queuedSends.shift();
|
|
1007
1588
|
send?.();
|
|
1008
1589
|
}
|
|
@@ -1027,6 +1608,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
1027
1608
|
// `openClientTurn` is left as-is — the model `turnComplete` that follows clears it.
|
|
1028
1609
|
session.sendRealtimeInput({ text });
|
|
1029
1610
|
this.responseActive = true;
|
|
1611
|
+
this.markTurnOpen();
|
|
1030
1612
|
if (this.idleSignal === 'interactionStatus') {
|
|
1031
1613
|
this.interactionInProgress = true;
|
|
1032
1614
|
this.scheduleSafetyBackstop();
|
|
@@ -1101,6 +1683,7 @@ let GeminiRealtimeClient = class GeminiRealtimeClient extends BaseRealtimeClient
|
|
|
1101
1683
|
this.setState('speaking');
|
|
1102
1684
|
}
|
|
1103
1685
|
this.pendingToolCallNames.delete(callID);
|
|
1686
|
+
this.endTurnIfToolResultsIn();
|
|
1104
1687
|
}
|
|
1105
1688
|
/**
|
|
1106
1689
|
* Parses a JSON-stringified tool result into the structured object Gemini's
|