@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.
@@ -103,48 +103,89 @@ var createMicrophoneCapture = (options) => {
103
103
  let sourceNode = null;
104
104
  let processorNode = null;
105
105
  let mediaStream = null;
106
- const start = async () => {
107
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
108
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
109
- }
110
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
111
- if (!AudioContextCtor) {
112
- throw new Error("Browser microphone capture requires AudioContext support.");
113
- }
114
- try {
115
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
116
- audio: {
117
- autoGainControl: true,
118
- channelCount: options.channelCount ?? 1,
119
- echoCancellation: true,
120
- noiseSuppression: true
106
+ let lifecycleGeneration = 0;
107
+ let startPromise = null;
108
+ const release = (resources) => {
109
+ resources.processorNode?.disconnect();
110
+ resources.sourceNode?.disconnect();
111
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
112
+ resources.audioContext?.close();
113
+ };
114
+ const start = () => {
115
+ if (mediaStream)
116
+ return Promise.resolve();
117
+ if (startPromise)
118
+ return startPromise;
119
+ const generation = ++lifecycleGeneration;
120
+ const operation = async () => {
121
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
122
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
123
+ }
124
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
125
+ if (!AudioContextCtor) {
126
+ throw new Error("Browser microphone capture requires AudioContext support.");
127
+ }
128
+ let pendingAudioContext = null;
129
+ let pendingMediaStream = null;
130
+ let pendingProcessorNode = null;
131
+ let pendingSourceNode = null;
132
+ try {
133
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
134
+ audio: {
135
+ autoGainControl: true,
136
+ channelCount: options.channelCount ?? 1,
137
+ echoCancellation: true,
138
+ noiseSuppression: true
139
+ }
140
+ });
141
+ if (generation !== lifecycleGeneration) {
142
+ throw new Error("Microphone capture stopped during startup.");
121
143
  }
122
- });
123
- audioContext = new AudioContextCtor;
124
- if (audioContext.state === "suspended") {
125
- await audioContext.resume();
144
+ pendingAudioContext = new AudioContextCtor;
145
+ if (pendingAudioContext.state === "suspended") {
146
+ await pendingAudioContext.resume();
147
+ }
148
+ if (generation !== lifecycleGeneration) {
149
+ throw new Error("Microphone capture stopped during startup.");
150
+ }
151
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
152
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
153
+ pendingProcessorNode.onaudioprocess = (event) => {
154
+ const channel = event.inputBuffer.getChannelData(0);
155
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
156
+ const pcm = floatTo16BitPCM(downsampled);
157
+ options.onLevel?.(getPcmLevel(pcm));
158
+ options.onAudio(pcm);
159
+ };
160
+ pendingSourceNode.connect(pendingProcessorNode);
161
+ pendingProcessorNode.connect(pendingAudioContext.destination);
162
+ if (generation !== lifecycleGeneration) {
163
+ throw new Error("Microphone capture stopped during startup.");
164
+ }
165
+ audioContext = pendingAudioContext;
166
+ mediaStream = pendingMediaStream;
167
+ processorNode = pendingProcessorNode;
168
+ sourceNode = pendingSourceNode;
169
+ } catch (error) {
170
+ release({
171
+ audioContext: pendingAudioContext,
172
+ mediaStream: pendingMediaStream,
173
+ processorNode: pendingProcessorNode,
174
+ sourceNode: pendingSourceNode
175
+ });
176
+ throw error;
126
177
  }
127
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
128
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
129
- processorNode.onaudioprocess = (event) => {
130
- const channel = event.inputBuffer.getChannelData(0);
131
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
132
- const pcm = floatTo16BitPCM(downsampled);
133
- options.onLevel?.(getPcmLevel(pcm));
134
- options.onAudio(pcm);
135
- };
136
- sourceNode.connect(processorNode);
137
- processorNode.connect(audioContext.destination);
138
- } catch (error) {
139
- stop();
140
- throw error;
141
- }
178
+ };
179
+ const pending = operation().finally(() => {
180
+ if (startPromise === pending)
181
+ startPromise = null;
182
+ });
183
+ startPromise = pending;
184
+ return pending;
142
185
  };
