dsh-live-voice 0.0.1-developing → 0.1.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.
@@ -0,0 +1,147 @@
1
+ // @ts-nocheck
2
+ const BASE = '/api/dsh-live-voice/whisper';
3
+ const UNLOADED =
4
+ 'Whisper settings routes are not loaded. A normal DSH server restart is required to load updated plugin routes; refreshing this page alone is not enough.';
5
+ export async function whisperSettingsRequest(
6
+ path,
7
+ { method = 'GET', config, signal } = {},
8
+ fetchImpl = globalThis.fetch,
9
+ ) {
10
+ const response = await fetchImpl(BASE + path, {
11
+ method,
12
+ credentials: 'same-origin',
13
+ signal,
14
+ headers: config ? { 'content-type': 'application/json' } : undefined,
15
+ body: config ? JSON.stringify(config) : undefined,
16
+ });
17
+ if (response.status === 401 || response.status === 403)
18
+ throw new Error('Sign in to DSH to manage Whisper settings.');
19
+ if (response.status === 404 || response.status === 405) throw new Error(UNLOADED);
20
+ let body;
21
+ try {
22
+ body = await response.json();
23
+ } catch {
24
+ throw new Error(UNLOADED);
25
+ }
26
+ if (typeof body?.ok !== 'boolean') throw new Error(UNLOADED);
27
+ if (!response.ok || !body.ok)
28
+ throw new Error(body.error?.message || 'Whisper settings request failed.');
29
+ return body.value;
30
+ }
31
+ export function createWhisperSettings(React) {
32
+ const h = React.createElement;
33
+ return function WhisperSettings({ controller }) {
34
+ const [draft, setDraft] = React.useState({
35
+ url: 'http://127.0.0.1:8080/inference',
36
+ healthUrl: '/health',
37
+ timeoutMs: 30000,
38
+ });
39
+ const [busy, setBusy] = React.useState(true),
40
+ [loaded, setLoaded] = React.useState(false),
41
+ [error, setError] = React.useState(''),
42
+ [message, setMessage] = React.useState('');
43
+ const active = React.useRef(null);
44
+ async function run(action) {
45
+ active.current?.abort();
46
+ const abort = new AbortController();
47
+ active.current = abort;
48
+ setBusy(true);
49
+ setError('');
50
+ setMessage('');
51
+ try {
52
+ if (action === 'load') {
53
+ const value = await whisperSettingsRequest('/config', { signal: abort.signal });
54
+ if (!abort.signal.aborted) {
55
+ setDraft(value);
56
+ setLoaded(true);
57
+ }
58
+ } else if (action === 'save') {
59
+ await controller.endConversation?.();
60
+ const value = await whisperSettingsRequest('/config', {
61
+ method: 'PUT',
62
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
63
+ signal: abort.signal,
64
+ });
65
+ if (!abort.signal.aborted) {
66
+ setDraft(value);
67
+ setMessage('Saved on the DSH host. Active host transcription requests were cancelled.');
68
+ await controller.refreshCapabilities?.();
69
+ }
70
+ } else {
71
+ const value = await whisperSettingsRequest('/test', {
72
+ method: 'POST',
73
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
74
+ signal: abort.signal,
75
+ });
76
+ if (!abort.signal.aborted) {
77
+ if (!value.supported) throw new Error(value.reason || 'Whisper health check failed.');
78
+ setMessage(
79
+ 'Connection successful. Health endpoint responded; transcription was not tested. Unsaved edits have not been applied.',
80
+ );
81
+ }
82
+ }
83
+ } catch (reason) {
84
+ if (!abort.signal.aborted) setError(reason.message || String(reason));
85
+ } finally {
86
+ if (!abort.signal.aborted) setBusy(false);
87
+ }
88
+ }
89
+ React.useEffect(() => {
90
+ void run('load');
91
+ return () => active.current?.abort();
92
+ }, []);
93
+ function field(label, key, type = 'text') {
94
+ return h(
95
+ 'label',
96
+ null,
97
+ label,
98
+ h('input', {
99
+ type,
100
+ value: draft[key],
101
+ disabled: busy || !loaded,
102
+ autoComplete: 'off',
103
+ ...(type === 'number' ? { min: 100, max: 300000, step: 1 } : {}),
104
+ onChange: (event) => {
105
+ setDraft({ ...draft, [key]: event.target.value });
106
+ setMessage('Unsaved changes');
107
+ setError('');
108
+ },
109
+ }),
110
+ );
111
+ }
112
+ return h(
113
+ React.Fragment,
114
+ null,
115
+ h(
116
+ 'p',
117
+ null,
118
+ 'Host-wide settings. Only unauthenticated loopback HTTP URLs (localhost, 127.0.0.1, [::1]) are allowed. Loopback means the DSH host, not this browser. All health checks and audio requests run through the authenticated backend.',
119
+ ),
120
+ field('Endpoint URL', 'url'),
121
+ field('Health URL or path', 'healthUrl'),
122
+ field('Request timeout (ms)', 'timeoutMs', 'number'),
123
+ h(
124
+ 'div',
125
+ { className: 'dlv-settings-actions' },
126
+ h(
127
+ 'button',
128
+ { type: 'button', disabled: busy || !loaded, onClick: () => run('save') },
129
+ 'Save Whisper settings',
130
+ ),
131
+ h(
132
+ 'button',
133
+ { type: 'button', disabled: busy || !loaded, onClick: () => run('test') },
134
+ 'Test connection',
135
+ ),
136
+ h(
137
+ 'button',
138
+ { type: 'button', disabled: busy, onClick: () => run('load') },
139
+ 'Reload saved settings',
140
+ ),
141
+ ),
142
+ busy ? h('p', { role: 'status' }, 'Contacting DSH host…') : null,
143
+ message ? h('p', { role: 'status' }, message) : null,
144
+ error ? h('p', { role: 'alert' }, error) : null,
145
+ );
146
+ };
147
+ }
@@ -0,0 +1,564 @@
1
+ // @ts-nocheck
2
+ import { TranscriptDraft } from './transcript.ts';
3
+ import { normalizeSettings } from './settings.ts';
4
+
5
+ const message = (error) => error?.message || String(error);
6
+ function cancellable(promise, signal) {
7
+ return new Promise((resolve, reject) => {
8
+ const abort = () => reject(Object.assign(new Error('Cancelled'), { name: 'AbortError' }));
9
+ if (signal.aborted) {
10
+ abort();
11
+ return;
12
+ }
13
+ signal.addEventListener('abort', abort, { once: true });
14
+ Promise.resolve(promise)
15
+ .then(resolve, reject)
16
+ .finally(() => signal.removeEventListener('abort', abort));
17
+ });
18
+ }
19
+
20
+ /** Session-owned policy. Input transitions are serialized; generations invalidate late work. */
21
+ export class VoiceCoordinator {
22
+ constructor({ recognition, engines, meter, composer, settings = {} }) {
23
+ Object.assign(this, { recognition, engines, meter, composer });
24
+ this.listeners = new Set();
25
+ this.transcript = new TranscriptDraft();
26
+ this.epoch = 0;
27
+ this.speechEpoch = 0;
28
+ this.controlEpoch = 0;
29
+ this.queue = [];
30
+ this.consumed = new Map();
31
+ this.unfinished = new Set();
32
+ this.suppressed = new Set();
33
+ this.inputTail = Promise.resolve();
34
+ this.speechBarrier = Promise.resolve();
35
+ this.disposed = false;
36
+ this.inputReleaseError = null;
37
+ this.speechStopError = null;
38
+ this.snapshot = {
39
+ conversation: false,
40
+ listening: false,
41
+ recognizing: false,
42
+ speaking: false,
43
+ paused: false,
44
+ starting: false,
45
+ error: null,
46
+ activeMessageId: null,
47
+ autoSendAt: null,
48
+ capabilities: {},
49
+ settings: normalizeSettings(settings),
50
+ };
51
+ this.autoSendTimer = null;
52
+ this.autoSendDraft = null;
53
+ this.assistantSpeechTimer = null;
54
+ this.assistantSpeechNotBefore = 0;
55
+ this.recognition.lang = this.snapshot.settings.recognitionLang;
56
+ this.getSnapshot = () => this.snapshot;
57
+ this.subscribe = (listener) => {
58
+ if (this.disposed) return () => {};
59
+ this.listeners.add(listener);
60
+ return () => this.listeners.delete(listener);
61
+ };
62
+ }
63
+ patch(next) {
64
+ this.snapshot = { ...this.snapshot, ...next };
65
+ for (const listener of this.listeners) {
66
+ try {
67
+ listener();
68
+ } catch {
69
+ /* Views do not own resource cleanup. */
70
+ }
71
+ }
72
+ }
73
+ clearError() {
74
+ if (!this.disposed) this.patch({ error: null });
75
+ }
76
+ replaceRecognition(recognition) {
77
+ if (this.disposed || !recognition) return;
78
+ this.recognition = recognition;
79
+ Object.assign(recognition, {
80
+ lang: this.snapshot.settings.recognitionLang,
81
+ processLocally: this.snapshot.settings.recognitionProcessLocally,
82
+ autoInstallLocalPack: this.snapshot.settings.recognitionAutoInstall,
83
+ voiceDetectionPreset: this.snapshot.settings.voiceDetectionPreset,
84
+ });
85
+ this.patch({ capabilities: { ...this.snapshot.capabilities, recognition: undefined } });
86
+ }
87
+ explainRecognition() {
88
+ throw new Error(
89
+ (this.snapshot.capabilities.capture?.reason ||
90
+ this.snapshot.capabilities.recognition?.reason ||
91
+ 'Local speech recognition is unavailable.') +
92
+ ' Open Settings → Live Voice to check language and engine availability.',
93
+ );
94
+ }
95
+ updateSettings(next) {
96
+ if (this.disposed) return;
97
+ const settings = normalizeSettings({ ...this.snapshot.settings, ...next });
98
+ Object.assign(this.recognition, {
99
+ lang: settings.recognitionLang,
100
+ processLocally: settings.recognitionProcessLocally,
101
+ autoInstallLocalPack: settings.recognitionAutoInstall,
102
+ voiceDetectionPreset: settings.voiceDetectionPreset,
103
+ });
104
+ this.patch({ settings, error: null });
105
+ if (settings.sendingMode !== 'automatic') this.cancelAutoSend();
106
+ if (Object.hasOwn(next, 'announceAssistantMessages') && !settings.announceAssistantMessages) {
107
+ this.queue = [];
108
+ this.assistantSpeechNotBefore = 0;
109
+ this._cancelAssistantSpeechTimer();
110
+ }
111
+ if (Object.hasOwn(next, 'assistantSpeechDelaySeconds') && this.assistantSpeechNotBefore > 0) {
112
+ this.assistantSpeechNotBefore = Date.now() + settings.assistantSpeechDelaySeconds * 1000;
113
+ this._cancelAssistantSpeechTimer();
114
+ this._drain();
115
+ }
116
+ }
117
+ async refreshCapabilities() {
118
+ const request = (this.capabilityRequest = (this.capabilityRequest || 0) + 1);
119
+ const caps = {};
120
+ const lang = this.snapshot.settings.recognitionLang;
121
+ Object.assign(this.recognition, {
122
+ lang,
123
+ processLocally: this.snapshot.settings.recognitionProcessLocally,
124
+ autoInstallLocalPack: this.snapshot.settings.recognitionAutoInstall,
125
+ });
126
+ const probes = Object.entries(this.engines).map(([id, engine]) => [
127
+ id,
128
+ () => engine.capability?.() ?? engine.getCapabilities(),
129
+ ]);
130
+ probes.push(['recognition', () => this.recognition.capability({ lang })]);
131
+ probes.push(['capture', () => this.meter.capability()]);
132
+ await Promise.all(
133
+ probes.map(async ([id, probe]) => {
134
+ try {
135
+ caps[id] = await probe();
136
+ } catch (error) {
137
+ caps[id] = { supported: false, reason: message(error) };
138
+ }
139
+ // A pending microphone/language check must not hide usable speech engines.
140
+ if (
141
+ !this.disposed &&
142
+ request === this.capabilityRequest &&
143
+ lang === this.snapshot.settings.recognitionLang
144
+ ) {
145
+ this.patch({ capabilities: { ...this.snapshot.capabilities, [id]: caps[id] } });
146
+ }
147
+ }),
148
+ );
149
+ return caps;
150
+ }
151
+ _input(task) {
152
+ const result = this.inputTail.then(task);
153
+ this.inputTail = result.catch(() => {});
154
+ return result;
155
+ }
156
+ async _releaseInput() {
157
+ const results = await Promise.allSettled([
158
+ Promise.resolve().then(() => this.recognition.stop()),
159
+ Promise.resolve().then(() => this.meter.stop()),
160
+ ]);
161
+ const errors = results
162
+ .filter((result) => result.status === 'rejected')
163
+ .map((result) => result.reason);
164
+ if (errors.length) throw new AggregateError(errors, errors.map(message).join('; '));
165
+ }
166
+ startDictation() {
167
+ return this.startListening(false);
168
+ }
169
+ startConversation() {
170
+ return this.startListening(true);
171
+ }
172
+ startListening(conversation = this.snapshot.conversation) {
173
+ if (this.disposed) return Promise.resolve();
174
+ // Reserve input ownership before awaiting any speech teardown.
175
+ this.patch({ error: null });
176
+ const stopped = this.stopSpeech(false);
177
+ return this._startInput(conversation, stopped);
178
+ }
179
+ _startInput(conversation, prerequisite = Promise.resolve()) {
180
+ if (this.disposed) return Promise.resolve();
181
+ const epoch = ++this.epoch;
182
+ this.inputController?.abort();
183
+ const controller = new AbortController();
184
+ this.inputController = controller;
185
+ this.patch({ starting: true, listening: false, recognizing: false, conversation });
186
+ const valid = () => !this.disposed && epoch === this.epoch;
187
+ return this._input(async () => {
188
+ if (!valid()) return;
189
+ try {
190
+ await prerequisite;
191
+ if (!valid()) return;
192
+ if (this.speechStopError) throw this.speechStopError;
193
+ await this._releaseInput();
194
+ if (!valid()) return;
195
+ const lang = this.snapshot.settings.recognitionLang;
196
+ this.recognition.lang = lang;
197
+ const capability = await cancellable(
198
+ this.recognition.capability({ lang }),
199
+ controller.signal,
200
+ );
201
+ if (!valid()) return;
202
+ if (!capability.supported)
203
+ throw new Error(capability.reason || 'Local browser recognition is unavailable.');
204
+ let started;
205
+ try {
206
+ started = await this.meter.start({ signal: controller.signal });
207
+ } catch (error) {
208
+ if (valid())
209
+ this.patch({
210
+ capabilities: {
211
+ ...this.snapshot.capabilities,
212
+ capture: {
213
+ supported: false,
214
+ permission: error?.name === 'NotAllowedError' ? 'denied' : 'error',
215
+ reason:
216
+ error?.name === 'NotAllowedError'
217
+ ? 'Microphone permission was denied. Allow it in browser settings, then refresh availability.'
218
+ : `Microphone capture failed: ${message(error)}`,
219
+ },
220
+ },
221
+ });
222
+ throw error;
223
+ }
224
+ if (!valid()) {
225
+ await this._releaseInput();
226
+ return;
227
+ }
228
+ if (!started) throw new Error('Microphone metering could not start.');
229
+ this.transcript.reset();
230
+ await this.recognition.start({
231
+ lang,
232
+ signal: controller.signal,
233
+ onResult: (result) => {
234
+ if (valid()) this.onResult(result);
235
+ },
236
+ onActivity: (active) => {
237
+ if (!valid()) return;
238
+ if (active) {
239
+ this.cancelAutoSend();
240
+ this.assistantSpeechNotBefore = Infinity;
241
+ this._cancelAssistantSpeechTimer();
242
+ } else
243
+ this.assistantSpeechNotBefore =
244
+ Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
245
+ this.patch({ recognizing: active });
246
+ if (active && this.snapshot.settings.mode === 'headphones') void this.pauseSpeech();
247
+ if (!active) this._drain();
248
+ },
249
+ onError: (error) => {
250
+ if (!valid()) return;
251
+ const ended = this.endConversation();
252
+ this.patch({ error: message(error) });
253
+ void ended;
254
+ },
255
+ });
256
+ if (!valid()) {
257
+ await this._releaseInput();
258
+ return;
259
+ }
260
+ this.patch({ listening: true, starting: false });
261
+ this._drain();
262
+ } catch (error) {
263
+ try {
264
+ await this._releaseInput();
265
+ } catch (cleanup) {
266
+ error = new AggregateError([error, cleanup], message(error) + '; ' + message(cleanup));
267
+ }
268
+ if (valid()) {
269
+ this.queue = [];
270
+ this.patch({
271
+ starting: false,
272
+ listening: false,
273
+ conversation: false,
274
+ recognizing: false,
275
+ error: message(error),
276
+ });
277
+ }
278
+ }
279
+ });
280
+ }
281
+ onResult({ final = '', interim = '' }) {
282
+ if (this.disposed || (!this.snapshot.listening && !this.snapshot.starting)) return;
283
+ if (this.snapshot.speaking && !this.snapshot.paused) {
284
+ if (this.snapshot.settings.mode === 'speaker') return;
285
+ if (final || interim) void this.pauseSpeech();
286
+ }
287
+ // A native event can contain a final result without an interim result. Do not
288
+ // run a second empty hypothesis update: it would replace the just-committed
289
+ // result with a stale composer snapshot before the editor publishes it.
290
+ if (final) {
291
+ const next = this.transcript.update(this.composer.getDraft(), final, true);
292
+ this.composer.setDraft(next);
293
+ if (this.snapshot.settings.sendingMode === 'automatic') this.scheduleAutoSend(next);
294
+ }
295
+ if (interim) {
296
+ this.cancelAutoSend();
297
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), interim));
298
+ }
299
+ if (!interim && this.snapshot.recognizing)
300
+ this.assistantSpeechNotBefore =
301
+ Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
302
+ this.patch({ recognizing: !!interim });
303
+ this._drain();
304
+ }
305
+ scheduleAutoSend(draft) {
306
+ this.cancelAutoSend();
307
+ if (!draft.trim() || typeof this.composer.submit !== 'function') return;
308
+ const delay = this.snapshot.settings.autoSendDelaySeconds * 1000;
309
+ this.autoSendDraft = draft;
310
+ this.patch({ autoSendAt: Date.now() + delay });
311
+ this.autoSendTimer = setTimeout(() => {
312
+ this.autoSendTimer = null;
313
+ const expected = this.autoSendDraft;
314
+ this.autoSendDraft = null;
315
+ this.patch({ autoSendAt: null });
316
+ if (
317
+ !this.disposed &&
318
+ this.snapshot.settings.sendingMode === 'automatic' &&
319
+ expected === this.composer.getDraft()
320
+ ) {
321
+ try {
322
+ this.composer.submit();
323
+ // Web Speech keeps a cumulative native result list for the lifetime of
324
+ // one recognition instance. Start a fresh instance at the turn boundary
325
+ // so the sent utterance cannot prefix the next one.
326
+ this.transcript.reset();
327
+ this.recognition.reset?.();
328
+ } catch (error) {
329
+ this.patch({ error: message(error) });
330
+ }
331
+ }
332
+ }, delay);
333
+ }
334
+ cancelAutoSend() {
335
+ if (this.autoSendTimer !== null) clearTimeout(this.autoSendTimer);
336
+ this.autoSendTimer = null;
337
+ this.autoSendDraft = null;
338
+ if (this.snapshot?.autoSendAt !== null) this.patch({ autoSendAt: null });
339
+ }
340
+ composerChanged(draft) {
341
+ if (this.autoSendDraft !== null && draft !== this.autoSendDraft) this.cancelAutoSend();
342
+ }
343
+ stopListening() {
344
+ this.cancelAutoSend();
345
+ ++this.epoch;
346
+ this.inputController?.abort();
347
+ this.patch({ listening: false, recognizing: false, starting: false });
348
+ return this._input(async () => {
349
+ try {
350
+ await this._releaseInput();
351
+ this.inputReleaseError = null;
352
+ } catch (error) {
353
+ this.inputReleaseError = error;
354
+ this.patch({ error: message(error) });
355
+ }
356
+ this.transcript.reset();
357
+ });
358
+ }
359
+ async cancelDictation() {
360
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), '', true));
361
+ await this.stopListening();
362
+ }
363
+ async endConversation() {
364
+ this._cancelAssistantSpeechTimer();
365
+ this.assistantSpeechNotBefore = 0;
366
+ this.patch({ conversation: false });
367
+ await Promise.all([this.stopListening(), this.stopSpeech(false)]);
368
+ }
369
+ _suppressPending() {
370
+ for (const id of this.unfinished) this.suppressed.add(id);
371
+ if (this.snapshot.activeMessageId !== null) this.suppressed.add(this.snapshot.activeMessageId);
372
+ for (const item of this.queue) this.suppressed.add(item.id);
373
+ this.queue = [];
374
+ }
375
+ async stopSpeech(resumeListening = true) {
376
+ const epoch = ++this.speechEpoch;
377
+ ++this.controlEpoch;
378
+ this._suppressPending();
379
+ this.patch({ speaking: false, paused: false, activeMessageId: null });
380
+ const stopped = this.speechBarrier.then(async () => {
381
+ const results = await Promise.allSettled(
382
+ Object.values(this.engines).map((engine) => Promise.resolve().then(() => engine.stop())),
383
+ );
384
+ const failed = results.find((result) => result.status === 'rejected');
385
+ this.speechStopError = failed?.reason ?? null;
386
+ if (failed) throw failed.reason;
387
+ });
388
+ this.speechBarrier = stopped.catch(() => {});
389
+ try {
390
+ await stopped;
391
+ } catch (error) {
392
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
393
+ return;
394
+ }
395
+ if (
396
+ epoch === this.speechEpoch &&
397
+ !this.disposed &&
398
+ resumeListening &&
399
+ this.snapshot.conversation &&
400
+ !this.snapshot.listening &&
401
+ !this.snapshot.starting
402
+ )
403
+ await this._startInput(true);
404
+ }
405
+ async pauseSpeech() {
406
+ if (this.disposed || !this.snapshot.speaking || this.snapshot.paused) return;
407
+ const epoch = this.speechEpoch;
408
+ const control = ++this.controlEpoch;
409
+ try {
410
+ const result = await this.engines[this.snapshot.settings.engine].pause();
411
+ if (
412
+ epoch === this.speechEpoch &&
413
+ control === this.controlEpoch &&
414
+ this.snapshot.speaking &&
415
+ result !== false
416
+ )
417
+ this.patch({ paused: true });
418
+ } catch (error) {
419
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
420
+ }
421
+ }
422
+ async resumeSpeech() {
423
+ if (this.disposed || !this.snapshot.paused) return;
424
+ const epoch = this.speechEpoch;
425
+ const control = ++this.controlEpoch;
426
+ try {
427
+ if (this.snapshot.settings.mode === 'speaker') {
428
+ await this.stopListening();
429
+ if (this.inputReleaseError) throw this.inputReleaseError;
430
+ }
431
+ if (epoch !== this.speechEpoch || control !== this.controlEpoch) return;
432
+ const result = await this.engines[this.snapshot.settings.engine].resume();
433
+ if (epoch === this.speechEpoch && control === this.controlEpoch && result !== false)
434
+ this.patch({ paused: false });
435
+ } catch (error) {
436
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
437
+ }
438
+ }
439
+ async speak(text, messageId = null) {
440
+ if (this.disposed) return;
441
+ const stopped = this.stopSpeech(false);
442
+ const epoch = this.speechEpoch;
443
+ await stopped;
444
+ if (!this.disposed && epoch === this.speechEpoch && !this.speechStopError)
445
+ return this.play(text, messageId);
446
+ }
447
+ async play(text, messageId) {
448
+ if (this.disposed || !text.trim()) return;
449
+ if (this.snapshot.speaking) {
450
+ this.queue.push({ text, id: messageId });
451
+ return;
452
+ }
453
+ const epoch = ++this.speechEpoch;
454
+ const engine = this.engines[this.snapshot.settings.engine];
455
+ if (!engine) {
456
+ this.queue = [];
457
+ this.patch({ error: 'Speech engine unavailable.' });
458
+ return;
459
+ }
460
+ // Reserve playback synchronously so incoming stream chunks cannot overtake gating.
461
+ this.patch({ speaking: true, paused: false, activeMessageId: messageId, error: null });
462
+ let failed = false;
463
+ try {
464
+ await this.speechBarrier;
465
+ if (this.speechStopError) throw this.speechStopError;
466
+ if (epoch !== this.speechEpoch || this.disposed) return;
467
+ if (this.snapshot.settings.mode === 'speaker') {
468
+ await this.stopListening();
469
+ if (this.inputReleaseError) throw this.inputReleaseError;
470
+ }
471
+ if (epoch !== this.speechEpoch || this.disposed) return;
472
+ await engine.speak(text, {
473
+ voice: this.snapshot.settings.voice || undefined,
474
+ rate: this.snapshot.settings.rate,
475
+ });
476
+ } catch (error) {
477
+ if (epoch === this.speechEpoch) {
478
+ failed = true;
479
+ this._suppressPending();
480
+ if (error.name !== 'AbortError') this.patch({ error: message(error) });
481
+ }
482
+ } finally {
483
+ if (epoch === this.speechEpoch && !this.disposed) {
484
+ this.patch({ speaking: false, paused: false, activeMessageId: null });
485
+ if (!failed && this.queue.length) this._drain();
486
+ else if (this.snapshot.conversation && !this.snapshot.listening && !this.snapshot.starting)
487
+ await this._startInput(true);
488
+ }
489
+ }
490
+ }
491
+ _cancelAssistantSpeechTimer() {
492
+ if (this.assistantSpeechTimer !== null) clearTimeout(this.assistantSpeechTimer);
493
+ this.assistantSpeechTimer = null;
494
+ }
495
+ _drain() {
496
+ if (
497
+ this.disposed ||
498
+ !this.snapshot.conversation ||
499
+ !this.snapshot.settings.announceAssistantMessages ||
500
+ this.snapshot.speaking ||
501
+ this.snapshot.recognizing ||
502
+ this.snapshot.starting ||
503
+ !this.queue.length
504
+ )
505
+ return;
506
+ const wait = this.assistantSpeechNotBefore - Date.now();
507
+ if (wait > 0) {
508
+ if (Number.isFinite(wait) && this.assistantSpeechTimer === null)
509
+ this.assistantSpeechTimer = setTimeout(() => {
510
+ this.assistantSpeechTimer = null;
511
+ this._drain();
512
+ }, wait);
513
+ return;
514
+ }
515
+ this._cancelAssistantSpeechTimer();
516
+ this.assistantSpeechNotBefore = 0;
517
+ const next = this.queue.shift();
518
+ void this.play(next.text, next.id);
519
+ }
520
+ /** Baselines survive conversation toggles; cancelled message IDs remain suppressed. */
521
+ observeMessage(id, text, { complete = false, baseline = false } = {}) {
522
+ if (this.disposed) return;
523
+ if (complete) this.unfinished.delete(id);
524
+ else this.unfinished.add(id);
525
+ if (
526
+ baseline ||
527
+ !this.snapshot.conversation ||
528
+ !this.snapshot.settings.announceAssistantMessages ||
529
+ this.suppressed.has(id)
530
+ ) {
531
+ this.consumed.set(id, text.length);
532
+ return;
533
+ }
534
+ const offset = this.consumed.get(id) || 0;
535
+ if (text.length < offset) {
536
+ this.consumed.set(id, text.length);
537
+ return;
538
+ }
539
+ const remaining = text.slice(offset);
540
+ const boundary = complete
541
+ ? remaining.length
542
+ : Math.max(
543
+ remaining.lastIndexOf('. '),
544
+ remaining.lastIndexOf('? '),
545
+ remaining.lastIndexOf('! '),
546
+ remaining.lastIndexOf('\n'),
547
+ ) + 1;
548
+ if (boundary <= 0) return;
549
+ const chunk = remaining.slice(0, boundary).trim();
550
+ this.consumed.set(id, offset + boundary);
551
+ if (chunk) {
552
+ this.queue.push({ text: chunk, id });
553
+ this._drain();
554
+ }
555
+ }
556
+ async dispose() {
557
+ if (this.disposed) return;
558
+ this.cancelAutoSend();
559
+ this._cancelAssistantSpeechTimer();
560
+ this.disposed = true;
561
+ this.listeners.clear();
562
+ await this.endConversation();
563
+ }
564
+ }