@absolutejs/voice 0.0.22-beta.655 → 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.
@@ -11442,11 +11442,19 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
11442
11442
  var DEFAULT_PING_INTERVAL = 30000;
11443
11443
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
11444
11444
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
11445
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
11445
11446
  var MAX_PENDING_CONTROL_MESSAGES = 100;
11446
11447
  var RECONNECT_BASE_DELAY_MS = 500;
11447
11448
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
11448
11449
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
11449
11450
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
11451
+
11452
+ class VoiceReconnectRejectedError extends Error {
11453
+ constructor(message = "Voice authorization could not be renewed.") {
11454
+ super(message);
11455
+ this.name = "VoiceReconnectRejectedError";
11456
+ }
11457
+ }
11450
11458
  var noop = () => {};
11451
11459
  var noopAsync = () => Promise.resolve();
11452
11460
  var noopUnsubscribe = () => noop;
@@ -11526,6 +11534,9 @@ var createVoiceConnection = (path, options = {}) => {
11526
11534
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
11527
11535
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
11528
11536
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
11537
+ let lifecycleGeneration = 0;
11538
+ let disposed = false;
11539
+ let reconnectPreparationAbort = null;
11529
11540
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
11530
11541
  const state = {
11531
11542
  isConnected: false,
@@ -11589,6 +11600,9 @@ var createVoiceConnection = (path, options = {}) => {
11589
11600
  pendingCallControls.clear();
11590
11601
  };
11591
11602
  const scheduleReconnect = () => {
11603
+ if (disposed)
11604
+ return;
11605
+ const scheduledGeneration = lifecycleGeneration;
11592
11606
  state.reconnectAttempts += 1;
11593
11607
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
11594
11608
  const nextAttemptAt = Date.now() + delayMs;
@@ -11603,18 +11617,44 @@ var createVoiceConnection = (path, options = {}) => {
11603
11617
  type: "connection"
11604
11618
  });
11605
11619
  state.reconnectTimeout = setTimeout(async () => {
11620
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
11621
+ return;
11606
11622
  if (state.reconnectAttempts > maxReconnectAttempts) {
11607
11623
  emitTerminalFailure("Voice connection could not be restored.");
11608
11624
  return;
11609
11625
  }
11610
11626
  try {
11611
- await options.prepareReconnect?.({
11612
- attempt: state.reconnectAttempts,
11613
- path,
11614
- scenarioId: state.scenarioId,
11615
- sessionId: state.sessionId
11616
- });
11617
- } catch {
11627
+ if (options.prepareReconnect) {
11628
+ const preparationAbort = new AbortController;
11629
+ reconnectPreparationAbort = preparationAbort;
11630
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
11631
+ try {
11632
+ await Promise.race([
11633
+ options.prepareReconnect({
11634
+ attempt: state.reconnectAttempts,
11635
+ path,
11636
+ scenarioId: state.scenarioId,
11637
+ sessionId: state.sessionId,
11638
+ signal: preparationAbort.signal
11639
+ }),
11640
+ new Promise((_, reject) => {
11641
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
11642
+ })
11643
+ ]);
11644
+ } finally {
11645
+ clearTimeout(timeout);
11646
+ if (reconnectPreparationAbort === preparationAbort) {
11647
+ reconnectPreparationAbort = null;
11648
+ }
11649
+ }
11650
+ }
11651
+ } catch (error) {
11652
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
11653
+ return;
11654
+ if (error instanceof VoiceReconnectRejectedError) {
11655
+ emitTerminalFailure(error.message);
11656
+ return;
11657
+ }
11618
11658
  if (state.reconnectAttempts < maxReconnectAttempts) {
11619
11659
  scheduleReconnect();
11620
11660
  } else {
@@ -11622,10 +11662,14 @@ var createVoiceConnection = (path, options = {}) => {
11622
11662
  }
11623
11663
  return;
11624
11664
  }
11665
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
11666
+ return;
11625
11667
  connect();
11626
11668
  }, delayMs);
11627
11669
  };
11628
11670
  const connect = () => {
11671
+ if (disposed)
11672
+ return;
11629
11673
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
11630
11674
  ws.binaryType = "arraybuffer";
11631
11675
  ws.onopen = () => {
@@ -11696,6 +11740,7 @@ var createVoiceConnection = (path, options = {}) => {
11696
11740
  state.ws = null;
11697
11741
  state.isConnected = false;
11698
11742
  clearTimers();
11743
+ rejectPendingCallControls("Voice connection interrupted");
11699
11744
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
11700
11745
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
11701
11746
  if (authorizationFailure) {
@@ -11726,6 +11771,8 @@ var createVoiceConnection = (path, options = {}) => {
11726
11771
  state.ws = ws;
11727
11772
  };
11728
11773
  const sendSerialized = (value) => {
11774
+ if (disposed)
11775
+ return;
11729
11776
  if (state.ws?.readyState === WS_OPEN) {
11730
11777
  state.ws.send(value);
11731
11778
  return;
@@ -11761,6 +11808,9 @@ var createVoiceConnection = (path, options = {}) => {
11761
11808
  send({ type: "end_turn" });
11762
11809
  };
11763
11810
  const callControl = (message) => {
11811
+ if (state.ws?.readyState !== WS_OPEN) {
11812
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
11813
+ }
11764
11814
  const requestId = crypto.randomUUID();
11765
11815
  const { promise, reject, resolve } = Promise.withResolvers();
11766
11816
  const timeout = setTimeout(() => {
@@ -11768,16 +11818,21 @@ var createVoiceConnection = (path, options = {}) => {
11768
11818
  reject(new Error(`${message.action} acknowledgement timed out`));
11769
11819
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
11770
11820
  pendingCallControls.set(requestId, { reject, resolve, timeout });
11771
- send({
11821
+ state.ws.send(JSON.stringify({
11772
11822
  ...message,
11773
11823
  requestId,
11774
11824
  type: "call_control"
11775
- });
11825
+ }));
11776
11826
  return promise;
11777
11827
  };
11778
11828
  const close = (reason = "client-close") => {
11829
+ disposed = true;
11830
+ reconnectPreparationAbort?.abort();
11831
+ reconnectPreparationAbort = null;
11832
+ lifecycleGeneration += 1;
11779
11833
  clearTimers();
11780
11834
  rejectPendingCallControls("Voice connection closed");
11835
+ state.pendingMessages.length = 0;
11781
11836
  if (state.ws) {
11782
11837
  const ws = state.ws;
11783
11838
  state.ws = null;
@@ -11790,8 +11845,13 @@ var createVoiceConnection = (path, options = {}) => {
11790
11845
  listeners.clear();
11791
11846
  };
11792
11847
  const disconnect = () => {
11848
+ disposed = true;
11849
+ reconnectPreparationAbort?.abort();
11850
+ reconnectPreparationAbort = null;
11851
+ lifecycleGeneration += 1;
11793
11852
  clearTimers();
11794
11853
  rejectPendingCallControls("Voice connection disconnected");
11854
+ state.pendingMessages.length = 0;
11795
11855
  if (state.ws) {
11796
11856
  const ws = state.ws;
11797
11857
  state.ws = null;
@@ -12039,7 +12099,10 @@ var createVoiceStream = (path, options = {}) => {
12039
12099
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
12040
12100
  }) : null;
12041
12101
  const subscribers = new Set;
12102
+ let disposed = false;
12042
12103
  const start = (input) => Promise.resolve().then(() => {
12104
+ if (disposed)
12105
+ return;
12043
12106
  if (!input?.sessionId && !input?.scenarioId) {
12044
12107
  return;
12045
12108
  }
@@ -12098,6 +12161,7 @@ var createVoiceStream = (path, options = {}) => {
12098
12161
  return connection.callControl(message);
12099
12162
  },
12100
12163
  close(reason) {
12164
+ disposed = true;
12101
12165
  unsubscribeConnection();
12102
12166
  browserMediaReporter?.close();
12103
12167
  connection.close(reason);
@@ -12105,6 +12169,7 @@ var createVoiceStream = (path, options = {}) => {
12105
12169
  notify();
12106
12170
  },
12107
12171
  disconnect() {
12172
+ disposed = true;
12108
12173
  unsubscribeConnection();
12109
12174
  browserMediaReporter?.close();
12110
12175
  connection.disconnect();
@@ -12302,48 +12367,89 @@ var createMicrophoneCapture = (options) => {
12302
12367
  let sourceNode = null;
12303
12368
  let processorNode = null;
12304
12369
  let mediaStream = null;
12305
- const start = async () => {
12306
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
12307
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
12308
- }
12309
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
12310
- if (!AudioContextCtor) {
12311
- throw new Error("Browser microphone capture requires AudioContext support.");
12312
- }
12313
- try {
12314
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
12315
- audio: {
12316
- autoGainControl: true,
12317
- channelCount: options.channelCount ?? 1,
12318
- echoCancellation: true,
12319
- noiseSuppression: true
12370
+ let lifecycleGeneration = 0;
12371
+ let startPromise = null;
12372
+ const release = (resources) => {
12373
+ resources.processorNode?.disconnect();
12374
+ resources.sourceNode?.disconnect();
12375
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
12376
+ resources.audioContext?.close();
12377
+ };
12378
+ const start = () => {
12379
+ if (mediaStream)
12380
+ return Promise.resolve();
12381
+ if (startPromise)
12382
+ return startPromise;
12383
+ const generation = ++lifecycleGeneration;
12384
+ const operation = async () => {
12385
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
12386
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
12387
+ }
12388
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
12389
+ if (!AudioContextCtor) {
12390
+ throw new Error("Browser microphone capture requires AudioContext support.");
12391
+ }
12392
+ let pendingAudioContext = null;
12393
+ let pendingMediaStream = null;
12394
+ let pendingProcessorNode = null;
12395
+ let pendingSourceNode = null;
12396
+ try {
12397
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
12398
+ audio: {
12399
+ autoGainControl: true,
12400
+ channelCount: options.channelCount ?? 1,
12401
+ echoCancellation: true,
12402
+ noiseSuppression: true
12403
+ }
12404
+ });
12405
+ if (generation !== lifecycleGeneration) {
12406
+ throw new Error("Microphone capture stopped during startup.");
12320
12407
  }
12321
- });
12322
- audioContext = new AudioContextCtor;
12323
- if (audioContext.state === "suspended") {
12324
- await audioContext.resume();
12408
+ pendingAudioContext = new AudioContextCtor;
12409
+ if (pendingAudioContext.state === "suspended") {
12410
+ await pendingAudioContext.resume();
12411
+ }
12412
+ if (generation !== lifecycleGeneration) {
12413
+ throw new Error("Microphone capture stopped during startup.");
12414
+ }
12415
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
12416
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
12417
+ pendingProcessorNode.onaudioprocess = (event) => {
12418
+ const channel = event.inputBuffer.getChannelData(0);
12419
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
12420
+ const pcm = floatTo16BitPCM(downsampled);
12421
+ options.onLevel?.(getPcmLevel(pcm));
12422
+ options.onAudio(pcm);
12423
+ };
12424
+ pendingSourceNode.connect(pendingProcessorNode);
12425
+ pendingProcessorNode.connect(pendingAudioContext.destination);
12426
+ if (generation !== lifecycleGeneration) {
12427
+ throw new Error("Microphone capture stopped during startup.");
12428
+ }
12429
+ audioContext = pendingAudioContext;
12430
+ mediaStream = pendingMediaStream;
12431
+ processorNode = pendingProcessorNode;
12432
+ sourceNode = pendingSourceNode;
12433
+ } catch (error) {
12434
+ release({
12435
+ audioContext: pendingAudioContext,
12436
+ mediaStream: pendingMediaStream,
12437
+ processorNode: pendingProcessorNode,
12438
+ sourceNode: pendingSourceNode
12439
+ });
12440
+ throw error;
12325
12441
  }
12326
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
12327
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
12328
- processorNode.onaudioprocess = (event) => {
12329
- const channel = event.inputBuffer.getChannelData(0);
12330
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
12331
- const pcm = floatTo16BitPCM(downsampled);
12332
- options.onLevel?.(getPcmLevel(pcm));
12333
- options.onAudio(pcm);
12334
- };
12335
- sourceNode.connect(processorNode);
12336
- processorNode.connect(audioContext.destination);
12337
- } catch (error) {
12338
- stop();
12339
- throw error;
12340
- }
12442
+ };
12443
+ const pending = operation().finally(() => {
12444
+ if (startPromise === pending)
12445
+ startPromise = null;
12446
+ });
12447
+ startPromise = pending;
12448
+ return pending;
12341
12449
  };
12342
12450
  const stop = () => {
12343
- processorNode?.disconnect();
12344
- sourceNode?.disconnect();
12345
- mediaStream?.getTracks().forEach((track) => track.stop());
12346
- audioContext?.close();
12451
+ lifecycleGeneration += 1;
12452
+ release({ audioContext, mediaStream, processorNode, sourceNode });
12347
12453
  options.onLevel?.(0);
12348
12454
  audioContext = null;
12349
12455
  mediaStream = null;
@@ -12699,6 +12805,9 @@ var createVoiceController = (path, options = {}) => {
12699
12805
  ...options.connection
12700
12806
  });
12701
12807
  let capture = null;
12808
+ let captureGeneration = 0;
12809
+ let disposed = false;
12810
+ let startRecordingPromise = null;
12702
12811
  let state = createInitialState2(stream);
12703
12812
  const subscribers = new Set;
12704
12813
  const notify = () => {
@@ -12755,6 +12864,7 @@ var createVoiceController = (path, options = {}) => {
12755
12864
  return capture;
12756
12865
  };
12757
12866
  const stopRecording = () => {
12867
+ captureGeneration += 1;
12758
12868
  capture?.stop();
12759
12869
  capture = null;
12760
12870
  state = {
@@ -12764,39 +12874,63 @@ var createVoiceController = (path, options = {}) => {
12764
12874
  notify();
12765
12875
  };
12766
12876
  const startRecording = async () => {
12877
+ if (disposed) {
12878
+ throw new Error("Voice controller is closed.");
12879
+ }
12767
12880
  if (state.isRecording) {
12768
12881
  return;
12769
12882
  }
12770
- try {
12771
- state = {
12772
- ...state,
12773
- recordingError: null
12774
- };
12775
- notify();
12776
- await ensureCapture().start();
12777
- state = {
12778
- ...state,
12779
- isRecording: true
12780
- };
12781
- notify();
12782
- } catch (error) {
12783
- capture?.stop();
12784
- capture = null;
12785
- state = {
12786
- ...state,
12787
- isRecording: false,
12788
- recordingError: error instanceof Error ? error.message : String(error)
12789
- };
12790
- notify();
12791
- throw error;
12792
- }
12883
+ if (startRecordingPromise)
12884
+ return startRecordingPromise;
12885
+ const generation = ++captureGeneration;
12886
+ const activeCapture = ensureCapture();
12887
+ const operation = (async () => {
12888
+ try {
12889
+ state = {
12890
+ ...state,
12891
+ recordingError: null
12892
+ };
12893
+ notify();
12894
+ await activeCapture.start();
12895
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
12896
+ activeCapture.stop();
12897
+ return;
12898
+ }
12899
+ state = {
12900
+ ...state,
12901
+ isRecording: true
12902
+ };
12903
+ notify();
12904
+ } catch (error) {
12905
+ activeCapture.stop();
12906
+ if (generation !== captureGeneration || capture !== activeCapture) {
12907
+ throw error;
12908
+ }
12909
+ capture = null;
12910
+ state = {
12911
+ ...state,
12912
+ isRecording: false,
12913
+ recordingError: error instanceof Error ? error.message : String(error)
12914
+ };
12915
+ notify();
12916
+ throw error;
12917
+ }
12918
+ })();
12919
+ const pending = operation.finally(() => {
12920
+ if (startRecordingPromise === pending)
12921
+ startRecordingPromise = null;
12922
+ });
12923
+ startRecordingPromise = pending;
12924
+ return startRecordingPromise;
12793
12925
  };
12794
12926
  const close = (reason) => {
12927
+ disposed = true;
12795
12928
  unsubscribeStream();
12796
12929
  stopRecording();
12797
12930
  stream.close(reason);
12798
12931
  };
12799
12932
  const disconnect = () => {
12933
+ disposed = true;
12800
12934
  unsubscribeStream();
12801
12935
  stopRecording();
12802
12936
  stream.disconnect();