@absolutejs/voice 0.0.22-beta.630 → 0.0.22-beta.632

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.
@@ -1721,6 +1721,7 @@ var createTimeStretcher = () => {
1721
1721
  };
1722
1722
 
1723
1723
  // src/client/audioPlayer.ts
1724
+ var activeAudioPlayers = new Set;
1724
1725
  var DEFAULT_LOOKAHEAD_MS = 15;
1725
1726
  var DEFAULT_VOLUME = 1;
1726
1727
  var DEFAULT_PLAYBACK_RATE = 1;
@@ -1729,6 +1730,7 @@ var MAX_PLAYBACK_RATE = 2;
1729
1730
  var STRETCH_BYPASS_EPSILON = 0.01;
1730
1731
  var ANALYSER_FFT_SIZE = 256;
1731
1732
  var PCM_BYTE_MIDPOINT = 128;
1733
+ var GAP_EPSILON_S = 0.03;
1732
1734
  var createInitialState3 = () => ({
1733
1735
  activeSourceCount: 0,
1734
1736
  error: null,
@@ -1800,6 +1802,36 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1800
1802
  let interruptPromise = null;
1801
1803
  let resolveInterruptPromise = null;
1802
1804
  let interruptFallbackTimer = null;
1805
+ let concurrencyCounted = false;
1806
+ let observedPeakConcurrency = 0;
1807
+ let scheduledDurationMs = 0;
1808
+ let gapCount = 0;
1809
+ let totalGapMs = 0;
1810
+ let maxGapMs = 0;
1811
+ let errorCount = 0;
1812
+ let hasScheduledAnyChunk = false;
1813
+ const sampleConcurrency = () => {
1814
+ if (activeAudioPlayers.size > observedPeakConcurrency) {
1815
+ observedPeakConcurrency = activeAudioPlayers.size;
1816
+ }
1817
+ };
1818
+ const markPlayerActive = () => {
1819
+ if (concurrencyCounted) {
1820
+ return;
1821
+ }
1822
+ concurrencyCounted = true;
1823
+ activeAudioPlayers.add(sampleConcurrency);
1824
+ for (const sample of activeAudioPlayers) {
1825
+ sample();
1826
+ }
1827
+ };
1828
+ const markPlayerInactive = () => {
1829
+ if (!concurrencyCounted) {
1830
+ return;
1831
+ }
1832
+ concurrencyCounted = false;
1833
+ activeAudioPlayers.delete(sampleConcurrency);
1834
+ };
1803
1835
  const notify = () => {
1804
1836
  for (const subscriber of subscribers) {
1805
1837
  subscriber();
@@ -1913,7 +1945,17 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1913
1945
  });
1914
1946
  maybeResolveInterrupt();
1915
1947
  };
1916
- const startAt = Math.max(context.currentTime + lookaheadSeconds, queueEndTime);
1948
+ const earliestStart = context.currentTime + lookaheadSeconds;
1949
+ const startAt = Math.max(earliestStart, queueEndTime);
1950
+ if (hasScheduledAnyChunk && earliestStart > queueEndTime + GAP_EPSILON_S) {
1951
+ const gapMs = (earliestStart - queueEndTime) * 1000;
1952
+ gapCount += 1;
1953
+ totalGapMs += gapMs;
1954
+ maxGapMs = Math.max(maxGapMs, gapMs);
1955
+ }
1956
+ hasScheduledAnyChunk = true;
1957
+ scheduledDurationMs += buffer.duration / rate * 1000;
1958
+ sampleConcurrency();
1917
1959
  queueEndTime = startAt + buffer.duration / rate;
1918
1960
  sourceNodes.add(node);
1919
1961
  setState({
@@ -1981,6 +2023,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1981
2023
  queuedChunkCount: state.queuedChunkCount + nextChunks.length
1982
2024
  });
1983
2025
  } catch (error) {
2026
+ errorCount += 1;
1984
2027
  setState({
1985
2028
  error: error instanceof Error ? error.message : String(error)
1986
2029
  });
@@ -2004,6 +2047,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2004
2047
  return state.activeSourceCount;
2005
2048
  },
2006
2049
  close: async () => {
2050
+ markPlayerInactive();
2007
2051
  unsubscribeSource();
2008
2052
  stopQueuedPlayback({ forceClear: true });
2009
2053
  clearInterruptTimer();
@@ -2043,6 +2087,21 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2043
2087
  return Math.sqrt(sumSquares / analyserBuffer.length);
2044
2088
  },
2045
2089
  getSnapshot: () => state,
