@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.
@@ -110,6 +110,7 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
110
110
  var DEFAULT_PING_INTERVAL = 30000;
111
111
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
112
112
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
113
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
113
114
  var MAX_PENDING_CONTROL_MESSAGES = 100;
114
115
  var RECONNECT_BASE_DELAY_MS = 500;
115
116
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
@@ -203,6 +204,7 @@ var createVoiceConnection = (path, options = {}) => {
203
204
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
204
205
  let lifecycleGeneration = 0;
205
206
  let disposed = false;
207
+ let reconnectPreparationAbort = null;
206
208
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
207
209
  const state = {
208
210
  isConnected: false,
@@ -290,12 +292,30 @@ var createVoiceConnection = (path, options = {}) => {
290
292
  return;
291
293
  }
292
294
  try {
293
- await options.prepareReconnect?.({
294
- attempt: state.reconnectAttempts,
295
- path,
296
- scenarioId: state.scenarioId,
297
- sessionId: state.sessionId
298
- });
295
+ if (options.prepareReconnect) {
296
+ const preparationAbort = new AbortController;
297
+ reconnectPreparationAbort = preparationAbort;
298
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
299
+ try {
300
+ await Promise.race([
301
+ options.prepareReconnect({
302
+ attempt: state.reconnectAttempts,
303
+ path,
304
+ scenarioId: state.scenarioId,
305
+ sessionId: state.sessionId,
306
+ signal: preparationAbort.signal
307
+ }),
308
+ new Promise((_, reject) => {
309
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
310
+ })
311
+ ]);
312
+ } finally {
313
+ clearTimeout(timeout);
314
+ if (reconnectPreparationAbort === preparationAbort) {
315
+ reconnectPreparationAbort = null;
316
+ }
317
+ }
318
+ }
299
319
  } catch (error) {
300
320
  if (disposed || lifecycleGeneration !== scheduledGeneration)
301
321
  return;
@@ -419,6 +439,8 @@ var createVoiceConnection = (path, options = {}) => {
419
439
  state.ws = ws;
420
440
  };
421
441
  const sendSerialized = (value) => {
442
+ if (disposed)
443
+ return;
422
444
  if (state.ws?.readyState === WS_OPEN) {
423
445
  state.ws.send(value);
424
446
  return;
@@ -473,6 +495,8 @@ var createVoiceConnection = (path, options = {}) => {
473
495
  };
474
496
  const close = (reason = "client-close") => {
475
497
  disposed = true;
498
+ reconnectPreparationAbort?.abort();
499
+ reconnectPreparationAbort = null;
476
500
  lifecycleGeneration += 1;
477
501
  clearTimers();
478
502
  rejectPendingCallControls("Voice connection closed");
@@ -490,6 +514,8 @@ var createVoiceConnection = (path, options = {}) => {
490
514
  };
491
515
  const disconnect = () => {
492
516
  disposed = true;
517
+ reconnectPreparationAbort?.abort();
518
+ reconnectPreparationAbort = null;
493
519
  lifecycleGeneration += 1;
494
520
  clearTimers();
495
521
  rejectPendingCallControls("Voice connection disconnected");
@@ -1663,7 +1689,10 @@ var createVoiceStream = (path, options = {}) => {
1663
1689
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1664
1690
  }) : null;
1665
1691
  const subscribers = new Set;
1692
+ let disposed = false;
1666
1693
  const start = (input) => Promise.resolve().then(() => {
1694
+ if (disposed)
1695
+ return;
1667
1696
  if (!input?.sessionId && !input?.scenarioId) {
1668
1697
  return;
1669
1698
  }
@@ -1722,6 +1751,7 @@ var createVoiceStream = (path, options = {}) => {
1722
1751
  return connection.callControl(message);
1723
1752
  },
1724
1753
  close(reason) {
1754
+ disposed = true;
1725
1755
  unsubscribeConnection();
1726
1756
  browserMediaReporter?.close();
1727
1757
  connection.close(reason);
@@ -1729,6 +1759,7 @@ var createVoiceStream = (path, options = {}) => {
1729
1759
  notify();
1730
1760
  },
1731
1761
  disconnect() {
1762
+ disposed = true;
1732
1763
  unsubscribeConnection();
1733
1764
  browserMediaReporter?.close();
1734
1765
  connection.disconnect();
@@ -1887,48 +1918,89 @@ var createMicrophoneCapture = (options) => {
1887
1918
  let sourceNode = null;
1888
1919
  let processorNode = null;
1889
1920
  let mediaStream = null;
1890
- const start = async () => {
1891
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
1892
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
1893
- }
1894
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
1895
- if (!AudioContextCtor) {
1896
- throw new Error("Browser microphone capture requires AudioContext support.");
1897
- }
1898
- try {
1899
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
1900
- audio: {
1901
- autoGainControl: true,
1902
- channelCount: options.channelCount ?? 1,
1903
- echoCancellation: true,
1904
- noiseSuppression: true
1921
+ let lifecycleGeneration = 0;
1922
+ let startPromise = null;
1923
+ const release = (resources) => {
1924
+ resources.processorNode?.disconnect();
1925
+ resources.sourceNode?.disconnect();
1926
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
1927
+ resources.audioContext?.close();
1928
+ };
1929
+ const start = () => {
1930
+ if (mediaStream)
1931
+ return Promise.resolve();
1932
+ if (startPromise)
1933
+ return startPromise;
1934
+ const generation = ++lifecycleGeneration;
1935
+ const operation = async () => {
1936
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
1937
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
1938
+ }
1939
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
1940
+ if (!AudioContextCtor) {
1941
+ throw new Error("Browser microphone capture requires AudioContext support.");
1942
+ }
1943
+ let pendingAudioContext = null;
1944
+ let pendingMediaStream = null;
1945
+ let pendingProcessorNode = null;
1946
+ let pendingSourceNode = null;
1947
+ try {
1948
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
1949
+ audio: {
1950
+ autoGainControl: true,
1951
+ channelCount: options.channelCount ?? 1,
1952
+ echoCancellation: true,
1953
+ noiseSuppression: true
1954
+ }
1955
+ });
1956
+ if (generation !== lifecycleGeneration) {
1957
+ throw new Error("Microphone capture stopped during startup.");
1905
1958
  }
1906
- });
1907
- audioContext = new AudioContextCtor;
1908
- if (audioContext.state === "suspended") {
1909
- await audioContext.resume();
1959
+ pendingAudioContext = new AudioContextCtor;
1960
+ if (pendingAudioContext.state === "suspended") {
1961
+ await pendingAudioContext.resume();
1962
+ }
1963
+ if (generation !== lifecycleGeneration) {
1964
+ throw new Error("Microphone capture stopped during startup.");
1965
+ }
1966
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
1967
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
1968
+ pendingProcessorNode.onaudioprocess = (event) => {
1969
+ const channel = event.inputBuffer.getChannelData(0);
1970
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
1971
+ const pcm = floatTo16BitPCM(downsampled);
1972
+ options.onLevel?.(getPcmLevel(pcm));
1973
+ options.onAudio(pcm);
1974
+ };
1975
+ pendingSourceNode.connect(pendingProcessorNode);
1976
+ pendingProcessorNode.connect(pendingAudioContext.destination);
1977
+ if (generation !== lifecycleGeneration) {
1978
+ throw new Error("Microphone capture stopped during startup.");
1979
+ }
1980
+ audioContext = pendingAudioContext;
1981
+ mediaStream = pendingMediaStream;
1982
+ processorNode = pendingProcessorNode;
1983
+ sourceNode = pendingSourceNode;
1984
+ } catch (error) {
1985
+ release({
1986
+ audioContext: pendingAudioContext,
1987
+ mediaStream: pendingMediaStream,
1988
+ processorNode: pendingProcessorNode,
1989
+ sourceNode: pendingSourceNode
1990
+ });
1991
+ throw error;
1910
1992
  }
1911
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
1912
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
1913
- processorNode.onaudioprocess = (event) => {
1914
- const channel = event.inputBuffer.getChannelData(0);
1915
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
1916
- const pcm = floatTo16BitPCM(downsampled);
1917
- options.onLevel?.(getPcmLevel(pcm));
1918
- options.onAudio(pcm);
1919
- };
1920
- sourceNode.connect(processorNode);
1921
- processorNode.connect(audioContext.destination);
1922
- } catch (error) {
1923
- stop();
1924
- throw error;
1925
- }
1993
+ };
1994
+ const pending = operation().finally(() => {
1995
+ if (startPromise === pending)
1996
+ startPromise = null;
1997
+ });
1998
+ startPromise = pending;
1999
+ return pending;
1926
2000
  };
1927
2001
  const stop = () => {
1928
- processorNode?.disconnect();
1929
- sourceNode?.disconnect();
1930
- mediaStream?.getTracks().forEach((track) => track.stop());
1931
- audioContext?.close();
2002
+ lifecycleGeneration += 1;
2003
+ release({ audioContext, mediaStream, processorNode, sourceNode });
1932
2004
  options.onLevel?.(0);
1933
2005
  audioContext = null;
1934
2006
  mediaStream = null;
@@ -2284,6 +2356,9 @@ var createVoiceController = (path, options = {}) => {
2284
2356
  ...options.connection
2285
2357
  });
2286
2358
  let capture = null;
2359
+ let captureGeneration = 0;
2360
+ let disposed = false;
2361
+ let startRecordingPromise = null;
2287
2362
  let state = createInitialState3(stream);
2288
2363
  const subscribers = new Set;
2289
2364
  const notify = () => {
@@ -2340,6 +2415,7 @@ var createVoiceController = (path, options = {}) => {
2340
2415
  return capture;
2341
2416
  };
2342
2417
  const stopRecording = () => {
2418
+ captureGeneration += 1;
2343
2419
  capture?.stop();
2344
2420
  capture = null;
2345
2421
  state = {
@@ -2349,39 +2425,63 @@ var createVoiceController = (path, options = {}) => {
2349
2425
  notify();
2350
2426
  };
2351
2427
  const startRecording = async () => {
2428
+ if (disposed) {
2429
+ throw new Error("Voice controller is closed.");
2430
+ }
2352
2431
  if (state.isRecording) {
2353
2432
  return;
2354
2433
  }
2355
- try {
2356
- state = {
2357
- ...state,
2358
- recordingError: null
2359
- };
2360
- notify();
2361
- await ensureCapture().start();
2362
- state = {
2363
- ...state,
2364
- isRecording: true
2365
- };
2366
- notify();
2367
- } catch (error) {
2368
- capture?.stop();
2369
- capture = null;
2370
- state = {
2371
- ...state,
2372
- isRecording: false,
2373
- recordingError: error instanceof Error ? error.message : String(error)
2374
- };
2375
- notify();
2376
- throw error;
2377
- }
2434
+ if (startRecordingPromise)
2435
+ return startRecordingPromise;
2436
+ const generation = ++captureGeneration;
2437
+ const activeCapture = ensureCapture();
2438
+ const operation = (async () => {
2439
+ try {
2440
+ state = {
2441
+ ...state,
2442
+ recordingError: null
2443
+ };
2444
+ notify();
2445
+ await activeCapture.start();
2446
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
2447
+ activeCapture.stop();
2448
+ return;
2449
+ }
2450
+ state = {
2451
+ ...state,
2452
+ isRecording: true
2453
+ };
2454
+ notify();
2455
+ } catch (error) {
2456
+ activeCapture.stop();
2457
+ if (generation !== captureGeneration || capture !== activeCapture) {
2458
+ throw error;
2459
+ }
2460
+ capture = null;
2461
+ state = {
2462
+ ...state,
2463
+ isRecording: false,
2464
+ recordingError: error instanceof Error ? error.message : String(error)
2465
+ };
2466
+ notify();
2467
+ throw error;
2468
+ }
2469
+ })();
2470
+ const pending = operation.finally(() => {
2471
+ if (startRecordingPromise === pending)
2472
+ startRecordingPromise = null;
2473
+ });
2474
+ startRecordingPromise = pending;
2475
+ return startRecordingPromise;
2378
2476
  };
2379
2477
  const close = (reason) => {
2478
+ disposed = true;
2380
2479
  unsubscribeStream();
2381
2480
  stopRecording();
2382
2481
  stream.close(reason);
2383
2482
  };
2384
2483
  const disconnect = () => {
2484
+ disposed = true;
2385
2485
  unsubscribeStream();
2386
2486
  stopRecording();
2387
2487
  stream.disconnect();
@@ -1367,7 +1367,11 @@ export type VoiceConnectionOptions = {
1367
1367
  path: string;
1368
1368
  scenarioId: string | null;
1369
1369
  sessionId: string;
1370
+ signal: AbortSignal;
1370
1371
  }) => Promise<void> | void;
1372
+ /** Maximum time allowed for one prepareReconnect hook. A hook that exceeds
1373
+ * this deadline is aborted and consumes the attempt. Default 10000. */
1374
+ prepareReconnectTimeoutMs?: number;
1371
1375
  pingInterval?: number;
1372
1376
  /** Additional query values sent on every initial or reconnecting socket. */
1373
1377
  query?: Record<string, string>;