arcane-os 0.35.0 → 0.36.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.
@@ -876,9 +876,11 @@ function createSTTActivationController({
876
876
  }
877
877
 
878
878
  const VOICE_LABELS=completeValue({
879
+ cancel:'Cancel pending transcription',
879
880
  complete:'Complete Transcription',
880
881
  description:'Record one or more segments. Each segment is transcribed after you press Stop.',
881
882
  empty:'Your transcription will appear here after you stop recording.',
883
+ retry:'Retry failed segment',
882
884
  start:'Start',
883
885
  stop:'Stop',
884
886
  transcription:'Voice transcription'
@@ -889,8 +891,10 @@ const VOICE_MESSAGES=completeValue({
889
891
  complete:'Complete.',
890
892
  completeError:'Unable to complete this transcription.',
891
893
  completing:'Completing transcription...',
894
+ draining:'Microphone stopped. Finishing captured segments...',
892
895
  emptyAudio:'No audio was captured. Try recording again.',
893
896
  interrupted:'Recording was interrupted. Your completed transcription is still available.',
897
+ listening:'Listening...',
894
898
  noSpeech:'No speech was transcribed. You can try another segment.',
895
899
  ready:'Ready.',
896
900
  recording:'Recording...',
@@ -904,7 +908,8 @@ const VOICE_MESSAGES=completeValue({
904
908
  transcribeError:'Unable to transcribe this recording.',
905
909
  transcribing:'Transcribing this segment...',
906
910
  transcriptReplaced:'Transcript replaced.',
907
- unsupported:'Audio recording is not supported by this browser.'
911
+ unsupported:'Audio recording is not supported by this browser.',
912
+ waiting:'Captured audio is waiting for the selected transcription service.'
908
913
  });
909
914
 
910
915
  function normalizeVoiceOptions(input={},previous={}){
@@ -953,6 +958,29 @@ function normalizeVoiceOptions(input={},previous={}){
953
958
  if(!is.string(separator)){
954
959
  throw new TypeError('separator must be a string');
955
960
  }
961
+ const capture={
962
+ mode:'manual',
963
+ preRollMs:1500,
964
+ quietMs:2000,
965
+ chunkMs:30000,
966
+ activityThreshold:0.02,
967
+ ...record(previous.capture,'Previous voice capture options'),
968
+ ...record(input.capture,'Voice capture options')
969
+ };
970
+ if(!['manual','continuous'].includes(capture.mode)){
971
+ throw new TypeError('capture.mode must be manual or continuous');
972
+ }
973
+ for(const key of ['preRollMs','quietMs','chunkMs','activityThreshold']){
974
+ const value=capture[key];
975
+ if(!is.finite(value)||value<0
976
+ ||(['quietMs','chunkMs'].includes(key)&&value===0)){
977
+ throw new TypeError(`capture.${key} must be a finite ${key==='quietMs'||key==='chunkMs'?'positive':'nonnegative'} number`);
978
+ }
979
+ }
980
+ const controls=option(input,previous,'controls','standard');
981
+ if(!['standard','simple'].includes(controls)){
982
+ throw new TypeError('controls must be standard or simple');
983
+ }
956
984
 
957
985
  return {
958
986
  labels,
@@ -966,6 +994,9 @@ function normalizeVoiceOptions(input={},previous={}){
966
994
  'Voice messages'
967
995
  ),
968
996
  mediaConstraints:{...constraints},
997
+ capture,
998
+ controls,
999
+ showComplete:option(input,previous,'showComplete',true)!==false,
969
1000
  mimeTypes:[...new Set(mimeTypes.map(value=>value.trim()).filter(Boolean))],
970
1001
  persist:option(input,previous,'persist',true)!==false,
971
1002
  transcribe:optionalCallback(input,previous,['transcribe']),
@@ -0,0 +1,446 @@
1
+ import Is from 'strong-type';
2
+ import {arcaneLogging} from 'arcane-os/logging';
3
+
4
+ const is = new Is(false);
5
+
6
+ /**
7
+ * Browser microphone capture with activity-selected, non-overlapping WAV clips.
8
+ * Callbacks are observational and never delay the microphone or worklet.
9
+ * stop() delivers the final active clip; cancel() abandons pending delivery.
10
+ */
11
+ export default class ContinuousVoiceCapture {
12
+ #options;
13
+ #onSegment;
14
+ #onError;
15
+ #onState;
16
+ #session = null;
17
+
18
+ constructor(
19
+ {
20
+ preRollMs = 1500,
21
+ quietMs = 2000,
22
+ chunkMs = 30000,
23
+ activityThreshold = 0.02,
24
+ onSegment,
25
+ onError,
26
+ onState
27
+ } = {}
28
+ ) {
29
+ if (!is.finite(preRollMs) || preRollMs < 0) {
30
+ throw new RangeError('preRollMs must be a finite nonnegative duration.');
31
+ }
32
+ if (!is.finite(quietMs) || quietMs <= 0) {
33
+ throw new RangeError('quietMs must be a finite positive duration.');
34
+ }
35
+ if (!is.finite(chunkMs) || chunkMs <= 0) {
36
+ throw new RangeError('chunkMs must be a finite positive duration.');
37
+ }
38
+ if (!is.finite(activityThreshold) || activityThreshold < 0) {
39
+ throw new RangeError('activityThreshold must be a finite nonnegative amplitude.');
40
+ }
41
+ this.#options = {preRollMs, quietMs, chunkMs, activityThreshold};
42
+ this.#onSegment = onSegment;
43
+ this.#onError = onError;
44
+ this.#onState = onState;
45
+ }
46
+
47
+ async start(
48
+ {
49
+ mediaConstraints = {audio: true},
50
+ signal
51
+ } = {}
52
+ ) {
53
+ if ((this.#session && !this.#session.settled) || signal?.aborted) return false;
54
+
55
+ const session = {
56
+ phase: 'starting',
57
+ cancelled: false,
58
+ settled: false,
59
+ stream: null,
60
+ context: null,
61
+ source: null,
62
+ node: null,
63
+ cleanup: [],
64
+ errors: [],
65
+ resolveStartup: null,
66
+ resolveStop: null
67
+ };
68
+ session.startupCancelled = new Promise(
69
+ function ownStartupCancellation(resolve) {
70
+ session.resolveStartup = resolve;
71
+ }
72
+ );
73
+ session.stopped = new Promise(
74
+ function ownCaptureStop(resolve) {
75
+ session.resolveStop = resolve;
76
+ }
77
+ );
78
+ this.#session = session;
79
+
80
+ const owner = this;
81
+ try {
82
+ if (signal) {
83
+ function cancelAbortedCapture() {
84
+ if (owner.#session === session) owner.cancel();
85
+ }
86
+ signal.addEventListener(
87
+ 'abort',
88
+ cancelAbortedCapture,
89
+ {once: true}
90
+ );
91
+ session.cleanup.push(
92
+ function removeCaptureAbortListener() {
93
+ signal.removeEventListener('abort', cancelAbortedCapture);
94
+ }
95
+ );
96
+ }
97
+ } catch (error) {
98
+ this.#fail(session, error);
99
+ return false;
100
+ }
101
+ this.#notify(session, this.#onState, 'starting');
102
+ if (!this.#starting(session)) return false;
103
+
104
+ const preparation = this.#prepare(session, mediaConstraints);
105
+ return Promise.race(
106
+ [preparation, session.startupCancelled]
107
+ );
108
+ }
109
+
110
+ stop() {
111
+ const session = this.#session;
112
+ if (!session) return Promise.resolve();
113
+ if (session.settled || session.phase === 'stopping') return session.stopped;
114
+
115
+ // Device release is synchronous and precedes the ordered worklet flush.
116
+ const starting = session.phase === 'starting';
117
+ session.phase = 'stopping';
118
+ session.errors.push(...this.#releaseMicrophone(session));
119
+ if (session.settled) return session.stopped;
120
+ if (starting) {
121
+ this.#settle(session, 'stopped');
122
+ return session.stopped;
123
+ }
124
+ try {
125
+ if (session.context.state !== 'running') {
126
+ throw new Error('The audio context stopped before voice capture could flush.');
127
+ }
128
+ session.node.port.postMessage(
129
+ {type: 'stop'}
130
+ );
131
+ } catch (error) {
132
+ this.#fail(session, error);
133
+ }
134
+ return session.stopped;
135
+ }
136
+
137
+ cancel() {
138
+ const session = this.#session;
139
+ if (!session) return;
140
+ session.cancelled = true;
141
+ this.#settle(session, 'stopped');
142
+ }
143
+
144
+ destroy() {
145
+ this.cancel();
146
+ }
147
+
148
+ #starting(session) {
149
+ return this.#session === session && session.phase === 'starting';
150
+ }
151
+
152
+ async #prepare(session, mediaConstraints) {
153
+ try {
154
+ if (!is.function(globalThis.AudioContext)
155
+ || !is.function(globalThis.AudioWorkletNode)
156
+ || !is.function(globalThis.navigator?.mediaDevices?.getUserMedia)) {
157
+ throw new Error('Continuous voice capture requires microphone access and AudioWorklet.');
158
+ }
159
+ const context = new globalThis.AudioContext();
160
+ session.context = context;
161
+ if (!context.audioWorklet) {
162
+ throw new Error('This browser does not expose AudioWorklet microphone processing.');
163
+ }
164
+
165
+ const owner = this;
166
+ function observeAudioContextState() {
167
+ if (owner.#session !== session || session.phase === 'starting') return;
168
+ if (context.state !== 'running') {
169
+ owner.#fail(
170
+ session,
171
+ new Error(`Continuous voice capture was interrupted: audio context ${context.state}.`)
172
+ );
173
+ }
174
+ }
175
+ context.addEventListener('statechange', observeAudioContextState);
176
+ session.cleanup.push(
177
+ function removeAudioContextListener() {
178
+ context.removeEventListener('statechange', observeAudioContextState);
179
+ }
180
+ );
181
+
182
+ // Resume starts within the user's gesture while independent loading
183
+ // and microphone permission proceed. Only graph construction joins them.
184
+ const acquisition = this.#acquire(session, mediaConstraints);
185
+ const moduleReady = this.#loadWorklet(context);
186
+ const resumed = this.#resume(context);
187
+ await Promise.all(
188
+ [acquisition, moduleReady, resumed]
189
+ );
190
+ if (!this.#starting(session)) return false;
191
+ if (context.state !== 'running') {
192
+ throw new Error('The audio context is not running after microphone preparation.');
193
+ }
194
+
195
+ const source = context.createMediaStreamSource(session.stream);
196
+ session.source = source;
197
+ const node = new globalThis.AudioWorkletNode(
198
+ context,
199
+ 'arcane-continuous-voice-capture',
200
+ {
201
+ numberOfInputs: 1,
202
+ numberOfOutputs: 1,
203
+ outputChannelCount: [1],
204
+ channelCount: 1,
205
+ channelCountMode: 'explicit',
206
+ processorOptions: this.#options
207
+ }
208
+ );
209
+ session.node = node;
210
+ function receiveCaptureMessage(event) {
211
+ if (owner.#session !== session || session.settled) return;
212
+ try {
213
+ const message = event.data;
214
+ if (message.type === 'segment') {
215
+ const audio = new Blob(
216
+ [message.audio],
217
+ {type: 'audio/wav'}
218
+ );
219
+ owner.#notify(
220
+ session,
221
+ owner.#onSegment,
222
+ {
223
+ audio,
224
+ sequence: message.sequence,
225
+ reason: message.reason,
226
+ durationMs: message.durationMs
227
+ }
228
+ );
229
+ } else if (message.type === 'stopped') {
230
+ owner.#settle(session, 'stopped');
231
+ } else if (message.type === 'error') {
232
+ owner.#fail(session, message.error);
233
+ }
234
+ } catch (error) {
235
+ owner.#fail(session, error);
236
+ }
237
+ }
238
+ function reportCaptureProcessorError(event) {
239
+ owner.#fail(
240
+ session,
241
+ event.error ?? new Error(
242
+ 'The voice capture audio worklet failed.',
243
+ {cause: event}
244
+ )
245
+ );
246
+ }
247
+ function reportCaptureMessageError(event) {
248
+ owner.#fail(
249
+ session,
250
+ new Error(
251
+ 'A voice capture worklet message could not be received.',
252
+ {cause: event}
253
+ )
254
+ );
255
+ }
256
+ node.port.addEventListener('message', receiveCaptureMessage);
257
+ node.port.addEventListener('messageerror', reportCaptureMessageError);
258
+ node.addEventListener('processorerror', reportCaptureProcessorError);
259
+ session.cleanup.push(
260
+ function removeWorkletListeners() {
261
+ node.port.removeEventListener('message', receiveCaptureMessage);
262
+ node.port.removeEventListener('messageerror', reportCaptureMessageError);
263
+ node.removeEventListener('processorerror', reportCaptureProcessorError);
264
+ }
265
+ );
266
+ node.port.start();
267
+ source.connect(node);
268
+ node.connect(context.destination);
269
+ session.phase = 'listening';
270
+ this.#notify(session, this.#onState, 'listening');
271
+ return this.#session === session && session.phase === 'listening';
272
+ } catch (error) {
273
+ if (this.#session === session) this.#fail(session, error);
274
+ return false;
275
+ }
276
+ }
277
+
278
+ async #acquire(session, mediaConstraints) {
279
+ const stream = await globalThis.navigator.mediaDevices.getUserMedia(mediaConstraints);
280
+ if (!this.#starting(session)) {
281
+ for (const error of this.#stopTracks(stream)) {
282
+ this.#reportError(session, error);
283
+ }
284
+ return;
285
+ }
286
+ session.stream = stream;
287
+ const owner = this;
288
+ function reportMicrophoneEnded(event) {
289
+ owner.#interrupt(
290
+ session,
291
+ new Error(
292
+ 'The microphone ended during continuous voice capture.',
293
+ {cause: event}
294
+ )
295
+ );
296
+ }
297
+ for (const track of stream.getAudioTracks()) {
298
+ track.addEventListener(
299
+ 'ended',
300
+ reportMicrophoneEnded,
301
+ {once: true}
302
+ );
303
+ session.cleanup.push(
304
+ function removeMicrophoneEndedListener() {
305
+ track.removeEventListener('ended', reportMicrophoneEnded);
306
+ }
307
+ );
308
+ if (track.readyState === 'ended') {
309
+ throw new Error('The selected microphone has already ended.');
310
+ }
311
+ }
312
+ }
313
+
314
+ async #loadWorklet(context) {
315
+ await context.audioWorklet.addModule(
316
+ new URL('./VoiceCaptureWorklet.js', import.meta.url)
317
+ );
318
+ }
319
+
320
+ async #resume(context) {
321
+ await context.resume();
322
+ }
323
+
324
+ #notify(session, callback, value) {
325
+ if (session.cancelled
326
+ || (this.#session && this.#session !== session)
327
+ || !is.function(callback)) return;
328
+ const owner = this;
329
+ function observeCallbackFailure(error) {
330
+ if (owner.#session === session && !session.settled) {
331
+ owner.#interrupt(session, error);
332
+ } else {
333
+ owner.#reportError(session, error, true);
334
+ }
335
+ }
336
+ try {
337
+ Promise.resolve(callback(value)).catch(observeCallbackFailure);
338
+ } catch (error) {
339
+ observeCallbackFailure(error);
340
+ }
341
+ }
342
+
343
+ #reportError(session, error, terminal = false) {
344
+ if (session.cancelled
345
+ || (this.#session !== session && (!terminal || this.#session))
346
+ || !is.function(this.#onError)) {
347
+ arcaneLogging.error('Continuous voice capture failed:', error);
348
+ return;
349
+ }
350
+ function observeErrorHandlerFailure(callbackError) {
351
+ arcaneLogging.error('Continuous voice capture error handler failed:', callbackError);
352
+ }
353
+ try {
354
+ Promise.resolve(this.#onError(error)).catch(observeErrorHandlerFailure);
355
+ } catch (callbackError) {
356
+ observeErrorHandlerFailure(callbackError);
357
+ }
358
+ }
359
+
360
+ #fail(session, error) {
361
+ if (this.#session !== session || session.settled) return;
362
+ session.errors.push(error);
363
+ this.#settle(session, 'interrupted');
364
+ }
365
+
366
+ #interrupt(session, error) {
367
+ if (this.#session !== session || session.settled) return;
368
+ session.errors.push(error);
369
+ // A live worklet can still deliver the selected audio after device loss.
370
+ // Its final message precedes the stop acknowledgment and error callback.
371
+ this.stop();
372
+ }
373
+
374
+ #stopTracks(stream) {
375
+ const errors = [];
376
+ for (const track of stream?.getTracks() ?? []) {
377
+ try {
378
+ track.stop();
379
+ } catch (error) {
380
+ errors.push(error);
381
+ }
382
+ }
383
+ return errors;
384
+ }
385
+
386
+ #releaseMicrophone(session) {
387
+ const stream = session.stream;
388
+ session.stream = null;
389
+ const errors = this.#stopTracks(stream);
390
+ if (session.source) {
391
+ try {
392
+ session.source.disconnect();
393
+ } catch (error) {
394
+ errors.push(error);
395
+ }
396
+ session.source = null;
397
+ }
398
+ return errors;
399
+ }
400
+
401
+ #settle(session, state) {
402
+ if (session.settled) return;
403
+ session.settled = true;
404
+ session.phase = state;
405
+ for (const cleanup of session.cleanup) {
406
+ try {
407
+ cleanup();
408
+ } catch (error) {
409
+ session.errors.push(error);
410
+ }
411
+ }
412
+ session.cleanup = [];
413
+ session.errors.push(...this.#releaseMicrophone(session));
414
+ if (session.node) {
415
+ try {
416
+ session.node.disconnect();
417
+ } catch (error) {
418
+ session.errors.push(error);
419
+ }
420
+ try {
421
+ session.node.port.close();
422
+ } catch (error) {
423
+ session.errors.push(error);
424
+ }
425
+ }
426
+ const context = session.context;
427
+ if (context && context.state !== 'closed') {
428
+ const owner = this;
429
+ function observeContextCloseFailure(error) {
430
+ owner.#reportError(session, error);
431
+ }
432
+ try {
433
+ Promise.resolve(context.close()).catch(observeContextCloseFailure);
434
+ } catch (error) {
435
+ session.errors.push(error);
436
+ }
437
+ }
438
+ session.resolveStartup(false);
439
+ for (const error of session.errors) {
440
+ this.#reportError(session, error, true);
441
+ }
442
+ this.#notify(session, this.#onState, session.errors.length ? 'interrupted' : state);
443
+ if (this.#session === session) this.#session = null;
444
+ session.resolveStop();
445
+ }
446
+ }