@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.
@@ -335,48 +335,89 @@ var createMicrophoneCapture = (options) => {
335
335
  let sourceNode = null;
336
336
  let processorNode = null;
337
337
  let mediaStream = null;
338
- const start = async () => {
339
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
340
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
341
- }
342
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
343
- if (!AudioContextCtor) {
344
- throw new Error("Browser microphone capture requires AudioContext support.");
345
- }
346
- try {
347
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
348
- audio: {
349
- autoGainControl: true,
350
- channelCount: options.channelCount ?? 1,
351
- echoCancellation: true,
352
- noiseSuppression: true
338
+ let lifecycleGeneration = 0;
339
+ let startPromise = null;
340
+ const release = (resources) => {
341
+ resources.processorNode?.disconnect();
342
+ resources.sourceNode?.disconnect();
343
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
344
+ resources.audioContext?.close();
345
+ };
346
+ const start = () => {
347
+ if (mediaStream)
348
+ return Promise.resolve();
349
+ if (startPromise)
350
+ return startPromise;
351
+ const generation = ++lifecycleGeneration;
352
+ const operation = async () => {
353
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
354
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
355
+ }
356
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
357
+ if (!AudioContextCtor) {
358
+ throw new Error("Browser microphone capture requires AudioContext support.");
359
+ }
360
+ let pendingAudioContext = null;
361
+ let pendingMediaStream = null;
362
+ let pendingProcessorNode = null;
363
+ let pendingSourceNode = null;
364
+ try {
365
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
366
+ audio: {
367
+ autoGainControl: true,
368
+ channelCount: options.channelCount ?? 1,
369
+ echoCancellation: true,
370
+ noiseSuppression: true
371
+ }
372
+ });
373
+ if (generation !== lifecycleGeneration) {
374
+ throw new Error("Microphone capture stopped during startup.");
353
375
  }
354
- });
355
- audioContext = new AudioContextCtor;
356
- if (audioContext.state === "suspended") {
357
- await audioContext.resume();
376
+ pendingAudioContext = new AudioContextCtor;
377
+ if (pendingAudioContext.state === "suspended") {
378
+ await pendingAudioContext.resume();
379
+ }
380
+ if (generation !== lifecycleGeneration) {
381
+ throw new Error("Microphone capture stopped during startup.");
382
+ }
383
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
384
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
385
+ pendingProcessorNode.onaudioprocess = (event) => {
386
+ const channel = event.inputBuffer.getChannelData(0);
387
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
388
+ const pcm = floatTo16BitPCM(downsampled);
389
+ options.onLevel?.(getPcmLevel(pcm));
390
+ options.onAudio(pcm);
391
+ };
392
+ pendingSourceNode.connect(pendingProcessorNode);
393
+ pendingProcessorNode.connect(pendingAudioContext.destination);
394
+ if (generation !== lifecycleGeneration) {
395
+ throw new Error("Microphone capture stopped during startup.");
396
+ }
397
+ audioContext = pendingAudioContext;
398
+ mediaStream = pendingMediaStream;
399
+ processorNode = pendingProcessorNode;
400
+ sourceNode = pendingSourceNode;
401
+ } catch (error) {
402
+ release({
403
+ audioContext: pendingAudioContext,
404
+ mediaStream: pendingMediaStream,
405
+ processorNode: pendingProcessorNode,
406
+ sourceNode: pendingSourceNode
407
+ });
408
+ throw error;
358
409
  }
359
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
360
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
361
- processorNode.onaudioprocess = (event) => {
362
- const channel = event.inputBuffer.getChannelData(0);
363
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
364
- const pcm = floatTo16BitPCM(downsampled);
365
- options.onLevel?.(getPcmLevel(pcm));
366
- options.onAudio(pcm);
367
- };
368
- sourceNode.connect(processorNode);
369
- processorNode.connect(audioContext.destination);
370
- } catch (error) {
371
- stop();
372
- throw error;
373
- }
410
+ };
411
+ const pending = operation().finally(() => {
412
+ if (startPromise === pending)
413
+ startPromise = null;
414
+ });
415
+ startPromise = pending;
416
+ return pending;
374
417
  };
