@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.
Files changed (206) hide show
  1. package/README.md +156 -29
  2. package/dist/audio/audioMeter.d.ts +71 -0
  3. package/dist/audio/audioMeter.d.ts.map +1 -1
  4. package/dist/audio/audioMeter.js +83 -0
  5. package/dist/audio/audioMeter.js.map +1 -1
  6. package/dist/audio/mediaElementAudioRouter.d.ts +41 -0
  7. package/dist/audio/mediaElementAudioRouter.d.ts.map +1 -0
  8. package/dist/audio/mediaElementAudioRouter.js +82 -0
  9. package/dist/audio/mediaElementAudioRouter.js.map +1 -0
  10. package/dist/audio/micCapture.d.ts +12 -3
  11. package/dist/audio/micCapture.d.ts.map +1 -1
  12. package/dist/audio/micCapture.js +15 -1
  13. package/dist/audio/micCapture.js.map +1 -1
  14. package/dist/audio/pcmPlayback.d.ts +72 -4
  15. package/dist/audio/pcmPlayback.d.ts.map +1 -1
  16. package/dist/audio/pcmPlayback.js +101 -1
  17. package/dist/audio/pcmPlayback.js.map +1 -1
  18. package/dist/drivers/assemblyAIRealtimeClient.d.ts +2 -0
  19. package/dist/drivers/assemblyAIRealtimeClient.d.ts.map +1 -1
  20. package/dist/drivers/assemblyAIRealtimeClient.js +14 -0
  21. package/dist/drivers/assemblyAIRealtimeClient.js.map +1 -1
  22. package/dist/drivers/elevenLabsRealtimeClient.d.ts +2 -0
  23. package/dist/drivers/elevenLabsRealtimeClient.d.ts.map +1 -1
  24. package/dist/drivers/elevenLabsRealtimeClient.js +14 -0
  25. package/dist/drivers/elevenLabsRealtimeClient.js.map +1 -1
  26. package/dist/drivers/geminiAvatarOutput.d.ts +132 -0
  27. package/dist/drivers/geminiAvatarOutput.d.ts.map +1 -0
  28. package/dist/drivers/geminiAvatarOutput.js +310 -0
  29. package/dist/drivers/geminiAvatarOutput.js.map +1 -0
  30. package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts +38 -0
  31. package/dist/drivers/geminiEnterpriseRealtimeClient.d.ts.map +1 -0
  32. package/dist/drivers/geminiEnterpriseRealtimeClient.js +107 -0
  33. package/dist/drivers/geminiEnterpriseRealtimeClient.js.map +1 -0
  34. package/dist/drivers/geminiRealtimeClient.d.ts +314 -48
  35. package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -1
  36. package/dist/drivers/geminiRealtimeClient.js +759 -176
  37. package/dist/drivers/geminiRealtimeClient.js.map +1 -1
  38. package/dist/drivers/openAILiveClient.d.ts +5 -8
  39. package/dist/drivers/openAILiveClient.d.ts.map +1 -1
  40. package/dist/drivers/openAILiveClient.js +19 -32
  41. package/dist/drivers/openAILiveClient.js.map +1 -1
  42. package/dist/drivers/openAIRealtimeClient.d.ts +13 -26
  43. package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -1
  44. package/dist/drivers/openAIRealtimeClient.js +28 -55
  45. package/dist/drivers/openAIRealtimeClient.js.map +1 -1
  46. package/dist/generic/baseRealtimeClient.d.ts +173 -29
  47. package/dist/generic/baseRealtimeClient.d.ts.map +1 -1
  48. package/dist/generic/baseRealtimeClient.js +233 -14
  49. package/dist/generic/baseRealtimeClient.js.map +1 -1
  50. package/dist/generic/openAIProtocolClient.d.ts +2 -0
  51. package/dist/generic/openAIProtocolClient.d.ts.map +1 -1
  52. package/dist/generic/openAIProtocolClient.js +14 -0
  53. package/dist/generic/openAIProtocolClient.js.map +1 -1
  54. package/dist/index.d.ts +2 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/media/attachVideoSource.d.ts +23 -0
  59. package/dist/media/attachVideoSource.d.ts.map +1 -0
  60. package/dist/media/attachVideoSource.js +49 -0
  61. package/dist/media/attachVideoSource.js.map +1 -0
  62. package/dist/media/channelVideoSource.d.ts +110 -14
  63. package/dist/media/channelVideoSource.d.ts.map +1 -1
  64. package/dist/media/channelVideoSource.js +183 -18
  65. package/dist/media/channelVideoSource.js.map +1 -1
  66. package/dist/media/decoders/frameScheduler.d.ts +119 -0
  67. package/dist/media/decoders/frameScheduler.d.ts.map +1 -0
  68. package/dist/media/decoders/frameScheduler.js +238 -0
  69. package/dist/media/decoders/frameScheduler.js.map +1 -0
  70. package/dist/media/decoders/imageFrameDecoder.d.ts +58 -0
  71. package/dist/media/decoders/imageFrameDecoder.d.ts.map +1 -0
  72. package/dist/media/decoders/imageFrameDecoder.js +110 -0
  73. package/dist/media/decoders/imageFrameDecoder.js.map +1 -0
  74. package/dist/media/decoders/mseFmp4Decoder.d.ts +130 -0
  75. package/dist/media/decoders/mseFmp4Decoder.d.ts.map +1 -0
  76. package/dist/media/decoders/mseFmp4Decoder.js +402 -0
  77. package/dist/media/decoders/mseFmp4Decoder.js.map +1 -0
  78. package/dist/media/decoders/streamCanvas.d.ts +46 -0
  79. package/dist/media/decoders/streamCanvas.d.ts.map +1 -0
  80. package/dist/media/decoders/streamCanvas.js +163 -0
  81. package/dist/media/decoders/streamCanvas.js.map +1 -0
  82. package/dist/media/decoders/videoMimeTypes.d.ts +29 -0
  83. package/dist/media/decoders/videoMimeTypes.d.ts.map +1 -0
  84. package/dist/media/decoders/videoMimeTypes.js +50 -0
  85. package/dist/media/decoders/videoMimeTypes.js.map +1 -0
  86. package/dist/media/decoders/webCodecsChunkDecoder.d.ts +99 -0
  87. package/dist/media/decoders/webCodecsChunkDecoder.d.ts.map +1 -0
  88. package/dist/media/decoders/webCodecsChunkDecoder.js +265 -0
  89. package/dist/media/decoders/webCodecsChunkDecoder.js.map +1 -0
  90. package/dist/media/displayCapture.d.ts +128 -0
  91. package/dist/media/displayCapture.d.ts.map +1 -0
  92. package/dist/media/displayCapture.js +245 -0
  93. package/dist/media/displayCapture.js.map +1 -0
  94. package/dist/media/frameCapture.d.ts +25 -8
  95. package/dist/media/frameCapture.d.ts.map +1 -1
  96. package/dist/media/frameCapture.js +42 -72
  97. package/dist/media/frameCapture.js.map +1 -1
  98. package/dist/media/frameSampler.d.ts +94 -0
  99. package/dist/media/frameSampler.d.ts.map +1 -0
  100. package/dist/media/frameSampler.js +134 -0
  101. package/dist/media/frameSampler.js.map +1 -0
  102. package/dist/media/index.d.ts +32 -0
  103. package/dist/media/index.d.ts.map +1 -0
  104. package/dist/media/index.js +32 -0
  105. package/dist/media/index.js.map +1 -0
  106. package/dist/media/localMediaController.d.ts +116 -0
  107. package/dist/media/localMediaController.d.ts.map +1 -0
  108. package/dist/media/localMediaController.js +281 -0
  109. package/dist/media/localMediaController.js.map +1 -0
  110. package/dist/media/mediaLayout.d.ts +93 -0
  111. package/dist/media/mediaLayout.d.ts.map +1 -0
  112. package/dist/media/mediaLayout.js +187 -0
  113. package/dist/media/mediaLayout.js.map +1 -0
  114. package/dist/media/mediaPreview.d.ts +94 -0
  115. package/dist/media/mediaPreview.d.ts.map +1 -0
  116. package/dist/media/mediaPreview.js +154 -0
  117. package/dist/media/mediaPreview.js.map +1 -0
  118. package/dist/media/mediaStage.d.ts +99 -0
  119. package/dist/media/mediaStage.d.ts.map +1 -0
  120. package/dist/media/mediaStage.js +171 -0
  121. package/dist/media/mediaStage.js.map +1 -0
  122. package/dist/media/model.d.ts +154 -0
  123. package/dist/media/model.d.ts.map +1 -0
  124. package/dist/media/model.js +9 -0
  125. package/dist/media/model.js.map +1 -0
  126. package/dist/media/playbackClock.d.ts +20 -0
  127. package/dist/media/playbackClock.d.ts.map +1 -0
  128. package/dist/media/playbackClock.js +13 -0
  129. package/dist/media/playbackClock.js.map +1 -0
  130. package/dist/media/videoFrameDecoder.d.ts +86 -0
  131. package/dist/media/videoFrameDecoder.d.ts.map +1 -0
  132. package/dist/media/videoFrameDecoder.js +2 -0
  133. package/dist/media/videoFrameDecoder.js.map +1 -0
  134. package/dist/media/videoFrameDecoderRegistry.d.ts +40 -0
  135. package/dist/media/videoFrameDecoderRegistry.d.ts.map +1 -0
  136. package/dist/media/videoFrameDecoderRegistry.js +69 -0
  137. package/dist/media/videoFrameDecoderRegistry.js.map +1 -0
  138. package/dist/media/videoPacing.d.ts +32 -0
  139. package/dist/media/videoPacing.d.ts.map +1 -0
  140. package/dist/media/videoPacing.js +37 -0
  141. package/dist/media/videoPacing.js.map +1 -0
  142. package/dist/media/videoPlayout.d.ts +168 -0
  143. package/dist/media/videoPlayout.d.ts.map +1 -0
  144. package/dist/media/videoPlayout.js +217 -0
  145. package/dist/media/videoPlayout.js.map +1 -0
  146. package/dist/media/videoSourceArbiter.d.ts +250 -0
  147. package/dist/media/videoSourceArbiter.d.ts.map +1 -0
  148. package/dist/media/videoSourceArbiter.js +423 -0
  149. package/dist/media/videoSourceArbiter.js.map +1 -0
  150. package/dist/testing/conformanceAssertions.d.ts +25 -0
  151. package/dist/testing/conformanceAssertions.d.ts.map +1 -0
  152. package/dist/testing/conformanceAssertions.js +64 -0
  153. package/dist/testing/conformanceAssertions.js.map +1 -0
  154. package/dist/testing/conformanceFixtures.d.ts +76 -0
  155. package/dist/testing/conformanceFixtures.d.ts.map +1 -0
  156. package/dist/testing/conformanceFixtures.js +169 -0
  157. package/dist/testing/conformanceFixtures.js.map +1 -0
  158. package/dist/testing/conformanceGates.d.ts +32 -0
  159. package/dist/testing/conformanceGates.d.ts.map +1 -0
  160. package/dist/testing/conformanceGates.js +35 -0
  161. package/dist/testing/conformanceGates.js.map +1 -0
  162. package/dist/testing/conformanceMicrophone.d.ts +10 -0
  163. package/dist/testing/conformanceMicrophone.d.ts.map +1 -0
  164. package/dist/testing/conformanceMicrophone.js +71 -0
  165. package/dist/testing/conformanceMicrophone.js.map +1 -0
  166. package/dist/testing/conformanceTimeline.d.ts +128 -0
  167. package/dist/testing/conformanceTimeline.d.ts.map +1 -0
  168. package/dist/testing/conformanceTimeline.js +32 -0
  169. package/dist/testing/conformanceTimeline.js.map +1 -0
  170. package/dist/testing/conformanceVideoStream.d.ts +29 -0
  171. package/dist/testing/conformanceVideoStream.d.ts.map +1 -0
  172. package/dist/testing/conformanceVideoStream.js +39 -0
  173. package/dist/testing/conformanceVideoStream.js.map +1 -0
  174. package/dist/testing/index.d.ts +29 -0
  175. package/dist/testing/index.d.ts.map +1 -0
  176. package/dist/testing/index.js +28 -0
  177. package/dist/testing/index.js.map +1 -0
  178. package/dist/testing/realtimeVideoConformance.d.ts +23 -0
  179. package/dist/testing/realtimeVideoConformance.d.ts.map +1 -0
  180. package/dist/testing/realtimeVideoConformance.js +139 -0
  181. package/dist/testing/realtimeVideoConformance.js.map +1 -0
  182. package/dist/testing/realtimeVideoConformanceChecks.d.ts +4 -0
  183. package/dist/testing/realtimeVideoConformanceChecks.d.ts.map +1 -0
  184. package/dist/testing/realtimeVideoConformanceChecks.js +164 -0
  185. package/dist/testing/realtimeVideoConformanceChecks.js.map +1 -0
  186. package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts +4 -0
  187. package/dist/testing/realtimeVideoConformanceFrameChecks.d.ts.map +1 -0
  188. package/dist/testing/realtimeVideoConformanceFrameChecks.js +63 -0
  189. package/dist/testing/realtimeVideoConformanceFrameChecks.js.map +1 -0
  190. package/dist/testing/realtimeVideoConformanceSession.d.ts +108 -0
  191. package/dist/testing/realtimeVideoConformanceSession.d.ts.map +1 -0
  192. package/dist/testing/realtimeVideoConformanceSession.js +270 -0
  193. package/dist/testing/realtimeVideoConformanceSession.js.map +1 -0
  194. package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts +4 -0
  195. package/dist/testing/realtimeVideoConformanceTurnChecks.d.ts.map +1 -0
  196. package/dist/testing/realtimeVideoConformanceTurnChecks.js +248 -0
  197. package/dist/testing/realtimeVideoConformanceTurnChecks.js.map +1 -0
  198. package/dist/testing/realtimeVideoConformanceTypes.d.ts +155 -0
  199. package/dist/testing/realtimeVideoConformanceTypes.d.ts.map +1 -0
  200. package/dist/testing/realtimeVideoConformanceTypes.js +2 -0
  201. package/dist/testing/realtimeVideoConformanceTypes.js.map +1 -0
  202. package/dist/testing/recordingPlayout.d.ts +84 -0
  203. package/dist/testing/recordingPlayout.d.ts.map +1 -0
  204. package/dist/testing/recordingPlayout.js +120 -0
  205. package/dist/testing/recordingPlayout.js.map +1 -0
  206. 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 { CreateStreamFrameCapture } from '../media/frameCapture.js';
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.cameraCapture = null;
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
- this.resumptionHandle = null;
114
- this.lastConnectArgs = null;
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.resumptionHandle;
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 `'listening'` once audio is flowing.
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.setState('connecting');
209
- const { model, liveConfig, idleSignal, supportsScheduling, supportsBlocking, supportsInboundVideo, maxInboundVideoRate, requestedTracks } = this.parseSessionConfig(config);
210
- this.idleSignal = idleSignal;
211
- this.supportsScheduling = supportsScheduling;
212
- this.supportsBlocking = supportsBlocking;
213
- // Negotiate tracks. Video capability and its frame-rate ceiling are PER-MODEL DATA, minted
214
- // from the provider's profile table (GeminiLiveModelProfile.SupportsInboundVideo /
215
- // .MaxInboundVideoRate) and carried in the session config. Deriving either from the model
216
- // id would put a second answer to the same question in a second place: the two agreed only
217
- // because the model names happened to line up, and the next model to break that pattern
218
- // would diverge silently.
219
- const isVideoModel = supportsInboundVideo ?? model.toLowerCase().startsWith(LEGACY_VIDEO_MODEL_PREFIX);
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
- this.negotiateTracks(requestedTracks, supportedTracks);
237
- this.playback = this.createPlayback();
238
- const connectArgs = {
239
- Model: model,
240
- Config: liveConfig,
241
- EphemeralToken: config.EphemeralToken,
242
- OnMessage: (message) => this.handleServerMessage(message),
243
- OnError: (event) => this.handleTransportError(event),
244
- OnClose: (event) => this.handleTransportClose(event),
245
- };
246
- this.lastConnectArgs = connectArgs;
247
- this.session = await this.connectLiveSession(connectArgs);
248
- this.setState('connected');
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.cameraCapture = CreateStreamFrameCapture(this.cameraStream, {
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
- this.setState('listening');
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.cameraCapture?.Stop();
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
- this.resumptionHandle = null;
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 750ms minimum inter-frame spacing to serve as a backstop with deliberate jitter
327
- * headroom for 1 fps (1000ms) pacers (such as `ChannelInboundVideoBridge`'s `setInterval`,
328
- * `frameCapture`, and channel-level gates like `OnScreencastFrame`'s 1000ms pacer). Upstream
329
- * cadence generators and channel gates are the primary enforcers of the nominal 1 fps ceiling,
330
- * while this 750ms gate absorbs event loop and async dispatch jitter without dropping intended
331
- * 1Hz frames, while preventing any unpaced callers from bursting above 1.33 fps.
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 track unestablished).
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 < 750) {
344
- return false; // Throttled: 750ms jitter headroom backstop for upstream 1 fps pacers (Reviewer Items 25, 30, 33)
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
- if (this.isNonBlocking) {
439
- this.sendToolResponseTurn(callID, name, outputJson);
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 rawRequestedTracks = sessionConfig[REQUESTED_TRACKS_SESSION_KEY];
531
- let requestedTracks = undefined;
532
- if (Array.isArray(rawRequestedTracks)) {
533
- const list = [];
534
- for (const item of rawRequestedTracks) {
535
- if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
536
- const modality = typeof item['Modality'] === 'string' ? item['Modality'] : undefined;
537
- const direction = item['Direction'];
538
- if (modality && (direction === 'inbound' || direction === 'outbound')) {
539
- list.push({
540
- Modality: modality,
541
- Direction: direction,
542
- Encoding: typeof item['Encoding'] === 'string' ? item['Encoding'] : undefined,
543
- Rate: typeof item['Rate'] === 'number' ? item['Rate'] : undefined,
544
- RequiresConsent: typeof item['RequiresConsent'] === 'boolean' ? item['RequiresConsent'] : undefined,
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 { model, liveConfig, idleSignal, supportsScheduling, supportsBlocking, supportsInboundVideo, maxInboundVideoRate, requestedTracks };
825
+ return list;
552
826
  }
553
- /** Streams one base64 PCM16 mic chunk to the model (no-op once the session is gone, closed, or in error). */
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
- this.session.sendRealtimeInput({
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
- /** Surfaces a fatal websocket error and marks the session unusable. */
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 = event.message || (event.error instanceof Error ? event.error.message : String(event.error ?? 'unknown'));
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
- /** Reflects a provider-side close (unless the session already ended in error). */
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 && this.currentState !== 'error') {
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
- if (this.currentState !== 'error' && this.currentState !== 'closed') {
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: track resumption token updates (F7)
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
- if (message.sessionResumptionUpdate.resumable === false) {
603
- this.resumptionHandle = null;
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
- if (this.resumptionHandle) {
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
- * Resumes the live session using a previously captured session resumption handle (F7).
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
- if (!this.lastConnectArgs) {
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
- const reconnectArgs = {
634
- ...this.lastConnectArgs,
635
- Config: {
636
- ...this.lastConnectArgs.Config,
637
- sessionResumption: { handle },
638
- },
639
- };
640
- const oldSession = this.session;
641
- const newSession = await this.connectLiveSession(reconnectArgs);
642
- this.session = newSession;
643
- this.lastConnectArgs = reconnectArgs;
644
- try {
645
- oldSession?.close();
646
- }
647
- catch {
648
- // The old socket has already been replaced by newSession, so a close failure on it cannot affect the new session
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] Session resumption failed: ${err instanceof Error ? err.message : String(err)}`);
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
- if (usageMetadata.promptTokensDetails && Array.isArray(usageMetadata.promptTokensDetails)) {
700
- for (const detail of usageMetadata.promptTokensDetails) {
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
- /** Translates one {@link LiveServerContent} frame in provider-documented signal order. */
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.handleModelAudio(content.modelTurn);
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
- /** Decodes inline model-audio parts (base64 PCM16 @ 24 kHz) into the playout queue. */
797
- handleModelAudio(modelTurn) {
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 data = part.inlineData?.data;
806
- if (data) {
807
- this.markGenerationStarted();
808
- this.playback?.Enqueue(Base64ToArrayBuffer(data));
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, release the busy lock, reset response kind,
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
- * Releases busy state, commits any deferred open client turns without cutting off generation,
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 or transcription delta): the user's turn is
948
- * over (finalize their pending transcript), the model is busy, and the client is audibly /
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