arcane-os 0.94.0 → 0.95.0

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.
@@ -4,8 +4,8 @@ import {arcaneLogging} from 'arcane-os/logging';
4
4
  const is = new Is(false);
5
5
 
6
6
  /**
7
- * Browser microphone capture with activity-selected, non-overlapping WAV clips.
8
- * Callbacks are observational and never delay the microphone or worklet.
7
+ * Browser microphone capture with activity-selected WAV clips or continuous PCM.
8
+ * PCM callbacks settle in capture order without delaying the audio worklet.
9
9
  * stop() delivers the final active clip; cancel() abandons pending delivery.
10
10
  */
11
11
  export default class ContinuousVoiceCapture {
@@ -13,6 +13,8 @@ export default class ContinuousVoiceCapture {
13
13
  #onSegment;
14
14
  #onError;
15
15
  #onState;
16
+ #onPCM;
17
+ #onActivity;
16
18
  #session = null;
17
19
 
18
20
  constructor(
@@ -21,7 +23,11 @@ export default class ContinuousVoiceCapture {
21
23
  quietMs = 2000,
22
24
  chunkMs = 30000,
23
25
  activityThreshold = 0.02,
26
+ mode = 'segments',
27
+ pcmChunkMs = 100,
24
28
  onSegment,
29
+ onPCM,
30
+ onActivity,
25
31
  onError,
26
32
  onState
27
33
  } = {}
@@ -38,8 +44,16 @@ export default class ContinuousVoiceCapture {
38
44
  if (!is.finite(activityThreshold) || activityThreshold < 0) {
39
45
  throw new RangeError('activityThreshold must be a finite nonnegative amplitude.');
40
46
  }
41
- this.#options = {preRollMs, quietMs, chunkMs, activityThreshold};
47
+ if (!['segments', 'pcm'].includes(mode)) {
48
+ throw new TypeError('Voice capture mode must be segments or pcm.');
49
+ }
50
+ if (!is.finite(pcmChunkMs) || pcmChunkMs <= 0) {
51
+ throw new RangeError('pcmChunkMs must be a finite positive duration.');
52
+ }
53
+ this.#options = {preRollMs, quietMs, chunkMs, activityThreshold, mode, pcmChunkMs};
42
54
  this.#onSegment = onSegment;
55
+ this.#onPCM = onPCM;
56
+ this.#onActivity = onActivity;
43
57
  this.#onError = onError;
44
58
  this.#onState = onState;
45
59
  }
@@ -47,10 +61,11 @@ export default class ContinuousVoiceCapture {
47
61
  async start(
48
62
  {
49
63
  mediaConstraints = {audio: true},
64
+ sampleRate,
50
65
  signal
51
66
  } = {}
52
67
  ) {
53
- if ((this.#session && !this.#session.settled) || signal?.aborted) return false;
68
+ if (this.#session || signal?.aborted) return false;
54
69
 
55
70
  const session = {
56
71
  phase: 'starting',
@@ -62,8 +77,12 @@ export default class ContinuousVoiceCapture {
62
77
  node: null,
63
78
  cleanup: [],
64
79
  errors: [],
80
+ pcmDelivery: Promise.resolve(),
81
+ flushes: new Map(),
82
+ flushSequence: 0,
65
83
  resolveStartup: null,
66
- resolveStop: null
84
+ resolveStop: null,
85
+ rejectStop: null
67
86
  };
68
87
  session.startupCancelled = new Promise(
69
88
  function ownStartupCancellation(resolve) {
@@ -71,10 +90,12 @@ export default class ContinuousVoiceCapture {
71
90
  }
72
91
  );
73
92
  session.stopped = new Promise(
74
- function ownCaptureStop(resolve) {
93
+ function ownCaptureStop(resolve, reject) {
75
94
  session.resolveStop = resolve;
95
+ session.rejectStop = reject;
76
96
  }
77
97
  );
98
+ session.stopped.catch(function observeCaptureStopFailure() {});
78
99
  this.#session = session;
79
100
 
80
101
  const owner = this;
@@ -101,7 +122,7 @@ export default class ContinuousVoiceCapture {
101
122
  this.#notify(session, this.#onState, 'starting');
102
123
  if (!this.#starting(session)) return false;
103
124
 
104
- const preparation = this.#prepare(session, mediaConstraints);
125
+ const preparation = this.#prepare(session, mediaConstraints, sampleRate);
105
126
  return Promise.race(
106
127
  [preparation, session.startupCancelled]
107
128
  );
@@ -136,27 +157,46 @@ export default class ContinuousVoiceCapture {
136
157
 
137
158
  cancel() {
138
159
  const session = this.#session;
139
- if (!session) return;
160
+ if (!session) return Promise.resolve();
140
161
  session.cancelled = true;
141
162
  this.#settle(session, 'stopped');
163
+ return session.stopped;
164
+ }
165
+
166
+ flush() {
167
+ const session = this.#session;
168
+ if (!session || session.phase !== 'listening') return session?.pcmDelivery ?? Promise.resolve();
169
+ session.flushSequence += 1;
170
+ const id = session.flushSequence;
171
+ const task = new Promise(function ownCaptureFlush(resolve, reject) {
172
+ session.flushes.set(id, {resolve, reject});
173
+ });
174
+ try {
175
+ session.node.port.postMessage({type: 'flush', id});
176
+ } catch (error) {
177
+ session.flushes.delete(id);
178
+ this.#fail(session, error);
179
+ return Promise.reject(error);
180
+ }
181
+ return task;
142
182
  }
143
183
 
144
184
  destroy() {
145
- this.cancel();
185
+ return this.cancel();
146
186
  }
147
187
 
148
188
  #starting(session) {
149
189
  return this.#session === session && session.phase === 'starting';
150
190
  }
151
191
 
152
- async #prepare(session, mediaConstraints) {
192
+ async #prepare(session, mediaConstraints, sampleRate) {
153
193
  try {
154
194
  if (!is.function(globalThis.AudioContext)
155
195
  || !is.function(globalThis.AudioWorkletNode)
156
196
  || !is.function(globalThis.navigator?.mediaDevices?.getUserMedia)) {
157
197
  throw new Error('Continuous voice capture requires microphone access and AudioWorklet.');
158
198
  }
159
- const context = new globalThis.AudioContext();
199
+ const context = new globalThis.AudioContext(sampleRate === undefined ? undefined : {sampleRate});
160
200
  session.context = context;
161
201
  if (!context.audioWorklet) {
162
202
  throw new Error('This browser does not expose AudioWorklet microphone processing.');
@@ -226,6 +266,22 @@ export default class ContinuousVoiceCapture {
226
266
  durationMs: message.durationMs
227
267
  }
228
268
  );
269
+ } else if (message.type === 'pcm') {
270
+ owner.#deliverPCM(session, {
271
+ samples: new Float32Array(message.audio),
272
+ sampleRate: message.sampleRate,
273
+ channels: 1,
274
+ sequence: message.sequence
275
+ });
276
+ } else if (message.type === 'activity') {
277
+ owner.#notify(session, owner.#onActivity, {
278
+ active: message.active,
279
+ audioTime: message.audioTime
280
+ });
281
+ } else if (message.type === 'flushed') {
282
+ const flush = session.flushes.get(message.id);
283
+ session.flushes.delete(message.id);
284
+ if (flush) session.pcmDelivery.then(flush.resolve, flush.reject);
229
285
  } else if (message.type === 'stopped') {
230
286
  owner.#settle(session, 'stopped');
231
287
  } else if (message.type === 'error') {
@@ -321,6 +377,23 @@ export default class ContinuousVoiceCapture {
321
377
  await context.resume();
322
378
  }
323
379
 
380
+ #deliverPCM(session, value) {
381
+ const owner = this;
382
+ // Capture and inference run concurrently. This ordered delivery chain
383
+ // retains every sample while the consumer catches up; no activity gate
384
+ // selects or discards audio in continuous mode.
385
+ session.pcmDelivery = session.pcmDelivery.then(async function deliverCapturedPCM() {
386
+ if (!session.cancelled && is.function(owner.#onPCM)) {
387
+ await owner.#onPCM(value);
388
+ }
389
+ });
390
+ // Observe failure without converting the delivery/flush contract to success.
391
+ session.pcmDelivery.catch(function reportPCMDeliveryFailure(error) {
392
+ if (!session.settled) owner.#fail(session, error);
393
+ else owner.#reportError(session, error, true);
394
+ });
395
+ }
396
+
324
397
  #notify(session, callback, value) {
325
398
  if (session.cancelled
326
399
  || (this.#session && this.#session !== session)
@@ -424,23 +497,47 @@ export default class ContinuousVoiceCapture {
424
497
  }
425
498
  }
426
499
  const context = session.context;
500
+ let contextClosing = Promise.resolve();
427
501
  if (context && context.state !== 'closed') {
428
- const owner = this;
429
- function observeContextCloseFailure(error) {
430
- owner.#reportError(session, error);
431
- }
432
502
  try {
433
- Promise.resolve(context.close()).catch(observeContextCloseFailure);
503
+ contextClosing = Promise.resolve(context.close());
434
504
  } catch (error) {
435
505
  session.errors.push(error);
436
506
  }
437
507
  }
438
508
  session.resolveStartup(false);
439
- for (const error of session.errors) {
440
- this.#reportError(session, error, true);
509
+ for (const flush of session.flushes.values()) {
510
+ if (session.errors.length) flush.reject(session.errors[0]);
511
+ else session.pcmDelivery.then(flush.resolve, flush.reject);
441
512
  }
442
- this.#notify(session, this.#onState, session.errors.length ? 'interrupted' : state);
443
- if (this.#session === session) this.#session = null;
444
- session.resolveStop();
513
+ session.flushes.clear();
514
+ // stop() drains callbacks already accepted before the worklet's stop
515
+ // acknowledgment. cancel() explicitly abandons subsequent delivery;
516
+ // both operations join the context's actual asynchronous close.
517
+ const owner = this;
518
+ Promise.allSettled(
519
+ [session.pcmDelivery, contextClosing]
520
+ ).then(
521
+ function finishCapturedCleanup(results) {
522
+ for (const result of results) {
523
+ if (result.status === 'rejected' && !session.errors.includes(result.reason)) {
524
+ session.errors.push(result.reason);
525
+ }
526
+ }
527
+ if (owner.#session === session) owner.#session = null;
528
+ if (session.errors.length) {
529
+ session.rejectStop(
530
+ session.errors.length === 1 ? session.errors[0] : new AggregateError(
531
+ session.errors,
532
+ 'Voice capture delivery or cleanup failed.'
533
+ )
534
+ );
535
+ } else {
536
+ session.resolveStop();
537
+ }
538
+ for (const error of session.errors) owner.#reportError(session, error, true);
539
+ owner.#notify(session, owner.#onState, session.errors.length ? 'interrupted' : state);
540
+ }
541
+ );
445
542
  }
446
543
  }
@@ -0,0 +1,198 @@
1
+ import ContinuousVoiceCapture from './ContinuousVoiceCapture.js';
2
+
3
+ /**
4
+ * One microphone lifetime and one scoped Core ASR stream.
5
+ * Recognition callbacks receive (result, {requestStop}). They request a drain
6
+ * without awaiting it, then return; the external owner joins stop() or done.
7
+ * Awaiting outer stop()/done/forceEndpoint() from a recognition callback waits on that
8
+ * callback itself. Segments do not submit chat turns.
9
+ */
10
+ export default function createNativeSpeechCapture({speech, model, ...options}) {
11
+ const controller = new AbortController();
12
+ let stream;
13
+ let started = false;
14
+ let stopping;
15
+ let settled = false;
16
+ let cancelled = false;
17
+ let cleanup;
18
+ let detachSignal;
19
+ let resolveDone;
20
+ let rejectDone;
21
+ const done = new Promise(function ownNativeCapture(resolve, reject) {
22
+ resolveDone = resolve;
23
+ rejectDone = reject;
24
+ });
25
+ // The caller still receives rejection through done; this observer prevents
26
+ // a synchronous device failure from becoming an unhandled rejection first.
27
+ done.catch(function observeNativeCaptureFailure() {});
28
+
29
+ function finish(error, result) {
30
+ if (settled) return;
31
+ settled = true;
32
+ detachSignal?.();
33
+ if (error) rejectDone(error);
34
+ else resolveDone(result);
35
+ }
36
+
37
+ function fail(error) {
38
+ if (settled || cleanup) return cleanup;
39
+ return endCapture(error, error);
40
+ }
41
+
42
+ function endCapture(reason, primaryError) {
43
+ if (settled || cleanup) return cleanup ?? done;
44
+ const captureCleanup = capture.cancel();
45
+ // Join the explicit native cancellation operation, not the immediately
46
+ // aborted browser request, before releasing the capture/provider lane.
47
+ // Native cancellation proceeds while capture joins its pending PCM.
48
+ const nativeCleanup = Promise.resolve().then(function cancelNativeStream() {
49
+ return stream?.cancel(reason);
50
+ });
51
+ cleanup = Promise.allSettled(
52
+ [captureCleanup, nativeCleanup]
53
+ ).then(
54
+ function nativeCaptureReleased(results) {
55
+ const errors = primaryError ? [primaryError] : [];
56
+ for (const result of results) {
57
+ if (result.status === 'rejected' && !errors.includes(result.reason)) errors.push(result.reason);
58
+ }
59
+ const error = errors.length > 1
60
+ ? new AggregateError(errors, 'Native capture failed during cleanup.')
61
+ : errors[0];
62
+ finish(error, results[1].value);
63
+ if (error) return options.onError?.(error);
64
+ }
65
+ );
66
+ cleanup.catch(function observeCaptureErrorCallback(error) {
67
+ console.error('Native speech capture error callback failed.', error);
68
+ });
69
+ controller.abort(reason);
70
+ return cleanup;
71
+ }
72
+
73
+ const capture = new ContinuousVoiceCapture({
74
+ mode: 'pcm',
75
+ pcmChunkMs: options.pcmChunkMs ?? 100,
76
+ activityThreshold: options.activityThreshold ?? 0.02,
77
+ async onPCM(record) {
78
+ try {
79
+ await stream.ready;
80
+ controller.signal.throwIfAborted();
81
+ const sampleRate = options.streamOptions?.sampleRate ?? options.sampleRate ?? 16000;
82
+ if (record.sampleRate !== sampleRate) {
83
+ throw new Error(`Microphone PCM sample rate ${record.sampleRate} differs from native stream rate ${sampleRate}.`);
84
+ }
85
+ await stream.push(record.samples);
86
+ } catch (error) {
87
+ // The owned cancellation ends an in-flight browser request;
88
+ // that expected abort is distinct from actual delivery failure.
89
+ if (controller.signal.aborted && (
90
+ error === controller.signal.reason || error?.name === 'AbortError' || error?.code === 'ARCANE_CANCELLED'
91
+ )) return;
92
+ throw error;
93
+ }
94
+ },
95
+ onActivity: options.onActivity,
96
+ onState: options.onState,
97
+ onError: fail
98
+ });
99
+
100
+ function cancel(reason) {
101
+ if (settled) return done;
102
+ cancelled = true;
103
+ endCapture(reason);
104
+ return done;
105
+ }
106
+
107
+ function stop() {
108
+ if (stopping || settled) return stopping ?? done;
109
+ if (!stream) return cancel();
110
+ async function drainNativeCapture() {
111
+ await capture.stop();
112
+ if (!controller.signal.aborted) await stream.finish();
113
+ return done;
114
+ }
115
+ stopping = drainNativeCapture().catch(
116
+ function nativeCaptureStopFailed(error) {
117
+ fail(error);
118
+ return done;
119
+ }
120
+ );
121
+ return stopping;
122
+ }
123
+
124
+ function requestStop() {
125
+ // A callback requests the same terminal drain without joining itself.
126
+ // done retains its success or failure for the external owning caller.
127
+ stop().catch(function observeRequestedCaptureStop() {});
128
+ }
129
+
130
+ const callbackControls = {requestStop};
131
+
132
+ return {
133
+ done,
134
+ async start({signal, mediaConstraints} = {}) {
135
+ if (started || settled) return false;
136
+ started = true;
137
+ function externalCancellation() { cancel(signal.reason); }
138
+ if (signal) {
139
+ signal.addEventListener('abort', externalCancellation, {once: true});
140
+ detachSignal = function removeCaptureSignal() {
141
+ signal.removeEventListener('abort', externalCancellation);
142
+ };
143
+ if (signal.aborted) {
144
+ cancel(signal.reason);
145
+ return false;
146
+ }
147
+ }
148
+ try {
149
+ stream = speech.openStream({model, sampleRate: options.sampleRate ?? 16000, ...options.streamOptions}, {
150
+ signal: controller.signal,
151
+ onProgress: options.onProgress,
152
+ onRequest: options.onRequest,
153
+ async onUpdate(result) {
154
+ if (controller.signal.aborted) return;
155
+ await options.onUpdate?.(result, callbackControls);
156
+ // These are ASR snapshot callbacks, not semantic turn-end
157
+ // decisions. Preserve every field and replacement partial.
158
+ if (result.final) await options.onSegment?.(result, callbackControls);
159
+ else await options.onInterim?.(result, callbackControls);
160
+ }
161
+ });
162
+ stream.completion.then(async function nativeStreamFinished(result) {
163
+ if (cleanup) return;
164
+ await capture.cancel();
165
+ if (cleanup) return cleanup;
166
+ finish(undefined, result);
167
+ }, fail).catch(function captureObserverFailed(error) {
168
+ return fail(error);
169
+ });
170
+ // Microphone permission remains in the initiating user gesture;
171
+ // its samples queue while the native stream becomes ready.
172
+ const capturing = capture.start({
173
+ signal: controller.signal,
174
+ sampleRate: options.streamOptions?.sampleRate ?? options.sampleRate ?? 16000,
175
+ mediaConstraints: mediaConstraints ?? options.mediaConstraints ?? {audio: {echoCancellation: true}}
176
+ });
177
+ const [ready] = await Promise.all([capturing, stream.ready]);
178
+ if (!ready && !settled) cancel();
179
+ return ready && !settled;
180
+ } catch (error) {
181
+ if (!cancelled) await fail(error);
182
+ else await cleanup;
183
+ return false;
184
+ }
185
+ },
186
+ async forceEndpoint() {
187
+ if (!started || settled || stopping) return [];
188
+ await capture.flush();
189
+ controller.signal.throwIfAborted();
190
+ return stream.forceEndpoint();
191
+ },
192
+ stop,
193
+ requestStop,
194
+ cancel,
195
+ destroy: cancel,
196
+ current() { return stream?.current() ?? null; }
197
+ };
198
+ }