arcane-os 0.93.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/twin-cloud.mjs +109 -46
  4. package/browser-runtime/core/client.mjs +197 -0
  5. package/docs/reference/ai/twin-cloud.md +35 -14
  6. package/docs/reference/availability-and-normalization.md +1 -1
  7. package/docs/reference/inventory/package-api.json +96 -12
  8. package/docs/reference/local-ai.md +81 -5
  9. package/docs/reference/native-magpie.md +98 -0
  10. package/docs/reference/native-nemotron.md +204 -0
  11. package/docs/reference/native-speech.md +364 -11
  12. package/docs/reference/runtime-modules.md +21 -18
  13. package/docs/reference/sdk-api.md +152 -11
  14. package/package.json +6 -1
  15. package/runtime/arcane/modules/AI.js +114 -14
  16. package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
  17. package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
  18. package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
  19. package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
  20. package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
  21. package/schemas/arcane-app.schema.json +2 -2
  22. package/schemas/arcane-package.schema.json +2 -2
  23. package/src/core/services/speech.mjs +320 -25
  24. package/src/local-ai/config.mjs +12 -1
  25. package/src/local-ai/dev.mjs +71 -4
  26. package/src/local-ai/install.mjs +113 -5
  27. package/src/local-ai/kokoro/frontend.mjs +219 -0
  28. package/src/local-ai/kokoro/helper.mjs +168 -0
  29. package/src/local-ai/kokoro/index.mjs +645 -0
  30. package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
  31. package/src/local-ai/kokoro/native/main.cpp +249 -0
  32. package/src/local-ai/magpie/build.mjs +36 -0
  33. package/src/local-ai/magpie/helper.mjs +254 -0
  34. package/src/local-ai/magpie/index.mjs +488 -0
  35. package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
  36. package/src/local-ai/magpie/native/main.cpp +270 -0
  37. package/src/local-ai/nemotron/build.mjs +53 -0
  38. package/src/local-ai/nemotron/helper.mjs +308 -0
  39. package/src/local-ai/nemotron/index.mjs +637 -0
  40. package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
  41. package/src/local-ai/nemotron/native/main.cpp +282 -0
  42. package/src/native/portable-layout.mjs +67 -9
@@ -1368,6 +1368,9 @@ function validateProvider(provider) {
1368
1368
  }
1369
1369
  if (provider.role === 'stt' && is.function(provider.createCapture)) {
1370
1370
  record.createCapture = provider.createCapture.bind(provider);
1371
+ if (is.function(provider.supportsCapture)) {
1372
+ record.supportsCapture = provider.supportsCapture.bind(provider);
1373
+ }
1371
1374
  }
1372
1375
  const admitted = completeValue(record);
1373
1376
  PROVIDER_RECORDS.set(provider, admitted);
@@ -3360,14 +3363,14 @@ export class AIProviderRuntime {
3360
3363
  return this.#disposeAllPromise;
3361
3364
  }
3362
3365
 
