arcane-os 0.1.2 → 0.2.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 +17 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +280 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +713 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +6 -4
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +244 -13
- package/src/import-map.mjs +59 -1
- package/src/packager/core.mjs +2 -2
- package/src/runtime.mjs +14 -4
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +4 -4
- package/src/toolchain.mjs +3 -0
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +1 -1
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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:
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
border:.2em;
|
|
20
|
-
border-color:var(--border-color);
|
|
72
|
+
#stopButton {
|
|
73
|
+
display: none;
|
|
21
74
|
}
|
|
22
75
|
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
.
|
|
29
|
-
|
|
86
|
+
.speech_status[data-tone="error"] {
|
|
87
|
+
color: var(--arcane-danger, var(--danger-color));
|
|
30
88
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
119
|
+
|
|
120
|
+
#endButton {
|
|
121
|
+
grid-row: 1;
|
|
122
|
+
grid-column: 2;
|
|
39
123
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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">
|
|
51
|
-
<button id="stopButton" type="button" class=
|
|
52
|
-
<
|
|
53
|
-
<button id="
|
|
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
|
-
|
|
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
|
|
60
|
-
const host
|
|
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
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
let
|
|
70
|
-
let
|
|
71
|
-
let
|
|
72
|
-
let
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
muteToggle
|
|
132
|
-
);
|
|
278
|
+
return host;
|
|
279
|
+
}
|
|
133
280
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
618
|
+
event.preventDefault();
|
|
619
|
+
releaseActivePointer(event.pointerId);
|
|
620
|
+
stop();
|
|
210
621
|
}
|
|
211
622
|
|
|
212
|
-
|
|
213
|
-
if(
|
|
623
|
+
function handleRecordPointerCancel(event) {
|
|
624
|
+
if (event.pointerId !== activePointerId) {
|
|
214
625
|
return;
|
|
215
626
|
}
|
|
216
627
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
if(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
660
|
+
event.preventDefault();
|
|
661
|
+
keyboardRecording = true;
|
|
662
|
+
recordButton.classList.add('pressed');
|
|
663
|
+
void record();
|
|
664
|
+
}
|
|
244
665
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
666
|
+
function handleRecordKeyUp(event) {
|
|
667
|
+
if (!keyboardRecording || ![' ', 'Enter'].includes(event.key)) {
|
|
668
|
+
return;
|
|
248
669
|
}
|
|
249
670
|
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
692
|
+
function muteToggle() {
|
|
693
|
+
if (pendingTTSIntent === 'load' || ttsRole.state === 'loading') {
|
|
694
|
+
requestUserMute();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
256
697
|
|
|
257
|
-
if(
|
|
258
|
-
|
|
698
|
+
if (host.muted) {
|
|
699
|
+
requestUserUnmute();
|
|
700
|
+
} else {
|
|
701
|
+
requestUserMute();
|
|
259
702
|
}
|
|
260
703
|
}
|
|
261
704
|
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
1052
|
+
async function transcribeAudio(audioFile) {
|
|
1053
|
+
const generation = ++transcriptionGeneration;
|
|
1054
|
+
transcriptionActive = true;
|
|
1055
|
+
localStatus = {
|
|
1056
|
+
message: 'Transcribing speech.',
|
|
1057
|
+
tone: ''
|
|
324
1058
|
};
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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>
|