@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.
@@ -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,11 +811,19 @@ 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;
776
818
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
777
819
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
820
+
821
+ class VoiceReconnectRejectedError extends Error {
822
+ constructor(message = "Voice authorization could not be renewed.") {
823
+ super(message);
824
+ this.name = "VoiceReconnectRejectedError";
825
+ }
826
+ }
778
827
  var noop = () => {};
779
828
  var noopAsync = () => Promise.resolve();
780
829
  var noopUnsubscribe = () => noop;
@@ -854,6 +903,9 @@ var createVoiceConnection = (path, options = {}) => {
854
903
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
855
904
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
856
905
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
906
+ let lifecycleGeneration = 0;
907
+ let disposed = false;
908
+ let reconnectPreparationAbort = null;
857
909
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
858
910
  const state = {
859
911
  isConnected: false,
@@ -917,6 +969,9 @@ var createVoiceConnection = (path, options = {}) => {
917
969
  pendingCallControls.clear();
918
970
  };
919
971
  const scheduleReconnect = () => {
972
+ if (disposed)
973
+ return;
974
+ const scheduledGeneration = lifecycleGeneration;
920
975
  state.reconnectAttempts += 1;
921
976
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
922
977
  const nextAttemptAt = Date.now() + delayMs;
@@ -931,18 +986,44 @@ var createVoiceConnection = (path, options = {}) => {
931
986
  type: "connection"
932
987
  });
933
988
  state.reconnectTimeout = setTimeout(async () => {
989
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
990
+ return;
934
991
  if (state.reconnectAttempts > maxReconnectAttempts) {
935
992
  emitTerminalFailure("Voice connection could not be restored.");
936
993
  return;
937
994
  }
938
995
  try {
939
- await options.prepareReconnect?.({
940
- attempt: state.reconnectAttempts,
941
- path,
942
- scenarioId: state.scenarioId,
943
- sessionId: state.sessionId
944
- });
945
- } catch {
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
+ }
1020
+ } catch (error) {
1021
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
1022
+ return;
1023
+ if (error instanceof VoiceReconnectRejectedError) {
1024
+ emitTerminalFailure(error.message);
1025
+ return;
1026
+ }
946
1027
  if (state.reconnectAttempts < maxReconnectAttempts) {
947
1028
  scheduleReconnect();
948
1029
  } else {
@@ -950,10 +1031,14 @@ var createVoiceConnection = (path, options = {}) => {
950
1031
  }
951
1032
  return;
952
1033
  }
1034
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
1035
+ return;
953
1036
  connect();
954
1037
  }, delayMs);
955
1038
  };
956
1039
  const connect = () => {
1040
+ if (disposed)
1041
+ return;
957
1042
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
958
1043
  ws.binaryType = "arraybuffer";
959
1044
  ws.onopen = () => {
@@ -1024,6 +1109,7 @@ var createVoiceConnection = (path, options = {}) => {
1024
1109
  state.ws = null;
1025
1110
  state.isConnected = false;
1026
1111
  clearTimers();
1112
+ rejectPendingCallControls("Voice connection interrupted");
1027
1113
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
1028
1114
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
1029
1115
  if (authorizationFailure) {
@@ -1054,6 +1140,8 @@ var createVoiceConnection = (path, options = {}) => {
1054
1140
  state.ws = ws;
1055
1141
  };
1056
1142
  const sendSerialized = (value) => {
1143
+ if (disposed)
1144
+ return;
1057
1145
  if (state.ws?.readyState === WS_OPEN) {
1058
1146
  state.ws.send(value);
1059
1147
  return;
@@ -1089,6 +1177,9 @@ var createVoiceConnection = (path, options = {}) => {
1089
1177
  send({ type: "end_turn" });
1090
1178
  };
1091
1179
  const callControl = (message) => {
1180
+ if (state.ws?.readyState !== WS_OPEN) {
1181
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
1182
+ }
1092
1183
  const requestId = crypto.randomUUID();
1093
1184
  const { promise, reject, resolve } = Promise.withResolvers();
1094
1185
  const timeout = setTimeout(() => {
@@ -1096,16 +1187,21 @@ var createVoiceConnection = (path, options = {}) => {
1096
1187
  reject(new Error(`${message.action} acknowledgement timed out`));
1097
1188
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
1098
1189
  pendingCallControls.set(requestId, { reject, resolve, timeout });
1099
- send({
1190
+ state.ws.send(JSON.stringify({
1100
1191
  ...message,
1101
1192
  requestId,
1102
1193
  type: "call_control"
1103
- });
1194
+ }));
1104
1195
  return promise;
1105
1196
  };
1106
1197
  const close = (reason = "client-close") => {
1198
+ disposed = true;
1199
+ reconnectPreparationAbort?.abort();
1200
+ reconnectPreparationAbort = null;
1201
+ lifecycleGeneration += 1;
1107
1202
  clearTimers();
1108
1203
  rejectPendingCallControls("Voice connection closed");
1204
+ state.pendingMessages.length = 0;
1109
1205
  if (state.ws) {
1110
1206
  const ws = state.ws;
1111
1207
  state.ws = null;
@@ -1118,8 +1214,13 @@ var createVoiceConnection = (path, options = {}) => {
1118
1214
  listeners.clear();
1119
1215
  };
1120
1216
  const disconnect = () => {
1217
+ disposed = true;
1218
+ reconnectPreparationAbort?.abort();
1219
+ reconnectPreparationAbort = null;
1220
+ lifecycleGeneration += 1;
1121
1221
  clearTimers();
1122
1222
  rejectPendingCallControls("Voice connection disconnected");
1223
+ state.pendingMessages.length = 0;
1123
1224
  if (state.ws) {
1124
1225
  const ws = state.ws;
1125
1226
  state.ws = null;
@@ -1367,7 +1468,10 @@ var createVoiceStream = (path, options = {}) => {
1367
1468
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1368
1469
  }) : null;
1369
1470
  const subscribers = new Set;
1471
+ let disposed = false;
1370
1472
  const start = (input) => Promise.resolve().then(() => {
1473
+ if (disposed)
1474
+ return;
1371
1475
  if (!input?.sessionId && !input?.scenarioId) {
1372
1476
  return;
1373
1477
  }
@@ -1426,6 +1530,7 @@ var createVoiceStream = (path, options = {}) => {
1426
1530
  return connection.callControl(message);
1427
1531
  },
1428
1532
  close(reason) {
1533
+ disposed = true;
1429
1534
  unsubscribeConnection();
1430
1535
  browserMediaReporter?.close();
1431
1536
  connection.close(reason);
@@ -1433,6 +1538,7 @@ var createVoiceStream = (path, options = {}) => {
1433
1538
  notify();
1434
1539
  },
1435
1540
  disconnect() {
1541
+ disposed = true;
1436
1542
  unsubscribeConnection();
1437
1543
  browserMediaReporter?.close();
1438
1544
  connection.disconnect();
@@ -1836,6 +1942,9 @@ var createVoiceController = (path, options = {}) => {
1836
1942
  ...options.connection
1837
1943
  });
1838
1944
  let capture = null;
1945
+ let captureGeneration = 0;
1946
+ let disposed = false;
1947
+ let startRecordingPromise = null;
1839
1948
  let state = createInitialState2(stream);
1840
1949
  const subscribers = new Set;
1841
1950
  const notify = () => {
@@ -1892,6 +2001,7 @@ var createVoiceController = (path, options = {}) => {
1892
2001
  return capture;
1893
2002
  };
1894
2003
  const stopRecording = () => {
2004
+ captureGeneration += 1;
1895
2005
  capture?.stop();
1896
2006
  capture = null;
1897
2007
  state = {
@@ -1901,39 +2011,63 @@ var createVoiceController = (path, options = {}) => {
1901
2011
  notify();
1902
2012
  };
1903
2013
  const startRecording = async () => {
2014
+ if (disposed) {
2015
+ throw new Error("Voice controller is closed.");
2016
+ }
1904
2017
  if (state.isRecording) {
1905
2018
  return;
1906
2019
  }
1907
- try {
1908
- state = {
1909
- ...state,
1910
- recordingError: null
1911
- };
1912
- notify();
1913
- await ensureCapture().start();
1914
- state = {
1915
- ...state,
1916
- isRecording: true
1917
- };
1918
- notify();
1919
- } catch (error) {
1920
- capture?.stop();
1921
- capture = null;
1922
- state = {
1923
- ...state,
1924
- isRecording: false,
1925
- recordingError: error instanceof Error ? error.message : String(error)
1926
- };
1927
- notify();
1928
- throw error;
1929
- }
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;
1930
2062
  };
1931
2063
  const close = (reason) => {
2064
+ disposed = true;
1932
2065
  unsubscribeStream();
1933
2066
  stopRecording();
1934
2067
  stream.close(reason);
1935
2068
  };
1936
2069
  const disconnect = () => {
2070
+ disposed = true;
1937
2071
  unsubscribeStream();
1938
2072
  stopRecording();
1939
2073
  stream.disconnect();