@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.
- package/dist/hosts/IRealtimeMediaHost.d.ts +25 -30
- package/dist/hosts/IRealtimeMediaHost.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/session/RealtimeSessionRuntime.d.ts +285 -65
- package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
- package/dist/session/RealtimeSessionRuntime.js +690 -165
- package/dist/session/RealtimeSessionRuntime.js.map +1 -1
- package/dist/session/RecordingShardDrain.d.ts +59 -0
- package/dist/session/RecordingShardDrain.d.ts.map +1 -0
- package/dist/session/RecordingShardDrain.js +135 -0
- package/dist/session/RecordingShardDrain.js.map +1 -0
- package/dist/session/RecordingShardQueue.d.ts +54 -37
- package/dist/session/RecordingShardQueue.d.ts.map +1 -1
- package/dist/session/RecordingShardQueue.js +87 -83
- package/dist/session/RecordingShardQueue.js.map +1 -1
- package/dist/session/RecordingShardStore.d.ts +57 -0
- package/dist/session/RecordingShardStore.d.ts.map +1 -0
- package/dist/session/RecordingShardStore.js +184 -0
- package/dist/session/RecordingShardStore.js.map +1 -0
- package/dist/session/RecordingShardWindow.d.ts +42 -0
- package/dist/session/RecordingShardWindow.d.ts.map +1 -0
- package/dist/session/RecordingShardWindow.js +88 -0
- package/dist/session/RecordingShardWindow.js.map +1 -0
- package/dist/session/RecordingTransport.d.ts +66 -0
- package/dist/session/RecordingTransport.d.ts.map +1 -0
- package/dist/session/RecordingTransport.js +61 -0
- package/dist/session/RecordingTransport.js.map +1 -0
- 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
|
|
178
|
-
* are the host's, because both are platform-specific
|
|
179
|
-
*
|
|
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
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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;
|
|
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
|
|
433
|
-
this.
|
|
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
|
-
*
|
|
436
|
-
*
|
|
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.
|
|
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)
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* the
|
|
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
|
|
734
|
-
static { this.
|
|
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
|
|
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.
|
|
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
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
-
/**
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
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
|
|
2054
|
+
async flushRecordingShards() {
|
|
1820
2055
|
const recorder = this.recorder;
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
if (!recorder || !agentSessionId || !queue) {
|
|
2056
|
+
const pipeline = this.recordingShards;
|
|
2057
|
+
if (!recorder || !pipeline) {
|
|
1824
2058
|
return;
|
|
1825
2059
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
1893
|
-
*
|
|
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
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
|
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]
|
|
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
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
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
|
|
1917
|
-
const
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
*
|
|
3136
|
-
*
|
|
3137
|
-
*
|
|
3138
|
-
*
|
|
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
|
-
/**
|
|
3185
|
-
|
|
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
|
|
3523
|
-
*
|
|
3524
|
-
*
|
|
3525
|
-
*
|
|
3526
|
-
*
|
|
3527
|
-
*
|
|
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
|
-
//
|
|
3544
|
-
//
|
|
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
|
|
3547
|
-
|
|
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"
|
|
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
|
-
//
|
|
4033
|
-
//
|
|
4034
|
-
//
|
|
4035
|
-
|
|
4036
|
-
|
|
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.
|
|
4102
|
-
|
|
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
|
}
|