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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/docs/reference/inventory/runtime-components.json +13 -6
- package/docs/reference/inventory/runtime-modules.json +28 -4
- package/docs/reference/runtime-components.md +202 -22
- package/docs/reference/runtime-modules.md +109 -1
- package/package.json +2 -2
- package/runtime/arcane/components/data-view.html +18 -2
- package/runtime/arcane/components/file-manager.html +512 -88
- package/runtime/arcane/components/voice-transcription.html +611 -55
- package/runtime/arcane/modules/ComponentContracts.js +32 -1
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +446 -0
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +194 -0
|
@@ -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
|
+
}
|