@absolutejs/voice 0.0.22-beta.656 → 0.0.22-beta.658

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.
@@ -517,48 +517,89 @@ var createMicrophoneCapture = (options) => {
517
517
  let sourceNode = null;
518
518
  let processorNode = null;
519
519
  let mediaStream = null;
520
- const start = async () => {
521
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
522
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
523
- }
524
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
525
- if (!AudioContextCtor) {
526
- throw new Error("Browser microphone capture requires AudioContext support.");
527
- }
528
- try {
529
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
530
- audio: {
531
- autoGainControl: true,
532
- channelCount: options.channelCount ?? 1,
533
- echoCancellation: true,
534
- noiseSuppression: true
520
+ let lifecycleGeneration = 0;
521
+ let startPromise = null;
522
+ const release = (resources) => {
523
+ resources.processorNode?.disconnect();
524
+ resources.sourceNode?.disconnect();
525
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
526
+ resources.audioContext?.close();
527
+ };
528
+ const start = () => {
529
+ if (mediaStream)
530
+ return Promise.resolve();
531
+ if (startPromise)
532
+ return startPromise;
533
+ const generation = ++lifecycleGeneration;
534
+ const operation = async () => {
535
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
536
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
537
+ }
538
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
539
+ if (!AudioContextCtor) {
540
+ throw new Error("Browser microphone capture requires AudioContext support.");
541
+ }
542
+ let pendingAudioContext = null;
543
+ let pendingMediaStream = null;
544
+ let pendingProcessorNode = null;
545
+ let pendingSourceNode = null;
546
+ try {
547
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
548
+ audio: {
549
+ autoGainControl: true,
550
+ channelCount: options.channelCount ?? 1,
551
+ echoCancellation: true,
552
+ noiseSuppression: true
553
+ }
554
+ });
555
+ if (generation !== lifecycleGeneration) {
556
+ throw new Error("Microphone capture stopped during startup.");
535
557
  }
536
- });
537
- audioContext = new AudioContextCtor;
538
- if (audioContext.state === "suspended") {
539
- await audioContext.resume();
558
+ pendingAudioContext = new AudioContextCtor;
559
+ if (pendingAudioContext.state === "suspended") {
560
+ await pendingAudioContext.resume();
561
+ }
562
+ if (generation !== lifecycleGeneration) {
563
+ throw new Error("Microphone capture stopped during startup.");
564
+ }
565
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
566
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
567
+ pendingProcessorNode.onaudioprocess = (event) => {
568
+ const channel = event.inputBuffer.getChannelData(0);
569
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
570
+ const pcm = floatTo16BitPCM(downsampled);
571
+ options.onLevel?.(getPcmLevel(pcm));
572
+ options.onAudio(pcm);
573
+ };
574
+ pendingSourceNode.connect(pendingProcessorNode);
575
+ pendingProcessorNode.connect(pendingAudioContext.destination);
576
+ if (generation !== lifecycleGeneration) {
577
+ throw new Error("Microphone capture stopped during startup.");
578
+ }
579
+ audioContext = pendingAudioContext;
580
+ mediaStream = pendingMediaStream;
581
+ processorNode = pendingProcessorNode;
582
+ sourceNode = pendingSourceNode;
583
+ } catch (error) {
584
+ release({
585
+ audioContext: pendingAudioContext,
586
+ mediaStream: pendingMediaStream,
587
+ processorNode: pendingProcessorNode,
588
+ sourceNode: pendingSourceNode
589
+ });
590
+ throw error;
540
591
  }
541
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
542
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
543
- processorNode.onaudioprocess = (event) => {
544
- const channel = event.inputBuffer.getChannelData(0);
545
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
546
- const pcm = floatTo16BitPCM(downsampled);
547
- options.onLevel?.(getPcmLevel(pcm));
548
- options.onAudio(pcm);
549
- };
550
- sourceNode.connect(processorNode);
551
- processorNode.connect(audioContext.destination);
552
- } catch (error) {
553
- stop();
554
- throw error;
555
- }
592
+ };
593
+ const pending = operation().finally(() => {
594
+ if (startPromise === pending)
595
+ startPromise = null;
596
+ });
597
+ startPromise = pending;
598
+ return pending;
556
599
  };
