@memberjunction/realtime-runtime 6.2.0-edge.4 → 6.2.0-edge.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/hosts/IRealtimeMediaHost.d.ts +25 -30
  2. package/dist/hosts/IRealtimeMediaHost.d.ts.map +1 -1
  3. package/dist/index.d.ts +5 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/session/RealtimeSessionRuntime.d.ts +285 -65
  8. package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
  9. package/dist/session/RealtimeSessionRuntime.js +690 -165
  10. package/dist/session/RealtimeSessionRuntime.js.map +1 -1
  11. package/dist/session/RecordingShardDrain.d.ts +59 -0
  12. package/dist/session/RecordingShardDrain.d.ts.map +1 -0
  13. package/dist/session/RecordingShardDrain.js +135 -0
  14. package/dist/session/RecordingShardDrain.js.map +1 -0
  15. package/dist/session/RecordingShardQueue.d.ts +54 -37
  16. package/dist/session/RecordingShardQueue.d.ts.map +1 -1
  17. package/dist/session/RecordingShardQueue.js +87 -83
  18. package/dist/session/RecordingShardQueue.js.map +1 -1
  19. package/dist/session/RecordingShardStore.d.ts +57 -0
  20. package/dist/session/RecordingShardStore.d.ts.map +1 -0
  21. package/dist/session/RecordingShardStore.js +184 -0
  22. package/dist/session/RecordingShardStore.js.map +1 -0
  23. package/dist/session/RecordingShardWindow.d.ts +42 -0
  24. package/dist/session/RecordingShardWindow.d.ts.map +1 -0
  25. package/dist/session/RecordingShardWindow.js +88 -0
  26. package/dist/session/RecordingShardWindow.js.map +1 -0
  27. package/dist/session/RecordingTransport.d.ts +66 -0
  28. package/dist/session/RecordingTransport.d.ts.map +1 -0
  29. package/dist/session/RecordingTransport.js +61 -0
  30. package/dist/session/RecordingTransport.js.map +1 -0
  31. package/package.json +10 -9
@@ -5,7 +5,7 @@ import { AddRealtimeUsageRecord, AIEngineBase, HasRealtimeUsage } from '@memberj
5
5
  import { GraphQLRealtimeSessionClient } from '@memberjunction/graphql-dataprovider';
6
6
  import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
7
7
  import { DEFAULT_REALTIME_AUDIO_TRACKS, ParseRealtimeAvatarStatus, ParseRealtimeClientTransport } from '@memberjunction/ai';
8
- import { CompareExposure, DeclaresNativeTools, IsIdentityVerifiedEventPayload, NormalizeChannelKey, ParseRealtimeSessionClientPolicy, ResolveClientTools, SelectNativeChannelTools, UserExposureReason } from '@memberjunction/ai-core-plus';
8
+ import { CompareExposure, DeclaresNativeTools, IsIdentityVerifiedEventPayload, NormalizeChannelKey, ParseRealtimeSessionClientPolicy, RECORDING_TERMINAL_MAX_BYTES, ResolveClientTools, SelectNativeChannelTools, UserExposureReason } from '@memberjunction/ai-core-plus';
9
9
  import { BaseRealtimeClient, LoadAssemblyAIRealtimeClient, LoadElevenLabsRealtimeClient, LoadGeminiEnterpriseRealtimeClient, LoadGeminiRealtimeClient, LoadHuggingFaceRealtimeClient, LoadOpenAIRealtimeClient, LoadxAIRealtimeClient, VideoSourceArbiter, REQUESTED_TRACKS_SESSION_KEY } from '@memberjunction/ai-realtime-client';
10
10
  import { DefaultRealtimeSessionLauncher, HasClientCredential } from './session-launcher.js';
11
11
  import { RequestsAgentVideo, ResolveAvatarNotice } from './avatar-notice.js';
@@ -23,6 +23,26 @@ import { AppClientToolRegistry, DEFAULT_APP_TOOL_OWNER } from './app-client-tool
23
23
  import { UserSettingsExposurePreferences } from './channel-exposure-preferences.js';
24
24
  import { BuildChannelCandidate, FindPreparedChannel, MergeToolMetadata, PreparedChannelsShowAgentVideo, ReconcileChannelsWithPolicy, ResolveLocalChannelScope, ToolsByChannelKey } from './channel-session-scope.js';
25
25
  import { RecordingShardQueue } from './RecordingShardQueue.js';
26
+ import { RecordingShardWindow } from './RecordingShardWindow.js';
27
+ import { CreateRecordingShardStore } from './RecordingShardStore.js';
28
+ import { CreateRecordingTransportForProvider, DeleteStoredRecordingShard, DrainStoredRecordingShardsOnce } from './RecordingShardDrain.js';
29
+ /**
30
+ * How long teardown waits for the server to acknowledge the recording's terminal request, measured
31
+ * from after `CloseAgentSession`. The terminal is `keepalive`, so it completes even if the page goes
32
+ * away; the wait only keeps `SavingRecording$` (and so a host's "don't close yet" guard) up while the
33
+ * server has not confirmed. Bounded so a dead server can never hold the End of a call hostage —
34
+ * unacknowledged shards stay in the local store and the server janitor assembles what arrived.
35
+ */
36
+ export const RECORDING_TERMINAL_ACK_TIMEOUT_MS = 3000;
37
+ /**
38
+ * How long teardown waits for the recorder to stop and flush its encoder. `Stop()` runs BEFORE
39
+ * `CloseAgentSession`, so a wedged encoder must not be able to hold the End of a call (and the
40
+ * server session) open; past this the recording ends at the packets already encoded.
41
+ */
42
+ export const RECORDER_STOP_TIMEOUT_MS = 2000;
43
+ function describeRecordingFailure(failure) {
44
+ return failure instanceof Error ? failure.message : String(failure);
45
+ }
26
46
  /**
27
47
  * `MJ: User Settings` key for the per-user "record this voice call" consent toggle. Stored as
28
48
  * the literal string `'true'`/`'false'` (read with `=== 'true'`), cross-device via
@@ -174,9 +194,9 @@ function microphoneStartError(reason, message) {
174
194
  export class RealtimeSessionRuntime {
175
195
  /**
176
196
  * @param mediaHost the platform's media capabilities. The runtime never touches `navigator`,
177
- * `Blob` or `FileReader` itself — microphone acquisition and audio recording
178
- * are the host's, because both are platform-specific product decisions
179
- * (permission UX, container format, where the bytes live).
197
+ * `Blob` or `FileReader` itself — microphone acquisition and audio capture/encoding
198
+ * are the host's, because both are platform-specific (permission UX, which
199
+ * encoder the platform offers).
180
200
  */
