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.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/browser-runtime/ai/twin-cloud.mjs +109 -46
- package/browser-runtime/core/client.mjs +197 -0
- package/docs/reference/ai/twin-cloud.md +35 -14
- package/docs/reference/availability-and-normalization.md +1 -1
- package/docs/reference/inventory/package-api.json +96 -12
- package/docs/reference/local-ai.md +81 -5
- package/docs/reference/native-magpie.md +98 -0
- package/docs/reference/native-nemotron.md +204 -0
- package/docs/reference/native-speech.md +364 -11
- package/docs/reference/runtime-modules.md +21 -18
- package/docs/reference/sdk-api.md +152 -11
- package/package.json +6 -1
- package/runtime/arcane/modules/AI.js +114 -14
- package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
- package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
- package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
- package/schemas/arcane-app.schema.json +2 -2
- package/schemas/arcane-package.schema.json +2 -2
- package/src/core/services/speech.mjs +320 -25
- package/src/local-ai/config.mjs +12 -1
- package/src/local-ai/dev.mjs +71 -4
- package/src/local-ai/install.mjs +113 -5
- package/src/local-ai/kokoro/frontend.mjs +219 -0
- package/src/local-ai/kokoro/helper.mjs +168 -0
- package/src/local-ai/kokoro/index.mjs +645 -0
- package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
- package/src/local-ai/kokoro/native/main.cpp +249 -0
- package/src/local-ai/magpie/build.mjs +36 -0
- package/src/local-ai/magpie/helper.mjs +254 -0
- package/src/local-ai/magpie/index.mjs +488 -0
- package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
- package/src/local-ai/magpie/native/main.cpp +270 -0
- package/src/local-ai/nemotron/build.mjs +53 -0
- package/src/local-ai/nemotron/helper.mjs +308 -0
- package/src/local-ai/nemotron/index.mjs +637 -0
- package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
- package/src/local-ai/nemotron/native/main.cpp +282 -0
- 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
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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())
|
|
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
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
}
|