@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.
@@ -110,11 +110,19 @@ 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;
116
117
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
117
118
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
119
+
120
+ class VoiceReconnectRejectedError extends Error {
121
+ constructor(message = "Voice authorization could not be renewed.") {
122
+ super(message);
123
+ this.name = "VoiceReconnectRejectedError";
124
+ }
125
+ }
118
126
  var noop = () => {};
119
127
  var noopAsync = () => Promise.resolve();
120
128
  var noopUnsubscribe = () => noop;
@@ -194,6 +202,9 @@ var createVoiceConnection = (path, options = {}) => {
194
202
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
195
203
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
196
204
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
205
+ let lifecycleGeneration = 0;
206
+ let disposed = false;
207
+ let reconnectPreparationAbort = null;
197
208
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
198
209
  const state = {
199
210
  isConnected: false,
@@ -257,6 +268,9 @@ var createVoiceConnection = (path, options = {}) => {
257
268
  pendingCallControls.clear();
258
269
  };
259
270
  const scheduleReconnect = () => {
271
+ if (disposed)
272
+ return;
273
+ const scheduledGeneration = lifecycleGeneration;
260
274
  state.reconnectAttempts += 1;
261
275
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
262
276
  const nextAttemptAt = Date.now() + delayMs;
@@ -271,18 +285,44 @@ var createVoiceConnection = (path, options = {}) => {
271
285
  type: "connection"
272
286
  });
273
287
  state.reconnectTimeout = setTimeout(async () => {
288
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
289
+ return;
274
290
  if (state.reconnectAttempts > maxReconnectAttempts) {
275
291
  emitTerminalFailure("Voice connection could not be restored.");
276
292
  return;
277
293
  }
278
294
  try {
279
- await options.prepareReconnect?.({
280
- attempt: state.reconnectAttempts,
281
- path,
282
- scenarioId: state.scenarioId,
283
- sessionId: state.sessionId
284
- });
285
- } catch {
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
+ }
319
+ } catch (error) {
320
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
321
+ return;
322
+ if (error instanceof VoiceReconnectRejectedError) {
323
+ emitTerminalFailure(error.message);
324
+ return;
325
+ }
286
326
  if (state.reconnectAttempts < maxReconnectAttempts) {
287
327
  scheduleReconnect();
288
328
  } else {
@@ -290,10 +330,14 @@ var createVoiceConnection = (path, options = {}) => {
290
330
  }
291
331
  return;
292
332
  }
333
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
334
+ return;
293
335
  connect();
294
336
  }, delayMs);
295
337
  };
296
338
  const connect = () => {
339
+ if (disposed)
340
+ return;
297
341
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
298
342
  ws.binaryType = "arraybuffer";
299
343
  ws.onopen = () => {
@@ -364,6 +408,7 @@ var createVoiceConnection = (path, options = {}) => {
364
408
  state.ws = null;
365
409
  state.isConnected = false;
366
410
  clearTimers();
411
+ rejectPendingCallControls("Voice connection interrupted");
367
412
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
368
413
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
369
414
  if (authorizationFailure) {
@@ -394,6 +439,8 @@ var createVoiceConnection = (path, options = {}) => {
394
439
  state.ws = ws;
395
440
  };
396
441
  const sendSerialized = (value) => {
442
+ if (disposed)
443
+ return;
397
444
  if (state.ws?.readyState === WS_OPEN) {
398
445
  state.ws.send(value);
399
446
  return;
@@ -429,6 +476,9 @@ var createVoiceConnection = (path, options = {}) => {
429
476
  send({ type: "end_turn" });
430
477
  };
431
478
  const callControl = (message) => {
479
+ if (state.ws?.readyState !== WS_OPEN) {
480
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
481
+ }
432
482
  const requestId = crypto.randomUUID();
433
483
  const { promise, reject, resolve } = Promise.withResolvers();
434
484
  const timeout = setTimeout(() => {
@@ -436,16 +486,21 @@ var createVoiceConnection = (path, options = {}) => {
436
486
  reject(new Error(`${message.action} acknowledgement timed out`));
437
487
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
438
488
  pendingCallControls.set(requestId, { reject, resolve, timeout });
439
- send({
489
+ state.ws.send(JSON.stringify({
440
490
  ...message,
441
491
  requestId,
442
492
  type: "call_control"
443
- });
493
+ }));
444
494
  return promise;
445
495
  };
446
496
  const close = (reason = "client-close") => {
497
+ disposed = true;
498
+ reconnectPreparationAbort?.abort();
499
+ reconnectPreparationAbort = null;
500
+ lifecycleGeneration += 1;
447
501
  clearTimers();
448
502
  rejectPendingCallControls("Voice connection closed");
503
+ state.pendingMessages.length = 0;
449
504
  if (state.ws) {
450
505
  const ws = state.ws;
451
506
  state.ws = null;
@@ -458,8 +513,13 @@ var createVoiceConnection = (path, options = {}) => {
458
513
  listeners.clear();
459
514
  };
460
515
  const disconnect = () => {
516
+ disposed = true;
517
+ reconnectPreparationAbort?.abort();
518
+ reconnectPreparationAbort = null;
519
+ lifecycleGeneration += 1;
461
520
  clearTimers();
462
521
  rejectPendingCallControls("Voice connection disconnected");
522
+ state.pendingMessages.length = 0;
463
523
  if (state.ws) {
464
524
  const ws = state.ws;
465
525
  state.ws = null;
@@ -1629,7 +1689,10 @@ var createVoiceStream = (path, options = {}) => {
1629
1689
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
1630
1690
  }) : null;
1631
1691
  const subscribers = new Set;
1692
+ let disposed = false;
1632
1693
  const start = (input) => Promise.resolve().then(() => {
1694
+ if (disposed)
1695
+ return;
1633
1696
  if (!input?.sessionId && !input?.scenarioId) {
1634
1697
  return;
1635
1698
  }
@@ -1688,6 +1751,7 @@ var createVoiceStream = (path, options = {}) => {
1688
1751
  return connection.callControl(message);
1689
1752
  },
1690
1753
  close(reason) {
1754
+ disposed = true;
1691
1755
  unsubscribeConnection();
1692
1756
  browserMediaReporter?.close();
1693
1757
  connection.close(reason);
@@ -1695,6 +1759,7 @@ var createVoiceStream = (path, options = {}) => {
1695
1759
  notify();
1696
1760
  },
1697
1761
  disconnect() {
1762
+ disposed = true;
1698
1763
  unsubscribeConnection();
1699
1764
  browserMediaReporter?.close();
1700
1765
  connection.disconnect();
@@ -1853,48 +1918,89 @@ var createMicrophoneCapture = (options) => {
1853
1918
  let sourceNode = null;
1854
1919
  let processorNode = null;
1855
1920
  let mediaStream = null;
1856
- const start = async () => {
1857
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
1858
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
1859
- }
1860
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
1861
- if (!AudioContextCtor) {
1862
- throw new Error("Browser microphone capture requires AudioContext support.");
1863
- }
1864
- try {
1865
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
1866
- audio: {
1867
- autoGainControl: true,
1868
- channelCount: options.channelCount ?? 1,
1869
- echoCancellation: true,
1870
- 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.");
1871
1958
  }
1872
- });
1873
- audioContext = new AudioContextCtor;
1874
- if (audioContext.state === "suspended") {
1875
- 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;
1876
1992
  }
1877
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
1878
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
1879
- processorNode.onaudioprocess = (event) => {
1880
- const channel = event.inputBuffer.getChannelData(0);
1881
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
1882
- const pcm = floatTo16BitPCM(downsampled);
1883
- options.onLevel?.(getPcmLevel(pcm));
1884
- options.onAudio(pcm);
1885
- };
1886
- sourceNode.connect(processorNode);
1887
- processorNode.connect(audioContext.destination);
1888
- } catch (error) {
1889
- stop();
1890
- throw error;
1891
- }
1993
+ };
1994
+ const pending = operation().finally(() => {
1995
+ if (startPromise === pending)
1996
+ startPromise = null;
1997
+ });
1998
+ startPromise = pending;
1999
+ return pending;
1892
2000
  };
1893
2001
  const stop = () => {
1894
- processorNode?.disconnect();
1895
- sourceNode?.disconnect();
1896
- mediaStream?.getTracks().forEach((track) => track.stop());
1897
- audioContext?.close();
2002
+ lifecycleGeneration += 1;
2003
+ release({ audioContext, mediaStream, processorNode, sourceNode });
1898
2004
  options.onLevel?.(0);
1899
2005
  audioContext = null;
1900
2006
  mediaStream = null;
@@ -2250,6 +2356,9 @@ var createVoiceController = (path, options = {}) => {
2250
2356
  ...options.connection
2251
2357
  });
2252
2358
  let capture = null;
2359
+ let captureGeneration = 0;
2360
+ let disposed = false;
2361
+ let startRecordingPromise = null;
2253
2362
  let state = createInitialState3(stream);
2254
2363
  const subscribers = new Set;
2255
2364
  const notify = () => {
@@ -2306,6 +2415,7 @@ var createVoiceController = (path, options = {}) => {
2306
2415
  return capture;
2307
2416
  };
2308
2417
  const stopRecording = () => {
2418
+ captureGeneration += 1;
2309
2419
  capture?.stop();
2310
2420
  capture = null;
2311
2421
  state = {
@@ -2315,39 +2425,63 @@ var createVoiceController = (path, options = {}) => {
2315
2425
  notify();
2316
2426
  };
2317
2427
  const startRecording = async () => {
2428
+ if (disposed) {
2429
+ throw new Error("Voice controller is closed.");
2430
+ }
2318
2431
  if (state.isRecording) {
2319
2432
  return;
2320
2433
  }
2321
- try {
2322
- state = {
2323
- ...state,
2324
- recordingError: null
2325
- };
2326
- notify();
2327
- await ensureCapture().start();
2328
- state = {
2329
- ...state,
2330
- isRecording: true
2331
- };
2332
- notify();
2333
- } catch (error) {
2334
- capture?.stop();
2335
- capture = null;
2336
- state = {
2337
- ...state,
2338
- isRecording: false,
2339
- recordingError: error instanceof Error ? error.message : String(error)
2340
- };
2341
- notify();
2342
- throw error;
2343
- }
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;
2344
2476
  };
2345
2477
  const close = (reason) => {
2478
+ disposed = true;
2346
2479
  unsubscribeStream();
2347
2480
  stopRecording();
2348
2481
  stream.close(reason);
2349
2482
  };
2350
2483
  const disconnect = () => {
2484
+ disposed = true;
2351
2485
  unsubscribeStream();
2352
2486
  stopRecording();
2353
2487
  stream.disconnect();
@@ -12840,5 +12974,6 @@ export {
12840
12974
  bindVoiceReactiveSource,
12841
12975
  bindVoiceProviderSimulationControls,
12842
12976
  bindVoiceHTMX,
12843
- bindVoiceBargeIn
12977
+ bindVoiceBargeIn,
12978
+ VoiceReconnectRejectedError
12844
12979
  };
@@ -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>;