181
201
  constructor(mediaHost) {
182
202
  this.mediaHost = mediaHost;
@@ -254,10 +274,11 @@ export class RealtimeSessionRuntime {
254
274
  this.Minimized$ = this._minimized$.asObservable();
255
275
  /**
256
276
  * True while the end-of-call recording is being saved: from the first statement of a teardown
257
- * that holds a recorder until that teardown settles. False at construction and for unrecorded
258
- * sessions. Exists for #5195: the page must stay open until the consolidated upload lands, or
259
- * the recording is lost. The runtime is platform-free, so hosts own the reaction (a browser
260
- * `beforeunload` guard, a "saving" indicator, etc.).
277
+ * that holds a recorder until that teardown settles — which includes waiting (at most
278
+ * {@link RECORDING_TERMINAL_ACK_TIMEOUT_MS}) for the server to acknowledge the recording's terminal
279
+ * request. False at construction and for unrecorded sessions. Exists for #5195; the runtime is
280
+ * platform-free, so hosts own the reaction (a "saving" indicator, a disabled navigation button,
281
+ * etc.).
261
282
  */
262
283
  this.SavingRecording$ = this._savingRecording$.asObservable();
263
284
  /**
@@ -423,23 +444,40 @@ export class RealtimeSessionRuntime {
423
444
  // ── Browser-side call recording ────────────────────────────────────────────
424
445
  /**
425
446
  * The active session's audio recorder (mic + agent mix), or `null` when the user didn't
426
- * consent or the browser can't record. Created after the client connects; stopped + uploaded
427
- * at teardown.
447
+ * consent or the browser can't record. Created after the client connects; its packets are
448
+ * shipped as 5 s shards during the call, and it is stopped at teardown.
428
449
  */
429
450
  this.recorder = null;
430
451
  /** ISO timestamp of when recording started — sent to the server on session start. */
431
452
  this.recordingStartedAtIso = null;
432
- /** Interval that flushes ~15s crash-recovery shards to the server during a recording. */
433
- this.segmentTimer = null;
453
+ /** Interval that cuts and uploads a shard every {@link recordingShardFlushMs} during a recording. */
454
+ this.shardFlushTimer = null;
455
+ /**
456
+ * The current recording's shard pipeline (window → store → queue → transport), or null when not
457
+ * recording. A fresh pipeline per recording keeps a flush still in flight from a previous session
458
+ * off this session's indexes. Its queue is closed when the recording ends; anything it had not
459
+ * delivered stays in the local store for the next-load drain.
460
+ */
461
+ this.recordingShards = null;
462
+ /**
463
+ * Bytes of this page's recording `keepalive` requests still in flight. The browser's keepalive budget
464
+ * is per page, and closing a tab fires `visibilitychange` and `pagehide` back to back, so each dispatch
465
+ * budgets against what the previous one has not finished sending.
466
+ */
467
+ this.keepaliveBytesInFlight = 0;
468
+ /** `sessionId/index` of shards whose `keepalive` copy is still in flight; never sent twice at once. */
469
+ this.keepaliveShardsInFlight = new Set();
470
+ /** The durable local shard store, created on first use and shared by every recording of this runtime. */
471
+ this.shardStore = null;
434
472
  /**
435
- * Owns shard indexing and retry for the current recording: it assigns each shard its index and
436
- * keeps failed shards to resend under that same index. A fresh queue per recording keeps a flush
437
- * still in flight from a previous session off this session's indexes. Null when not recording.
438
- * A recording that ends discards its queue, and any retained shard with it: the consolidated
439
- * end-of-call upload supersedes them, and if that upload is lost too, server-side recovery
440
- * (#5196) fills the gap with silence.
473
+ * True once a drain ran with a transport; the drain then does nothing until a recording ends and
474
+ * re-arms it ({@link rearmPendingShardDrain}).
441
475
  */
442
- this.shardQueue = null;
476
+ this.pendingShardsDrained = false;
477
+ /** Bumped by every re-arm, so a drain already in flight cannot mark itself done over a re-arm. */
478
+ this.drainGeneration = 0;
479
+ /** The drain in flight, so overlapping callers share it instead of uploading the same shards twice. */
480
+ this.drainInFlight = null;
443
481
  // ── Server-side liveness ───────────────────────────────────────────────────
444
482
  /**
445
483
  * Interval that tells the server this session is still in use, or `null` when no session is
@@ -450,12 +488,33 @@ export class RealtimeSessionRuntime {
450
488
  * Recording-relative ms offset at which the IN-FLIGHT (not-yet-finalized) turn's audio
451
489
  * actually BEGAN — captured the moment that turn's audio/text starts flowing (its first
452
490
  * interim transcript), NOT inherited from the previous turn's end. `null` before the first
453
- * turn / between turns (until the next turn's audio starts). Sent as `utteranceStartMs` on
454
- * the turn's final transcript so per-turn timing lines up with the recording even when a
455
- * tool-call / silence gap sits between turns (the inherit-previous-end model mis-stamped
456
- * the post-gap turn at the pre-gap offset). See {@link markTurnAudioStart}.
491
+ * turn / between turns (until the next turn's audio starts), and it stays `null` for a turn
492
+ * whose start was never measured. Sent as `utteranceStartMs` on the turn's final transcript
493
+ * AS-IS — `null` is relayed as `null`, never replaced with 0 — so per-turn timing lines up
494
+ * with the recording even when a tool-call / silence gap sits between turns (the
495
+ * inherit-previous-end model mis-stamped the post-gap turn at the pre-gap offset). See
496
+ * {@link markTurnAudioStart}. A user speech onset signalled by the client does NOT land here;
497
+ * it has its own slot, {@link userSpeechStartMs}.
457
498
  */
458
499
  this.currentTurnStartMs = null;
500
+ /**
501
+ * Recording-relative ms offset of the first user speech onset the client signalled
502
+ * (`OnUserSpeechStart`) since the last final user transcript, or `null` when none was signalled.
503
+ * It has its own slot, apart from {@link currentTurnStartMs}, so a user onset never claims the
504
+ * assistant turn's start: OpenAI user transcripts arrive asynchronously, often after the
505
+ * assistant's interims (or its final) for the reply have already flowed. See
506
+ * {@link markUserSpeechStart}.
507
+ */
508
+ this.userSpeechStartMs = null;
509
+ /**
510
+ * `true` once an assistant turn has finalized after {@link userSpeechStartMs} was stamped BEFORE
511
+ * that turn began. The held onset may still be claimed by a late user transcript (OpenAI
512
+ * transcribes asynchronously), but the NEXT onset replaces it: an onset no transcript ever claimed
513
+ * (a VAD false positive, a failed transcription) must not become the start of the next real user
514
+ * turn. A barge-in onset (stamped during the assistant turn) is not superseded by that turn's
515
+ * final. An EMPTY user transcript releases the onset outright (see onClientTranscript).
516
+ */
517
+ this.userSpeechStartSuperseded = false;
459
518
  /**
460
519
  * Wall-anchor of the SESSION clock (#3832): `performance.now()` at the moment the call went
461
520
  * live, or `null` before any call has. Read only through {@link nowTurnOffsetMs}.
@@ -662,6 +721,16 @@ export class RealtimeSessionRuntime {
662
721
  * Owner-keyed — see {@link AppClientToolRegistry}.
663
722
  */
664
723
  this.appToolRegistry = new AppClientToolRegistry();
724
+ // Re-upload shards a previous, interrupted call left in the local store. Deferred a tick so a
725
+ // host can assign `Provider` right after construction; when it still has no transport the drain
726
+ // defers itself and session start tries again. Unref'd so it never holds a Node process open.
727
+ const drainTimer = setTimeout(() => {
728
+ void this.DrainPendingRecordingShards();
729
+ }, 0);
730
+ // Node returns a Timeout object with unref(); browsers return a number.
731
+ if (typeof drainTimer === 'object' && drainTimer !== null && 'unref' in drainTimer && typeof drainTimer.unref === 'function') {
732
+ drainTimer.unref();
733
+ }
665
734
  }
666
735
  /** Synchronous read of {@link SavingRecording$}. */
667
736
  get IsSavingRecording() {
@@ -730,8 +799,20 @@ export class RealtimeSessionRuntime {
730
799
  UpdateAppContext(snapshot) {
731
800
  this._appContext$.next(snapshot);
732
801
  }
733
- /** How often crash-recovery shards are flushed during a recording. */
734
- static { this.segmentFlushMs = 15000; }
802
+ /** How often packets are cut into shards: the 5 s shard window, so each tick seals about one window. */
803
+ static { this.recordingShardFlushMs = 5000; }
804
+ /**
805
+ * Bytes of `keepalive` request bodies one terminal may use, the tail included. Browsers share about
806
+ * 64 KiB of in-flight keepalive bodies per page; the brief reserves 48 KiB of it for the recording,
807
+ * leaving room for a host's own close beacon (Caliber sends one from `pagehide` too).
808
+ */
809
+ static { this.recordingKeepaliveBudgetBytes = RECORDING_TERMINAL_MAX_BYTES; }
810
+ /**
811
+ * Most runs one drain call makes (each run is itself bounded): it runs again when it was re-armed while
812
+ * running, or when a run deleted shards the server refused for good, which frees the page for the
813
+ * shards behind them.
814
+ */
815
+ static { this.maxDrainRunsPerCall = 3; }
735
816
  /**
736
817
  * How often the client asserts liveness. Comfortably under `SessionJanitor`'s
737
818
  * `closeThresholdMinutes` (15 by default) so several pulses must be missed in a row before a
@@ -903,7 +984,7 @@ export class RealtimeSessionRuntime {
903
984
  * pickers, and never synthesize overrides beyond what the user explicitly chose.
904
985
  * Omit/`null` for the server's defaults (today's behavior).
905
986
  * @param recordingConsent Optional EXPLICIT "record this call" consent for THIS session. When
906
- * `true`, the browser records a mic + agent-audio mix and uploads it at session end. When
987
+ * `true`, the browser records a mic + agent-audio mix and uploads it as 5 s Opus shards during the call. When
907
988
  * omitted/`null`, the per-user persisted preference (`mj.realtimeVoice.recordingConsent.v1`
908
989
  * via {@link UserInfoEngine}) is read as the default. `false` never records.
909
990
  * @param mediaCollectionId Optional per-session media-kit override (`MJ: Collections.ID`). When set,
@@ -1060,6 +1141,9 @@ export class RealtimeSessionRuntime {
1060
1141
  this.resetState();
1061
1142
  this._active$.next(true);
1062
1143
  this.setConnectionState('connecting');
1144
+ // A drain the constructor deferred (no provider/transport yet) gets its second chance here, and
1145
+ // runs whether or not THIS session records: the pending shards belong to an earlier call.
1146
+ void this.DrainPendingRecordingShards();
1063
1147
  // Resolve recording consent for this session: explicit value wins, else the per-user
1064
1148
  // persisted preference. Computed before mint so it can be reported to the server.
1065
1149
  const consent = options.recordingConsent ?? this.readPersistedRecordingConsent();
@@ -1583,7 +1667,7 @@ export class RealtimeSessionRuntime {
1583
1667
  }
1584
1668
  client.SendText(trimmed);
1585
1669
  // Relay as a user turn — same path spoken input uses (caption + persisted transcript).
1586
- void this.onUserTranscript(trimmed);
1670
+ void this.onUserTranscript(trimmed, 'typed');
1587
1671
  }
1588
1672
  /**
1589
1673
  * Mute / unmute the local microphone track. Returns the new muted state. While the call has no microphone track (a
@@ -1728,6 +1812,8 @@ export class RealtimeSessionRuntime {
1728
1812
  // turn one's cue wherever the clocks happen to differ.
1729
1813
  this.currentTurnStartMs = recorder.IsRecording ? 0 : null;
1730
1814
  this.turnAudioStartCaptured = false;
1815
+ this.userSpeechStartMs = null;
1816
+ this.userSpeechStartSuperseded = false;
1731
1817
  if (this.recorder) {
1732
1818
  // WebRTC drivers (OpenAI): the agent's track usually lands AFTER Connect() resolves, so
1733
1819
  // `remoteStream` above is null here and this handler attaches it later. PCM-playback
@@ -1735,7 +1821,7 @@ export class RealtimeSessionRuntime {
1735
1821
  // `remoteStream` is already set and the handler fires immediately with the same stream;
1736
1822
  // AttachRemoteStream is idempotent, so it is mixed only once.
1737
1823
  client.OnRemoteMediaStream?.((stream) => this.recorder?.AttachRemoteStream(stream));
1738
- this.startSegmentFlushing();
1824
+ this.startShardFlushing();
1739
1825
  }
1740
1826
  }
1741
1827
  catch (error) {
@@ -1743,16 +1829,159 @@ export class RealtimeSessionRuntime {
1743
1829
  this.recorder = null;
1744
1830
  }
1745
1831
  }
1746
- /** Begins flushing ~15s crash-recovery shards to the server for the duration of the recording. */
1747
- startSegmentFlushing() {
1748
- this.shardQueue = new RecordingShardQueue();
1749
- this.segmentTimer = setInterval(() => { void this.flushRecordingSegment(); }, RealtimeSessionRuntime.segmentFlushMs);
1832
+ // ── Recording shards (MJ#5294) ───────────────────────────────────────────────
1833
+ //
1834
+ // The recorder hands over Opus packets; the runtime cuts them into 5 s `MJOPUS1` shards, writes
1835
+ // each one to the local store BEFORE its upload starts, uploads it as raw bytes, and deletes it from
1836
+ // the store only on the server's ack. The call ends with a `keepalive` terminal request carrying the
1837
+ // final partial shard and the counts the server needs to know it has everything.
1838
+ /**
1839
+ * Builds the transport for shard uploads and terminal requests. The default posts to MJAPI's REST
1840
+ * recording routes, derived from the session's GraphQL provider configuration, with credentials
1841
+ * read per request so a refreshed token is used. Returns null — shards then wait in the local
1842
+ * store — when the provider has no URL (a test double, a provider that is not GraphQL, or one not
1843
+ * configured yet). Override to supply another transport.
1844
+ */
1845
+ createRecordingTransport() {
1846
+ return CreateRecordingTransportForProvider(this.Provider);
1847
+ }
1848
+ /**
1849
+ * Builds the durable local shard store. The default is IndexedDB where the host has it and a no-op
1850
+ * store elsewhere (Node, some React Native runtimes). Called once per runtime. Override to supply
1851
+ * another store.
1852
+ */
1853
+ createRecordingShardStore() {
1854
+ return CreateRecordingShardStore(globalThis.indexedDB);
1750
1855
  }
1751
- /** Stops the periodic crash-recovery shard flush. */
1752
- stopSegmentFlushing() {
1753
- if (this.segmentTimer) {
1754
- clearInterval(this.segmentTimer);
1755
- this.segmentTimer = null;
1856
+ /** The provider's signed-in user, or null (no provider yet, or an anonymous page). */
1857
+ signedInUserID() {
1858
+ return this.Provider?.CurrentUser?.ID ?? null;
1859
+ }
1860
+ /** The runtime's shard store, created on first use. */
1861
+ ensureShardStore() {
1862
+ this.shardStore ??= this.createRecordingShardStore();
1863
+ return this.shardStore;
1864
+ }
1865
+ /**
1866
+ * Re-uploads shards an earlier, interrupted call left in the local store (crash, closed tab, dead
1867
+ * network), each under its OWN session id and index, oldest first, at most
1868
+ * {@link MAX_DRAINED_SHARDS_PER_LOAD} per runtime. A shard is deleted only on the server's ack.
1869
+ *
1870
+ * Runs once per runtime instance, re-armed whenever a recording ends (which may leave shards behind
1871
+ * for the next session start on the same page). When there is no transport yet (no provider configuration, e.g.
1872
+ * before login) it returns 0 WITHOUT marking itself done, so a later call — session start — drains
1873
+ * instead of the shards being skipped for this page. Concurrent calls share one run. Never rejects.
1874
+ *
1875
+ * @returns the number of shards the server acknowledged in this call.
1876
+ */
1877
+ DrainPendingRecordingShards() {
1878
+ if (this.pendingShardsDrained) {
1879
+ return Promise.resolve(0);
1880
+ }
1881
+ this.drainInFlight ??= this.drainPendingShards().finally(() => {
1882
+ this.drainInFlight = null;
1883
+ });
1884
+ return this.drainInFlight;
1885
+ }
1886
+ /** Makes the next {@link DrainPendingRecordingShards} call run again, even one already in flight. */
1887
+ rearmPendingShardDrain() {
1888
+ this.pendingShardsDrained = false;
1889
+ this.drainGeneration++;
1890
+ }
1891
+ /**
1892
+ * The body of {@link DrainPendingRecordingShards}, up to {@link maxDrainRunsPerCall} runs. It runs
1893
+ * again after a re-arm while a run is in flight (a recovery after an outage gave up on more shards),
1894
+ * because the running pass listed the store before those shards were there; and after a run deleted
1895
+ * refused shards, because those filled the page that the shards behind them now get.
1896
+ */
1897
+ async drainPendingShards() {
1898
+ try {
1899
+ const transport = this.createRecordingTransport();
1900
+ if (!transport) {
1901
+ return 0;
1902
+ }
1903
+ let delivered = 0;
1904
+ for (let run = 1; run <= RealtimeSessionRuntime.maxDrainRunsPerCall; run++) {
1905
+ // A re-arm while this run is in flight must win over this run marking itself done.
1906
+ const generation = this.drainGeneration;
1907
+ const outcome = await this.drainStoredShardsOnce(transport);
1908
+ delivered += outcome.Delivered;
1909
+ // Done only when every listed shard was delivered or refused for good: a drain during an outage
1910
+ // must not stop the next session start or recovery from trying again.
1911
+ this.pendingShardsDrained = generation === this.drainGeneration && outcome.Delivered + outcome.Refused === outcome.Listed;
1912
+ if (generation === this.drainGeneration && outcome.Refused === 0) {
1913
+ break;
1914
+ }
1915
+ }
1916
+ return delivered;
1917
+ }
1918
+ catch (error) {
1919
+ console.warn(`[RealtimeSession] Draining stored recording shards failed: ${describeRecordingFailure(error)}; will retry at the next session start.`, error);
1920
+ return 0;
1921
+ }
1922
+ }
1923
+ /** One drain pass over the signed-in user's stored shards (see {@link DrainStoredRecordingShardsOnce}). */
1924
+ drainStoredShardsOnce(transport) {
1925
+ return DrainStoredRecordingShardsOnce(this.ensureShardStore(), transport, this.signedInUserID());
1926
+ }
1927
+ /** Builds this recording's shard pipeline and starts the 5 s flush. */
1928
+ startShardFlushing() {
1929
+ const sessionId = this.agentSessionId;
1930
+ if (!sessionId) {
1931
+ // Unreachable in practice (the session id is adopted before recording starts), but without
1932
+ // one there is nowhere to send shards, so say so rather than recording into the void.
1933
+ console.warn('[RealtimeSession] Recording started without an agent session id; shards will not be saved.');
1934
+ return;
1935
+ }
1936
+ const transport = this.createRecordingTransport();
1937
+ if (!transport) {
1938
+ console.warn(`[RealtimeSession] No recording transport for session ${sessionId} (provider has no REST configuration); shards are kept in the local store only.`);
1939
+ }
1940
+ // True once the queue gave up on a shard and until an upload succeeds again: that first success
1941
+ // re-sends what the queue dropped meanwhile (see recoverStoredShardsAfterOutage).
1942
+ let shardsGivenUp = false;
1943
+ this.recordingShards = {
1944
+ SessionID: sessionId,
1945
+ UserID: this.signedInUserID(),
1946
+ Window: new RecordingShardWindow(),
1947
+ Transport: transport,
1948
+ // Closes over this recording's session id and transport — never the runtime's current ones.
1949
+ Queue: new RecordingShardQueue(async (shard) => {
1950
+ if (!transport) {
1951
+ return false;
1952
+ }
1953
+ const acked = (await transport.UploadShard(sessionId, shard)) === 'Acked';
1954
+ if (acked) {
1955
+ await this.deleteStoredShard(sessionId, shard.Index);
1956
+ if (shardsGivenUp) {
1957
+ shardsGivenUp = false;
1958
+ this.recoverStoredShardsAfterOutage(sessionId);
1959
+ }
1960
+ }
1961
+ return acked;
1962
+ }, () => {
1963
+ shardsGivenUp = true;
1964
+ }),
1965
+ };
1966
+ this.shardFlushTimer = setInterval(() => { void this.flushRecordingShards(); }, RealtimeSessionRuntime.recordingShardFlushMs);
1967
+ }
1968
+ /**
1969
+ * Uploads succeed again after failing: re-sends, from the local store, the shards the queue gave up
1970
+ * on during the outage. The queue's attempts run out after about 10 s of a dead network (or an
1971
+ * expired token), and without this those shards would wait for a later page load — which a widget
1972
+ * guest never makes — leaving a permanent gap. Runs in the background, outside any page-lifecycle
1973
+ * handler, so the IndexedDB read is allowed. Never rejects.
1974
+ */
1975
+ recoverStoredShardsAfterOutage(sessionId) {
1976
+ console.log(`[RealtimeSession] Recording uploads for session ${sessionId} recovered; re-sending stored shards.`);
1977
+ this.rearmPendingShardDrain();
1978
+ void this.DrainPendingRecordingShards();
1979
+ }
1980
+ /** Stops the periodic shard flush. */
1981
+ stopShardFlushing() {
1982
+ if (this.shardFlushTimer) {
1983
+ clearInterval(this.shardFlushTimer);
1984
+ this.shardFlushTimer = null;
1756
1985
  }
1757
1986
  }
1758
1987
  /**
@@ -1811,128 +2040,344 @@ export class RealtimeSessionRuntime {
1811
2040
  }
1812
2041
  }
1813
2042
  /**
1814
- * Uploads the chunks captured since the last flush as one crash-recovery shard (durability only;
1815
- * the canonical file is still the full upload at teardown). Best-effort — never disturbs the call:
1816
- * a failed shard is retained by the queue and resent under its own index on later flushes, up to a
1817
- * bounded number of attempts (#5197).
2043
+ * One 5 s tick: cuts the packets encoded since the last tick into shards, stores each one, then
2044
+ * uploads them. Best-effort — never disturbs the call and never rejects. Overlapping ticks are
2045
+ * safe: `Append` and `SealTail` are synchronous, so indexes are assigned in order, and the queue
2046
+ * bounds how many flushes upload at once. A failed shard is retained by the queue and resent under
2047
+ * its own index on later ticks (#5197), and stays in the store until acknowledged.
2048
+ *
2049
+ * Every tick seals whatever it took, even under a full window (the window's 250-packet and byte caps
2050
+ * still split a larger batch). Capture starts slightly after this timer, so a tick almost never
2051
+ * holds exactly 250 packets; sealing only full windows would leave nearly 5 s of encoded audio in
2052
+ * memory at every moment, unstored and unsent, which a killed tab loses.
1818
2053
  */
1819
- async flushRecordingSegment() {
2054
+ async flushRecordingShards() {
1820
2055
  const recorder = this.recorder;
1821
- const agentSessionId = this.agentSessionId;
1822
- const queue = this.shardQueue;
1823
- if (!recorder || !agentSessionId || !queue) {
2056
+ const pipeline = this.recordingShards;
2057
+ if (!recorder || !pipeline) {
1824
2058
  return;
1825
2059
  }
1826
- // Read once: a shard's sample rate is fixed at capture, and the recorder may be stopped by the
1827
- // time a retry of it runs.
1828
- const sampleRate = recorder.SampleRate;
1829
- await queue.Flush(() => recorder.SnapshotNewSegmentBase64(), (segmentIndex, audioBase64) => this.uploadRecordingShard(agentSessionId, sampleRate, segmentIndex, audioBase64));
2060
+ let sealed;
2061
+ try {
2062
+ sealed = pipeline.Window.Append(recorder.TakeEncodedPackets());
2063
+ const tick = pipeline.Window.SealTail();
2064
+ if (tick) {
2065
+ sealed.push(tick);
2066
+ }
2067
+ }
2068
+ catch (error) {
2069
+ console.warn(`[RealtimeSession] Failed to take encoded recording packets for session ${pipeline.SessionID}; this tick's audio is lost:`, error);
2070
+ return;
2071
+ }
2072
+ await this.storeShards(pipeline, sealed);
2073
+ await pipeline.Queue.Flush(sealed);
2074
+ }
2075
+ /** Writes shards to the local store, in order, before their upload starts. Never rejects. */
2076
+ async storeShards(pipeline, shards) {
2077
+ const store = this.ensureShardStore();
2078
+ for (const shard of shards) {
2079
+ try {
2080
+ await store.Put({ SessionID: pipeline.SessionID, UserID: pipeline.UserID, Index: shard.Index, Bytes: shard.Bytes, SavedAt: Date.now() });
2081
+ }
2082
+ catch (error) {
2083
+ // The store contract is never-throw; an override that does must still not stop the upload.
2084
+ console.warn(`[RealtimeSession] Failed to store recording shard ${pipeline.SessionID}/${shard.Index}; uploading it anyway:`, error);
2085
+ }
2086
+ }
2087
+ }
2088
+ /** Removes an acknowledged shard from the local store. Never rejects. */
2089
+ deleteStoredShard(sessionId, index) {
2090
+ return DeleteStoredRecordingShard(this.ensureShardStore(), sessionId, index);
1830
2091
  }
1831
2092
  /**
1832
- * Sends one shard and reports whether the server stored it. The resolver reports a failed
1833
- * ownership load or storage write as `false`, not as an error — that is how #5197 lost a shard
1834
- * without a single client-side log line — so the typed result must be checked, not just the absence
1835
- * of a throw.
2093
+ * Tells the server, right now, how much of the recording exists — for a host's `pagehide` and
2094
+ * `visibilitychange → hidden` handlers, where the page may be gone a moment later. Synchronous:
2095
+ * it seals the pending partial shard and fires the `keepalive` terminal with it, then re-sends
2096
+ * with `keepalive` whatever the server has not acknowledged yet (see
2097
+ * {@link dispatchRecordingTerminal}), and returns without waiting. Never stops the recorder: after
2098
+ * a `hidden` the call may well continue, and later audio simply starts the next shard index (the
2099
+ * tail is sealed, so no index is ever re-sent with different bytes). Also works while End is still
2100
+ * stopping the recorder, which is exactly when a host that does not await End navigates away.
2101
+ * No-op when nothing is recording, there is no session id or no transport, or nothing was
2102
+ * captured. Never throws.
2103
+ *
2104
+ * Deliberately opens NO IndexedDB transaction inside the handler: a `pagehide` handler gets a few
2105
+ * milliseconds, and a store write started there could not complete during unload anyway. The
2106
+ * shards it sealed are written to the store from a `setTimeout(0)` task after the handler returns:
2107
+ * on a page that is merely hidden (the call goes on) that keeps the durable copy until the server
2108
+ * acknowledges it; on a page that is unloading the task just never runs.
1836
2109
  */
1837
- async uploadRecordingShard(agentSessionId, sampleRate, segmentIndex, audioBase64) {
1838
- const mutation = `
1839
- mutation UploadRealtimeRecordingSegment($agentSessionId: String!, $segmentIndex: Int!, $audioBase64: String!, $mimeType: String!) {
1840
- UploadRealtimeRecordingSegment(agentSessionId: $agentSessionId, segmentIndex: $segmentIndex, audioBase64: $audioBase64, mimeType: $mimeType)
1841
- }
1842
- `;
1843
- // Shards are HEADER-LESS raw little-endian PCM16 (mime audio/L16 with the capture sample rate),
1844
- // NOT individually-playable WAV — recovery is concatenate-in-order then WAV-wrap. The canonical
1845
- // seekable WAV is the consolidated end-of-call upload below.
1846
- const shardMime = `audio/L16;rate=${sampleRate}`;
1847
- const result = (await this.gql().ExecuteGQL(mutation, {
1848
- agentSessionId,
1849
- segmentIndex,
1850
- audioBase64,
1851
- mimeType: shardMime,
1852
- }));
1853
- return result?.UploadRealtimeRecordingSegment === true;
1854
- }
1855
- /**
1856
- * Stops the active recorder and encodes the captured audio. Split from the upload so teardown can
1857
- * close the server session in between (#5195). Best-effort: returns `null` when nothing was
1858
- * recorded or the stop failed (warned) — it must NEVER block teardown.
1859
- */
1860
- async stopRecording() {
1861
- this.stopSegmentFlushing();
1862
- // Close the queue with the recording: its retained shards (base64 audio) are released now, not at
1863
- // the next session start, and a flush still in flight stops sending, so no resend lands after the
1864
- // end-of-call upload below has deleted the shards.
1865
- this.shardQueue?.Close();
1866
- this.shardQueue = null;
2110
+ SendRecordingTerminal(options) {
1867
2111
  const recorder = this.recorder;
1868
- this.recorder = null;
2112
+ const pipeline = this.recordingShards;
2113
+ if (!recorder || !pipeline || !pipeline.Transport) {
2114
+ return;
2115
+ }
2116
+ try {
2117
+ const sealed = pipeline.Window.Append(recorder.TakeEncodedPackets());
2118
+ const tail = pipeline.Window.SealTail();
2119
+ if (pipeline.Window.LastSealedIndex < 0) {
2120
+ return;
2121
+ }
2122
+ const final = options?.Final ?? true;
2123
+ if (sealed.length === 0 && !tail && pipeline.FinalTerminal?.LastSealedIndex === pipeline.Window.LastSealedIndex) {
2124
+ return;
2125
+ }
2126
+ void this.dispatchRecordingTerminal(pipeline, sealed, tail, true, final);
2127
+ }
2128
+ catch (error) {
2129
+ console.warn(`[RealtimeSession] Failed to send the recording terminal for session ${pipeline.SessionID}:`, error);
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Fires the terminal request, then re-sends with `keepalive` every shard the server has not
2134
+ * acknowledged, then stores the shards sealed for this terminal. Shared by End and the page-lifecycle
2135
+ * handlers. Everything that needs the network is dispatched synchronously, before any storage work,
2136
+ * because the page may close at any moment from here on: an ordinary `fetch` is aborted on unload,
2137
+ * and a `keepalive` one is not.
2138
+ *
2139
+ * - The terminal goes first so it always gets the keepalive budget. The server waits for the shards
2140
+ * the terminal says exist, and assembles as soon as the last missing one arrives.
2141
+ * - The re-sent shards are the queue's unacknowledged ones (on the wire, waiting in a batch, or
2142
+ * retained after a failure) plus `sealedNow`, oldest first, within
2143
+ * {@link recordingKeepaliveBudgetBytes} together with the tail. A `sealedNow` shard that does not
2144
+ * fit, or whose keepalive upload fails, goes through the ordinary queue instead (it may still
2145
+ * land if the page stays). The queue keeps its own shards either way; a duplicate is acked as one.
2146
+ * - `sealedNow` and the tail are stored after the dispatch, from a `setTimeout(0)` task when called
2147
+ * from a page-lifecycle handler (`deferStore`). A shard's stored copy is deleted on its ack, after
2148
+ * the store write, so a fast ack can never leave an orphan row.
2149
+ *
2150
+ * Resolves true when the terminal was acknowledged. Never rejects.
2151
+ */
2152
+ dispatchRecordingTerminal(pipeline, sealedNow, tail, deferStore, final) {
2153
+ const sessionId = pipeline.SessionID;
2154
+ const terminalAcked = this.sendRecordingTerminal(pipeline, tail, final);
2155
+ const toStore = tail ? [...sealedNow, tail] : sealedNow;
2156
+ let stored = Promise.resolve();
2157
+ if (toStore.length > 0) {
2158
+ stored = deferStore ? this.storeShardsLater(pipeline, toStore) : this.storeShards(pipeline, toStore);
2159
+ }
2160
+ this.resendUnacknowledgedWithKeepalive(pipeline, sealedNow, stored);
2161
+ const ackedPromise = terminalAcked.then(async (acked) => {
2162
+ await stored;
2163
+ if (!tail) {
2164
+ return acked;
2165
+ }
2166
+ if (acked) {
2167
+ await this.deleteStoredShard(sessionId, tail.Index);
2168
+ }
2169
+ else {
2170
+ // Retried as an ordinary shard; a Flush on a closed queue (the recording ended) is a logged no-op.
2171
+ await pipeline.Queue.Flush([tail]);
2172
+ }
2173
+ return acked;
2174
+ });
2175
+ if (final) {
2176
+ pipeline.FinalTerminal = { LastSealedIndex: pipeline.Window.LastSealedIndex, Acked: terminalAcked };
2177
+ }
2178
+ return ackedPromise;
2179
+ }
2180
+ /** Counts `bytes` against the page's keepalive budget until `request` settles. */
2181
+ trackKeepalive(bytes, request) {
2182
+ this.keepaliveBytesInFlight += bytes;
2183
+ return request.finally(() => {
2184
+ this.keepaliveBytesInFlight -= bytes;
2185
+ });
2186
+ }
2187
+ /**
2188
+ * The `keepalive` re-send part of {@link dispatchRecordingTerminal}, budgeted against everything this
2189
+ * page still has in flight (the terminal just sent included). A shard whose `keepalive` copy is already
2190
+ * on the wire is not sent again. Never throws.
2191
+ */
2192
+ resendUnacknowledgedWithKeepalive(pipeline, sealedNow, stored) {
2193
+ const transport = pipeline.Transport;
2194
+ const sessionId = pipeline.SessionID;
2195
+ const fresh = new Set(sealedNow.map((shard) => shard.Index));
2196
+ const toQueue = (shard) => stored.then(() => pipeline.Queue.Flush([shard]));
2197
+ const byIndex = new Map();
2198
+ for (const shard of [...pipeline.Queue.Unacknowledged(), ...sealedNow]) {
2199
+ byIndex.set(shard.Index, shard);
2200
+ }
2201
+ for (const shard of [...byIndex.values()].sort((a, b) => a.Index - b.Index)) {
2202
+ const key = `${sessionId}/${shard.Index}`;
2203
+ if (this.keepaliveShardsInFlight.has(key)) {
2204
+ continue;
2205
+ }
2206
+ const budget = RealtimeSessionRuntime.recordingKeepaliveBudgetBytes - this.keepaliveBytesInFlight;
2207
+ if (!transport || shard.Bytes.length > budget) {
2208
+ if (fresh.has(shard.Index)) {
2209
+ void toQueue(shard);
2210
+ }
2211
+ continue;
2212
+ }
2213
+ this.keepaliveShardsInFlight.add(key);
2214
+ void this.trackKeepalive(shard.Bytes.length, transport.UploadShard(sessionId, shard, { Keepalive: true }).then((outcome) => outcome === 'Acked'))
2215
+ .finally(() => this.keepaliveShardsInFlight.delete(key))
2216
+ .catch((error) => {
2217
+ console.warn(`[RealtimeSession] Keepalive upload of recording shard ${sessionId}/${shard.Index} failed: ${describeRecordingFailure(error)}`, error);
2218
+ return false;
2219
+ })
2220
+ .then(async (acked) => {
2221
+ await stored;
2222
+ if (acked) {
2223
+ await this.deleteStoredShard(sessionId, shard.Index);
2224
+ }
2225
+ else if (fresh.has(shard.Index)) {
2226
+ await pipeline.Queue.Flush([shard]);
2227
+ }
2228
+ });
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Stores shards from a `setTimeout(0)` task rather than now — the path for shards sealed inside a
2233
+ * page-lifecycle handler, which must not open an IndexedDB transaction itself. Resolves once they
2234
+ * are stored (never, if the page unloads first). Never rejects.
2235
+ */
2236
+ storeShardsLater(pipeline, shards) {
2237
+ return new Promise((resolve) => {
2238
+ setTimeout(() => {
2239
+ void this.storeShards(pipeline, shards).then(resolve);
2240
+ }, 0);
2241
+ });
2242
+ }
2243
+ /**
2244
+ * Sends the terminal request for a pipeline whose tail is already sealed. Resolves true on a server
2245
+ * ack and logs the outcome with the session id. Never rejects.
2246
+ */
2247
+ async sendRecordingTerminal(pipeline, tail, final) {
2248
+ const lastShardIndex = pipeline.Window.LastSealedIndex;
2249
+ const capturedSamples = pipeline.Window.SealedSamples;
2250
+ const context = `session ${pipeline.SessionID} (last shard ${lastShardIndex}, ${capturedSamples} samples)`;
2251
+ if (!pipeline.Transport) {
2252
+ console.warn(`[RealtimeSession] Recording terminal NOT sent for ${context}: no recording transport; stored shards are drained on a later load.`);
2253
+ return false;
2254
+ }
2255
+ try {
2256
+ const acked = await this.trackKeepalive(tail?.Bytes.length ?? 0, pipeline.Transport.SendTerminal(pipeline.SessionID, {
2257
+ Tail: tail?.Bytes ?? null,
2258
+ LastShardIndex: lastShardIndex,
2259
+ CapturedSamples: capturedSamples,
2260
+ Final: final,
2261
+ }));
2262
+ if (!acked) {
2263
+ console.warn(`[RealtimeSession] Recording terminal for ${context} was not acknowledged; the server janitor assembles what arrived.`);
2264
+ }
2265
+ return acked;
2266
+ }
2267
+ catch (error) {
2268
+ console.warn(`[RealtimeSession] Recording terminal for ${context} failed: ${describeRecordingFailure(error)}`, error);
2269
+ return false;
2270
+ }
2271
+ }
2272
+ /**
2273
+ * Ends the recording at teardown and STARTS its terminal request: stops the recorder (flushing the
2274
+ * encoder), cuts the remaining packets into shards with the tail trimmed to the recorder's captured
2275
+ * sample count, and dispatches the `keepalive` terminal plus `keepalive` re-sends of everything not
2276
+ * yet acknowledged, before storing anything ({@link dispatchRecordingTerminal}). Teardown awaits the
2277
+ * returned terminal only after `CloseAgentSession` ({@link awaitRecordingTerminal}), so the small,
2278
+ * critical close never waits behind the recording (#5195).
2279
+ *
2280
+ * The recorder and pipeline stay visible to {@link SendRecordingTerminal} until the recorder has
2281
+ * stopped: a `pagehide` in that window (a host that navigates away without awaiting End) still
2282
+ * sends what was captured, and End then continues at the next index.
2283
+ *
2284
+ * Returns null when nothing was recording or nothing was captured (no terminal is sent then).
2285
+ * Best-effort: never throws, never blocks teardown on the network.
2286
+ */
2287
+ async finishRecording() {
2288
+ this.stopShardFlushing();
2289
+ const recorder = this.recorder;
2290
+ const pipeline = this.recordingShards;
1869
2291
  this.currentTurnStartMs = null;
1870
2292
  this.turnAudioStartCaptured = false;
1871
- if (!recorder) {
2293
+ this.userSpeechStartMs = null;
2294
+ this.userSpeechStartSuperseded = false;
2295
+ if (!recorder || !pipeline) {
2296
+ this.recorder = null;
2297
+ this.recordingShards = null;
1872
2298
  return null;
1873
2299
  }
1874
2300
  try {
1875
- // Capture the recorder MIME (now 'audio/wav') BEFORE Stop() — the getter reads '' once stopped.
1876
- const mimeType = recorder.MimeType;
1877
- const audioBase64 = await recorder.StopAndEncode();
1878
- // Read the real waveform peaks computed during capture (survives the stop via the snapshot).
1879
- const peaks = recorder.GetPeaks();
1880
- if (!audioBase64) {
1881
- console.warn('[RealtimeSession] ⚠️ recording NOT uploaded — empty recording.');
2301
+ const capturedSamples = await this.stopRecorder(recorder, pipeline.SessionID);
2302
+ this.recorder = null;
2303
+ this.recordingShards = null;
2304
+ const sealed = pipeline.Window.Append(recorder.TakeEncodedPackets());
2305
+ // Without a count from the recorder the tail keeps every packet it holds (SealTail's default).
2306
+ const tail = pipeline.Window.SealTail(capturedSamples === null ? undefined : Math.max(0, capturedSamples - pipeline.Window.SealedSamples));
2307
+ if (pipeline.Window.LastSealedIndex < 0) {
2308
+ pipeline.Queue.Close();
1882
2309
  return null;
1883
2310
  }
1884
- return { MimeType: mimeType, AudioBase64: audioBase64, Peaks: peaks };
2311
+ if (sealed.length === 0 && !tail && pipeline.FinalTerminal?.LastSealedIndex === pipeline.Window.LastSealedIndex) {
2312
+ return { Pipeline: pipeline, Acked: pipeline.FinalTerminal.Acked };
2313
+ }
2314
+ return { Pipeline: pipeline, Acked: this.dispatchRecordingTerminal(pipeline, sealed, tail, false, true) };
1885
2315
  }
1886
2316
  catch (error) {
1887
- console.warn('[RealtimeSession] Failed to stop call recording:', error);
2317
+ console.warn(`[RealtimeSession] Failed to finish the call recording for session ${pipeline.SessionID}; stored shards are drained at the next session start or load:`, error);
2318
+ this.recorder = null;
2319
+ this.recordingShards = null;
2320
+ pipeline.Queue.Close();
2321
+ this.rearmPendingShardDrain();
1888
2322
  return null;
1889
2323
  }
1890
2324
  }
1891
2325
  /**
1892
- * Uploads a recording returned by {@link stopRecording}. Never throws. No-op when there is
1893
- * nothing to upload; warns when there is no session id to attach the file to.
2326
+ * Stops the recorder, bounded by {@link RECORDER_STOP_TIMEOUT_MS}. Resolves the captured sample
2327
+ * count, or null when the recorder threw or did not finish in time (logged) — the caller then ends
2328
+ * the recording at the packets already encoded. Never rejects.
1894
2329
  */
1895
- async uploadStoppedRecording(agentSessionId, recording) {
1896
- if (!recording) {
1897
- return;
1898
- }
1899
- if (!agentSessionId) {
1900
- console.warn('[RealtimeSession] ⚠️ recording NOT uploaded — no session id.');
1901
- return;
1902
- }
2330
+ async stopRecorder(recorder, sessionId) {
2331
+ let timer;
2332
+ const timedOut = new Promise((resolve) => {
2333
+ timer = setTimeout(() => resolve('timeout'), RECORDER_STOP_TIMEOUT_MS);
2334
+ });
1903
2335
  try {
1904
- await this.uploadRecording(agentSessionId, recording.AudioBase64, recording.MimeType, recording.Peaks);
2336
+ const outcome = await Promise.race([recorder.Stop(), timedOut]);
2337
+ if (outcome === 'timeout') {
2338
+ console.warn(`[RealtimeSession] Recorder did not stop within ${RECORDER_STOP_TIMEOUT_MS}ms for session ${sessionId}; ending the recording at the packets already encoded.`);
2339
+ return null;
2340
+ }
2341
+ return outcome;
1905
2342
  }
1906
2343
  catch (error) {
1907
- console.warn(`[RealtimeSession] Failed to upload call recording for session ${agentSessionId}:`, error);
2344
+ console.warn(`[RealtimeSession] Recorder failed to stop for session ${sessionId}; ending the recording at the packets already encoded:`, error);
2345
+ return null;
2346
+ }
2347
+ finally {
2348
+ clearTimeout(timer);
1908
2349
  }
1909
2350
  }
1910
2351
  /**
1911
- * Runs the `UploadRealtimeRecording` mutation. A `Success:false` payload is logged; a transport
1912
- * rejection propagates to `uploadStoppedRecording`, which catches and logs it. Sends the
1913
- * capture-time waveform `peaks` (max-abs per bucket, normalized 0..1) so the server can persist a
1914
- * `peaks.json` sidecar for fast waveform rendering without re-decoding the audio.
2352
+ * Waits for the terminal {@link finishRecording} started, bounded by
2353
+ * {@link RECORDING_TERMINAL_ACK_TIMEOUT_MS}, logs the outcome, then closes the recording's queue
2354
+ * (anything it had not delivered stays in the local store for the next-load drain). Never throws.
1915
2355
  */
1916
- async uploadRecording(agentSessionId, audioBase64, mimeType, peaks) {
1917
- const mutation = `
1918
- mutation UploadRealtimeRecording($agentSessionId: String!, $audioBase64: String!, $mimeType: String!, $consent: Boolean, $peaks: [Float!]) {
1919
- UploadRealtimeRecording(agentSessionId: $agentSessionId, audioBase64: $audioBase64, mimeType: $mimeType, consent: $consent, peaks: $peaks) {
1920
- Success
1921
- FileID
1922
- ErrorMessage
1923
- }
1924
- }
1925
- `;
1926
- const result = await this.gql().ExecuteGQL(mutation, {
1927
- agentSessionId,
1928
- audioBase64,
1929
- mimeType,
1930
- consent: true,
1931
- peaks
2356
+ async awaitRecordingTerminal(pending) {
2357
+ const sessionId = pending.Pipeline.SessionID;
2358
+ let timer;
2359
+ const timedOut = new Promise((resolve) => {
2360
+ timer = setTimeout(() => resolve('timeout'), RECORDING_TERMINAL_ACK_TIMEOUT_MS);
1932
2361
  });
1933
- const payload = result?.UploadRealtimeRecording;
1934
- if (!payload?.Success) {
1935
- console.warn(`[RealtimeSession] ❌ recording upload reported failure: ${payload?.ErrorMessage ?? 'unknown error'} (full result: ${JSON.stringify(result)})`);
2362
+ try {
2363
+ const outcome = await Promise.race([pending.Acked.then((ok) => (ok ? 'ack' : 'failed')), timedOut]);
2364
+ if (outcome === 'ack') {
2365
+ console.log(`[RealtimeSession] Recording terminal acknowledged for session ${sessionId}.`);
2366
+ }
2367
+ else if (outcome === 'timeout') {
2368
+ console.warn(`[RealtimeSession] Recording terminal for session ${sessionId} not acknowledged within ${RECORDING_TERMINAL_ACK_TIMEOUT_MS}ms; ending anyway (the keepalive request may still land).`);
2369
+ }
2370
+ // 'failed' was already logged with its reason by sendRecordingTerminal.
2371
+ }
2372
+ finally {
2373
+ clearTimeout(timer);
2374
+ pending.Pipeline.Queue.Close();
2375
+ // The recording may have left shards in the store (an unacknowledged tail, windows the queue
2376
+ // still held or had on the wire at Close), so re-arm the drain for the next session start on
2377
+ // this page. Re-armed here, after the wait, so it never races the recording's own terminal;
2378
+ // and unconditionally, because whether a fire-and-forget upload landed is not known here and
2379
+ // a drain of an empty store costs one read.
2380
+ this.rearmPendingShardDrain();
1936
2381
  }
1937
2382
  }
1938
2383
  /**
@@ -2956,6 +3401,11 @@ export class RealtimeSessionRuntime {
2956
3401
  client.OnInterruption(() => {
2957
3402
  this.cancelPendingNarration();
2958
3403
  });
3404
+ // The provider VAD saw the user start speaking. Final-only user-transcript drivers (OpenAI,
3405
+ // HuggingFace, xAI) emit no user interim, so this is their only real user-turn start. It goes
3406
+ // to its own slot, not the shared turn slot: the user transcript arrives asynchronously, so the
3407
+ // assistant's reply may already be streaming and must keep its own start.
3408
+ client.OnUserSpeechStart(() => this.markUserSpeechStart());
2959
3409
  client.OnRemoteVideo((video) => this.onAgentVideo(client, video));
2960
3410
  }
2961
3411
  /**
@@ -3058,6 +3508,11 @@ export class RealtimeSessionRuntime {
3058
3508
  }
3059
3509
  return;
3060
3510
  }
3511
+ if (transcript.Role === 'User' && transcript.Text.trim().length === 0) {
3512
+ // An empty final user transcript (a VAD false positive, an empty transcription) never reaches
3513
+ // relayTranscript, so release its onset here: it must not become the next real turn's start.
3514
+ this.releaseUserSpeechStart();
3515
+ }
3061
3516
  if (transcript.Role === 'Assistant') {
3062
3517
  this.hasActiveInterimUserCaption = false;
3063
3518
  this.pendingUserCaption = '';
@@ -3132,11 +3587,10 @@ export class RealtimeSessionRuntime {
3132
3587
  * - NORMAL turns only — NARRATION interims are ephemeral and never persisted, so they must not
3133
3588
  * claim the next real turn's start slot.
3134
3589
  *
3135
- * Works for any role whose driver surfaces interim deltas (all drivers for the assistant; the
3136
- * relevant case here — the post-tool-gap assistant answer — and user-interim drivers like
3137
- * Gemini/AssemblyAI). For final-only user turns (OpenAI/xAI/ElevenLabs) no interim arrives, so
3138
- * {@link relayTranscript} falls back to the seeded/prior start — the gap case that drifts is the
3139
- * assistant answer, which always has interims.
3590
+ * Stamped on a turn's first interim transcript (all drivers for the assistant — the post-tool-gap
3591
+ * assistant answer — and user-interim drivers like Gemini/AssemblyAI). Final-only user turns
3592
+ * (OpenAI-protocol drivers) take their start from {@link markUserSpeechStart}'s own slot
3593
+ * instead; one with no speech-start signal either (ElevenLabs) relays `null` as-is.
3140
3594
  */
3141
3595
  markTurnAudioStart(kind) {
3142
3596
  if (this.turnAudioStartCaptured || kind === 'narration') {
@@ -3149,15 +3603,39 @@ export class RealtimeSessionRuntime {
3149
3603
  this.currentTurnStartMs = offset;
3150
3604
  this.turnAudioStartCaptured = true;
3151
3605
  }
3606
+ /**
3607
+ * Stamps {@link userSpeechStartMs} when the client signals that the user began to speak. Keeps
3608
+ * the FIRST onset since the last final user transcript: the VAD fires again after a
3609
+ * mid-sentence pause, and that second onset is not where the utterance began. An onset that an
3610
+ * assistant turn which began after it has since superseded ({@link userSpeechStartSuperseded}) is
3611
+ * replaced instead.
3612
+ */
3613
+ markUserSpeechStart() {
3614
+ if (this.userSpeechStartMs !== null && !this.userSpeechStartSuperseded) {
3615
+ return;
3616
+ }
3617
+ const offset = this.nowTurnOffsetMs();
3618
+ if (offset === null) {
3619
+ return;
3620
+ }
3621
+ this.userSpeechStartMs = offset;
3622
+ this.userSpeechStartSuperseded = false;
3623
+ }
3624
+ /** Ends the held user onset: a final user transcript (empty or not) closed the utterance it began. */
3625
+ releaseUserSpeechStart() {
3626
+ this.userSpeechStartMs = null;
3627
+ this.userSpeechStartSuperseded = false;
3628
+ }
3152
3629
  /**
3153
3630
  * The current per-turn offset in ms — the RECORDER's clock when one runs (an offset into a
3154
3631
  * seekable file), else the SESSION clock (#3832: orderable and displayable, not seekable),
3155
3632
  * else `null` before any call is live. One function so the two stamp sites cannot disagree
3156
- * about which clock a session is on.
3633
+ * about which clock a session is on. Always whole milliseconds: the offsets travel as GraphQL
3634
+ * `Int`, which refuses a fraction, and a 48 kHz sample clock rarely lands on a whole millisecond.
3157
3635
  */
3158
3636
  nowTurnOffsetMs() {
3159
3637
  if (this.recorder) {
3160
- return this.recorder.NowOffsetMs();
3638
+ return Math.round(this.recorder.NowOffsetMs());
3161
3639
  }
3162
3640
  if (this.sessionClockStartMs !== null) {
3163
3641
  return Math.max(0, Math.round(performance.now() - this.sessionClockStartMs));
@@ -3181,8 +3659,13 @@ export class RealtimeSessionRuntime {
3181
3659
  }
3182
3660
  this.appendCaption({ Role: role, Text: text });
3183
3661
  }
3184
- /** Finalizes the user turn: push a caption + relay the final transcript. */
3185
- async onUserTranscript(transcript) {
3662
+ /**
3663
+ * Finalizes the user turn: push a caption + relay the final transcript.
3664
+ *
3665
+ * @param source `'typed'` for a {@link SendText} turn: it has no speech onset, so it neither takes
3666
+ * nor clears the held one (a spoken transcript may still be pending for it).
3667
+ */
3668
+ async onUserTranscript(transcript, source = 'speech') {
3186
3669
  if (transcript.trim().length === 0) {
3187
3670
  return;
3188
3671
  }
@@ -3191,7 +3674,7 @@ export class RealtimeSessionRuntime {
3191
3674
  this.firstUserTranscript = transcript;
3192
3675
  }
3193
3676
  this.appendCaption({ Role: 'User', Text: transcript });
3194
- await this.relayTranscript('user', transcript);
3677
+ await this.relayTranscript('user', transcript, false, source);
3195
3678
  }
3196
3679
  // ── Tool calling ───────────────────────────────────────────────────────────
3197
3680
  /**
@@ -3519,37 +4002,71 @@ export class RealtimeSessionRuntime {
3519
4002
  * Relays a final transcript turn to MJ via `RelayRealtimeTranscript`.
3520
4003
  *
3521
4004
  * When the session is being recorded, per-turn timing rides along: `utteranceEndMs` is the
3522
- * recording-relative offset at finalization, and `utteranceStartMs` is the offset captured by
3523
- * {@link markTurnAudioStart} when THIS turn's audio actually began (its first interim) — NOT
3524
- * inherited from the previous turn's end. That distinction is the timing fix: when a tool-call
3525
- * / silence gap sits between turns, the post-gap turn's audio starts much later, so inheriting
3526
- * the prior turn's end stamped the cue ~gap-length too early. Both are omitted (left `null`)
3527
- * when the session isn't being recorded.
4005
+ * recording-relative offset at finalization, and `utteranceStartMs` is where THIS turn's audio
4006
+ * actually began — NOT inherited from the previous turn's end. For a user turn whose onset the
4007
+ * client signalled, that is {@link userSpeechStartMs}; otherwise it is the offset captured by
4008
+ * {@link markTurnAudioStart} on the turn's first interim. That distinction is the timing fix:
4009
+ * when a tool-call / silence gap sits between turns, the post-gap turn's audio starts much
4010
+ * later, so inheriting the prior turn's end stamped the cue ~gap-length too early. Both are
4011
+ * omitted (left `null`) when the session isn't being recorded. `utteranceStartMs` is also
4012
+ * `null` when no start was measured for this turn; it is never replaced with a placeholder 0.
3528
4013
  *
3529
4014
  * A correction (`replacesPrevious`) doesn't open a new turn, so it carries no start and doesn't
3530
4015
  * reset the per-turn start guard. After a normal finalization the guard is cleared so the NEXT
3531
- * turn re-stamps its start from where ITS audio begins.
4016
+ * turn re-stamps its start from where ITS audio begins — except for a user turn that used its
4017
+ * own speech-start slot, which leaves the shared slot alone (it may hold the in-flight
4018
+ * assistant turn's start). Every final user transcript, corrections included, clears the
4019
+ * speech-start slot.
3532
4020
  *
3533
4021
  * @param replacesPrevious CORRECTION semantics: the server updates the session's most
3534
4022
  * recent persisted turn of this role IN PLACE instead of appending (e.g. ElevenLabs'
3535
4023
  * post-barge-in `agent_response_correction`).
4024
+ * @param source `'typed'` for a user turn sent with {@link SendText}: it does not read or clear
4025
+ * {@link userSpeechStartMs}, which belongs to the user's speech.
3536
4026
  */
3537
- async relayTranscript(role, text, replacesPrevious = false) {
4027
+ async relayTranscript(role, text, replacesPrevious = false, source = 'speech') {
3538
4028
  if (!this.agentSessionId) {
3539
4029
  return;
3540
4030
  }
4031
+ // Every final user transcript closes the signalled onset — a correction too, since it ends the
4032
+ // same utterance — so a later onset (an xAI mid-sentence resume) can't leak into the next turn.
4033
+ let userSpeechStartMs = null;
4034
+ if (role === 'user' && source === 'speech') {
4035
+ userSpeechStartMs = this.userSpeechStartMs;
4036
+ this.releaseUserSpeechStart();
4037
+ }
3541
4038
  // Per-turn timing against whichever clock the session is on (#3832): the recorder's when one
3542
- // runs, else the session clock. `utteranceStartMs` is where this turn's audio actually began
3543
- // (captured by markTurnAudioStart on the first interim); the `?? 0` fallback covers a turn
3544
- // whose interim was missed / a final-only first turn.
4039
+ // runs, else the session clock. `utteranceStartMs` is where this turn's audio actually began.
4040
+ // It is null when no start was measured for this turn (a final-only driver with no speech-start
4041
+ // signal), so the server stores NULL rather than a placeholder 0.
3545
4042
  const utteranceEndMs = this.nowTurnOffsetMs();
3546
- const utteranceStartMs = utteranceEndMs !== null && !replacesPrevious ? (this.currentTurnStartMs ?? 0) : null;
3547
- if (utteranceEndMs !== null && !replacesPrevious) {
4043
+ const opensTurn = utteranceEndMs !== null && !replacesPrevious;
4044
+ let utteranceStartMs = null;
4045
+ if (opensTurn && userSpeechStartMs !== null) {
4046
+ // A user turn with a signalled onset. Leave the shared slot alone: the user transcript
4047
+ // arrives asynchronously, so it may already hold the in-flight assistant reply's start.
4048
+ utteranceStartMs = userSpeechStartMs;
4049
+ if (!this.turnAudioStartCaptured) {
4050
+ // No turn has captured a start, so the slot holds only the recording-start seed (0). This
4051
+ // user turn was the first turn; drop the seed so a later unmeasured turn relays null, not 0.
4052
+ this.currentTurnStartMs = null;
4053
+ }
4054
+ }
4055
+ else if (opensTurn) {
4056
+ utteranceStartMs = this.currentTurnStartMs;
3548
4057
  // This turn is finalized — arm the NEXT turn to re-stamp its start from its own first
3549
4058
  // interim (handles a tool-call gap before the next turn). Stop inheriting this end as the
3550
- // next start. `null` means "not yet captured"; relay falls back to `?? 0` if no interim fires.
4059
+ // next start. `null` means "not yet captured" and is relayed as-is if nothing stamps it.
3551
4060
  this.currentTurnStartMs = null;
3552
4061
  this.turnAudioStartCaptured = false;
4062
+ if (role === 'assistant' && this.userSpeechStartMs !== null && (utteranceStartMs === null || this.userSpeechStartMs < utteranceStartMs)) {
4063
+ // The floor moved on past an onset that began BEFORE this assistant turn: a late user
4064
+ // transcript may still claim it, but the next onset replaces it (see userSpeechStartSuperseded).
4065
+ // An onset that began DURING this turn is a barge-in; it belongs to the user speech still
4066
+ // pending, so it stays the first onset. With no measured assistant start we cannot tell, and
4067
+ // treat the onset as earlier.
4068
+ this.userSpeechStartSuperseded = true;
4069
+ }
3553
4070
  }
3554
4071
  try {
3555
4072
  const mutation = `
@@ -3980,6 +4497,14 @@ export class RealtimeSessionRuntime {
3980
4497
  // set (the captured per-save id covers the race anyway), then dispose the plugins.
3981
4498
  this.flushAllChannelSaves();
3982
4499
  this.disposeChannels();
4500
+ // End the recording and START its keepalive terminal request now: stop the recorder (which flushes
4501
+ // the encoder) before anything else that awaits. A tab closed right after End gets only the pagehide
4502
+ // terminal, which carries packets already encoded; releasing the mic and disconnecting the driver can
4503
+ // take long enough for the page to die first, and the audio still inside the encoder would be lost.
4504
+ // It also goes out before the usage flush and the close, so it is on the wire if the page dies during
4505
+ // either. Best-effort — finishRecording never throws. Null when nothing was recorded.
4506
+ const recordingTerminal = await this.finishRecording();
4507
+ this.recordingStartedAtIso = null;
3983
4508
  // The camera and screen share go first: the camera runs on the controller closed next.
3984
4509
  this.closeCaptures();
3985
4510
  // Defensive: stop the mic even when Connect never ran (the client also stops the
@@ -4013,11 +4538,6 @@ export class RealtimeSessionRuntime {
4013
4538
  this.client = null;
4014
4539
  }
4015
4540
  }
4016
- // Stop + encode the call recording now (the recorder must be detached before the session id is
4017
- // cleared); the upload itself happens after the close below. Best-effort — stopRecording
4018
- // swallows its own errors. Null when nothing was recorded.
4019
- const recording = await this.stopRecording();
4020
- this.recordingStartedAtIso = null;
4021
4541
  // Final usage flush WHILE the live session id is still set (the relay mutation also
4022
4542
  // accepts a Closed session, so ordering vs. CloseAgentSession is belt-and-braces).
4023
4543
  if (this.usageFlushTimer) {
@@ -4029,11 +4549,12 @@ export class RealtimeSessionRuntime {
4029
4549
  if (closeServerSession && this.agentSessionId) {
4030
4550
  await this.closeServerSession(this.agentSessionId);
4031
4551
  }
4032
- // Upload AFTER the close. The WAV is tens of MB and takes seconds; a page unload aborts the
4033
- // request, and nothing after an aborted await ever runs — so the small, critical close goes
4034
- // first (#5195). The server accepts the upload on a Closed session (loadOwnedSession does not
4035
- // check status), and uploadStoppedRecording never throws.
4036
- await this.uploadStoppedRecording(this.agentSessionId, recording);
4552
+ // Wait for the terminal's ack only AFTER the close: nothing after an aborted await ever runs, so
4553
+ // the small, critical close must never queue behind the recording (#5195). Bounded by
4554
+ // RECORDING_TERMINAL_ACK_TIMEOUT_MS; SavingRecording$ stays up until it settles.
4555
+ if (recordingTerminal) {
4556
+ await this.awaitRecordingTerminal(recordingTerminal);
4557
+ }
4037
4558
  // Capture the session id BEFORE we null it so the lifecycle emit carries it.
4038
4559
  // Skip emitting when there was no live session (defensive — teardown is safe
4039
4560
  // to call without an active session).
@@ -4098,12 +4619,16 @@ export class RealtimeSessionRuntime {
4098
4619
  this.pendingUserCaption = '';
4099
4620
  this.hasActiveInterimUserCaption = false;
4100
4621
  this.SetMinimized(false);
4101
- this.stopSegmentFlushing();
4102
- this.shardQueue = null;
4622
+ this.stopShardFlushing();
4623
+ // Defensive: teardown already ended any previous recording; a stray pipeline must not keep sending.
4624
+ this.recordingShards?.Queue.Close();
4625
+ this.recordingShards = null;
4103
4626
  this.recorder = null;
4104
4627
  this.recordingStartedAtIso = null;
4105
4628
  this.currentTurnStartMs = null;
4106
4629
  this.turnAudioStartCaptured = false;
4630
+ this.userSpeechStartMs = null;
4631
+ this.userSpeechStartSuperseded = false;
4107
4632
  this.usedChannelNames.clear();
4108
4633
  this.microphoneMuted = false;
4109
4634
  }