557
600
  const stop = () => {
558
- processorNode?.disconnect();
559
- sourceNode?.disconnect();
560
- mediaStream?.getTracks().forEach((track) => track.stop());
561
- audioContext?.close();
601
+ lifecycleGeneration += 1;
602
+ release({ audioContext, mediaStream, processorNode, sourceNode });
562
603
  options.onLevel?.(0);
563
604
  audioContext = null;
564
605
  mediaStream = null;
@@ -770,6 +811,7 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
770
811
  var DEFAULT_PING_INTERVAL = 30000;
771
812
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
772
813
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
814
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
773
815
  var MAX_PENDING_CONTROL_MESSAGES = 100;
774
816
  var RECONNECT_BASE_DELAY_MS = 500;
775
817
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
@@ -863,6 +905,7 @@ var createVoiceConnection = (path, options = {}) => {
863
905
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
864
906
  let lifecycleGeneration = 0;
865
907
  let disposed = false;
908
+ let reconnectPreparationAbort = null;
866
909
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
867
910
  const state = {
868
911
  isConnected: false,
@@ -950,12 +993,30 @@ var createVoiceConnection = (path, options = {}) => {
950
993
  return;
951
994
  }
952
995
  try {
953
- await options.prepareReconnect?.({
954
- attempt: state.reconnectAttempts,
955
- path,
956
- scenarioId: state.scenarioId,
957
- sessionId: state.sessionId
958
- });
996
+ if (options.prepareReconnect) {
997
+ const preparationAbort = new AbortController;
998
+ reconnectPreparationAbort = preparationAbort;
999
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
1000
+ try {
1001
+ await Promise.race([
1002
+ options.prepareReconnect({
1003
+ attempt: state.reconnectAttempts,
1004
+ path,
1005
+ scenarioId: state.scenarioId,
1006
+ sessionId: state.sessionId,
1007
+ signal: preparationAbort.signal
1008
+ }),
1009
+ new Promise((_, reject) => {
1010
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
1011
+ })
1012
+ ]);
1013
+ } finally {
1014
+ clearTimeout(timeout);
1015
+ if (reconnectPreparationAbort === preparationAbort) {
1016
+ reconnectPreparationAbort = null;
1017
+ }
1018
+ }
1019
+ }
959
1020
  } catch (error) {
960
1021
  if (disposed || lifecycleGeneration !== scheduledGeneration)
961
1022
  return;
@@ -1079,6 +1140,8 @@ var createVoiceConnection = (path, options = {}) => {
1079
1140
  state.ws = ws;
1080
1141
  };
1081
1142
  const sendSerialized = (value) => {
1143
+ if (disposed)
1144
+ return;
1082
1145
  if (state.ws?.readyState === WS_OPEN) {
1083
1146
  state.ws.send(value);
1084
1147
  return;
@@ -1133,6 +1196,8 @@ var createVoiceConnection = (path, options = {}) => {
1133
1196
  };
1134
1197
  const close = (reason = "client-close") => {
1135
1198
  disposed = true;
1199
+ reconnectPreparationAbort?.abort();
1200
+ reconnectPreparationAbort = null;
1136
1201
  lifecycleGeneration += 1;
1137
1202
  clearTimers();
1138
1203
  rejectPendingCallControls("Voice connection closed");
@@ -1150,6 +1215,8 @@ var createVoiceConnection = (path, options = {}) => {
1150
1215
  };
1151
1216
  const disconnect = () => {
1152
1217
  disposed = true;
1218
+ reconnectPreparationAbort?.abort();
1219
+ reconnectPreparationAbort = null;
1153
1220
  lifecycleGeneration += 1;
1154
1221
  clearTimers();
1155
1222
  rejectPendingCallControls("Voice connection disconnected");
@@ -1401,7 +1468,10 @@ var createVoiceStream = (path, options = {}) => {
1401
1468
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1402
1469
  }) : null;
1403
1470
  const subscribers = new Set;
1471
+ let disposed = false;
1404
1472
  const start = (input) => Promise.resolve().then(() => {
1473
+ if (disposed)
1474
+ return;
1405
1475
  if (!input?.sessionId && !input?.scenarioId) {
1406
1476
  return;
1407
1477
  }
@@ -1460,6 +1530,7 @@ var createVoiceStream = (path, options = {}) => {
1460
1530
  return connection.callControl(message);
1461
1531
  },
1462
1532
  close(reason) {
1533
+ disposed = true;
1463
1534
  unsubscribeConnection();
1464
1535
  browserMediaReporter?.close();
1465
1536
  connection.close(reason);
@@ -1467,6 +1538,7 @@ var createVoiceStream = (path, options = {}) => {
1467
1538
  notify();
1468
1539
  },
1469
1540
  disconnect() {
1541
+ disposed = true;
1470
1542
  unsubscribeConnection();
1471
1543
  browserMediaReporter?.close();
1472
1544
  connection.disconnect();
@@ -1870,6 +1942,9 @@ var createVoiceController = (path, options = {}) => {
1870
1942
  ...options.connection
1871
1943
  });
1872
1944
  let capture = null;
1945
+ let captureGeneration = 0;
1946
+ let disposed = false;
1947
+ let startRecordingPromise = null;
1873
1948
  let state = createInitialState2(stream);
1874
1949
  const subscribers = new Set;
1875
1950
  const notify = () => {
@@ -1926,6 +2001,7 @@ var createVoiceController = (path, options = {}) => {
1926
2001
  return capture;
1927
2002
  };
1928
2003
  const stopRecording = () => {
2004
+ captureGeneration += 1;
1929
2005
  capture?.stop();
1930
2006
  capture = null;
1931
2007
  state = {
@@ -1935,39 +2011,63 @@ var createVoiceController = (path, options = {}) => {
1935
2011
  notify();
1936
2012
  };
1937
2013
  const startRecording = async () => {
2014
+ if (disposed) {
2015
+ throw new Error("Voice controller is closed.");
2016
+ }
1938
2017
  if (state.isRecording) {
1939
2018
  return;
1940
2019
  }
1941
- try {
1942
- state = {
1943
- ...state,
1944
- recordingError: null
1945
- };
1946
- notify();
1947
- await ensureCapture().start();
1948
- state = {
1949
- ...state,
1950
- isRecording: true
1951
- };
1952
- notify();
1953
- } catch (error) {
1954
- capture?.stop();
1955
- capture = null;
1956
- state = {
1957
- ...state,
1958
- isRecording: false,
1959
- recordingError: error instanceof Error ? error.message : String(error)
1960
- };
1961
- notify();
1962
- throw error;
1963
- }
2020
+ if (startRecordingPromise)
2021
+ return startRecordingPromise;
2022
+ const generation = ++captureGeneration;
2023
+ const activeCapture = ensureCapture();
2024
+ const operation = (async () => {
2025
+ try {
2026
+ state = {
2027
+ ...state,
2028
+ recordingError: null
2029
+ };
2030
+ notify();
2031
+ await activeCapture.start();
2032
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
2033
+ activeCapture.stop();
2034
+ return;
2035
+ }
2036
+ state = {
2037
+ ...state,
2038
+ isRecording: true
2039
+ };
2040
+ notify();
2041
+ } catch (error) {
2042
+ activeCapture.stop();
2043
+ if (generation !== captureGeneration || capture !== activeCapture) {
2044
+ throw error;
2045
+ }
2046
+ capture = null;
2047
+ state = {
2048
+ ...state,
2049
+ isRecording: false,
2050
+ recordingError: error instanceof Error ? error.message : String(error)
2051
+ };
2052
+ notify();
2053
+ throw error;
2054
+ }
2055
+ })();
2056
+ const pending = operation.finally(() => {
2057
+ if (startRecordingPromise === pending)
2058
+ startRecordingPromise = null;
2059
+ });
2060
+ startRecordingPromise = pending;
2061
+ return startRecordingPromise;
1964
2062
  };
1965
2063
  const close = (reason) => {
2064
+ disposed = true;
1966
2065
  unsubscribeStream();
1967
2066
  stopRecording();
1968
2067
  stream.close(reason);
1969
2068
  };
1970
2069
  const disconnect = () => {
2070
+ disposed = true;
1971
2071
  unsubscribeStream();
1972
2072
  stopRecording();
1973
2073
  stream.disconnect();
@@ -2738,48 +2738,89 @@ var createMicrophoneCapture = (options) => {
2738
2738
  let sourceNode = null;
2739
2739
  let processorNode = null;
2740
2740
  let mediaStream = null;
2741
- const start = async () => {
2742
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
2743
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
2744
- }
2745
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
2746
- if (!AudioContextCtor) {
2747
- throw new Error("Browser microphone capture requires AudioContext support.");
2748
- }
2749
- try {
2750
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
2751
- audio: {
2752
- autoGainControl: true,
2753
- channelCount: options.channelCount ?? 1,
2754
- echoCancellation: true,
2755
- noiseSuppression: true
2741
+ let lifecycleGeneration = 0;
2742
+ let startPromise = null;
2743
+ const release = (resources) => {
2744
+ resources.processorNode?.disconnect();
2745
+ resources.sourceNode?.disconnect();
2746
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
2747
+ resources.audioContext?.close();
2748
+ };
2749
+ const start = () => {
2750
+ if (mediaStream)
2751
+ return Promise.resolve();
2752
+ if (startPromise)
2753
+ return startPromise;
2754
+ const generation = ++lifecycleGeneration;
2755
+ const operation = async () => {
2756
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
2757
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
2758
+ }
2759
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
2760
+ if (!AudioContextCtor) {
2761
+ throw new Error("Browser microphone capture requires AudioContext support.");
2762
+ }
2763
+ let pendingAudioContext = null;
2764
+ let pendingMediaStream = null;
2765
+ let pendingProcessorNode = null;
2766
+ let pendingSourceNode = null;
2767
+ try {
2768
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
2769
+ audio: {
2770
+ autoGainControl: true,
2771
+ channelCount: options.channelCount ?? 1,
2772
+ echoCancellation: true,
2773
+ noiseSuppression: true
2774
+ }
2775
+ });
2776
+ if (generation !== lifecycleGeneration) {
2777
+ throw new Error("Microphone capture stopped during startup.");
2756
2778
  }
2757
- });
2758
- audioContext = new AudioContextCtor;
2759
- if (audioContext.state === "suspended") {
2760
- await audioContext.resume();
2761
- }
2762
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
2763
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
2764
- processorNode.onaudioprocess = (event) => {
2765
- const channel = event.inputBuffer.getChannelData(0);
2766
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
2767
- const pcm = floatTo16BitPCM(downsampled);
2768
- options.onLevel?.(getPcmLevel(pcm));
2769
- options.onAudio(pcm);
2770
- };
2771
- sourceNode.connect(processorNode);
2772
- processorNode.connect(audioContext.destination);
2773
- } catch (error) {
2774
- stop();
2775
- throw error;
2776
- }
2779
+ pendingAudioContext = new AudioContextCtor;
2780
+ if (pendingAudioContext.state === "suspended") {
2781
+ await pendingAudioContext.resume();
2782
+ }
2783
+ if (generation !== lifecycleGeneration) {
2784
+ throw new Error("Microphone capture stopped during startup.");
2785
+ }
2786
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
2787
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
2788
+ pendingProcessorNode.onaudioprocess = (event) => {
2789
+ const channel = event.inputBuffer.getChannelData(0);
2790
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
2791
+ const pcm = floatTo16BitPCM(downsampled);
2792
+ options.onLevel?.(getPcmLevel(pcm));
2793
+ options.onAudio(pcm);
2794
+ };
2795
+ pendingSourceNode.connect(pendingProcessorNode);
2796
+ pendingProcessorNode.connect(pendingAudioContext.destination);
2797
+ if (generation !== lifecycleGeneration) {
2798
+ throw new Error("Microphone capture stopped during startup.");
2799
+ }
2800
+ audioContext = pendingAudioContext;
2801
+ mediaStream = pendingMediaStream;
2802
+ processorNode = pendingProcessorNode;
2803
+ sourceNode = pendingSourceNode;
2804
+ } catch (error) {
2805
+ release({
2806
+ audioContext: pendingAudioContext,
2807
+ mediaStream: pendingMediaStream,
2808
+ processorNode: pendingProcessorNode,
2809
+ sourceNode: pendingSourceNode
2810
+ });
2811
+ throw error;
2812
+ }
2813
+ };
2814
+ const pending = operation().finally(() => {
2815
+ if (startPromise === pending)
2816
+ startPromise = null;
2817
+ });
2818
+ startPromise = pending;
2819
+ return pending;
2777
2820
  };
2778
2821
  const stop = () => {
2779
- processorNode?.disconnect();
2780
- sourceNode?.disconnect();
2781
- mediaStream?.getTracks().forEach((track) => track.stop());
2782
- audioContext?.close();
2822
+ lifecycleGeneration += 1;
2823
+ release({ audioContext, mediaStream, processorNode, sourceNode });
2783
2824
  options.onLevel?.(0);
2784
2825
  audioContext = null;
2785
2826
  mediaStream = null;
@@ -2991,6 +3032,7 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
2991
3032
  var DEFAULT_PING_INTERVAL = 30000;
2992
3033
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
2993
3034
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
3035
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
2994
3036
  var MAX_PENDING_CONTROL_MESSAGES = 100;
2995
3037
  var RECONNECT_BASE_DELAY_MS = 500;
2996
3038
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
@@ -3084,6 +3126,7 @@ var createVoiceConnection = (path, options = {}) => {
3084
3126
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
3085
3127
  let lifecycleGeneration = 0;
3086
3128
  let disposed = false;
3129
+ let reconnectPreparationAbort = null;
3087
3130
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
3088
3131
  const state = {
3089
3132
  isConnected: false,
@@ -3171,12 +3214,30 @@ var createVoiceConnection = (path, options = {}) => {
3171
3214
  return;
3172
3215
  }
3173
3216
  try {
3174
- await options.prepareReconnect?.({
3175
- attempt: state.reconnectAttempts,
3176
- path,
3177
- scenarioId: state.scenarioId,
3178
- sessionId: state.sessionId
3179
- });
3217
+ if (options.prepareReconnect) {
3218
+ const preparationAbort = new AbortController;
3219
+ reconnectPreparationAbort = preparationAbort;
3220
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
3221
+ try {
3222
+ await Promise.race([
3223
+ options.prepareReconnect({
3224
+ attempt: state.reconnectAttempts,
3225
+ path,
3226
+ scenarioId: state.scenarioId,
3227
+ sessionId: state.sessionId,
3228
+ signal: preparationAbort.signal
3229
+ }),
3230
+ new Promise((_, reject) => {
3231
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
3232
+ })
3233
+ ]);
3234
+ } finally {
3235
+ clearTimeout(timeout);
3236
+ if (reconnectPreparationAbort === preparationAbort) {
3237
+ reconnectPreparationAbort = null;
3238
+ }
3239
+ }
3240
+ }
3180
3241
  } catch (error) {
3181
3242
  if (disposed || lifecycleGeneration !== scheduledGeneration)
3182
3243
  return;
@@ -3300,6 +3361,8 @@ var createVoiceConnection = (path, options = {}) => {
3300
3361
  state.ws = ws;
3301
3362
  };
3302
3363
  const sendSerialized = (value) => {
3364
+ if (disposed)
3365
+ return;
3303
3366
  if (state.ws?.readyState === WS_OPEN) {
3304
3367
  state.ws.send(value);
3305
3368
  return;
@@ -3354,6 +3417,8 @@ var createVoiceConnection = (path, options = {}) => {
3354
3417
  };
3355
3418
  const close = (reason = "client-close") => {
3356
3419
  disposed = true;
3420
+ reconnectPreparationAbort?.abort();
3421
+ reconnectPreparationAbort = null;
3357
3422
  lifecycleGeneration += 1;
3358
3423
  clearTimers();
3359
3424
  rejectPendingCallControls("Voice connection closed");
@@ -3371,6 +3436,8 @@ var createVoiceConnection = (path, options = {}) => {
3371
3436
  };
3372
3437
  const disconnect = () => {
3373
3438
  disposed = true;
3439
+ reconnectPreparationAbort?.abort();
3440
+ reconnectPreparationAbort = null;
3374
3441
  lifecycleGeneration += 1;
3375
3442
  clearTimers();
3376
3443
  rejectPendingCallControls("Voice connection disconnected");
@@ -3622,7 +3689,10 @@ var createVoiceStream = (path, options = {}) => {
3622
3689
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
3623
3690
  }) : null;
3624
3691
  const subscribers = new Set;
3692
+ let disposed = false;
3625
3693
  const start = (input) => Promise.resolve().then(() => {
3694
+ if (disposed)
3695
+ return;
3626
3696
  if (!input?.sessionId && !input?.scenarioId) {
3627
3697
  return;
3628
3698
  }
@@ -3681,6 +3751,7 @@ var createVoiceStream = (path, options = {}) => {
3681
3751
  return connection.callControl(message);
3682
3752
  },
3683
3753
  close(reason) {
3754
+ disposed = true;
3684
3755
  unsubscribeConnection();
3685
3756
  browserMediaReporter?.close();
3686
3757
  connection.close(reason);
@@ -3688,6 +3759,7 @@ var createVoiceStream = (path, options = {}) => {
3688
3759
  notify();
3689
3760
  },
3690
3761
  disconnect() {
3762
+ disposed = true;
3691
3763
  unsubscribeConnection();
3692
3764
  browserMediaReporter?.close();
3693
3765
  connection.disconnect();
@@ -4091,6 +4163,9 @@ var createVoiceController = (path, options = {}) => {
4091
4163
  ...options.connection
4092
4164
  });
4093
4165
  let capture = null;
4166
+ let captureGeneration = 0;
4167
+ let disposed = false;
4168
+ let startRecordingPromise = null;
4094
4169
  let state = createInitialState3(stream);
4095
4170
  const subscribers = new Set;
4096
4171
  const notify = () => {
@@ -4147,6 +4222,7 @@ var createVoiceController = (path, options = {}) => {
4147
4222
  return capture;
4148
4223
  };
4149
4224
  const stopRecording = () => {
4225
+ captureGeneration += 1;
4150
4226
  capture?.stop();
4151
4227
  capture = null;
4152
4228
  state = {
@@ -4156,39 +4232,63 @@ var createVoiceController = (path, options = {}) => {
4156
4232
  notify();
4157
4233
  };
4158
4234
  const startRecording = async () => {
4235
+ if (disposed) {
4236
+ throw new Error("Voice controller is closed.");
4237
+ }
4159
4238
  if (state.isRecording) {
4160
4239
  return;
4161
4240
  }
4162
- try {
4163
- state = {
4164
- ...state,
4165
- recordingError: null
4166
- };
4167
- notify();
4168
- await ensureCapture().start();
4169
- state = {
4170
- ...state,
4171
- isRecording: true
4172
- };
4173
- notify();
4174
- } catch (error) {
4175
- capture?.stop();
4176
- capture = null;
4177
- state = {
4178
- ...state,
4179
- isRecording: false,
4180
- recordingError: error instanceof Error ? error.message : String(error)
4181
- };
4182
- notify();
4183
- throw error;
4184
- }
4241
+ if (startRecordingPromise)
4242
+ return startRecordingPromise;
4243
+ const generation = ++captureGeneration;
4244
+ const activeCapture = ensureCapture();
4245
+ const operation = (async () => {
4246
+ try {
4247
+ state = {
4248
+ ...state,
4249
+ recordingError: null
4250
+ };
4251
+ notify();
4252
+ await activeCapture.start();
4253
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
4254
+ activeCapture.stop();
4255
+ return;
4256
+ }
4257
+ state = {
4258
+ ...state,
4259
+ isRecording: true
4260
+ };
4261
+ notify();
4262
+ } catch (error) {
4263
+ activeCapture.stop();
4264
+ if (generation !== captureGeneration || capture !== activeCapture) {
4265
+ throw error;
4266
+ }
4267
+ capture = null;
4268
+ state = {
4269
+ ...state,
4270
+ isRecording: false,
4271
+ recordingError: error instanceof Error ? error.message : String(error)
4272
+ };
4273
+ notify();
4274
+ throw error;
4275
+ }
4276
+ })();
4277
+ const pending = operation.finally(() => {
4278
+ if (startRecordingPromise === pending)
4279
+ startRecordingPromise = null;
4280
+ });
4281
+ startRecordingPromise = pending;
4282
+ return startRecordingPromise;
4185
4283
  };
4186
4284
  const close = (reason) => {
4285
+ disposed = true;
4187
4286
  unsubscribeStream();
4188
4287
  stopRecording();
4189
4288
  stream.close(reason);
4190
4289
  };
4191
4290
  const disconnect = () => {
4291
+ disposed = true;
4192
4292
  unsubscribeStream();
4193
4293
  stopRecording();
4194
4294
  stream.disconnect();