@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.
@@ -2738,48 +2738,89 @@ var createMicrophoneCapture = (options) => {
2738
2738
  let sourceNode = null;
2739
2739
  let processorNode = null;
2740
2740
  let mediaStream = null;
2741
- const start = async () => {
2742
- if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
2743
- throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
2744
- }
2745
- const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
2746
- if (!AudioContextCtor) {
2747
- throw new Error("Browser microphone capture requires AudioContext support.");
2748
- }
2749
- try {
2750
- mediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
2751
- audio: {
2752
- autoGainControl: true,
2753
- channelCount: options.channelCount ?? 1,
2754
- echoCancellation: true,
2755
- noiseSuppression: true
2741
+ let lifecycleGeneration = 0;
2742
+ let startPromise = null;
2743
+ const release = (resources) => {
2744
+ resources.processorNode?.disconnect();
2745
+ resources.sourceNode?.disconnect();
2746
+ resources.mediaStream?.getTracks().forEach((track) => track.stop());
2747
+ resources.audioContext?.close();
2748
+ };
2749
+ const start = () => {
2750
+ if (mediaStream)
2751
+ return Promise.resolve();
2752
+ if (startPromise)
2753
+ return startPromise;
2754
+ const generation = ++lifecycleGeneration;
2755
+ const operation = async () => {
2756
+ if (!options.stream && (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia)) {
2757
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
2758
+ }
2759
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
2760
+ if (!AudioContextCtor) {
2761
+ throw new Error("Browser microphone capture requires AudioContext support.");
2762
+ }
2763
+ let pendingAudioContext = null;
2764
+ let pendingMediaStream = null;
2765
+ let pendingProcessorNode = null;
2766
+ let pendingSourceNode = null;
2767
+ try {
2768
+ pendingMediaStream = options.stream ?? await navigator.mediaDevices.getUserMedia({
2769
+ audio: {
2770
+ autoGainControl: true,
2771
+ channelCount: options.channelCount ?? 1,
2772
+ echoCancellation: true,
2773
+ noiseSuppression: true
2774
+ }
2775
+ });
2776
+ if (generation !== lifecycleGeneration) {
2777
+ throw new Error("Microphone capture stopped during startup.");
2756
2778
  }
2757
- });
2758
- audioContext = new AudioContextCtor;
2759
- if (audioContext.state === "suspended") {
2760
- await audioContext.resume();
2761
- }
2762
- sourceNode = audioContext.createMediaStreamSource(mediaStream);
2763
- processorNode = audioContext.createScriptProcessor(4096, 1, 1);
2764
- processorNode.onaudioprocess = (event) => {
2765
- const channel = event.inputBuffer.getChannelData(0);
2766
- const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
2767
- const pcm = floatTo16BitPCM(downsampled);
2768
- options.onLevel?.(getPcmLevel(pcm));
2769
- options.onAudio(pcm);
2770
- };
2771
- sourceNode.connect(processorNode);
2772
- processorNode.connect(audioContext.destination);
2773
- } catch (error) {
2774
- stop();
2775
- throw error;
2776
- }
2779
+ pendingAudioContext = new AudioContextCtor;
2780
+ if (pendingAudioContext.state === "suspended") {
2781
+ await pendingAudioContext.resume();
2782
+ }
2783
+ if (generation !== lifecycleGeneration) {
2784
+ throw new Error("Microphone capture stopped during startup.");
2785
+ }
2786
+ pendingSourceNode = pendingAudioContext.createMediaStreamSource(pendingMediaStream);
2787
+ pendingProcessorNode = pendingAudioContext.createScriptProcessor(4096, 1, 1);
2788
+ pendingProcessorNode.onaudioprocess = (event) => {
2789
+ const channel = event.inputBuffer.getChannelData(0);
2790
+ const downsampled = downsampleBuffer(channel, pendingAudioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
2791
+ const pcm = floatTo16BitPCM(downsampled);
2792
+ options.onLevel?.(getPcmLevel(pcm));
2793
+ options.onAudio(pcm);
2794
+ };
2795
+ pendingSourceNode.connect(pendingProcessorNode);
2796
+ pendingProcessorNode.connect(pendingAudioContext.destination);
2797
+ if (generation !== lifecycleGeneration) {
2798
+ throw new Error("Microphone capture stopped during startup.");
2799
+ }
2800
+ audioContext = pendingAudioContext;
2801
+ mediaStream = pendingMediaStream;
2802
+ processorNode = pendingProcessorNode;
2803
+ sourceNode = pendingSourceNode;
2804
+ } catch (error) {
2805
+ release({
2806
+ audioContext: pendingAudioContext,
2807
+ mediaStream: pendingMediaStream,
2808
+ processorNode: pendingProcessorNode,
2809
+ sourceNode: pendingSourceNode
2810
+ });
2811
+ throw error;
2812
+ }
2813
+ };
2814
+ const pending = operation().finally(() => {
2815
+ if (startPromise === pending)
2816
+ startPromise = null;
2817
+ });
2818
+ startPromise = pending;
2819
+ return pending;
2777
2820
  };
2778
2821
  const stop = () => {
2779
- processorNode?.disconnect();
2780
- sourceNode?.disconnect();
2781
- mediaStream?.getTracks().forEach((track) => track.stop());
2782
- audioContext?.close();
2822
+ lifecycleGeneration += 1;
2823
+ release({ audioContext, mediaStream, processorNode, sourceNode });
2783
2824
  options.onLevel?.(0);
2784
2825
  audioContext = null;
2785
2826
  mediaStream = null;
@@ -2991,11 +3032,19 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
2991
3032
  var DEFAULT_PING_INTERVAL = 30000;
2992
3033
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
2993
3034
  var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
3035
+ var DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS = 1e4;
2994
3036
  var MAX_PENDING_CONTROL_MESSAGES = 100;
2995
3037
  var RECONNECT_BASE_DELAY_MS = 500;
2996
3038
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
2997
3039
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
2998
3040
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
3041
+
3042
+ class VoiceReconnectRejectedError extends Error {
3043
+ constructor(message = "Voice authorization could not be renewed.") {
3044
+ super(message);
3045
+ this.name = "VoiceReconnectRejectedError";
3046
+ }
3047
+ }
2999
3048
  var noop = () => {};
3000
3049
  var noopAsync = () => Promise.resolve();
3001
3050
  var noopUnsubscribe = () => noop;
@@ -3075,6 +3124,9 @@ var createVoiceConnection = (path, options = {}) => {
3075
3124
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
3076
3125
  const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
3077
3126
  const reconnectResetAfterMs = options.reconnectResetAfterMs ?? DEFAULT_RECONNECT_RESET_AFTER_MS;
3127
+ let lifecycleGeneration = 0;
3128
+ let disposed = false;
3129
+ let reconnectPreparationAbort = null;
3078
3130
  const computeReconnectDelayMs = (attempt) => computeVoiceReconnectDelayMs(attempt, RECONNECT_BASE_DELAY_MS, reconnectMaxDelayMs);
3079
3131
  const state = {
3080
3132
  isConnected: false,
@@ -3138,6 +3190,9 @@ var createVoiceConnection = (path, options = {}) => {
3138
3190
  pendingCallControls.clear();
3139
3191
  };
3140
3192
  const scheduleReconnect = () => {
3193
+ if (disposed)
3194
+ return;
3195
+ const scheduledGeneration = lifecycleGeneration;
3141
3196
  state.reconnectAttempts += 1;
3142
3197
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
3143
3198
  const nextAttemptAt = Date.now() + delayMs;
@@ -3152,18 +3207,44 @@ var createVoiceConnection = (path, options = {}) => {
3152
3207
  type: "connection"
3153
3208
  });
3154
3209
  state.reconnectTimeout = setTimeout(async () => {
3210
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
3211
+ return;
3155
3212
  if (state.reconnectAttempts > maxReconnectAttempts) {
3156
3213
  emitTerminalFailure("Voice connection could not be restored.");
3157
3214
  return;
3158
3215
  }
3159
3216
  try {
3160
- await options.prepareReconnect?.({
3161
- attempt: state.reconnectAttempts,
3162
- path,
3163
- scenarioId: state.scenarioId,
3164
- sessionId: state.sessionId
3165
- });
3166
- } catch {
3217
+ if (options.prepareReconnect) {
3218
+ const preparationAbort = new AbortController;
3219
+ reconnectPreparationAbort = preparationAbort;
3220
+ const timeout = setTimeout(() => preparationAbort.abort("Voice reconnect preparation timed out"), options.prepareReconnectTimeoutMs ?? DEFAULT_PREPARE_RECONNECT_TIMEOUT_MS);
3221
+ try {
3222
+ await Promise.race([
3223
+ options.prepareReconnect({
3224
+ attempt: state.reconnectAttempts,
3225
+ path,
3226
+ scenarioId: state.scenarioId,
3227
+ sessionId: state.sessionId,
3228
+ signal: preparationAbort.signal
3229
+ }),
3230
+ new Promise((_, reject) => {
3231
+ preparationAbort.signal.addEventListener("abort", () => reject(new Error("Voice reconnect preparation timed out.")), { once: true });
3232
+ })
3233
+ ]);
3234
+ } finally {
3235
+ clearTimeout(timeout);
3236
+ if (reconnectPreparationAbort === preparationAbort) {
3237
+ reconnectPreparationAbort = null;
3238
+ }
3239
+ }
3240
+ }
3241
+ } catch (error) {
3242
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
3243
+ return;
3244
+ if (error instanceof VoiceReconnectRejectedError) {
3245
+ emitTerminalFailure(error.message);
3246
+ return;
3247
+ }
3167
3248
  if (state.reconnectAttempts < maxReconnectAttempts) {
3168
3249
  scheduleReconnect();
3169
3250
  } else {
@@ -3171,10 +3252,14 @@ var createVoiceConnection = (path, options = {}) => {
3171
3252
  }
3172
3253
  return;
3173
3254
  }
3255
+ if (disposed || lifecycleGeneration !== scheduledGeneration)
3256
+ return;
3174
3257
  connect();
3175
3258
  }, delayMs);
3176
3259
  };
3177
3260
  const connect = () => {
3261
+ if (disposed)
3262
+ return;
3178
3263
  const ws = new WebSocket(buildWsUrl(path, state.sessionId, state.scenarioId, options.query ?? {}));
3179
3264
  ws.binaryType = "arraybuffer";
3180
3265
  ws.onopen = () => {
@@ -3245,6 +3330,7 @@ var createVoiceConnection = (path, options = {}) => {
3245
3330
  state.ws = null;
3246
3331
  state.isConnected = false;
3247
3332
  clearTimers();
3333
+ rejectPendingCallControls("Voice connection interrupted");
3248
3334
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
3249
3335
  const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
3250
3336
  if (authorizationFailure) {
@@ -3275,6 +3361,8 @@ var createVoiceConnection = (path, options = {}) => {
3275
3361
  state.ws = ws;
3276
3362
  };
3277
3363
  const sendSerialized = (value) => {
3364
+ if (disposed)
3365
+ return;
3278
3366
  if (state.ws?.readyState === WS_OPEN) {
3279
3367
  state.ws.send(value);
3280
3368
  return;
@@ -3310,6 +3398,9 @@ var createVoiceConnection = (path, options = {}) => {
3310
3398
  send({ type: "end_turn" });
3311
3399
  };
3312
3400
  const callControl = (message) => {
3401
+ if (state.ws?.readyState !== WS_OPEN) {
3402
+ return Promise.reject(new Error(`${message.action} is unavailable while disconnected`));
3403
+ }
3313
3404
  const requestId = crypto.randomUUID();
3314
3405
  const { promise, reject, resolve } = Promise.withResolvers();
3315
3406
  const timeout = setTimeout(() => {
@@ -3317,16 +3408,21 @@ var createVoiceConnection = (path, options = {}) => {
3317
3408
  reject(new Error(`${message.action} acknowledgement timed out`));
3318
3409
  }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
3319
3410
  pendingCallControls.set(requestId, { reject, resolve, timeout });
3320
- send({
3411
+ state.ws.send(JSON.stringify({
3321
3412
  ...message,
3322
3413
  requestId,
3323
3414
  type: "call_control"
3324
- });
3415
+ }));
3325
3416
  return promise;
3326
3417
  };
3327
3418
  const close = (reason = "client-close") => {
3419
+ disposed = true;
3420
+ reconnectPreparationAbort?.abort();
3421
+ reconnectPreparationAbort = null;
3422
+ lifecycleGeneration += 1;
3328
3423
  clearTimers();
3329
3424
  rejectPendingCallControls("Voice connection closed");
3425
+ state.pendingMessages.length = 0;
3330
3426
  if (state.ws) {
3331
3427
  const ws = state.ws;
3332
3428
  state.ws = null;
@@ -3339,8 +3435,13 @@ var createVoiceConnection = (path, options = {}) => {
3339
3435
  listeners.clear();
3340
3436
  };
3341
3437
  const disconnect = () => {
3438
+ disposed = true;
3439
+ reconnectPreparationAbort?.abort();
3440
+ reconnectPreparationAbort = null;
3441
+ lifecycleGeneration += 1;
3342
3442
  clearTimers();
3343
3443
  rejectPendingCallControls("Voice connection disconnected");
3444
+ state.pendingMessages.length = 0;
3344
3445
  if (state.ws) {
3345
3446
  const ws = state.ws;
3346
3447
  state.ws = null;
@@ -3588,7 +3689,10 @@ var createVoiceStream = (path, options = {}) => {
3588
3689
  getSessionId: () => options.browserMedia ? options.browserMedia.getSessionId?.() ?? connection.getSessionId() : connection.getSessionId()
3589
3690
  }) : null;
3590
3691
  const subscribers = new Set;
3692
+ let disposed = false;
3591
3693
  const start = (input) => Promise.resolve().then(() => {
3694
+ if (disposed)
3695
+ return;
3592
3696
  if (!input?.sessionId && !input?.scenarioId) {
3593
3697
  return;
3594
3698
  }
@@ -3647,6 +3751,7 @@ var createVoiceStream = (path, options = {}) => {
3647
3751
  return connection.callControl(message);
3648
3752
  },
3649
3753
  close(reason) {
3754
+ disposed = true;
3650
3755
  unsubscribeConnection();
3651
3756
  browserMediaReporter?.close();
3652
3757
  connection.close(reason);
@@ -3654,6 +3759,7 @@ var createVoiceStream = (path, options = {}) => {
3654
3759
  notify();
3655
3760
  },
3656
3761
  disconnect() {
3762
+ disposed = true;
3657
3763
  unsubscribeConnection();
3658
3764
  browserMediaReporter?.close();
3659
3765
  connection.disconnect();
@@ -4057,6 +4163,9 @@ var createVoiceController = (path, options = {}) => {
4057
4163
  ...options.connection
4058
4164
  });
4059
4165
  let capture = null;
4166
+ let captureGeneration = 0;
4167
+ let disposed = false;
4168
+ let startRecordingPromise = null;
4060
4169
  let state = createInitialState3(stream);
4061
4170
  const subscribers = new Set;
4062
4171
  const notify = () => {
@@ -4113,6 +4222,7 @@ var createVoiceController = (path, options = {}) => {
4113
4222
  return capture;
4114
4223
  };
4115
4224
  const stopRecording = () => {
4225
+ captureGeneration += 1;
4116
4226
  capture?.stop();
4117
4227
  capture = null;
4118
4228
  state = {
@@ -4122,39 +4232,63 @@ var createVoiceController = (path, options = {}) => {
4122
4232
  notify();
4123
4233
  };
4124
4234
  const startRecording = async () => {
4235
+ if (disposed) {
4236
+ throw new Error("Voice controller is closed.");
4237
+ }
4125
4238
  if (state.isRecording) {
4126
4239
  return;
4127
4240
  }
4128
- try {
4129
- state = {
4130
- ...state,
4131
- recordingError: null
4132
- };
4133
- notify();
4134
- await ensureCapture().start();
4135
- state = {
4136
- ...state,
4137
- isRecording: true
4138
- };
4139
- notify();
4140
- } catch (error) {
4141
- capture?.stop();
4142
- capture = null;
4143
- state = {
4144
- ...state,
4145
- isRecording: false,
4146
- recordingError: error instanceof Error ? error.message : String(error)
4147
- };
4148
- notify();
4149
- throw error;
4150
- }
4241
+ if (startRecordingPromise)
4242
+ return startRecordingPromise;
4243
+ const generation = ++captureGeneration;
4244
+ const activeCapture = ensureCapture();
4245
+ const operation = (async () => {
4246
+ try {
4247
+ state = {
4248
+ ...state,
4249
+ recordingError: null
4250
+ };
4251
+ notify();
4252
+ await activeCapture.start();
4253
+ if (disposed || generation !== captureGeneration || capture !== activeCapture) {
4254
+ activeCapture.stop();
4255
+ return;
4256
+ }
4257
+ state = {
4258
+ ...state,
4259
+ isRecording: true
4260
+ };
4261
+ notify();
4262
+ } catch (error) {
4263
+ activeCapture.stop();
4264
+ if (generation !== captureGeneration || capture !== activeCapture) {
4265
+ throw error;
4266
+ }
4267
+ capture = null;
4268
+ state = {
4269
+ ...state,
4270
+ isRecording: false,
4271
+ recordingError: error instanceof Error ? error.message : String(error)
4272
+ };
4273
+ notify();
4274
+ throw error;
4275
+ }
4276
+ })();
4277
+ const pending = operation.finally(() => {
4278
+ if (startRecordingPromise === pending)
4279
+ startRecordingPromise = null;
4280
+ });
4281
+ startRecordingPromise = pending;
4282
+ return startRecordingPromise;
4151
4283
  };
4152
4284
  const close = (reason) => {
4285
+ disposed = true;
4153
4286
  unsubscribeStream();
4154
4287
  stopRecording();
4155
4288
  stream.close(reason);
4156
4289
  };
4157
4290
  const disconnect = () => {
4291
+ disposed = true;
4158
4292
  unsubscribeStream();
4159
4293
  stopRecording();
4160
4294
  stream.disconnect();