arcane-os 0.1.2 → 0.2.1

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 (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/NOTICE +5 -3
  3. package/README.md +73 -24
  4. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
  5. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
  6. package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
  7. package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
  8. package/browser-runtime/ai/browser-speech-providers.mjs +780 -0
  9. package/browser-runtime/ai/browser-speech.mjs +9 -0
  10. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
  11. package/browser-runtime/ai/browser-wasm.mjs +46 -1
  12. package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
  13. package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
  14. package/browser-runtime/ai/model-controller.mjs +138 -12
  15. package/browser-runtime/ai/speech-worker-client.mjs +207 -0
  16. package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
  17. package/browser-runtime/ai/wllama/index.mjs +389 -0
  18. package/docs/architecture.md +132 -22
  19. package/docs/reference/README.md +1 -1
  20. package/docs/reference/ai/browser-wasm.md +101 -42
  21. package/docs/reference/availability-and-normalization.md +19 -5
  22. package/docs/reference/behavioral-testing.md +18 -5
  23. package/docs/reference/cli.md +2 -2
  24. package/docs/reference/inventory/package-api.json +14 -14
  25. package/docs/reference/protocols.md +4 -4
  26. package/docs/reference/sdk-api.md +68 -38
  27. package/docs/work-amplification.md +8 -4
  28. package/package.json +7 -3
  29. package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
  30. package/runtime/arcane/components/chat.html +551 -62
  31. package/runtime/arcane/components/speech.html +1113 -265
  32. package/runtime/arcane/entities/Chat.js +246 -43
  33. package/runtime/arcane/modules/AI.js +1394 -162
  34. package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
  35. package/runtime/arcane/modules/AIRuntimeState.js +872 -0
  36. package/runtime/arcane/modules/ConfiguredAIChatSession.js +382 -31
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -0
  38. package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
  39. package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
  40. package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
  41. package/schemas/arcane-lock.schema.json +10 -6
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +273 -26
  45. package/src/doctor.mjs +1 -3
  46. package/src/import-map.mjs +193 -84
  47. package/src/packager/core.mjs +313 -41
  48. package/src/runtime.mjs +14 -4
  49. package/src/scaffold.mjs +45 -17
  50. package/src/sdk-browser-runtime.mjs +28 -75
  51. package/src/templates/workspace-template.mjs +27 -8
  52. package/src/toolchain.mjs +13 -2
  53. package/src/workspace-runtime.mjs +1 -1
  54. package/src/workspace.mjs +178 -25
@@ -1,365 +1,1213 @@
1
- <link rel="stylesheet" href="./arcane/css/layout.css?v=3" />
1
+ <link rel="stylesheet" href="./arcane/css/layout.css?v=3">
2
2
  <style>
3
- button{
4
- width:16em;
5
- -webkit-touch-callout: none;
6
- -webkit-user-select: none;
7
- -khtml-user-select: none;
8
- -moz-user-select: none;
9
- -ms-user-select: none;
3
+ :host {
4
+ --arcane-speech-padding-block-end: .85rem;
5
+ --arcane-speech-padding-block-start: .65rem;
6
+ --arcane-speech-padding-inline: .75rem;
7
+ display: grid;
8
+ grid-template-columns: auto minmax(0, 1fr) max-content max-content;
9
+ align-items: center;
10
+ gap: var(--arcane-space-3, .75rem);
11
+ box-sizing: border-box;
12
+ width: 100%;
13
+ min-width: 0;
14
+ padding:
15
+ var(--arcane-speech-padding-block-start)
16
+ var(--arcane-speech-padding-inline)
17
+ var(--arcane-speech-padding-block-end);
18
+ border: 1px solid var(--arcane-border, var(--border-color));
19
+ border-radius: var(--arcane-radius-large, 1rem);
20
+ background: var(--arcane-surface-raised, var(--modal-background));
21
+ box-shadow: var(--arcane-shadow-small, 0 .25rem .8rem var(--shadow-color));
22
+ }
23
+
24
+ button {
25
+ width: auto;
26
+ min-height: 2.65rem;
27
+ margin: 0;
28
+ padding: .6rem .9rem;
29
+ border: 1px solid var(--arcane-border, var(--border-color));
30
+ border-radius: var(--arcane-radius-medium, .8rem);
31
+ font: inherit;
32
+ font-weight: 700;
33
+ touch-action: none;
10
34
  user-select: none;
11
35
  }
12
36
 
13
- button:active{
14
- opacity:.5;
15
- }
37
+ button:focus-visible {
38
+ outline: .2rem solid var(--arcane-focus, var(--focus-color));
39
+ outline-offset: .18rem;
40
+ }
41
+
42
+ button:disabled {
43
+ cursor: not-allowed;
44
+ opacity: .55;
45
+ }
46
+
47
+ #recordButton {
48
+ grid-column: 1;
49
+ width: 5rem;
50
+ height: 5rem;
51
+ min-height: 5rem;
52
+ padding: .45rem .45rem .65rem;
53
+ border: .28rem solid var(--arcane-border, var(--border-color));
54
+ border-radius: 50%;
55
+ background: var(--arcane-action, var(--primary-color));
56
+ color: var(--arcane-action-text, var(--button-text-color, var(--text-color)));
57
+ box-shadow:
58
+ 0 0 0 .22rem var(--arcane-surface-raised, var(--modal-background)),
59
+ 0 0 0 .34rem var(--arcane-border, var(--border-color)),
60
+ var(--arcane-shadow-small, 0 .35rem .9rem var(--shadow-color));
61
+ font-size: .82rem;
62
+ line-height: 1.05;
63
+ text-align: center;
64
+ }
65
+
66
+ #recordButton:active,
67
+ #recordButton.pressed {
68
+ opacity: .82;
69
+ transform: scale(.94);
70
+ }
16
71
 
17
- button.pressed{
18
- opacity:.6;
19
- border:.2em;
20
- border-color:var(--border-color);
72
+ #stopButton {
73
+ display: none;
21
74
  }
22
75
 