375
418
  const stop = () => {
376
- processorNode?.disconnect();
377
- sourceNode?.disconnect();
378
- mediaStream?.getTracks().forEach((track) => track.stop());
379
- audioContext?.close();
419
+ lifecycleGeneration += 1;
420
+ release({ audioContext, mediaStream, processorNode, sourceNode });
380
421
  options.onLevel?.(0);
381
422
  audioContext = null;
382
423
  mediaStream = null;
@@ -588,11 +629,19 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
588
629
  var DEFAULT_PING_INTERVAL = 30000;
589
630
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
590
631
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
632
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
591
633
  var MAX_PENDING_CONTROL_MESSAGES = 100;
592
634
  var RECONNECT_BASE_DELAY_MS = 500;
593
635
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
594
636
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
595
637
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
638
+
639
+ class VoiceReconnectRejectedError extends Error {
640
+ constructor(message = "Voice authorization could not be renewed.") {
641
+ super(message);
642
+ this.name = "VoiceReconnectRejectedError";
643
+ }
644
+ }
596
645
  var noop = () => {};
597
646
  var noopAsync = () => Promise.resolve();
598
647
  var noopUnsubscribe = () => noop;
@@ -672,6 +721,9 @@ var createVoiceConnection = (path, options = {}) => {
672
721
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
673
722
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
674
723
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
724
+ let lifecycleGeneration = 0;
725
+ let disposed = false;
726
+ let reconnectPreparationAbort = null;
675
727
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
676
728
  const state = {
677
729
  isConnected: false,
@@ -735,6 +787,9 @@ var createVoiceConnection = (path, options = {}) => {
735
787
  pendingCallControls.clear();
736
788
  };
737
789
  const scheduleReconnect = () => {
790
+ if (disposed)
791
+ return;
792
+ const scheduledGeneration = lifecycleGeneration;
738
793
  state.reconnectAttempts += 1;
739
794
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
740
795
  const nextAttemptAt = Date.now() + delayMs;
@@ -749,18 +804,44 @@ var createVoiceConnection = (path, options = {}) => {
749
804
  type: "connection"
750
805
  });
751
806
  state.reconnectTimeout = setTimeout(async () => {
807
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
808
+ return;
752
809
  if (state.reconnectAttempts > maxReconnectAttempts) {
753
810
  emitTerminalFailure("Voice connection could not be restored.");
754
811
  return;
755
812
  }
756
813
  try {
757
- await options.prepareReconnect?.({
758
- attempt: state.reconnectAttempts,
759
- path,
760
- scenarioId: state.scenarioId,
761
- sessionId: state.sessionId
762
- });
763
- } catch {
814
+ if (options.prepareReconnect) {
815
+ const preparationAbort = new AbortController;
816
+ reconnectPreparationAbort = preparationAbort;
817
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
818
+ try {
819
+ await Promise.race([
820
+ options.prepareReconnect({
821
+ attempt: state.reconnectAttempts,
822
+ path,
823
+ scenarioId: state.scenarioId,
824
+ sessionId: state.sessionId,
825
+ signal: preparationAbort.signal
826
+ }),
827
+ new Promise((_, reject) => {
828
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
829
+ })
830
+ ]);
831
+ } finally {
832
+ clearTimeout(timeout);
833
+ if (reconnectPreparationAbort === preparationAbort) {
834
+ reconnectPreparationAbort = null;
835
+ }
836
+ }
837
+ }
838
+ } catch (error) {
839
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
840
+ return;
841
+ if (error instanceof VoiceReconnectRejectedError) {
842
+ emitTerminalFailure(error.message);
843
+ return;
844
+ }
764
845
  if (state.reconnectAttempts < maxReconnectAttempts) {
765
846
  scheduleReconnect();
766
847
  } else {
@@ -768,10 +849,14 @@ var createVoiceConnection = (path, options = {}) => {
768
849
  }
769
850
  return;
770
851
  }
852
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
853
+ return;
771
854
  connect();
772
855
  }, delayMs);
773
856
  };
774
857
  const connect = () => {
858
+ if (disposed)
859
+ return;
775
860
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
776
861
  ws.binaryType = "arraybuffer";
777
862
  ws.onopen = () => {
@@ -842,6 +927,7 @@ var createVoiceConnection = (path, options = {}) => {
842
927
  state.ws = null;
843
928
  state.isConnected = false;
844
929
  clearTimers();
930
+ rejectPendingCallControls("Voice connection interrupted");
845
931
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
846
932
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
847
933
  if (authorizationFailure) {
@@ -872,6 +958,8 @@ var createVoiceConnection = (path, options = {}) => {
872
958
  state.ws = ws;
873
959
  };
874
960
  const sendSerialized = (value) => {
961
+ if (disposed)
962
+ return;
875
963
  if (state.ws?.readyState === WS_OPEN) {
876
964
  state.ws.send(value);
877
965
  return;
@@ -907,6 +995,9 @@ var createVoiceConnection = (path, options = {}) => {
907
995
  send({ type: "end_turn" });
908
996
  };
909
997
  const callControl = (message) => {
998
+ if (state.ws?.readyState !== WS_OPEN) {
999
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
1000
+ }
910
1001
  const requestId = crypto.randomUUID();
911
1002
  const { promise, reject, resolve } = Promise.withResolvers();
912
1003
  const timeout = setTimeout(() => {
@@ -914,16 +1005,21 @@ var createVoiceConnection = (path, options = {}) => {
914
1005
  reject(new Error(`${message.action} acknowledgement timed out`));
915
1006
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
916
1007
  pendingCallControls.set(requestId, { reject, resolve, timeout });
917
- send({
1008
+ state.ws.send(JSON.stringify({
918
1009
  ...message,
919
1010
  requestId,
920
1011
  type: "call_control"
921
- });
1012
+ }));
922
1013
  return promise;
923
1014
  };
924
1015
  const close = (reason = "client-close") => {
1016
+ disposed = true;
1017
+ reconnectPreparationAbort?.abort();
1018
+ reconnectPreparationAbort = null;
1019
+ lifecycleGeneration += 1;
925
1020
  clearTimers();
926
1021
  rejectPendingCallControls("Voice connection closed");
1022
+ state.pendingMessages.length = 0;
927
1023
  if (state.ws) {
928
1024
  const ws = state.ws;
929
1025
  state.ws = null;
@@ -936,8 +1032,13 @@ var createVoiceConnection = (path, options = {}) => {
936
1032
  listeners.clear();
937
1033
  };
938
1034
  const disconnect = () => {
1035
+ disposed = true;
1036
+ reconnectPreparationAbort?.abort();
1037
+ reconnectPreparationAbort = null;
1038
+ lifecycleGeneration += 1;
939
1039
  clearTimers();
940
1040
  rejectPendingCallControls("Voice connection disconnected");
1041
+ state.pendingMessages.length = 0;
941
1042
  if (state.ws) {
942
1043
  const ws = state.ws;
943
1044
  state.ws = null;
@@ -1185,7 +1286,10 @@ var createVoiceStream = (path, options = {}) => {
1185
1286
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1186
1287
  }) : null;
1187
1288
  const subscribers = new Set;
1289
+ let disposed = false;
1188
1290
  const start = (input) => Promise.resolve().then(() => {
1291
+ if (disposed)
1292
+ return;
1189
1293
  if (!input?.sessionId && !input?.scenarioId) {
1190
1294
  return;
1191
1295
  }
@@ -1244,6 +1348,7 @@ var createVoiceStream = (path, options = {}) => {
1244
1348
  return connection.callControl(message);
1245
1349
  },
1246
1350
  close(reason) {
1351
+ disposed = true;
1247
1352
  unsubscribeConnection();
1248
1353
  browserMediaReporter?.close();
1249
1354
  connection.close(reason);
@@ -1251,6 +1356,7 @@ var createVoiceStream = (path, options = {}) => {
1251
1356
  notify();
1252
1357
  },
1253
1358
  disconnect() {
1359
+ disposed = true;
1254
1360
  unsubscribeConnection();
1255
1361
  browserMediaReporter?.close();
1256
1362
  connection.disconnect();
@@ -1654,6 +1760,9 @@ var createVoiceController = (path, options = {}) => {
1654
1760
  ...options.connection
1655
1761
  });
1656
1762
  let capture = null;
1763
+ let captureGeneration = 0;
1764
+ let disposed = false;
1765
+ let startRecordingPromise = null;
1657
1766
  let state = createInitialState2(stream);
1658
1767
  const subscribers = new Set;
1659
1768
  const notify = () => {
@@ -1710,6 +1819,7 @@ var createVoiceController = (path, options = {}) => {
1710
1819
  return capture;
1711
1820
  };
1712
1821
  const stopRecording = () => {
1822
+ captureGeneration += 1;
1713
1823
  capture?.stop();
1714
1824
  capture = null;
1715
1825
  state = {
@@ -1719,39 +1829,63 @@ var createVoiceController = (path, options = {}) => {
1719
1829
  notify();
1720
1830
  };
1721
1831
  const startRecording = async () => {
1832
+ if (disposed) {
1833
+ throw new Error("Voice controller is closed.");
1834
+ }
1722
1835
  if (state.isRecording) {
1723
1836
  return;
1724
1837
  }
1725
- try {
1726
- state = {
1727
- ...state,
1728
- recordingError: null
1729
- };
1730
- notify();
1731
- await ensureCapture().start();
1732
- state = {
1733
- ...state,
1734
- isRecording: true
1735
- };
1736
- notify();
1737
- } catch (error) {
1738
- capture?.stop();
1739
- capture = null;
1740
- state = {
1741
- ...state,
1742
- isRecording: false,
1743
- recordingError: error instanceof Error ? error.message : String(error)
1744
- };
1745
- notify();
1746
- throw error;
1747
- }
1838
+ if (startRecordingPromise)
1839
+ return startRecordingPromise;
1840
+ const generation = ++captureGeneration;
1841
+ const activeCapture = ensureCapture();
1842
+ const operation = (async () => {
1843
+ try {
1844
+ state = {
1845
+ ...state,
1846
+ recordingError: null
1847
+ };
1848
+ notify();
1849
+ await activeCapture.start();
1850
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
1851
+ activeCapture.stop();
1852
+ return;
1853
+ }
1854
+ state = {
1855
+ ...state,
1856
+ isRecording: true
1857
+ };
1858
+ notify();
1859
+ } catch (error) {
1860
+ activeCapture.stop();
1861
+ if (generation !== captureGeneration || capture !== activeCapture) {
1862
+ throw error;
1863
+ }
1864
+ capture = null;
1865
+ state = {
1866
+ ...state,
1867
+ isRecording: false,
1868
+ recordingError: error instanceof Error ? error.message : String(error)
1869
+ };
1870
+ notify();
1871
+ throw error;
1872
+ }
1873
+ })();
1874
+ const pending = operation.finally(() => {
1875
+ if (startRecordingPromise === pending)
1876
+ startRecordingPromise = null;
1877
+ });
1878
+ startRecordingPromise = pending;
1879
+ return startRecordingPromise;
1748
1880
  };
1749
1881
  const close = (reason) => {
1882
+ disposed = true;
1750
1883
  unsubscribeStream();
1751
1884
  stopRecording();
1752
1885
  stream.close(reason);
1753
1886
  };
1754
1887
  const disconnect = () => {
1888
+ disposed = true;
1755
1889
  unsubscribeStream();
1756
1890
  stopRecording();
1757
1891
  stream.disconnect();
@@ -22,5 +22,8 @@ type VoiceConnectionHandle = {
22
22
  simulateDisconnect: () => void;
23
23
  subscribe: (callback: (message: VoiceServerMessage) => void) => () => void;
24
24
  };
25
+ export declare class VoiceReconnectRejectedError extends Error {
26
+ constructor(message?: string);
27
+ }
25
28
  export declare const createVoiceConnection: (path: string, options?: VoiceConnectionOptions) => VoiceConnectionHandle;
26
29
  export {};