3363
- request(role, options = {}, {speechInputPrepared = false, observeToolText = null, onRetry = null} = {}) {
3366
+ request(role, options = {}, {speechInputPrepared = false, observeToolText = null, onRetry = null, speechStream = null} = {}) {
3364
3367
  if (SPEECH_ROLES.includes(role)) {
3365
3368
  arcaneLogging.debug('[Arcane speech runtime] request', role, options);
3366
3369
  }
3367
- return this.#requestRole(role, options, false, speechInputPrepared, observeToolText, onRetry);
3370
+ return this.#requestRole(role, options, false, speechInputPrepared, observeToolText, onRetry, speechStream);
3368
3371
  }
3369
3372
 
3370
- #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null, onRetry = null) {
3373
+ #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null, onRetry = null, speechStream = null) {
3371
3374
  this.#assertOpen();
3372
3375
  this.#assertNotConfiguring();
3373
3376
  assertRole(role);
@@ -3503,7 +3506,7 @@ export class AIProviderRuntime {
3503
3506
  }
3504
3507
  if ((!queued && slot.requestQueue.length)
3505
3508
  || slot.activeRequests.size >= maxConcurrentRequests) {
3506
- return this.#enqueueRoleRequest(slot, options, observeToolText, onRetry);
3509
+ return this.#enqueueRoleRequest(slot, options, observeToolText, onRetry, speechStream);
3507
3510
  }
3508
3511
  if (!slot.ready
3509
3512
  || providerStatus.state !== 'ready'
@@ -4064,7 +4067,7 @@ export class AIProviderRuntime {
4064
4067
  arcaneLogging.debug('[Arcane speech runtime] provider.request', {generation, requestSequence, operationId}, providerRequest);
4065
4068
  }
4066
4069
  const result = role === 'tts'
4067
- ? await provider.request(providerRequest, {speechInputPrepared: true})
4070
+ ? await provider.request(providerRequest, {speechInputPrepared: true, speechStream})
4068
4071
  : role === 'llm'
4069
4072
  ? await provider.request(
4070
4073
  providerRequest,
@@ -4088,9 +4091,14 @@ export class AIProviderRuntime {
4088
4091
  )
4089
4092
  : result;
4090
4093
  } catch (error) {
4091
- const normalized = isAbort(error, controller.signal)
4092
- ? normalizedAbort(error)
4093
- : error;
4094
+ // Streaming speech joins its native cancellation before release.
4095
+ // An aborted signal alone must not conceal a cleanup failure.
4096
+ const speechCancelled = speechStream && !error?.errors
4097
+ && (error === controller.signal.reason || error?.name === 'AbortError'
4098
+ || error?.code === 'ARCANE_REQUEST_ABORTED');
4099
+ const normalized = speechStream
4100
+ ? speechCancelled ? normalizedAbort(controller.signal.reason ?? error) : error
4101
+ : isAbort(error, controller.signal) ? normalizedAbort(error) : error;
4094
4102
  if (SPEECH_ROLES.includes(role)) {
4095
4103
  arcaneLogging.debug('[Arcane speech runtime] provider.request.error', {role, generation, requestSequence, operationId}, error);
4096
4104
  }
@@ -4134,7 +4142,8 @@ export class AIProviderRuntime {
4134
4142
  const provider = providerId === null
4135
4143
  ? this.#providerFor(slot)
4136
4144
  : this.#providers.get(providerKey('stt', providerId));
4137
- return is.function(provider?.createCapture);
4145
+ return is.function(provider?.createCapture)
4146
+ && (!is.function(provider.supportsCapture) || provider.supportsCapture());
4138
4147
  }
4139
4148
 
4140
4149
  createTranscriptionCapture(options = {}) {
@@ -4147,6 +4156,7 @@ export class AIProviderRuntime {
4147
4156
  const runtime = this;
4148
4157
  const generation = slot.generation;
4149
4158
  const capture = provider.createCapture(options);
4159
+ if (!capture) return null;
4150
4160
  const controller = new AbortController();
4151
4161
  let requestRecord = null;
4152
4162
  let detachSignal = null;
@@ -4185,7 +4195,7 @@ export class AIProviderRuntime {
4185
4195
  // permission retains the originating user gesture. It is never queued.
4186
4196
  return {
4187
4197
  done,
4188
- start({signal} = {}) {
4198
+ start({signal, ...captureOptions} = {}) {
4189
4199
  if (started || closed) return Promise.resolve(false);
4190
4200
  try {
4191
4201
  runtime.#assertOpen();
@@ -4217,7 +4227,7 @@ export class AIProviderRuntime {
4217
4227
  };
4218
4228
  slot.activeRequests.set(requestSequence, requestRecord);
4219
4229
  runtime.#publishRoleRequestState(slot);
4220
- const starting = capture.start({signal: controller.signal});
4230
+ const starting = capture.start({...captureOptions, signal: controller.signal});
4221
4231
  return Promise.resolve(starting).catch(
4222
4232
  function releaseFailedTranscriptionCaptureStart(error) {
4223
4233
  capture.cancel();
@@ -4230,16 +4240,22 @@ export class AIProviderRuntime {
4230
4240
  }
4231
4241
  },
4232
4242
  stop() {
4233
- capture.stop();
4234
- return done;
4243
+ return Promise.resolve(capture.stop()).then(function captureStopped() { return done; });
4235
4244
  },
4245
+ ...(is.function(capture.forceEndpoint) ? {
4246
+ forceEndpoint() {
4247
+ return capture.forceEndpoint();
4248
+ }
4249
+ } : {}),
4236
4250
  cancel() {
4237
4251
  controller.abort();
4238
4252
  capture.cancel();
4253
+ return done;
4239
4254
  },
4240
4255
  destroy() {
4241
4256
  controller.abort();
4242
4257
  capture.destroy();
4258
+ return done;
4243
4259
  }
4244
4260
  };
4245
4261
  }
@@ -4432,7 +4448,7 @@ export class AIProviderRuntime {
4432
4448
  );
4433
4449
  }
4434
4450
 
4435
- #enqueueRoleRequest(slot, options, observeToolText = null, onRetry = null) {
4451
+ #enqueueRoleRequest(slot, options, observeToolText = null, onRetry = null, speechStream = null) {
4436
4452
  if (SPEECH_ROLES.includes(slot.role)) {
4437
4453
  arcaneLogging.debug('[Arcane speech runtime] queue.enqueue', {
4438
4454
  role: slot.role,
@@ -4448,6 +4464,7 @@ export class AIProviderRuntime {
4448
4464
  options,
4449
4465
  observeToolText,
4450
4466
  onRetry,
4467
+ speechStream,
4451
4468
  resolve,
4452
4469
  reject,
4453
4470
  detachSignal: null
@@ -4520,7 +4537,7 @@ export class AIProviderRuntime {
4520
4537
  let operation;
4521
4538
  try {
4522
4539
  operation = runtime.#requestRole(
4523
- slot.role, entry.options, true, false, entry.observeToolText, entry.onRetry
4540
+ slot.role, entry.options, true, false, entry.observeToolText, entry.onRetry, entry.speechStream
4524
4541
  );
4525
4542
  } catch (error) {
4526
4543
  if (SPEECH_ROLES.includes(slot.role)) {
@@ -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
+ }