23
- .end{
24
- background-color:var(--danger-color);
25
- margin-left:1em;
76
+ .speech_status {
77
+ grid-column: 2;
78
+ min-width: 0;
79
+ margin: 0;
80
+ color: var(--arcane-muted, var(--text-color));
81
+ font-size: .86rem;
82
+ line-height: 1.35;
83
+ overflow-wrap: anywhere;
26
84
  }
27
85
 
28
- .mute{
29
- float:right;
86
+ .speech_status[data-tone="error"] {
87
+ color: var(--arcane-danger, var(--danger-color));
30
88
  }
31
-
32
- @media (max-width: 48em) {
33
- body {
34
- grid-template-columns: 2.2em 1fr;
89
+
90
+ #endButton {
91
+ grid-column: 3;
92
+ justify-self: end;
93
+ background: var(--arcane-danger, var(--danger-color));
94
+ border-color: var(--arcane-danger, var(--danger-color));
95
+ color: var(--arcane-action-text, var(--button-text-color, var(--text-color)));
96
+ }
97
+
98
+ #muteButton {
99
+ grid-column: 4;
100
+ justify-self: end;
101
+ float: none;
102
+ background: var(--arcane-secondary, var(--secondary-color));
103
+ color: var(--arcane-secondary-text, var(--text-color));
104
+ }
105
+
106
+ @media (max-width: 44rem) {
107
+ :host {
108
+ grid-template-columns: auto minmax(0, 1fr) auto;
109
+ }
110
+
111
+ #recordButton {
112
+ grid-row: 1 / span 2;
113
+ }
114
+
115
+ .speech_status {
116
+ grid-row: 2;
117
+ grid-column: 2 / span 2;
35
118
  }
36
-
37
- button {
38
- width:100%;
119
+
120
+ #endButton {
121
+ grid-row: 1;
122
+ grid-column: 2;
39
123
  }
40
-
41
- .end{
42
- margin:0;
124
+
125
+ #muteButton {
126
+ grid-row: 1;
127
+ grid-column: 3;
43
128
  }
44
129
  }
45
-
46
-
47
130
  </style>
48
131
 
49
132
  <p class="hidden">Will auto send after X seconds of silence set in code.</p>
50
- <button id="recordButton" type="button">Push To Talk</button>
51
- <button id="stopButton" type="button" class='hidden' disabled>Send</button>
52
- <button id="endButton" type="button" class="end">Complete</button>
53
- <button id="muteButton" type="button" class="mute" aria-pressed="true" aria-label="Unmute AI voice">Unmute</button>
133
+ <button id="recordButton" type="button" aria-label="Transcription unavailable" disabled>Hold to talk</button>
134
+ <button id="stopButton" type="button" class="hidden" disabled>Send</button>
135
+ <p id="speechStatus" class="speech_status" role="status" aria-live="polite"></p>
136
+ <button id="endButton" type="button" class="end">End</button>
137
+ <button id="muteButton" type="button" class="mute" aria-pressed="true" aria-label="Voice unavailable" disabled>Voice unavailable</button>
54
138
 
55
139
  <script type="module">
56
- import('../modules/DBLS.js');
57
- import('../modules/AI.js?v=7');
140
+ void import('../modules/DBLS.js').catch(
141
+ function reportDBLSImportFailure(error) {
142
+ console.error('The shared database runtime could not load.');
143
+ }
144
+ );
145
+ const aiModuleImport = import('../modules/AI.js?v=7');
146
+ const {
147
+ requestAIRuntimeIntent,
148
+ subscribeAIRuntimeState
149
+ } = await import('../modules/AIRuntimeState.js');
58
150
 
59
- const audioElement = new Audio();
60
- const host=this;
61
- const shadowRoot=host.shadowRoot;
151
+ const host = this;
152
+ const shadowRoot = host.shadowRoot;
62
153
  const recordButton = shadowRoot.querySelector('#recordButton');
63
154
  const stopButton = shadowRoot.querySelector('#stopButton');
155
+ const endButton = shadowRoot.querySelector('#endButton');
64
156
  const muteButton = shadowRoot.querySelector('#muteButton');
65
- const sourceNodes = [];
66
- const audioRecordingChunks=[];
67
- const silenceDuration=0;//2000;
68
-
69
- let mediaRecorder=null;
70
- let activeStream=null;
71
- let audioMessageChunks = '';
72
- let isSpeaking = false;
73
- let silenceTimer;
74
-
75
- /**
76
- * Configure the initial AI speech state. The default remains muted for
77
- * compatibility with existing consumers. Apps own preference persistence.
78
- */
79
- host.initialMuted=typeof host.initialMuted==='boolean'?host.initialMuted:true;
80
- host.muted=host.initialMuted;
81
- host.configure=configure;
82
- host.setAvailability=setAvailability;
83
- host.setMuted=setMuted;
84
- host.availability={
85
- microphone:Boolean(navigator.mediaDevices?.getUserMedia&&globalThis.MediaRecorder),
86
- stt:true,
87
- tts:true
157
+ const speechStatus = shadowRoot.querySelector('#speechStatus');
158
+ const silenceDuration = 0;
159
+ const runtimeStateAbortController = new AbortController();
160
+
161
+ let sttRole = unavailableRole('stt');
162
+ let ttsRole = unavailableRole('tts');
163
+ let microphonePermissionStatus = null;
164
+ let microphonePermissionListener = null;
165
+ let captureSession = null;
166
+ let recordingRequested = false;
167
+ let recordingGeneration = 0;
168
+ let transcriptionGeneration = 0;
169
+ let transcriptionActive = false;
170
+ let silenceTimer = null;
171
+ let activePointerId = null;
172
+ let keyboardRecording = false;
173
+ let localStatus = null;
174
+ let pendingTTSIntent = null;
175
+ let destroyed = false;
176
+
177
+ const initialMutedWasHydrated = typeof host.initialMuted === 'boolean';
178
+ host.initialMuted = initialMutedWasHydrated ? host.initialMuted : true;
179
+ host.muted = host.initialMuted;
180
+ host.configure = configure;
181
+ host.destroy = destroy;
182
+ host.reportTTSError = reportTTSError;
183
+ host.setAvailability = setAvailability;
184
+ host.setMuted = setMuted;
185
+ host.availability = {
186
+ microphone: Boolean(
187
+ navigator.mediaDevices?.getUserMedia
188
+ && globalThis.MediaRecorder
189
+ ),
190
+ stt: false,
191
+ tts: false
88
192
  };
89
- setAvailability({});
90
- setMuted(host.initialMuted);
193
+
194
+ recordButton.addEventListener('pointerdown', handleRecordPointerDown);
195
+ recordButton.addEventListener('pointerup', handleRecordPointerUp);
196
+ recordButton.addEventListener('pointercancel', handleRecordPointerCancel);
197
+ recordButton.addEventListener('lostpointercapture', handleLostPointerCapture);
198
+ recordButton.addEventListener('keydown', handleRecordKeyDown);
199
+ recordButton.addEventListener('keyup', handleRecordKeyUp);
200
+ recordButton.addEventListener('blur', handleRecordBlur);
201
+ muteButton.addEventListener('click', muteToggle);
202
+ endButton.addEventListener('click', submitCompletionCommand);
203
+ window.addEventListener('pagehide', destroy, { once: true });
204
+
205
+ subscribeAIRuntimeState(
206
+ synchronizeAIRuntimeState,
207
+ {
208
+ signal: runtimeStateAbortController.signal
209
+ }
210
+ );
211
+ synchronizeAIMutedState();
212
+ renderControls();
213
+ renderStatus();
91
214
  void observeMicrophonePermission();
92
- host.componentReady=true;
93
- host.dispatchEvent(new CustomEvent('speech-ready',{
94
- bubbles:true,
95
- composed:true,
96
- detail:{speech:host}
97
- }));
98
-
99
- recordButton.addEventListener(
100
- 'mousedown',
101
- record
215
+ void aiModuleImport.then(
216
+ synchronizeAIMutedState,
217
+ function reportAIModuleImportFailure(error) {
218
+ console.error('The shared AI runtime could not load.');
219
+ }
102
220
  );
103
221
 
104
- recordButton.addEventListener(
105
- 'touchstart',
106
- record
222
+ host.componentReady = true;
223
+ host.dispatchEvent(
224
+ new CustomEvent(
225
+ 'speech-ready',
226
+ {
227
+ bubbles: true,
228
+ composed: true,
229
+ detail: { speech: host }
230
+ }
231
+ )
107
232
  );
108
233
 
109
- recordButton.addEventListener(
110
- 'mouseup',
111
- stop
112
- );
234
+ function unavailableRole(role) {
235
+ return Object.freeze(
236
+ {
237
+ role,
238
+ state: 'unavailable',
239
+ providerId: null,
240
+ modelId: null,
241
+ localOnly: null,
242
+ loaded: false,
243
+ busy: false,
244
+ operationId: null,
245
+ progress: null,
246
+ error: null
247
+ }
248
+ );
249
+ }
113
250
 
114
- recordButton.addEventListener(
115
- 'touchend',
116
- stop
117
- );
251
+ function compatibilityRole(role, ready) {
252
+ return Object.freeze(
253
+ {
254
+ ...unavailableRole(role),
255
+ state: ready ? 'ready' : 'unavailable',
256
+ loaded: ready
257
+ }
258
+ );
259
+ }
118
260
 
119
- // recordButton.addEventListener(
120
- // 'click',
121
- // record
122
- // );
261
+ function configure(options = {}) {
262
+ if (Object.prototype.hasOwnProperty.call(options, 'initialMuted')) {
263
+ const configuredMuted = typeof options.initialMuted === 'boolean'
264
+ ? options.initialMuted
265
+ : true;
266
+ host.initialMuted = configuredMuted;
267
+ applyConfiguredMutedState(configuredMuted);
268
+ }
123
269
 
124
- // stopButton.addEventListener(
125
- // 'click',
126
- // stop
127
- // );
270
+ if (
271
+ Object.prototype.hasOwnProperty.call(options, 'stt')
272
+ || Object.prototype.hasOwnProperty.call(options, 'tts')
273
+ || Object.prototype.hasOwnProperty.call(options, 'microphone')
274
+ ) {
275
+ setAvailability(options);
276
+ }
128
277
 
129
- muteButton.addEventListener(
130
- 'click',
131
- muteToggle
132
- );
278
+ return host;
279
+ }
133
280
 
134
- shadowRoot.querySelector('#endButton').addEventListener(
135
- 'click',
136
- function submitCompletionCommand(event){
137
- event.target.disabled=true;
138
- transcribe('End now, run all tool functions.');
281
+ function setAvailability(input = {}) {
282
+ // Compatibility-only explicit adapter report. Canonical consumers use
283
+ // the sticky AIRuntimeState role records instead of this wrapper.
284
+ const previousSTTRole = sttRole;
285
+ const previousTTSRole = ttsRole;
286
+ const microphoneWasAvailable = host.availability.microphone;
287
+
288
+ if (Object.prototype.hasOwnProperty.call(input, 'microphone')) {
289
+ host.availability.microphone = Boolean(input.microphone);
290
+ }
291
+ if (Object.prototype.hasOwnProperty.call(input, 'stt')) {
292
+ sttRole = compatibilityRole('stt', Boolean(input.stt));
293
+ }
294
+ if (Object.prototype.hasOwnProperty.call(input, 'tts')) {
295
+ ttsRole = compatibilityRole('tts', Boolean(input.tts));
139
296
  }
140
- );
141
297
 
142
- window.addEventListener(
143
- 'ai-ready',
144
- function aiReadyHandler(){
145
- setMuted(host.muted);
298
+ if (microphoneWasAvailable && !host.availability.microphone) {
299
+ cancelSTTOperation('microphone-unavailable');
146
300
  }
147
- );
301
+ applyRoleTransitions(previousSTTRole, previousTTSRole);
302
+ return { ...host.availability };
303
+ }
304
+
305
+ function setMuted(muted) {
306
+ if (typeof muted !== 'boolean') {
307
+ throw new TypeError('muted must be boolean');
308
+ }
309
+
310
+ if (muted) {
311
+ requestUserMute();
312
+ } else {
313
+ requestUserUnmute();
314
+ }
315
+ return host;
316
+ }
317
+
318
+ function applyConfiguredMutedState(muted) {
319
+ host.muted = muted;
320
+ pendingTTSIntent = null;
321
+ if (muted) {
322
+ stopTTSPlayback();
323
+ } else {
324
+ synchronizeAIMutedState();
325
+ }
326
+ renderControls();
327
+ renderStatus();
328
+ }
148
329
 
149
- if(window.ai?.ready){
150
- setMuted(host.muted);
330
+ function synchronizeAIRuntimeState(snapshot) {
331
+ const previousSTTRole = sttRole;
332
+ const previousTTSRole = ttsRole;
333
+ sttRole = snapshot.roles.stt;
334
+ ttsRole = snapshot.roles.tts;
335
+ clearSettledTTSIntent();
336
+ applyRoleTransitions(previousSTTRole, previousTTSRole);
151
337
  }
152
338
 
153
- function configure(options={}){
154
- if(Object.prototype.hasOwnProperty.call(options,'initialMuted')){
155
- if(typeof options.initialMuted!=='boolean'){
156
- throw new TypeError('initialMuted must be boolean');
339
+ function applyRoleTransitions(previousSTTRole, previousTTSRole) {
340
+ const sttBecameUnready = previousSTTRole.state === 'ready'
341
+ && sttRole.state !== 'ready';
342
+ const ttsBecameUnready = previousTTSRole.state === 'ready'
343
+ && ttsRole.state !== 'ready';
344
+
345
+ host.availability.stt = sttRole.state === 'ready';
346
+ host.availability.tts = ttsRole.state === 'ready';
347
+
348
+ if (sttBecameUnready) {
349
+ cancelSTTOperation('runtime-unready');
350
+ }
351
+ if (ttsBecameUnready) {
352
+ host.muted = true;
353
+ stopTTSPlayback();
354
+ }
355
+ if (ttsRole.state === 'error' && !host.muted) {
356
+ host.muted = true;
357
+ stopTTSPlayback();
358
+ }
359
+
360
+ synchronizeAIMutedState();
361
+ renderControls();
362
+ renderStatus();
363
+ }
364
+
365
+ function clearSettledTTSIntent() {
366
+ if (!pendingTTSIntent) {
367
+ return;
368
+ }
369
+
370
+ if (
371
+ pendingTTSIntent === 'load'
372
+ && ['loading', 'ready', 'error'].includes(ttsRole.state)
373
+ ) {
374
+ pendingTTSIntent = null;
375
+ } else if (
376
+ pendingTTSIntent === 'unload'
377
+ && ['unloading', 'unloaded', 'unavailable', 'error', 'disposed'].includes(ttsRole.state)
378
+ ) {
379
+ pendingTTSIntent = null;
380
+ }
381
+ }
382
+
383
+ function renderControls() {
384
+ const transcriptionReady = sttRole.state === 'ready';
385
+ const microphoneReady = host.availability.microphone;
386
+ recordButton.disabled = !transcriptionReady
387
+ || !microphoneReady
388
+ || sttRole.busy
389
+ || transcriptionActive
390
+ || destroyed;
391
+ stopButton.disabled = recordButton.disabled;
392
+ recordButton.title = transcriptionButtonTitle();
393
+ recordButton.setAttribute('aria-label', recordButton.title);
394
+ recordButton.setAttribute(
395
+ 'aria-pressed',
396
+ String(recordingRequested || Boolean(captureSession))
397
+ );
398
+ renderTTSControl();
399
+ }
400
+
401
+ function transcriptionButtonTitle() {
402
+ if (destroyed || sttRole.state === 'disposed') {
403
+ return 'Transcription is unavailable.';
404
+ }
405
+ if (sttRole.state === 'loading') {
406
+ return 'The selected transcription service is loading.';
407
+ }
408
+ if (sttRole.state === 'error') {
409
+ return visibleErrorMessage(
410
+ sttRole.error,
411
+ 'The selected transcription service reported an error.'
412
+ );
413
+ }
414
+ if (sttRole.state !== 'ready') {
415
+ return 'The selected transcription service is unavailable.';
416
+ }
417
+ if (!host.availability.microphone) {
418
+ return 'Microphone access is unavailable. Text chat is still available.';
419
+ }
420
+ if (sttRole.busy || transcriptionActive) {
421
+ return 'The selected transcription service is busy.';
422
+ }
423
+ return 'Hold to talk';
424
+ }
425
+
426
+ function renderTTSControl() {
427
+ const providerSelected = typeof ttsRole.providerId === 'string'
428
+ && ttsRole.providerId.length > 0
429
+ && typeof ttsRole.modelId === 'string'
430
+ && ttsRole.modelId.length > 0;
431
+ let disabled = destroyed;
432
+ let label = host.muted ? 'Unmute' : 'Mute';
433
+ let description = label === 'Unmute' ? 'Unmute AI voice' : 'Mute AI voice';
434
+
435
+ if (pendingTTSIntent === 'unload') {
436
+ disabled = true;
437
+ label = 'Releasing voice…';
438
+ description = 'The selected voice is being released.';
439
+ } else if (ttsRole.state === 'unavailable' || ttsRole.state === 'disposed') {
440
+ disabled = true;
441
+ label = 'Voice unavailable';
442
+ description = 'The selected speech service is unavailable.';
443
+ } else if (ttsRole.state === 'unloading') {
444
+ disabled = true;
445
+ label = 'Releasing voice…';
446
+ description = 'The selected voice is being released.';
447
+ } else if (ttsRole.state === 'loading') {
448
+ disabled = !providerSelected;
449
+ label = providerSelected ? 'Cancel voice load' : 'Voice unavailable';
450
+ description = providerSelected
451
+ ? 'Cancel loading the selected voice.'
452
+ : 'The selected speech service is unavailable.';
453
+ } else if (ttsRole.state === 'unloaded') {
454
+ disabled = !providerSelected;
455
+ if (pendingTTSIntent === 'load') {
456
+ label = 'Cancel voice load';
457
+ description = 'Cancel loading the selected voice.';
157
458
  }
459
+ } else if (ttsRole.state === 'error') {
460
+ disabled = !providerSelected;
461
+ label = providerSelected ? 'Retry voice' : 'Voice unavailable';
462
+ description = providerSelected
463
+ ? 'Retry loading the selected voice.'
464
+ : 'The selected speech service is unavailable.';
465
+ }
466
+
467
+ muteButton.disabled = disabled;
468
+ muteButton.textContent = label;
469
+ muteButton.title = description;
470
+ muteButton.setAttribute('aria-label', description);
471
+ muteButton.setAttribute('aria-pressed', String(host.muted));
472
+ }
158
473
 
159
- host.initialMuted=options.initialMuted;
160
- setMuted(host.initialMuted);
474
+ function renderStatus() {
475
+ const messages = [];
476
+ let tone = '';
477
+
478
+ if (localStatus?.message) {
479
+ messages.push(localStatus.message);
480
+ tone = localStatus.tone;
481
+ } else {
482
+ messages.push(describeSTTRole());
483
+ }
484
+ messages.push(describeTTSRole());
485
+ if (sttRole.state === 'error' || ttsRole.state === 'error') {
486
+ tone = 'error';
161
487
  }
162
488
 
163
- if(
164
- Object.prototype.hasOwnProperty.call(options,'stt')
165
- ||Object.prototype.hasOwnProperty.call(options,'tts')
166
- ||Object.prototype.hasOwnProperty.call(options,'microphone')
167
- ){
168
- setAvailability(options);
489
+ speechStatus.textContent = messages.filter(Boolean).join(' ');
490
+ speechStatus.dataset.tone = tone;
491
+ speechStatus.setAttribute(
492
+ 'aria-busy',
493
+ String(
494
+ sttRole.state === 'loading'
495
+ || ttsRole.state === 'loading'
496
+ || sttRole.busy
497
+ || ttsRole.busy
498
+ || transcriptionActive
499
+ )
500
+ );
501
+ }
502
+
503
+ function describeSTTRole() {
504
+ if (sttRole.state === 'ready') {
505
+ if (!host.availability.microphone) {
506
+ return 'Microphone unavailable.';
507
+ }
508
+ return sttRole.busy ? 'Transcription busy.' : 'Transcription ready.';
509
+ }
510
+ if (sttRole.state === 'loading') {
511
+ return `Transcription ${formatRoleProgress(sttRole.progress, 'loading')}.`;
169
512
  }
513
+ if (sttRole.state === 'error') {
514
+ return `Transcription error: ${visibleErrorMessage(sttRole.error, 'Unknown error')}.`;
515
+ }
516
+ if (sttRole.state === 'disposed') {
517
+ return 'Transcription disposed.';
518
+ }
519
+ return 'Transcription unavailable.';
520
+ }
170
521
 
171
- return host;
522
+ function describeTTSRole() {
523
+ if (pendingTTSIntent === 'load' && ttsRole.state === 'unloaded') {
524
+ return 'Voice load requested; Cancel is available.';
525
+ }
526
+ if (pendingTTSIntent === 'unload') {
527
+ return 'Voice release requested.';
528
+ }
529
+ if (ttsRole.state === 'loading') {
530
+ return `Voice ${formatRoleProgress(ttsRole.progress, 'loading')}; Cancel is available.`;
531
+ }
532
+ if (ttsRole.state === 'unloading') {
533
+ return `Voice ${formatRoleProgress(ttsRole.progress, 'releasing')}.`;
534
+ }
535
+ if (ttsRole.state === 'ready') {
536
+ if (host.muted) {
537
+ return 'Voice ready and muted.';
538
+ }
539
+ return ttsRole.busy ? 'Voice speaking.' : 'Voice ready.';
540
+ }
541
+ if (ttsRole.state === 'unloaded') {
542
+ return host.muted ? 'Voice unloaded.' : 'Voice selected and waiting to load.';
543
+ }
544
+ if (ttsRole.state === 'error') {
545
+ return `Voice error: ${visibleErrorMessage(ttsRole.error, 'Unknown error')}.`;
546
+ }
547
+ if (ttsRole.state === 'disposed') {
548
+ return 'Voice disposed.';
549
+ }
550
+ return 'Voice unavailable.';
172
551
  }
173
552
 
174
- function setAvailability(input={}){
175
- const availability={
176
- microphone:Object.prototype.hasOwnProperty.call(input,'microphone')
177
- ?Boolean(input.microphone)
178
- :host.availability.microphone,
179
- stt:Object.prototype.hasOwnProperty.call(input,'stt')
180
- ?Boolean(input.stt)
181
- :host.availability.stt,
182
- tts:Object.prototype.hasOwnProperty.call(input,'tts')
183
- ?Boolean(input.tts)
184
- :host.availability.tts
185
- };
553
+ function formatRoleProgress(progress, fallback) {
554
+ if (!progress) {
555
+ return fallback;
556
+ }
557
+
558
+ const amount = progress.total === null
559
+ ? `${progress.completed} ${progress.unit}`
560
+ : `${progress.completed} of ${progress.total} ${progress.unit}`;
561
+ const heartbeat = progress.heartbeat ? ', active heartbeat' : '';
562
+ return `${progress.phase}, ${amount}${heartbeat}`;
563
+ }
186
564
 
187
- host.availability=availability;
188
- recordButton.disabled=!availability.stt||!availability.microphone;
189
- stopButton.disabled=recordButton.disabled;
190
- recordButton.title=!availability.stt
191
- ?'The selected transcription service is unavailable.'
192
- :!availability.microphone
193
- ?'Microphone access is unavailable. Text chat is still available.'
194
- :'Push to talk';
195
- recordButton.setAttribute('aria-label',recordButton.title);
196
- muteButton.disabled=!availability.tts;
197
-
198
- if(!availability.tts){
199
- muteButton.textContent='Voice unavailable';
200
- muteButton.setAttribute('aria-label','Selected speech service unavailable');
201
- if(window.ai){
202
- window.ai.muted=true;
203
- window.ai.stopAudio?.();
565
+ function visibleErrorMessage(error, fallback) {
566
+ const message = typeof error?.message === 'string'
567
+ ? error.message.trim()
568
+ : '';
569
+ return (message || fallback).slice(0, 240);
570
+ }
571
+
572
+ async function observeMicrophonePermission() {
573
+ if (!navigator.permissions?.query || !navigator.mediaDevices?.getUserMedia) {
574
+ return;
575
+ }
576
+
577
+ try {
578
+ microphonePermissionStatus = await navigator.permissions.query(
579
+ { name: 'microphone' }
580
+ );
581
+ microphonePermissionListener = synchronizeMicrophonePermission;
582
+ microphonePermissionStatus.addEventListener?.(
583
+ 'change',
584
+ microphonePermissionListener
585
+ );
586
+ synchronizeMicrophonePermission();
587
+ } catch (error) {
588
+ // Some supported browsers do not expose microphone permission
589
+ // state. getUserMedia remains the authoritative admission check.
590
+ }
591
+ }
592
+
593
+ function synchronizeMicrophonePermission() {
594
+ setAvailability(
595
+ {
596
+ microphone: microphonePermissionStatus?.state !== 'denied'
204
597
  }
205
- }else{
206
- setMuted(host.muted);
598
+ );
599
+ }
600
+
601
+ function handleRecordPointerDown(event) {
602
+ if (recordButton.disabled || event.button !== 0 || activePointerId !== null) {
603
+ return;
604
+ }
605
+
606
+ event.preventDefault();
607
+ activePointerId = event.pointerId;
608
+ recordButton.setPointerCapture?.(event.pointerId);
609
+ recordButton.classList.add('pressed');
610
+ void record();
611
+ }
612
+
613
+ function handleRecordPointerUp(event) {
614
+ if (event.pointerId !== activePointerId) {
615
+ return;
207
616
  }
208
617
 
209
- return {...availability};
618
+ event.preventDefault();
619
+ releaseActivePointer(event.pointerId);
620
+ stop();
210
621
  }
211
622
 
212
- async function observeMicrophonePermission(){
213
- if(!navigator.permissions?.query||!navigator.mediaDevices?.getUserMedia){
623
+ function handleRecordPointerCancel(event) {
624
+ if (event.pointerId !== activePointerId) {
214
625
  return;
215
626
  }
216
627
 
217
- try{
218
- const permission=await navigator.permissions.query({name:'microphone'});
219
- const synchronize=()=>setAvailability({
220
- microphone:permission.state!=='denied'
221
- });
222
- permission.addEventListener?.('change',synchronize);
223
- synchronize();
224
- }catch{
225
- // Some supported browsers do not expose microphone through the
226
- // Permissions API. getUserMedia remains the authoritative check.
628
+ releaseActivePointer(event.pointerId);
629
+ stop();
630
+ }
631
+
632
+ function handleLostPointerCapture(event) {
633
+ if (event.pointerId !== activePointerId) {
634
+ return;
227
635
  }
636
+
637
+ activePointerId = null;
638
+ recordButton.classList.remove('pressed');
639
+ stop();
228
640
  }
229
641
 
230
- function setMuted(muted){
231
- if(typeof muted!=='boolean'){
232
- throw new TypeError('muted must be boolean');
642
+ function releaseActivePointer(pointerId) {
643
+ activePointerId = null;
644
+ if (recordButton.hasPointerCapture?.(pointerId)) {
645
+ recordButton.releasePointerCapture(pointerId);
233
646
  }
647
+ recordButton.classList.remove('pressed');
648
+ }
234
649
 
235
- host.muted=muted;
236
- if(host.availability.tts){
237
- muteButton.textContent=muted?'Unmute':'Mute';
238
- muteButton.setAttribute('aria-pressed',String(muted));
239
- muteButton.setAttribute('aria-label',muted?'Unmute AI voice':'Mute AI voice');
650
+ function handleRecordKeyDown(event) {
651
+ if (
652
+ recordButton.disabled
653
+ || event.repeat
654
+ || keyboardRecording
655
+ || ![' ', 'Enter'].includes(event.key)
656
+ ) {
657
+ return;
240
658
  }
241
659
 
242
- if(window.ai){
243
- window.ai.muted=host.availability.tts?muted:true;
660
+ event.preventDefault();
661
+ keyboardRecording = true;
662
+ recordButton.classList.add('pressed');
663
+ void record();
664
+ }
244
665
 
245
- if(muted&&typeof window.ai.stopAudio==='function'){
246
- window.ai.stopAudio();
247
- }
666
+ function handleRecordKeyUp(event) {
667
+ if (!keyboardRecording || ![' ', 'Enter'].includes(event.key)) {
668
+ return;
248
669
  }
249
670
 
250
- return host;
671
+ event.preventDefault();
672
+ keyboardRecording = false;
673
+ recordButton.classList.remove('pressed');
674
+ stop();
675
+ }
676
+
677
+ function handleRecordBlur() {
678
+ if (!keyboardRecording) {
679
+ return;
680
+ }
681
+
682
+ keyboardRecording = false;
683
+ recordButton.classList.remove('pressed');
684
+ stop();
685
+ }
686
+
687
+ function submitCompletionCommand(event) {
688
+ event.target.disabled = true;
689
+ void transcribe('End now, run all tool functions.');
251
690
  }
252
691
 
253
- function muteToggle(){
254
- const muted=!host.muted;
255
- setMuted(muted);
692
+ function muteToggle() {
693
+ if (pendingTTSIntent === 'load' || ttsRole.state === 'loading') {
694
+ requestUserMute();
695
+ return;
696
+ }
256
697
 
257
- if(!muted){
258
- window.ai?.resumeAudio?.();
698
+ if (host.muted) {
699
+ requestUserUnmute();
700
+ } else {
701
+ requestUserMute();
259
702
  }
260
703
  }
261
704
 
262
- async function transcribe(transcription=''){
263
- const transcriptionEvent = new CustomEvent(
264
- 'speech-transcription-complete',
265
- {
266
- detail: { text: transcription }
705
+ function requestUserMute() {
706
+ const wasMuted = host.muted;
707
+ const shouldUnload = !wasMuted
708
+ || pendingTTSIntent === 'load'
709
+ || ['loading', 'ready', 'error'].includes(ttsRole.state);
710
+ host.muted = true;
711
+ stopTTSPlayback();
712
+ if (shouldUnload) {
713
+ requestTTSIntent('unload', 'user');
714
+ }
715
+ renderControls();
716
+ renderStatus();
717
+ }
718
+
719
+ function requestUserUnmute() {
720
+ const providerSelected = typeof ttsRole.providerId === 'string'
721
+ && ttsRole.providerId.length > 0
722
+ && typeof ttsRole.modelId === 'string'
723
+ && ttsRole.modelId.length > 0;
724
+ const canRequestLoad = ttsRole.state === 'ready'
725
+ || (
726
+ providerSelected
727
+ && ['unloaded', 'loading', 'error'].includes(ttsRole.state)
728
+ );
729
+ if (!canRequestLoad) {
730
+ host.muted = true;
731
+ stopTTSPlayback();
732
+ reportTTSLifecycleError(
733
+ new Error('No selected speech provider is available to load.'),
734
+ 'load'
735
+ );
736
+ return false;
737
+ }
738
+
739
+ host.muted = false;
740
+ synchronizeAIMutedState();
741
+ requestTTSIntent('load', 'user');
742
+ if (ttsRole.state === 'ready') {
743
+ resumeTTSPlayback();
744
+ }
745
+ renderControls();
746
+ renderStatus();
747
+ return true;
748
+ }
749
+
750
+ function requestTTSIntent(action, reason) {
751
+ pendingTTSIntent = action === 'load' && ttsRole.state === 'ready'
752
+ ? null
753
+ : action;
754
+ try {
755
+ requestAIRuntimeIntent(
756
+ {
757
+ role: 'tts',
758
+ action,
759
+ reason
760
+ }
761
+ );
762
+ } catch (error) {
763
+ pendingTTSIntent = null;
764
+ reportTTSLifecycleError(error, action);
765
+ }
766
+ }
767
+
768
+ function reportTTSLifecycleError(error, action) {
769
+ const message = visibleErrorMessage(
770
+ error,
771
+ `The voice ${action} request failed.`
772
+ );
773
+ localStatus = {
774
+ message,
775
+ tone: 'error'
776
+ };
777
+ host.dispatchEvent(
778
+ new CustomEvent(
779
+ 'speech-tts-lifecycle-error',
780
+ {
781
+ bubbles: true,
782
+ composed: true,
783
+ detail: {
784
+ action,
785
+ error,
786
+ message
787
+ }
788
+ }
789
+ )
790
+ );
791
+ renderControls();
792
+ renderStatus();
793
+ }
794
+
795
+ function reportTTSError(error) {
796
+ const message = visibleErrorMessage(
797
+ error,
798
+ 'Speech synthesis failed.'
799
+ );
800
+ host.muted = true;
801
+ stopTTSPlayback();
802
+ localStatus = {
803
+ message: `Speech synthesis failed: ${message}`,
804
+ tone: 'error'
805
+ };
806
+ host.dispatchEvent(
807
+ new CustomEvent(
808
+ 'speech-synthesis-error',
809
+ {
810
+ bubbles: true,
811
+ composed: true,
812
+ detail: {
813
+ error,
814
+ message
815
+ }
816
+ }
817
+ )
818
+ );
819
+ renderControls();
820
+ renderStatus();
821
+ return false;
822
+ }
823
+
824
+ function stopTTSPlayback() {
825
+ const aiRuntime = globalThis.ai;
826
+ if (!aiRuntime) {
827
+ return;
828
+ }
829
+
830
+ aiRuntime.muted = true;
831
+ aiRuntime.stopAudio?.();
832
+ }
833
+
834
+ function synchronizeAIMutedState() {
835
+ const aiRuntime = globalThis.ai;
836
+ if (!aiRuntime) {
837
+ return;
838
+ }
839
+
840
+ aiRuntime.muted = host.muted || ttsRole.state !== 'ready';
841
+ }
842
+
843
+ function resumeTTSPlayback() {
844
+ const aiRuntime = globalThis.ai;
845
+ if (!aiRuntime || typeof aiRuntime.resumeAudio !== 'function') {
846
+ return;
847
+ }
848
+
849
+ void Promise.resolve(aiRuntime.resumeAudio()).catch(
850
+ function reportTTSResumeFailure(error) {
851
+ reportTTSError(error);
267
852
  }
268
853
  );
854
+ }
269
855
 
270
- host.dispatchEvent(transcriptionEvent);
856
+ async function transcribe(transcription = '') {
857
+ host.dispatchEvent(
858
+ new CustomEvent(
859
+ 'speech-transcription-complete',
860
+ {
861
+ detail: { text: transcription }
862
+ }
863
+ )
864
+ );
271
865
  }
272
866
 
273
867
  async function record() {
274
- if(!host.availability.stt||!host.availability.microphone){
868
+ if (
869
+ destroyed
870
+ || sttRole.state !== 'ready'
871
+ || sttRole.busy
872
+ || !host.availability.microphone
873
+ || recordingRequested
874
+ || captureSession
875
+ || transcriptionActive
876
+ ) {
877
+ resetRecordPress();
275
878
  return false;
276
879
  }
277
- ai.stopAudio();
278
- audioRecordingChunks.splice(0);
279
- try{
280
- activeStream=await navigator.mediaDevices.getUserMedia({audio:true});
281
- }catch(error){
282
- if(['NotAllowedError','NotFoundError','NotReadableError','SecurityError'].includes(error?.name)){
283
- setAvailability({microphone:false});
284
- host.dispatchEvent(new CustomEvent('speech-microphone-unavailable',{
285
- bubbles:true,
286
- composed:true,
287
- detail:{reason:error.name}
288
- }));
289
- return false;
880
+
881
+ const generation = ++recordingGeneration;
882
+ recordingRequested = true;
883
+ localStatus = {
884
+ message: 'Recording. Release to transcribe.',
885
+ tone: ''
886
+ };
887
+ renderControls();
888
+ renderStatus();
889
+
890
+ let stream;
891
+ try {
892
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
893
+ } catch (error) {
894
+ recordingRequested = false;
895
+ resetRecordPress();
896
+ if (
897
+ ['NotAllowedError', 'NotFoundError', 'NotReadableError', 'SecurityError']
898
+ .includes(error?.name)
899
+ ) {
900
+ setAvailability({ microphone: false });
901
+ localStatus = {
902
+ message: 'Microphone access is unavailable. Text chat is still available.',
903
+ tone: 'error'
904
+ };
905
+ host.dispatchEvent(
906
+ new CustomEvent(
907
+ 'speech-microphone-unavailable',
908
+ {
909
+ bubbles: true,
910
+ composed: true,
911
+ detail: { reason: error.name }
912
+ }
913
+ )
914
+ );
915
+ } else {
916
+ reportTranscriptionError(error);
290
917
  }
291
- throw error;
292
- }
293
- mediaRecorder=new MediaRecorder(activeStream);
294
-
295
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
296
- const source = audioContext.createMediaStreamSource(activeStream);
297
- const processor = audioContext.createScriptProcessor(4096, 1, 1);
298
-
299
- source.connect(processor);
300
- processor.connect(audioContext.destination);
301
-
302
- processor.onaudioprocess = function(event) {
303
- const input = event.inputBuffer.getChannelData(0);
304
- const isSilent = input.every(sample => Math.abs(sample) < 0.05); // Threshold for silence
305
-
306
- if (isSilent&&silenceDuration) {
307
- console.log('silent');
308
- if(!silenceTimer){
309
- silenceTimer = setTimeout(() => {
310
-
311
- //console.log('TIMER SAYS STOPPING');
312
- stop();
313
- clearTimeout(silenceTimer);
314
- }, silenceDuration); // wait for specified milliseconds of silence
918
+ renderControls();
919
+ renderStatus();
920
+ return false;
921
+ }
922
+
923
+ if (
924
+ !recordingRequested
925
+ || generation !== recordingGeneration
926
+ || sttRole.state !== 'ready'
927
+ || destroyed
928
+ ) {
929
+ stopMediaStream(stream);
930
+ resetRecordPress();
931
+ return false;
932
+ }
933
+
934
+ let session = null;
935
+ try {
936
+ const recorder = new MediaRecorder(stream);
937
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
938
+ const audioContext = new AudioContext();
939
+ const source = audioContext.createMediaStreamSource(stream);
940
+
941
+ // ScriptProcessor remains the visible compatibility capture path
942
+ // in this increment. AudioWorklet needs its own reviewed protocol.
943
+ const processor = audioContext.createScriptProcessor(4096, 1, 1);
944
+ session = {
945
+ generation,
946
+ recorder,
947
+ stream,
948
+ audioContext,
949
+ source,
950
+ processor,
951
+ chunks: [],
952
+ cancelled: false,
953
+ released: false
954
+ };
955
+
956
+ captureSession = session;
957
+ source.connect(processor);
958
+ processor.connect(audioContext.destination);
959
+ processor.onaudioprocess = function handleAudioProcess(event) {
960
+ const input = event.inputBuffer.getChannelData(0);
961
+ const isSilent = input.every(
962
+ function isSilentSample(sample) {
963
+ return Math.abs(sample) < .05;
964
+ }
965
+ );
966
+
967
+ if (isSilent && silenceDuration) {
968
+ if (!silenceTimer) {
969
+ silenceTimer = setTimeout(stopAfterSilence, silenceDuration);
970
+ }
971
+ } else {
972
+ clearSilenceTimer();
973
+ }
974
+ };
975
+ recorder.ondataavailable = function collectAudioChunk(event) {
976
+ session.chunks.push(event.data);
977
+ };
978
+ recorder.onstop = function handleRecorderStop() {
979
+ void finalizeCapture(session);
980
+ };
981
+ recorder.start();
982
+ return true;
983
+ } catch (error) {
984
+ recordingRequested = false;
985
+ if (session) {
986
+ session.cancelled = true;
987
+ releaseCaptureSession(session);
988
+ if (captureSession === session) {
989
+ captureSession = null;
315
990
  }
316
991
  } else {
317
- clearTimeout(silenceTimer);
318
- silenceTimer=false;
992
+ stopMediaStream(stream);
319
993
  }
320
- };
994
+ resetRecordPress();
995
+ reportTranscriptionError(error);
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ function stopAfterSilence() {
1001
+ clearSilenceTimer();
1002
+ stop();
1003
+ }
1004
+
1005
+ function clearSilenceTimer() {
1006
+ if (silenceTimer !== null) {
1007
+ clearTimeout(silenceTimer);
1008
+ silenceTimer = null;
1009
+ }
1010
+ }
1011
+
1012
+ function stop() {
1013
+ recordingRequested = false;
1014
+ clearSilenceTimer();
1015
+ if (captureSession?.recorder.state !== 'inactive') {
1016
+ captureSession.recorder.stop();
1017
+ }
1018
+ return true;
1019
+ }
1020
+
1021
+ async function finalizeCapture(session) {
1022
+ releaseCaptureSession(session);
1023
+ if (captureSession === session) {
1024
+ captureSession = null;
1025
+ }
1026
+ recordingRequested = false;
1027
+ resetRecordPress();
1028
+
1029
+ if (
1030
+ session.cancelled
1031
+ || session.generation !== recordingGeneration
1032
+ || sttRole.state !== 'ready'
1033
+ || destroyed
1034
+ ) {
1035
+ renderControls();
1036
+ renderStatus();
1037
+ return false;
1038
+ }
1039
+
1040
+ const audioBlob = new Blob(
1041
+ session.chunks,
1042
+ { type: 'audio/webm; codecs=opus' }
1043
+ );
1044
+ const audioFile = new File(
1045
+ [audioBlob],
1046
+ 'audio.webm',
1047
+ { type: 'audio/webm' }
1048
+ );
1049
+ return transcribeAudio(audioFile);
1050
+ }
321
1051
 
322
- mediaRecorder.ondataavailable = event => {
323
- audioRecordingChunks.push(event.data);
1052
+ async function transcribeAudio(audioFile) {
1053
+ const generation = ++transcriptionGeneration;
1054
+ transcriptionActive = true;
1055
+ localStatus = {
1056
+ message: 'Transcribing speech.',
1057
+ tone: ''
324
1058
  };
325
-
326
- mediaRecorder.onstop = async () => {
327
- processor.disconnect();
328
- source.disconnect();
329
- activeStream?.getTracks().forEach(track=>track.stop());
330
- activeStream=null;
331
-
332
- const audioBlob = new Blob(audioRecordingChunks, { type: 'audio/webm; codecs=opus' });
333
- const audioFile = new File([audioBlob], 'audio.webm', { type: 'audio/webm' });
334
- //console.log(audioFile)
1059
+ renderControls();
1060
+ renderStatus();
335
1061
 
336
- //playBackAudio(audioBlob);
1062
+ try {
1063
+ const aiRuntime = globalThis.ai;
1064
+ if (sttRole.state !== 'ready' || typeof aiRuntime?.fetchSTT !== 'function') {
1065
+ throw new Error('The selected transcription service is unavailable.');
1066
+ }
337
1067
 
338
- const transcription = await ai.fetchSTT(audioFile);
1068
+ const transcription = await aiRuntime.fetchSTT(audioFile);
1069
+ if (
1070
+ generation !== transcriptionGeneration
1071
+ || sttRole.state !== 'ready'
1072
+ || destroyed
1073
+ ) {
1074
+ return false;
1075
+ }
1076
+ if (typeof transcription !== 'string') {
1077
+ throw new TypeError('The selected transcription service returned invalid text.');
1078
+ }
339
1079
 
340
- transcribe(transcription);
1080
+ localStatus = null;
1081
+ await transcribe(transcription);
1082
+ return true;
1083
+ } catch (error) {
1084
+ if (generation === transcriptionGeneration && !destroyed) {
1085
+ reportTranscriptionError(error);
1086
+ }
1087
+ return false;
1088
+ } finally {
1089
+ if (generation === transcriptionGeneration) {
1090
+ transcriptionActive = false;
1091
+ renderControls();
1092
+ renderStatus();
1093
+ }
1094
+ }
1095
+ }
341
1096
 
342
- console.log(transcription);
1097
+ function reportTranscriptionError(error) {
1098
+ const message = visibleErrorMessage(
1099
+ error,
1100
+ 'Speech transcription failed.'
1101
+ );
1102
+ localStatus = {
1103
+ message: `Transcription failed: ${message}`,
1104
+ tone: 'error'
343
1105
  };
344
-
345
- mediaRecorder.start();
346
- // recordButton.disabled = true;
347
- // stopButton.disabled = false;
1106
+ host.dispatchEvent(
1107
+ new CustomEvent(
1108
+ 'speech-transcription-error',
1109
+ {
1110
+ bubbles: true,
1111
+ composed: true,
1112
+ detail: {
1113
+ error,
1114
+ message
1115
+ }
1116
+ }
1117
+ )
1118
+ );
1119
+ renderControls();
1120
+ renderStatus();
348
1121
  }
349
1122
 
350
- async function stop() {
351
- if (mediaRecorder && mediaRecorder.state !== 'inactive') {
352
- mediaRecorder.stop();
1123
+ function cancelSTTOperation(reason) {
1124
+ const hadActiveOperation = recordingRequested
1125
+ || Boolean(captureSession)
1126
+ || transcriptionActive;
1127
+ recordingGeneration += 1;
1128
+ transcriptionGeneration += 1;
1129
+ recordingRequested = false;
1130
+ transcriptionActive = false;
1131
+ clearSilenceTimer();
1132
+ resetRecordPress();
1133
+
1134
+ if (captureSession) {
1135
+ captureSession.cancelled = true;
1136
+ if (captureSession.recorder.state !== 'inactive') {
1137
+ captureSession.recorder.stop();
1138
+ } else {
1139
+ releaseCaptureSession(captureSession);
1140
+ captureSession = null;
1141
+ }
1142
+ }
1143
+
1144
+ if (hadActiveOperation) {
1145
+ localStatus = {
1146
+ message: 'Transcription stopped because the selected service became unavailable.',
1147
+ tone: 'error'
1148
+ };
1149
+ host.dispatchEvent(
1150
+ new CustomEvent(
1151
+ 'speech-transcription-cancelled',
1152
+ {
1153
+ bubbles: true,
1154
+ composed: true,
1155
+ detail: { reason }
1156
+ }
1157
+ )
1158
+ );
353
1159
  }
354
- // recordButton.disabled = false;
355
- // stopButton.disabled = true;
356
1160
  }
357
1161
 
358
- async function playBackAudio(audioBlob) {
359
- const audioUrl = URL.createObjectURL(audioBlob);
360
- audioElement.src = audioUrl;
361
- audioElement.play();
1162
+ function releaseCaptureSession(session) {
1163
+ if (session.released) {
1164
+ return;
1165
+ }
1166
+
1167
+ session.released = true;
1168
+ session.processor.onaudioprocess = null;
1169
+ session.processor.disconnect?.();
1170
+ session.source.disconnect?.();
1171
+ stopMediaStream(session.stream);
1172
+ if (session.audioContext.state !== 'closed') {
1173
+ void session.audioContext.close().catch(
1174
+ function reportAudioContextCloseFailure(error) {
1175
+ console.warn('The microphone audio context could not close.');
1176
+ }
1177
+ );
1178
+ }
1179
+ }
1180
+
1181
+ function stopMediaStream(stream) {
1182
+ stream?.getTracks().forEach(
1183
+ function stopMediaTrack(track) {
1184
+ track.stop();
1185
+ }
1186
+ );
1187
+ }
1188
+
1189
+ function resetRecordPress() {
1190
+ recordButton.classList.remove('pressed');
1191
+ activePointerId = null;
1192
+ keyboardRecording = false;
362
1193
  }
363
1194
 
364
-
1195
+ function destroy() {
1196
+ if (destroyed) {
1197
+ return;
1198
+ }
1199
+
1200
+ destroyed = true;
1201
+ runtimeStateAbortController.abort();
1202
+ window.removeEventListener('pagehide', destroy);
1203
+ microphonePermissionStatus?.removeEventListener?.(
1204
+ 'change',
1205
+ microphonePermissionListener
1206
+ );
1207
+ cancelSTTOperation('component-destroyed');
1208
+ stopTTSPlayback();
1209
+ host.componentReady = false;
1210
+ renderControls();
1211
+ renderStatus();
1212
+ }
365
1213
  </script>