143
186
  const stop = () => {
144
- processorNode?.disconnect();
145
- sourceNode?.disconnect();
146
- mediaStream?.getTracks().forEach((track) => track.stop());
147
- audioContext?.close();
187
+ lifecycleGeneration += 1;
188
+ release({ audioContext, mediaStream, processorNode, sourceNode });
148
189
  options.onLevel?.(0);
149
190
  audioContext = null;
150
191
  mediaStream = null;
@@ -505,11 +546,19 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
505
546
  var DEFAULT_PING_INTERVAL = 30000;
506
547
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
507
548
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
549
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
508
550
  var MAX_PENDING_CONTROL_MESSAGES = 100;
509
551
  var RECONNECT_BASE_DELAY_MS = 500;
510
552
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
511
553
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
512
554
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
555
+
556
+ class VoiceReconnectRejectedError extends Error {
557
+ constructor(message = "Voice authorization could not be renewed.") {
558
+ super(message);
559
+ this.name = "VoiceReconnectRejectedError";
560
+ }
561
+ }
513
562
  var noop = () => {};
514
563
  var noopAsync = () => Promise.resolve();
515
564
  var noopUnsubscribe = () => noop;
@@ -589,6 +638,9 @@ var createVoiceConnection = (path, options = {}) => {
589
638
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
590
639
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
591
640
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
641
+ let lifecycleGeneration = 0;
642
+ let disposed = false;
643
+ let reconnectPreparationAbort = null;
592
644
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
593
645
  const state = {
594
646
  isConnected: false,
@@ -652,6 +704,9 @@ var createVoiceConnection = (path, options = {}) => {
652
704
  pendingCallControls.clear();
653
705
  };
654
706
  const scheduleReconnect = () => {
707
+ if (disposed)
708
+ return;
709
+ const scheduledGeneration = lifecycleGeneration;
655
710
  state.reconnectAttempts += 1;
656
711
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
657
712
  const nextAttemptAt = Date.now() + delayMs;
@@ -666,18 +721,44 @@ var createVoiceConnection = (path, options = {}) => {
666
721
  type: "connection"
667
722
  });
668
723
  state.reconnectTimeout = setTimeout(async () => {
724
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
725
+ return;
669
726
  if (state.reconnectAttempts > maxReconnectAttempts) {
670
727
  emitTerminalFailure("Voice connection could not be restored.");
671
728
  return;
672
729
  }
673
730
  try {
674
- await options.prepareReconnect?.({
675
- attempt: state.reconnectAttempts,
676
- path,
677
- scenarioId: state.scenarioId,
678
- sessionId: state.sessionId
679
- });
680
- } catch {
731
+ if (options.prepareReconnect) {
732
+ const preparationAbort = new AbortController;
733
+ reconnectPreparationAbort = preparationAbort;
734
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
735
+ try {
736
+ await Promise.race([
737
+ options.prepareReconnect({
738
+ attempt: state.reconnectAttempts,
739
+ path,
740
+ scenarioId: state.scenarioId,
741
+ sessionId: state.sessionId,
742
+ signal: preparationAbort.signal
743
+ }),
744
+ new Promise((_, reject) => {
745
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
746
+ })
747
+ ]);
748
+ } finally {
749
+ clearTimeout(timeout);
750
+ if (reconnectPreparationAbort === preparationAbort) {
751
+ reconnectPreparationAbort = null;
752
+ }
753
+ }
754
+ }
755
+ } catch (error) {
756
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
757
+ return;
758
+ if (error instanceof VoiceReconnectRejectedError) {
759
+ emitTerminalFailure(error.message);
760
+ return;
761
+ }
681
762
  if (state.reconnectAttempts < maxReconnectAttempts) {
682
763
  scheduleReconnect();
683
764
  } else {
@@ -685,10 +766,14 @@ var createVoiceConnection = (path, options = {}) => {
685
766
  }
686
767
  return;
687
768
  }
769
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
770
+ return;
688
771
  connect();
689
772
  }, delayMs);
690
773
  };
691
774
  const connect = () => {
775
+ if (disposed)
776
+ return;
692
777
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
693
778
  ws.binaryType = "arraybuffer";
694
779
  ws.onopen = () => {
@@ -759,6 +844,7 @@ var createVoiceConnection = (path, options = {}) => {
759
844
  state.ws = null;
760
845
  state.isConnected = false;
761
846
  clearTimers();
847
+ rejectPendingCallControls("Voice connection interrupted");
762
848
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
763
849
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
764
850
  if (authorizationFailure) {
@@ -789,6 +875,8 @@ var createVoiceConnection = (path, options = {}) => {
789
875
  state.ws = ws;
790
876
  };
791
877
  const sendSerialized = (value) => {
878
+ if (disposed)
879
+ return;
792
880
  if (state.ws?.readyState === WS_OPEN) {
793
881
  state.ws.send(value);
794
882
  return;
@@ -824,6 +912,9 @@ var createVoiceConnection = (path, options = {}) => {
824
912
  send({ type: "end_turn" });
825
913
  };
826
914
  const callControl = (message) => {
915
+ if (state.ws?.readyState !== WS_OPEN) {
916
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
917
+ }
827
918
  const requestId = crypto.randomUUID();
828
919
  const { promise, reject, resolve } = Promise.withResolvers();
829
920
  const timeout = setTimeout(() => {
@@ -831,16 +922,21 @@ var createVoiceConnection = (path, options = {}) => {
831
922
  reject(new Error(`${message.action} acknowledgement timed out`));
832
923
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
833
924
  pendingCallControls.set(requestId, { reject, resolve, timeout });
834
- send({
925
+ state.ws.send(JSON.stringify({
835
926
  ...message,
836
927
  requestId,
837
928
  type: "call_control"
838
- });
929
+ }));
839
930
  return promise;
840
931
  };
841
932
  const close = (reason = "client-close") => {
933
+ disposed = true;
934
+ reconnectPreparationAbort?.abort();
935
+ reconnectPreparationAbort = null;
936
+ lifecycleGeneration += 1;
842
937
  clearTimers();
843
938
  rejectPendingCallControls("Voice connection closed");
939
+ state.pendingMessages.length = 0;
844
940
  if (state.ws) {
845
941
  const ws = state.ws;
846
942
  state.ws = null;
@@ -853,8 +949,13 @@ var createVoiceConnection = (path, options = {}) => {
853
949
  listeners.clear();
854
950
  };
855
951
  const disconnect = () => {
952
+ disposed = true;
953
+ reconnectPreparationAbort?.abort();
954
+ reconnectPreparationAbort = null;
955
+ lifecycleGeneration += 1;
856
956
  clearTimers();
857
957
  rejectPendingCallControls("Voice connection disconnected");
958
+ state.pendingMessages.length = 0;
858
959
  if (state.ws) {
859
960
  const ws = state.ws;
860
961
  state.ws = null;
@@ -1102,7 +1203,10 @@ var createVoiceStream = (path, options = {}) => {
1102
1203
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1103
1204
  }) : null;
1104
1205
  const subscribers = new Set;
1206
+ let disposed = false;
1105
1207
  const start = (input) => Promise.resolve().then(() => {
1208
+ if (disposed)
1209
+ return;
1106
1210
  if (!input?.sessionId && !input?.scenarioId) {
1107
1211
  return;
1108
1212
  }
@@ -1161,6 +1265,7 @@ var createVoiceStream = (path, options = {}) => {
1161
1265
  return connection.callControl(message);
1162
1266
  },
1163
1267
  close(reason) {
1268
+ disposed = true;
1164
1269
  unsubscribeConnection();
1165
1270
  browserMediaReporter?.close();
1166
1271
  connection.close(reason);
@@ -1168,6 +1273,7 @@ var createVoiceStream = (path, options = {}) => {
1168
1273
  notify();
1169
1274
  },
1170
1275
  disconnect() {
1276
+ disposed = true;
1171
1277
  unsubscribeConnection();
1172
1278
  browserMediaReporter?.close();
1173
1279
  connection.disconnect();
@@ -1534,6 +1640,9 @@ var createVoiceController = (path, options = {}) => {
1534
1640
  ...options.connection
1535
1641
  });
1536
1642
  let capture = null;
1643
+ let captureGeneration = 0;
1644
+ let disposed = false;
1645
+ let startRecordingPromise = null;
1537
1646
  let state = createInitialState2(stream);
1538
1647
  const subscribers = new Set;
1539
1648
  const notify = () => {
@@ -1590,6 +1699,7 @@ var createVoiceController = (path, options = {}) => {
1590
1699
  return capture;
1591
1700
  };
1592
1701
  const stopRecording = () => {
1702
+ captureGeneration += 1;
1593
1703
  capture?.stop();
1594
1704
  capture = null;
1595
1705
  state = {
@@ -1599,39 +1709,63 @@ var createVoiceController = (path, options = {}) => {
1599
1709
  notify();
1600
1710
  };
1601
1711
  const startRecording = async () => {
1712
+ if (disposed) {
1713
+ throw new Error("Voice controller is closed.");
1714
+ }
1602
1715
  if (state.isRecording) {
1603
1716
  return;
1604
1717
  }
1605
- try {
1606
- state = {
1607
- ...state,
1608
- recordingError: null
1609
- };
1610
- notify();
1611
- await ensureCapture().start();
1612
- state = {
1613
- ...state,
1614
- isRecording: true
1615
- };
1616
- notify();
1617
- } catch (error) {
1618
- capture?.stop();
1619
- capture = null;
1620
- state = {
1621
- ...state,
1622
- isRecording: false,
1623
- recordingError: error instanceof Error ? error.message : String(error)
1624
- };
1625
- notify();
1626
- throw error;
1627
- }
1718
+ if (startRecordingPromise)
1719
+ return startRecordingPromise;
1720
+ const generation = ++captureGeneration;
1721
+ const activeCapture = ensureCapture();
1722
+ const operation = (async () => {
1723
+ try {
1724
+ state = {
1725
+ ...state,
1726
+ recordingError: null
1727
+ };
1728
+ notify();
1729
+ await activeCapture.start();
1730
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
1731
+ activeCapture.stop();
1732
+ return;
1733
+ }
1734
+ state = {
1735
+ ...state,
1736
+ isRecording: true
1737
+ };
1738
+ notify();
1739
+ } catch (error) {
1740
+ activeCapture.stop();
1741
+ if (generation !== captureGeneration || capture !== activeCapture) {
1742
+ throw error;
1743
+ }
1744
+ capture = null;
1745
+ state = {
1746
+ ...state,
1747
+ isRecording: false,
1748
+ recordingError: error instanceof Error ? error.message : String(error)
1749
+ };
1750
+ notify();
1751
+ throw error;
1752
+ }
1753
+ })();
1754
+ const pending = operation.finally(() => {
1755
+ if (startRecordingPromise === pending)
1756
+ startRecordingPromise = null;
1757
+ });
1758
+ startRecordingPromise = pending;
1759
+ return startRecordingPromise;
1628
1760
  };
1629
1761
  const close = (reason) => {
1762
+ disposed = true;
1630
1763
  unsubscribeStream();
1631
1764
  stopRecording();
1632
1765
  stream.close(reason);
1633
1766
  };
1634
1767
  const disconnect = () => {
1768
+ disposed = true;
1635
1769
  unsubscribeStream();
1636
1770
  stopRecording();
1637
1771
  stream.disconnect();
@@ -1,6 +1,6 @@
1
1
  export { bindVoiceReactiveSource, voiceSseReactiveSource, } from "./reactiveSource";
2
2
  export type { VoiceReactiveSource, VoiceSseReactiveSourceOptions, } from "./reactiveSource";
3
- export { createVoiceConnection } from "./connection";
3
+ export { createVoiceConnection, VoiceReconnectRejectedError, } from "./connection";
4
4
  export { checkBrowserVoiceSupport, probeBrowserVoiceSupport, } from "./browserVoiceSupport";
5
5
  export type { BrowserVoiceSupportProbe, BrowserVoiceSupportReport, } from "./browserVoiceSupport";
6
6
  export { createVoiceAudioPlayer, decodeVoiceAudioChunk } from "./audioPlayer";