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

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.
@@ -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();
package/dist/vue/index.js CHANGED
@@ -10838,6 +10838,7 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
10838
10838
  var DEFAULT_PING_INTERVAL = 30000;
10839
10839
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
10840
10840
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
10841
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
10841
10842
  var MAX_PENDING_CONTROL_MESSAGES = 100;
10842
10843
  var RECONNECT_BASE_DELAY_MS = 500;
10843
10844
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
@@ -10931,6 +10932,7 @@ var createVoiceConnection = (path, options = {}) => {
10931
10932
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
10932
10933
  let lifecycleGeneration = 0;
10933
10934
  let disposed = false;
10935
+ let reconnectPreparationAbort = null;
10934
10936
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
10935
10937
  const state = {
10936
10938
  isConnected: false,
@@ -11018,12 +11020,30 @@ var createVoiceConnection = (path, options = {}) => {
11018
11020
  return;
11019
11021
  }
11020
11022
  try {
11021
- await options.prepareReconnect?.({
11022
- attempt: state.reconnectAttempts,
11023
- path,
11024
- scenarioId: state.scenarioId,
11025
- sessionId: state.sessionId
11026
- });
11023
+ if (options.prepareReconnect) {
11024
+ const preparationAbort = new AbortController;
11025
+ reconnectPreparationAbort = preparationAbort;
11026
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
11027
+ try {
11028
+ await Promise.race([
11029
+ options.prepareReconnect({
11030
+ attempt: state.reconnectAttempts,
11031
+ path,
11032
+ scenarioId: state.scenarioId,
11033
+ sessionId: state.sessionId,
11034
+ signal: preparationAbort.signal
11035
+ }),
11036
+ new Promise((_, reject) => {
11037
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
11038
+ })
11039
+ ]);
11040
+ } finally {
11041
+ clearTimeout(timeout);
11042
+ if (reconnectPreparationAbort === preparationAbort) {
11043
+ reconnectPreparationAbort = null;
11044
+ }
11045
+ }
11046
+ }
11027
11047
  } catch (error) {
11028
11048
  if (disposed || lifecycleGeneration !== scheduledGeneration)
11029
11049
  return;
@@ -11147,6 +11167,8 @@ var createVoiceConnection = (path, options = {}) => {
11147
11167
  state.ws = ws;
11148
11168
  };
11149
11169
  const sendSerialized = (value) => {
11170
+ if (disposed)
11171
+ return;
11150
11172
  if (state.ws?.readyState === WS_OPEN) {
11151
11173
  state.ws.send(value);
11152
11174
  return;
@@ -11201,6 +11223,8 @@ var createVoiceConnection = (path, options = {}) => {
11201
11223
  };
11202
11224
  const close = (reason = "client-close") => {
11203
11225
  disposed = true;
11226
+ reconnectPreparationAbort?.abort();
11227
+ reconnectPreparationAbort = null;
11204
11228
  lifecycleGeneration += 1;
11205
11229
  clearTimers();
11206
11230
  rejectPendingCallControls("Voice connection closed");
@@ -11218,6 +11242,8 @@ var createVoiceConnection = (path, options = {}) => {
11218
11242
  };
11219
11243
  const disconnect = () => {
11220
11244
  disposed = true;
11245
+ reconnectPreparationAbort?.abort();
11246
+ reconnectPreparationAbort = null;
11221
11247
  lifecycleGeneration += 1;
11222
11248
  clearTimers();
11223
11249
  rejectPendingCallControls("Voice connection disconnected");
@@ -11469,7 +11495,10 @@ var createVoiceStream = (path, options = {}) => {
11469
11495
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
11470
11496
  }) : null;
11471
11497
  const subscribers = new Set;
11498
+ let disposed = false;
11472
11499
  const start = (input) => Promise.resolve().then(() => {
11500
+ if (disposed)
11501
+ return;
11473
11502
  if (!input?.sessionId && !input?.scenarioId) {
11474
11503
  return;
11475
11504
  }
@@ -11528,6 +11557,7 @@ var createVoiceStream = (path, options = {}) => {
11528
11557
  return connection.callControl(message);
11529
11558
  },
11530
11559
  close(reason) {
11560
+ disposed = true;
11531
11561
  unsubscribeConnection();
11532
11562
  browserMediaReporter?.close();
11533
11563
  connection.close(reason);
@@ -11535,6 +11565,7 @@ var createVoiceStream = (path, options = {}) => {
11535
11565
  notify();
11536
11566
  },
11537
11567
  disconnect() {
11568
+ disposed = true;
11538
11569
  unsubscribeConnection();
11539
11570
  browserMediaReporter?.close();
11540
11571
  connection.disconnect();
@@ -11753,48 +11784,89 @@ var createMicrophoneCapture = (options) => {
11753
11784
  let sourceNode = null;
11754
11785
  let processorNode = null;
11755
11786
  let mediaStream = null;
11756
- const start = async () => {
11757
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
11758
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
11759
- }
11760
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
11761
- if (!AudioContextCtor) {
11762
- throw new Error("Browser microphone capture requires AudioContext support.");
11763
- }
11764
- try {
11765
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
11766
- audio: {
11767
- autoGainControl: true,
11768
- channelCount: options.channelCount ?? 1,
11769
- echoCancellation: true,
11770
- noiseSuppression: true
11787
+ let lifecycleGeneration = 0;
11788
+ let startPromise = null;
11789
+ const release = (resources) => {
11790
+ resources.processorNode?.disconnect();
11791
+ resources.sourceNode?.disconnect();
11792
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
11793
+ resources.audioContext?.close();
11794
+ };
11795
+ const start = () => {
11796
+ if (mediaStream)
11797
+ return Promise.resolve();
11798
+ if (startPromise)
11799
+ return startPromise;
11800
+ const generation = ++lifecycleGeneration;
11801
+ const operation = async () => {
11802
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
11803
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
11804
+ }
11805
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
11806
+ if (!AudioContextCtor) {
11807
+ throw new Error("Browser microphone capture requires AudioContext support.");
11808
+ }
11809
+ let pendingAudioContext = null;
11810
+ let pendingMediaStream = null;
11811
+ let pendingProcessorNode = null;
11812
+ let pendingSourceNode = null;
11813
+ try {
11814
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
11815
+ audio: {
11816
+ autoGainControl: true,
11817
+ channelCount: options.channelCount ?? 1,
11818
+ echoCancellation: true,
11819
+ noiseSuppression: true
11820
+ }
11821
+ });
11822
+ if (generation !== lifecycleGeneration) {
11823
+ throw new Error("Microphone capture stopped during startup.");
11771
11824
  }
11772
- });
11773
- audioContext = new AudioContextCtor;
11774
- if (audioContext.state === "suspended") {
11775
- await audioContext.resume();
11825
+ pendingAudioContext = new AudioContextCtor;
11826
+ if (pendingAudioContext.state === "suspended") {
11827
+ await pendingAudioContext.resume();
11828
+ }
11829
+ if (generation !== lifecycleGeneration) {
11830
+ throw new Error("Microphone capture stopped during startup.");
11831
+ }
11832
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
11833
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
11834
+ pendingProcessorNode.onaudioprocess = (event) => {
11835
+ const channel = event.inputBuffer.getChannelData(0);
11836
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
11837
+ const pcm = floatTo16BitPCM(downsampled);
11838
+ options.onLevel?.(getPcmLevel(pcm));
11839
+ options.onAudio(pcm);
11840
+ };
11841
+ pendingSourceNode.connect(pendingProcessorNode);
11842
+ pendingProcessorNode.connect(pendingAudioContext.destination);
11843
+ if (generation !== lifecycleGeneration) {
11844
+ throw new Error("Microphone capture stopped during startup.");
11845
+ }
11846
+ audioContext = pendingAudioContext;
11847
+ mediaStream = pendingMediaStream;
11848
+ processorNode = pendingProcessorNode;
11849
+ sourceNode = pendingSourceNode;
11850
+ } catch (error) {
11851
+ release({
11852
+ audioContext: pendingAudioContext,
11853
+ mediaStream: pendingMediaStream,
11854
+ processorNode: pendingProcessorNode,
11855
+ sourceNode: pendingSourceNode
11856
+ });
11857
+ throw error;
11776
11858
  }
11777
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
11778
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
11779
- processorNode.onaudioprocess = (event) => {
11780
- const channel = event.inputBuffer.getChannelData(0);
11781
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
11782
- const pcm = floatTo16BitPCM(downsampled);
11783
- options.onLevel?.(getPcmLevel(pcm));
11784
- options.onAudio(pcm);
11785
- };
11786
- sourceNode.connect(processorNode);
11787
- processorNode.connect(audioContext.destination);
11788
- } catch (error) {
11789
- stop();
11790
- throw error;
11791
- }
11859
+ };
11860
+ const pending = operation().finally(() => {
11861
+ if (startPromise === pending)
11862
+ startPromise = null;
11863
+ });
11864
+ startPromise = pending;
11865
+ return pending;
11792
11866
  };
11793
11867
  const stop = () => {
11794
- processorNode?.disconnect();
11795
- sourceNode?.disconnect();
11796
- mediaStream?.getTracks().forEach((track) => track.stop());
11797
- audioContext?.close();
11868
+ lifecycleGeneration += 1;
11869
+ release({ audioContext, mediaStream, processorNode, sourceNode });
11798
11870
  options.onLevel?.(0);
11799
11871
  audioContext = null;
11800
11872
  mediaStream = null;
@@ -12150,6 +12222,9 @@ var createVoiceController = (path, options = {}) => {
12150
12222
  ...options.connection
12151
12223
  });
12152
12224
  let capture = null;
12225
+ let captureGeneration = 0;
12226
+ let disposed = false;
12227
+ let startRecordingPromise = null;
12153
12228
  let state = createInitialState2(stream);
12154
12229
  const subscribers = new Set;
12155
12230
  const notify = () => {
@@ -12206,6 +12281,7 @@ var createVoiceController = (path, options = {}) => {
12206
12281
  return capture;
12207
12282
  };
12208
12283
  const stopRecording = () => {
12284
+ captureGeneration += 1;
12209
12285
  capture?.stop();
12210
12286
  capture = null;
12211
12287
  state = {
@@ -12215,39 +12291,63 @@ var createVoiceController = (path, options = {}) => {
12215
12291
  notify();
12216
12292
  };
12217
12293
  const startRecording = async () => {
12294
+ if (disposed) {
12295
+ throw new Error("Voice controller is closed.");
12296
+ }
12218
12297
  if (state.isRecording) {
12219
12298
  return;
12220
12299
  }
12221
- try {
12222
- state = {
12223
- ...state,
12224
- recordingError: null
12225
- };
12226
- notify();
12227
- await ensureCapture().start();
12228
- state = {
12229
- ...state,
12230
- isRecording: true
12231
- };
12232
- notify();
12233
- } catch (error) {
12234
- capture?.stop();
12235
- capture = null;
12236
- state = {
12237
- ...state,
12238
- isRecording: false,
12239
- recordingError: error instanceof Error ? error.message : String(error)
12240
- };
12241
- notify();
12242
- throw error;
12243
- }
12300
+ if (startRecordingPromise)
12301
+ return startRecordingPromise;
12302
+ const generation = ++captureGeneration;
12303
+ const activeCapture = ensureCapture();
12304
+ const operation = (async () => {
12305
+ try {
12306
+ state = {
12307
+ ...state,
12308
+ recordingError: null
12309
+ };
12310
+ notify();
12311
+ await activeCapture.start();
12312
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
12313
+ activeCapture.stop();
12314
+ return;
12315
+ }
12316
+ state = {
12317
+ ...state,
12318
+ isRecording: true
12319
+ };
12320
+ notify();
12321
+ } catch (error) {
12322
+ activeCapture.stop();
12323
+ if (generation !== captureGeneration || capture !== activeCapture) {
12324
+ throw error;
12325
+ }
12326
+ capture = null;
12327
+ state = {
12328
+ ...state,
12329
+ isRecording: false,
12330
+ recordingError: error instanceof Error ? error.message : String(error)
12331
+ };
12332
+ notify();
12333
+ throw error;
12334
+ }
12335
+ })();
12336
+ const pending = operation.finally(() => {
12337
+ if (startRecordingPromise === pending)
12338
+ startRecordingPromise = null;
12339
+ });
12340
+ startRecordingPromise = pending;
12341
+ return startRecordingPromise;
12244
12342
  };
12245
12343
  const close = (reason) => {
12344
+ disposed = true;
12246
12345
  unsubscribeStream();
12247
12346
  stopRecording();
12248
12347
  stream.close(reason);
12249
12348
  };
12250
12349
  const disconnect = () => {
12350
+ disposed = true;
12251
12351
  unsubscribeStream();
12252
12352
  stopRecording();
12253
12353
  stream.disconnect();