2090
+ getIntegritySummary: () => {
2091
+ const chunksReceived = source.assistantAudio.length;
2092
+ const chunksScheduled = state.processedChunkCount;
2093
+ return {
2094
+ chunksReceived,
2095
+ chunksScheduled,
2096
+ errorCount,
2097
+ gapCount,
2098
+ maxConcurrentPlayers: observedPeakConcurrency,
2099
+ maxGapMs: Math.round(maxGapMs),
2100
+ ok: observedPeakConcurrency <= 1 && errorCount === 0 && chunksScheduled >= chunksReceived,
2101
+ scheduledDurationMs: Math.round(scheduledDurationMs),
2102
+ totalGapMs: Math.round(totalGapMs)
2103
+ };
2104
+ },
2046
2105
  interrupt: async () => {
2047
2106
  const startedAt = Date.now();
2048
2107
  const context = await ensureAudioContext();
@@ -2096,6 +2155,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2096
2155
  return;
2097
2156
  }
2098
2157
  await audioContext.suspend();
2158
+ markPlayerInactive();
2099
2159
  setState({
2100
2160
  activeSourceCount: sourceNodes.size,
2101
2161
  isActive: false,
@@ -2126,6 +2186,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2126
2186
  if (context.state === "suspended") {
2127
2187
  await context.resume();
2128
2188
  }
2189
+ markPlayerActive();
2129
2190
  setState({
2130
2191
  activeSourceCount: sourceNodes.size,
2131
2192
  isActive: true,
@@ -2133,6 +2194,8 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2133
2194
  });
2134
2195
  await queueSync();
2135
2196
  } catch (error) {
2197
+ errorCount += 1;
2198
+ markPlayerInactive();
2136
2199
  setState({
2137
2200
  error: error instanceof Error ? error.message : String(error),
2138
2201
  isActive: false,
@@ -528,6 +528,7 @@ var createTimeStretcher = () => {
528
528
  };
529
529
 
530
530
  // src/client/audioPlayer.ts
531
+ var activeAudioPlayers = new Set;
531
532
  var DEFAULT_LOOKAHEAD_MS = 15;
532
533
  var DEFAULT_VOLUME = 1;
533
534
  var DEFAULT_PLAYBACK_RATE = 1;
@@ -536,6 +537,7 @@ var MAX_PLAYBACK_RATE = 2;
536
537
  var STRETCH_BYPASS_EPSILON = 0.01;
537
538
  var ANALYSER_FFT_SIZE = 256;
538
539
  var PCM_BYTE_MIDPOINT = 128;
540
+ var GAP_EPSILON_S = 0.03;
539
541
  var createInitialState = () => ({
540
542
  activeSourceCount: 0,
541
543
  error: null,
@@ -607,6 +609,36 @@ var createVoiceAudioPlayer = (source, options = {}) => {
607
609
  let interruptPromise = null;
608
610
  let resolveInterruptPromise = null;
609
611
  let interruptFallbackTimer = null;
612
+ let concurrencyCounted = false;
613
+ let observedPeakConcurrency = 0;
614
+ let scheduledDurationMs = 0;
615
+ let gapCount = 0;
616
+ let totalGapMs = 0;
617
+ let maxGapMs = 0;
618
+ let errorCount = 0;
619
+ let hasScheduledAnyChunk = false;
620
+ const sampleConcurrency = () => {
621
+ if (activeAudioPlayers.size > observedPeakConcurrency) {
622
+ observedPeakConcurrency = activeAudioPlayers.size;
623
+ }
624
+ };
625
+ const markPlayerActive = () => {
626
+ if (concurrencyCounted) {
627
+ return;
628
+ }
629
+ concurrencyCounted = true;
630
+ activeAudioPlayers.add(sampleConcurrency);
631
+ for (const sample of activeAudioPlayers) {
632
+ sample();
633
+ }
634
+ };
635
+ const markPlayerInactive = () => {
636
+ if (!concurrencyCounted) {
637
+ return;
638
+ }
639
+ concurrencyCounted = false;
640
+ activeAudioPlayers.delete(sampleConcurrency);
641
+ };
610
642
  const notify = () => {
611
643
  for (const subscriber of subscribers) {
612
644
  subscriber();
@@ -720,7 +752,17 @@ var createVoiceAudioPlayer = (source, options = {}) => {
720
752
  });
721
753
  maybeResolveInterrupt();
722
754
  };
723
- const startAt = Math.max(context.currentTime + lookaheadSeconds, queueEndTime);
755
+ const earliestStart = context.currentTime + lookaheadSeconds;
756
+ const startAt = Math.max(earliestStart, queueEndTime);
757
+ if (hasScheduledAnyChunk && earliestStart > queueEndTime + GAP_EPSILON_S) {
758
+ const gapMs = (earliestStart - queueEndTime) * 1000;
759
+ gapCount += 1;
760
+ totalGapMs += gapMs;
761
+ maxGapMs = Math.max(maxGapMs, gapMs);
762
+ }
763
+ hasScheduledAnyChunk = true;
764
+ scheduledDurationMs += buffer.duration / rate * 1000;
765
+ sampleConcurrency();
724
766
  queueEndTime = startAt + buffer.duration / rate;
725
767
  sourceNodes.add(node);
726
768
  setState({
@@ -788,6 +830,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
788
830
  queuedChunkCount: state.queuedChunkCount + nextChunks.length
789
831
  });
790
832
  } catch (error) {
833
+ errorCount += 1;
791
834
  setState({
792
835
  error: error instanceof Error ? error.message : String(error)
793
836
  });
@@ -811,6 +854,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
811
854
  return state.activeSourceCount;
812
855
  },
813
856
  close: async () => {
857
+ markPlayerInactive();
814
858
  unsubscribeSource();
815
859
  stopQueuedPlayback({ forceClear: true });
816
860
  clearInterruptTimer();
@@ -850,6 +894,21 @@ var createVoiceAudioPlayer = (source, options = {}) => {
850
894
  return Math.sqrt(sumSquares / analyserBuffer.length);
851
895
  },
852
896
  getSnapshot: () => state,
897
+ getIntegritySummary: () => {
898
+ const chunksReceived = source.assistantAudio.length;
899
+ const chunksScheduled = state.processedChunkCount;
900
+ return {
901
+ chunksReceived,
902
+ chunksScheduled,
903
+ errorCount,
904
+ gapCount,
905
+ maxConcurrentPlayers: observedPeakConcurrency,
906
+ maxGapMs: Math.round(maxGapMs),
907
+ ok: observedPeakConcurrency <= 1 && errorCount === 0 && chunksScheduled >= chunksReceived,
908
+ scheduledDurationMs: Math.round(scheduledDurationMs),
909
+ totalGapMs: Math.round(totalGapMs)
910
+ };
911
+ },
853
912
  interrupt: async () => {
854
913
  const startedAt = Date.now();
855
914
  const context = await ensureAudioContext();
@@ -903,6 +962,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
903
962
  return;
904
963
  }
905
964
  await audioContext.suspend();
965
+ markPlayerInactive();
906
966
  setState({
907
967
  activeSourceCount: sourceNodes.size,
908
968
  isActive: false,
@@ -933,6 +993,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
933
993
  if (context.state === "suspended") {
934
994
  await context.resume();
935
995
  }
996
+ markPlayerActive();
936
997
  setState({
937
998
  activeSourceCount: sourceNodes.size,
938
999
  isActive: true,
@@ -940,6 +1001,8 @@ var createVoiceAudioPlayer = (source, options = {}) => {
940
1001
  });
941
1002
  await queueSync();
942
1003
  } catch (error) {
1004
+ errorCount += 1;
1005
+ markPlayerInactive();
943
1006
  setState({
944
1007
  error: error instanceof Error ? error.message : String(error),
945
1008
  isActive: false,
@@ -522,6 +522,17 @@ export type VoiceSessionHandle<TContext = unknown, TSession extends VoiceSession
522
522
  transferMode?: "cold" | "warm";
523
523
  }) => Promise<void>;
524
524
  close: (reason?: string) => Promise<void>;
525
+ /**
526
+ * Caller-driven in-call pause: the session stays LIVE (socket, state,
527
+ * transcript) but every idle/silence watchdog is suspended and turn
528
+ * processing is skipped, so a paused caller is never nudged or hung up on.
529
+ * Past `pause.maxMs` (default 10 min) the session closes gracefully with
530
+ * reason "pause-timeout" — completed turns stay durably persisted, so the
531
+ * host's normal draft/resume path takes over.
532
+ */
533
+ pause: () => Promise<void>;
534
+ /** Lift a caller-driven pause: watchdogs re-arm and turns process again. */
535
+ resume: () => Promise<void>;
525
536
  snapshot: () => Promise<TSession>;
526
537
  /**
527
538
  * Mutate the live turn-detection config for this session — useful when a
@@ -1016,6 +1027,12 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
1016
1027
  }) => string | Promise<string>);
1017
1028
  maxReprompts?: number;
1018
1029
  };
1030
+ /** Caller-driven in-call pause (VoiceSessionHandle.pause/resume). `maxMs`
1031
+ * bounds how long a session may sit paused before it closes gracefully
1032
+ * with reason "pause-timeout" (default 10 minutes). */
1033
+ pause?: {
1034
+ maxMs?: number;
1035
+ };
1019
1036
  stt?: STTAdapter;
1020
1037
  realtime?: RealtimeAdapter;
1021
1038
  realtimeInputFormat?: AudioFormat;
@@ -1161,7 +1178,7 @@ export type VoiceClientCloseMessage = {
1161
1178
  };
1162
1179
  export type VoiceClientCallControlMessage = {
1163
1180
  type: "call_control";
1164
- action: "complete" | "escalate" | "no-answer" | "transfer" | "voicemail";
1181
+ action: "complete" | "escalate" | "no-answer" | "pause" | "resume" | "transfer" | "voicemail";
1165
1182
  metadata?: Record<string, unknown>;
1166
1183
  reason?: string;
1167
1184
  target?: string;
@@ -1471,6 +1488,20 @@ export type VoiceAudioPlayerSource = {
1471
1488
  assistantAudio: VoiceStreamState["assistantAudio"];
1472
1489
  subscribe: (subscriber: () => void) => () => void;
1473
1490
  };
1491
+ /** Post-call playback integrity, for verifying the assistant audio actually
1492
+ * played cleanly (no overlap, no dropped/stalled packets) — emit it as a trace
1493
+ * so a garbled call is provable, not guessed. `ok` is the all-clear roll-up. */
1494
+ export type VoiceAudioIntegrity = {
1495
+ ok: boolean;
1496
+ chunksReceived: number;
1497
+ chunksScheduled: number;
1498
+ scheduledDurationMs: number;
1499
+ gapCount: number;
1500
+ totalGapMs: number;
1501
+ maxGapMs: number;
1502
+ maxConcurrentPlayers: number;
1503
+ errorCount: number;
1504
+ };
1474
1505
  export type VoiceAudioPlayer = {
1475
1506
  close: () => Promise<void>;
1476
1507
  error: string | null;
@@ -1478,6 +1509,8 @@ export type VoiceAudioPlayer = {
1478
1509
  * driving a visualizer from the actual voice. 0 when idle / no analyser. */
1479
1510
  getOutputLevel: () => number;
1480
1511
  getSnapshot: () => VoiceAudioPlayerState;
1512
+ /** Post-call playback-integrity roll-up (overlap, gaps, drops). */
1513
+ getIntegritySummary: () => VoiceAudioIntegrity;
1481
1514
  activeSourceCount: number;
1482
1515
  isActive: boolean;
1483
1516
  isPlaying: boolean;
package/dist/index.js CHANGED
@@ -4368,6 +4368,9 @@ var createVoiceSession = (options) => {
4368
4368
  if (callSilenceTimeoutMs === undefined || callSilenceFired) {
4369
4369
  return;
4370
4370
  }
4371
+ if (callerPaused) {
4372
+ return;
4373
+ }
4371
4374
  clearCallSilenceWatchdog();
4372
4375
  callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
4373
4376
  };
@@ -4413,6 +4416,9 @@ var createVoiceSession = (options) => {
4413
4416
  if (stuckCloseAfterMs === undefined || stuckCloseFired) {
4414
4417
  return;
4415
4418
  }
4419
+ if (callerPaused) {
4420
+ return;
4421
+ }
4416
4422
  clearStuckCloseWatchdog();
4417
4423
  stuckCloseWatchdog = setTimeout(fireStuckClose, stuckCloseAfterMs);
4418
4424
  };
@@ -4435,6 +4441,9 @@ var createVoiceSession = (options) => {
4435
4441
  idleRepromptsUsed = 0;
4436
4442
  }
4437
4443
  clearIdleRepromptWatchdog();
4444
+ if (callerPaused) {
4445
+ return;
4446
+ }
4438
4447
  if (idleRepromptsUsed >= idleRepromptMax) {
4439
4448
  return;
4440
4449
  }
@@ -4455,6 +4464,60 @@ var createVoiceSession = (options) => {
4455
4464
  });
4456
4465
  }, idleRepromptAfterMs);
4457
4466
  };
4467
+ const PAUSE_MAX_MS_DEFAULT = 600000;
4468
+ const pauseMaxMs = options.pause?.maxMs && options.pause.maxMs > 0 ? options.pause.maxMs : PAUSE_MAX_MS_DEFAULT;
4469
+ let callerPaused = false;
4470
+ let pauseTimeout = null;
4471
+ const clearPauseTimeout = () => {
4472
+ if (pauseTimeout) {
4473
+ clearTimeout(pauseTimeout);
4474
+ pauseTimeout = null;
4475
+ }
4476
+ };
4477
+ const pauseInternal = async () => {
4478
+ if (callerPaused) {
4479
+ return;
4480
+ }
4481
+ const snapshot = await readSession();
4482
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
4483
+ return;
4484
+ }
4485
+ callerPaused = true;
4486
+ clearCallSilenceWatchdog();
4487
+ clearStuckCloseWatchdog();
4488
+ clearIdleRepromptWatchdog();
4489
+ clearPauseTimeout();
4490
+ pauseTimeout = setTimeout(() => {
4491
+ pauseTimeout = null;
4492
+ api.close("pause-timeout");
4493
+ }, pauseMaxMs);
4494
+ await appendTrace({
4495
+ payload: {
4496
+ action: "call.paused",
4497
+ maxMs: pauseMaxMs
4498
+ },
4499
+ session: snapshot,
4500
+ type: "call.lifecycle"
4501
+ });
4502
+ };
4503
+ const resumeInternal = async () => {
4504
+ if (!callerPaused) {
4505
+ return;
4506
+ }
4507
+ callerPaused = false;
4508
+ clearPauseTimeout();
4509
+ kickCallSilenceWatchdog();
4510
+ kickStuckCloseWatchdog();
4511
+ kickIdleRepromptWatchdog(true);
4512
+ const snapshot = await readSession();
4513
+ await appendTrace({
4514
+ payload: {
4515
+ action: "call.resumed"
4516
+ },
4517
+ session: snapshot,
4518
+ type: "call.lifecycle"
4519
+ });
4520
+ };
4458
4521
  const recordingConfig = options.recording;
4459
4522
  const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
4460
4523
  const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
@@ -4962,6 +5025,7 @@ var createVoiceSession = (options) => {
4962
5025
  clearCallSilenceWatchdog();
4963
5026
  clearStuckCloseWatchdog();
4964
5027
  clearIdleRepromptWatchdog();
5028
+ clearPauseTimeout();
4965
5029
  clearAmdEvaluationTimer();
4966
5030
  await closeTTSSession("failed");
4967
5031
  await closeAdapter("failed");
@@ -5073,6 +5137,7 @@ var createVoiceSession = (options) => {
5073
5137
  clearCallSilenceWatchdog();
5074
5138
  clearStuckCloseWatchdog();
5075
5139
  clearIdleRepromptWatchdog();
5140
+ clearPauseTimeout();
5076
5141
  clearAmdEvaluationTimer();
5077
5142
  await closeTTSSession("complete");
5078
5143
  await closeAdapter("complete");
@@ -6033,6 +6098,19 @@ var createVoiceSession = (options) => {
6033
6098
  }
6034
6099
  kickStuckCloseWatchdog();
6035
6100
  kickIdleRepromptWatchdog(true);
6101
+ if (callerPaused) {
6102
+ await appendTrace({
6103
+ payload: {
6104
+ action: "turn.skipped",
6105
+ reason: "caller-paused",
6106
+ status: "skipped"
6107
+ },
6108
+ session,
6109
+ turnId: turn.id,
6110
+ type: "call.lifecycle"
6111
+ });
6112
+ return;
6113
+ }
6036
6114
  const liveOpsControl = await options.liveOps?.getControl(options.id);
6037
6115
  if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
6038
6116
  await appendTrace({
@@ -6883,6 +6961,7 @@ var createVoiceSession = (options) => {
6883
6961
  clearCallSilenceWatchdog();
6884
6962
  clearStuckCloseWatchdog();
6885
6963
  clearIdleRepromptWatchdog();
6964
+ clearPauseTimeout();
6886
6965
  clearAmdEvaluationTimer();
6887
6966
  if (options.noiseSuppressor?.close) {
6888
6967
  try {
@@ -6948,9 +7027,15 @@ var createVoiceSession = (options) => {
6948
7027
  markVoicemail: async (input) => runSerial("api.markVoicemail", async () => {
6949
7028
  await markVoicemailInternal(input);
6950
7029
  }),
7030
+ pause: async () => runSerial("api.pause", async () => {
7031
+ await pauseInternal();
7032
+ }),
6951
7033
  receiveAudio: async (audio) => runSerial("api.receiveAudio", async () => {
6952
7034
  await receiveAudioInternal(audio);
6953
7035
  }),
7036
+ resume: async () => runSerial("api.resume", async () => {
7037
+ await resumeInternal();
7038
+ }),
6954
7039
  snapshot: async () => runSerial("api.snapshot", async () => readSession()),
6955
7040
  transfer: async (input) => runSerial("api.transfer", async () => {
6956
7041
  await transferInternal(input);
@@ -39724,7 +39809,7 @@ var isVoiceClientMessage = (value) => {
39724
39809
  if (!("action" in value)) {
39725
39810
  return false;
39726
39811
  }
39727
- if (value.action !== "complete" && value.action !== "escalate" && value.action !== "no-answer" && value.action !== "transfer" && value.action !== "voicemail") {
39812
+ if (value.action !== "complete" && value.action !== "escalate" && value.action !== "no-answer" && value.action !== "pause" && value.action !== "resume" && value.action !== "transfer" && value.action !== "voicemail") {
39728
39813
  return false;
39729
39814
  }
39730
39815
  return (!("metadata" in value) || value.metadata === undefined || value.metadata !== null && typeof value.metadata === "object") && (!("reason" in value) || value.reason === undefined || typeof value.reason === "string") && (!("target" in value) || value.target === undefined || typeof value.target === "string");
@@ -40313,6 +40398,12 @@ var voice = (config) => {
40313
40398
  if (message.action === "complete") {
40314
40399
  await current.complete();
40315
40400
  }
40401
+ if (message.action === "pause") {
40402
+ await current.pause();
40403
+ }
40404
+ if (message.action === "resume") {
40405
+ await current.resume();
40406
+ }
40316
40407
  }
40317
40408
  if (message.type === "start" && message.sessionId && message.sessionId !== sessionState.sessionId) {
40318
40409
  const currentSession = runtime.activeSessions.get(sessionState.sessionId);
@@ -44373,7 +44464,9 @@ var createContractApi = (session) => ({
44373
44464
  fail: async () => {},
44374
44465
  markNoAnswer: async () => {},
44375
44466
  markVoicemail: async () => {},
44467
+ pause: async () => {},
44376
44468
  receiveAudio: async () => {},
44469
+ resume: async () => {},
44377
44470
  setTurnDetection: async () => ({
44378
44471
  silenceMs: 0,
44379
44472
  speechThreshold: 0,
@@ -1731,6 +1731,7 @@ var createTimeStretcher = () => {
1731
1731
  };
1732
1732
 
1733
1733
  // src/client/audioPlayer.ts
1734
+ var activeAudioPlayers = new Set;
1734
1735
  var DEFAULT_LOOKAHEAD_MS = 15;
1735
1736
  var DEFAULT_VOLUME = 1;
1736
1737
  var DEFAULT_PLAYBACK_RATE = 1;
@@ -1739,6 +1740,7 @@ var MAX_PLAYBACK_RATE = 2;
1739
1740
  var STRETCH_BYPASS_EPSILON = 0.01;
1740
1741
  var ANALYSER_FFT_SIZE = 256;
1741
1742
  var PCM_BYTE_MIDPOINT = 128;
1743
+ var GAP_EPSILON_S = 0.03;
1742
1744
  var createInitialState = () => ({
1743
1745
  activeSourceCount: 0,
1744
1746
  error: null,
@@ -1810,6 +1812,36 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1810
1812
  let interruptPromise = null;
1811
1813
  let resolveInterruptPromise = null;
1812
1814
  let interruptFallbackTimer = null;
1815
+ let concurrencyCounted = false;
1816
+ let observedPeakConcurrency = 0;
1817
+ let scheduledDurationMs = 0;
1818
+ let gapCount = 0;
1819
+ let totalGapMs = 0;
1820
+ let maxGapMs = 0;
1821
+ let errorCount = 0;
1822
+ let hasScheduledAnyChunk = false;
1823
+ const sampleConcurrency = () => {
1824
+ if (activeAudioPlayers.size > observedPeakConcurrency) {
1825
+ observedPeakConcurrency = activeAudioPlayers.size;
1826
+ }
1827
+ };
1828
+ const markPlayerActive = () => {
1829
+ if (concurrencyCounted) {
1830
+ return;
1831
+ }
1832
+ concurrencyCounted = true;
1833
+ activeAudioPlayers.add(sampleConcurrency);
1834
+ for (const sample of activeAudioPlayers) {
1835
+ sample();
1836
+ }
1837
+ };
1838
+ const markPlayerInactive = () => {
1839
+ if (!concurrencyCounted) {
1840
+ return;
1841
+ }
1842
+ concurrencyCounted = false;
1843
+ activeAudioPlayers.delete(sampleConcurrency);
1844
+ };
1813
1845
  const notify = () => {
1814
1846
  for (const subscriber of subscribers) {
1815
1847
  subscriber();
@@ -1923,7 +1955,17 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1923
1955
  });
1924
1956
  maybeResolveInterrupt();
1925
1957
  };
1926
- const startAt = Math.max(context.currentTime + lookaheadSeconds, queueEndTime);
1958
+ const earliestStart = context.currentTime + lookaheadSeconds;
1959
+ const startAt = Math.max(earliestStart, queueEndTime);
1960
+ if (hasScheduledAnyChunk && earliestStart > queueEndTime + GAP_EPSILON_S) {
1961
+ const gapMs = (earliestStart - queueEndTime) * 1000;
1962
+ gapCount += 1;
1963
+ totalGapMs += gapMs;
1964
+ maxGapMs = Math.max(maxGapMs, gapMs);
1965
+ }
1966
+ hasScheduledAnyChunk = true;
1967
+ scheduledDurationMs += buffer.duration / rate * 1000;
1968
+ sampleConcurrency();
1927
1969
  queueEndTime = startAt + buffer.duration / rate;
1928
1970
  sourceNodes.add(node);
1929
1971
  setState({
@@ -1991,6 +2033,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
1991
2033
  queuedChunkCount: state.queuedChunkCount + nextChunks.length
1992
2034
  });
1993
2035
  } catch (error) {
2036
+ errorCount += 1;
1994
2037
  setState({
1995
2038
  error: error instanceof Error ? error.message : String(error)
1996
2039
  });
@@ -2014,6 +2057,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2014
2057
  return state.activeSourceCount;
2015
2058
  },
2016
2059
  close: async () => {
2060
+ markPlayerInactive();
2017
2061
  unsubscribeSource();
2018
2062
  stopQueuedPlayback({ forceClear: true });
2019
2063
  clearInterruptTimer();
@@ -2053,6 +2097,21 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2053
2097
  return Math.sqrt(sumSquares / analyserBuffer.length);
2054
2098
  },
2055
2099
  getSnapshot: () => state,
2100
+ getIntegritySummary: () => {
2101
+ const chunksReceived = source.assistantAudio.length;
2102
+ const chunksScheduled = state.processedChunkCount;
2103
+ return {
2104
+ chunksReceived,
2105
+ chunksScheduled,
2106
+ errorCount,
2107
+ gapCount,
2108
+ maxConcurrentPlayers: observedPeakConcurrency,
2109
+ maxGapMs: Math.round(maxGapMs),
2110
+ ok: observedPeakConcurrency <= 1 && errorCount === 0 && chunksScheduled >= chunksReceived,
2111
+ scheduledDurationMs: Math.round(scheduledDurationMs),
2112
+ totalGapMs: Math.round(totalGapMs)
2113
+ };
2114
+ },
2056
2115
  interrupt: async () => {
2057
2116
  const startedAt = Date.now();
2058
2117
  const context = await ensureAudioContext();
@@ -2106,6 +2165,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2106
2165
  return;
2107
2166
  }
2108
2167
  await audioContext.suspend();
2168
+ markPlayerInactive();
2109
2169
  setState({
2110
2170
  activeSourceCount: sourceNodes.size,
2111
2171
  isActive: false,
@@ -2136,6 +2196,7 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2136
2196
  if (context.state === "suspended") {
2137
2197
  await context.resume();
2138
2198
  }
2199
+ markPlayerActive();
2139
2200
  setState({
2140
2201
  activeSourceCount: sourceNodes.size,
2141
2202
  isActive: true,
@@ -2143,6 +2204,8 @@ var createVoiceAudioPlayer = (source, options = {}) => {
2143
2204
  });
2144
2205
  await queueSync();
2145
2206
  } catch (error) {
2207
+ errorCount += 1;
2208
+ markPlayerInactive();
2146
2209
  setState({
2147
2210
  error: error instanceof Error ? error.message : String(error),
2148
2211
  isActive: false,
@@ -6688,6 +6751,9 @@ var createVoiceSession = (options) => {
6688
6751
  if (callSilenceTimeoutMs === undefined || callSilenceFired) {
6689
6752
  return;
6690
6753
  }
6754
+ if (callerPaused) {
6755
+ return;
6756
+ }
6691
6757
  clearCallSilenceWatchdog();
6692
6758
  callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
6693
6759
  };
@@ -6733,6 +6799,9 @@ var createVoiceSession = (options) => {
6733
6799
  if (stuckCloseAfterMs === undefined || stuckCloseFired) {
6734
6800
  return;
6735
6801
  }
6802
+ if (callerPaused) {
6803
+ return;
6804
+ }
6736
6805
  clearStuckCloseWatchdog();
6737
6806
  stuckCloseWatchdog = setTimeout(fireStuckClose, stuckCloseAfterMs);
6738
6807
  };
@@ -6755,6 +6824,9 @@ var createVoiceSession = (options) => {
6755
6824
  idleRepromptsUsed = 0;
6756
6825
  }
6757
6826
  clearIdleRepromptWatchdog();
6827
+ if (callerPaused) {
6828
+ return;
6829
+ }
6758
6830
  if (idleRepromptsUsed >= idleRepromptMax) {
6759
6831
  return;
6760
6832
  }
@@ -6775,6 +6847,60 @@ var createVoiceSession = (options) => {
6775
6847
  });
6776
6848
  }, idleRepromptAfterMs);
6777
6849
  };
6850
+ const PAUSE_MAX_MS_DEFAULT = 600000;
6851
+ const pauseMaxMs = options.pause?.maxMs && options.pause.maxMs > 0 ? options.pause.maxMs : PAUSE_MAX_MS_DEFAULT;
6852
+ let callerPaused = false;
6853
+ let pauseTimeout = null;
6854
+ const clearPauseTimeout = () => {
6855
+ if (pauseTimeout) {
6856
+ clearTimeout(pauseTimeout);
6857
+ pauseTimeout = null;
6858
+ }
6859
+ };
6860
+ const pauseInternal = async () => {
6861
+ if (callerPaused) {
6862
+ return;
6863
+ }
6864
+ const snapshot = await readSession();
6865
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
6866
+ return;
6867
+ }
6868
+ callerPaused = true;
6869
+ clearCallSilenceWatchdog();
6870
+ clearStuckCloseWatchdog();
6871
+ clearIdleRepromptWatchdog();
6872
+ clearPauseTimeout();
6873
+ pauseTimeout = setTimeout(() => {
6874
+ pauseTimeout = null;
6875
+ api.close("pause-timeout");
6876
+ }, pauseMaxMs);
6877
+ await appendTrace({
6878
+ payload: {
6879
+ action: "call.paused",
6880
+ maxMs: pauseMaxMs
6881
+ },
6882
+ session: snapshot,
6883
+ type: "call.lifecycle"
6884
+ });
6885
+ };
6886
+ const resumeInternal = async () => {
6887
+ if (!callerPaused) {
6888
+ return;
6889
+ }
6890
+ callerPaused = false;
6891
+ clearPauseTimeout();
6892
+ kickCallSilenceWatchdog();
6893
+ kickStuckCloseWatchdog();
6894
+ kickIdleRepromptWatchdog(true);
6895
+ const snapshot = await readSession();
6896
+ await appendTrace({
6897
+ payload: {
6898
+ action: "call.resumed"
6899
+ },
6900
+ session: snapshot,
6901
+ type: "call.lifecycle"
6902
+ });
6903
+ };
6778
6904
  const recordingConfig = options.recording;
6779
6905
  const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
6780
6906
  const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
@@ -7282,6 +7408,7 @@ var createVoiceSession = (options) => {
7282
7408
  clearCallSilenceWatchdog();
7283
7409
  clearStuckCloseWatchdog();
7284
7410
  clearIdleRepromptWatchdog();
7411
+ clearPauseTimeout();
7285
7412
  clearAmdEvaluationTimer();
7286
7413
  await closeTTSSession("failed");
7287
7414
  await closeAdapter("failed");
@@ -7393,6 +7520,7 @@ var createVoiceSession = (options) => {
7393
7520
  clearCallSilenceWatchdog();
7394
7521
  clearStuckCloseWatchdog();
7395
7522
  clearIdleRepromptWatchdog();
7523
+ clearPauseTimeout();
7396
7524
  clearAmdEvaluationTimer();
7397
7525
  await closeTTSSession("complete");
7398
7526
  await closeAdapter("complete");
@@ -8353,6 +8481,19 @@ var createVoiceSession = (options) => {
8353
8481
  }
8354
8482
  kickStuckCloseWatchdog();
8355
8483
  kickIdleRepromptWatchdog(true);
8484
+ if (callerPaused) {
8485
+ await appendTrace({
8486
+ payload: {
8487
+ action: "turn.skipped",
8488
+ reason: "caller-paused",
8489
+ status: "skipped"
8490
+ },
8491
+ session,
8492
+ turnId: turn.id,
8493
+ type: "call.lifecycle"
8494
+ });
8495
+ return;
8496
+ }
8356
8497
  const liveOpsControl = await options.liveOps?.getControl(options.id);
8357
8498
  if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
8358
8499
  await appendTrace({
@@ -9203,6 +9344,7 @@ var createVoiceSession = (options) => {
9203
9344
  clearCallSilenceWatchdog();
9204
9345
  clearStuckCloseWatchdog();
9205
9346
  clearIdleRepromptWatchdog();
9347
+ clearPauseTimeout();
9206
9348
  clearAmdEvaluationTimer();
9207
9349
  if (options.noiseSuppressor?.close) {
9208
9350
  try {
@@ -9268,9 +9410,15 @@ var createVoiceSession = (options) => {
9268
9410
  markVoicemail: async (input) => runSerial("api.markVoicemail", async () => {
9269
9411
  await markVoicemailInternal(input);
9270
9412
  }),
9413
+ pause: async () => runSerial("api.pause", async () => {
9414
+ await pauseInternal();
9415
+ }),
9271
9416
  receiveAudio: async (audio) => runSerial("api.receiveAudio", async () => {
9272
9417
  await receiveAudioInternal(audio);
9273
9418
  }),
9419
+ resume: async () => runSerial("api.resume", async () => {
9420
+ await resumeInternal();
9421
+ }),
9274
9422
  snapshot: async () => runSerial("api.snapshot", async () => readSession()),
9275
9423
  transfer: async (input) => runSerial("api.transfer", async () => {
9276
9424
  await transferInternal(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.630",
3
+ "version": "0.0.22-beta.632",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",