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.
package/lib/client.js ADDED
@@ -0,0 +1,3015 @@
1
+ window.__ModuleLoader__.load({id:"dsh-live-voice",factory:(require)=>{var module={exports:{}};var exports=module.exports;
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/client/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ apply: () => apply,
34
+ inject: () => inject
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+ var import_react = __toESM(require("react"), 1);
38
+
39
+ // src/core/transcript.ts
40
+ var TranscriptDraft = class {
41
+ constructor() {
42
+ this.reset();
43
+ }
44
+ reset() {
45
+ this.owned = null;
46
+ this.edited = false;
47
+ }
48
+ update(current, hypothesis, final = false) {
49
+ if (this.edited) {
50
+ if (final) this.edited = false;
51
+ return current;
52
+ }
53
+ let base = current;
54
+ let at = current.length;
55
+ if (this.owned) {
56
+ const { start, text: text2, before: before2, after: after2 } = this.owned;
57
+ if (current === before2 + text2 + after2) {
58
+ base = before2 + after2;
59
+ at = start;
60
+ } else if (text2 && current.indexOf(text2) >= 0 && current.indexOf(text2) === current.lastIndexOf(text2)) {
61
+ at = current.indexOf(text2);
62
+ base = current.slice(0, at) + current.slice(at + text2.length);
63
+ } else {
64
+ this.owned = null;
65
+ this.edited = !final;
66
+ return current;
67
+ }
68
+ }
69
+ const before = base.slice(0, at);
70
+ const after = base.slice(at);
71
+ const separator = hypothesis && before && !/\s$/.test(before) ? " " : "";
72
+ const text = separator + hypothesis;
73
+ const result = before + text + after;
74
+ this.owned = final ? null : { start: at, text, before, after };
75
+ return result;
76
+ }
77
+ };
78
+
79
+ // src/core/settings.ts
80
+ var voiceDetectionPresets = Object.freeze({
81
+ short: Object.freeze({
82
+ silenceMs: 900,
83
+ label: "Short",
84
+ description: "Send quickly after a short pause."
85
+ }),
86
+ natural: Object.freeze({
87
+ silenceMs: 1500,
88
+ label: "Natural",
89
+ description: "Allow normal pauses between phrases."
90
+ }),
91
+ long: Object.freeze({
92
+ silenceMs: 2200,
93
+ label: "Long",
94
+ description: "Wait through longer thinking pauses."
95
+ })
96
+ });
97
+ var usesPluginVoiceDetection = (engine) => ["whisper-http", "qwen-http"].includes(engine);
98
+ var qwenVoices = Object.freeze([
99
+ Object.freeze({ value: "aiden", label: "Aiden \u2014 male, American English" }),
100
+ Object.freeze({ value: "ryan", label: "Ryan \u2014 male, English" }),
101
+ Object.freeze({ value: "uncle_fu", label: "Uncle Fu \u2014 male, Chinese" }),
102
+ Object.freeze({ value: "dylan", label: "Dylan \u2014 male, Beijing Chinese" }),
103
+ Object.freeze({ value: "eric", label: "Eric \u2014 male, Sichuan Chinese" }),
104
+ Object.freeze({ value: "vivian", label: "Vivian \u2014 female, Chinese" }),
105
+ Object.freeze({ value: "serena", label: "Serena \u2014 female, Chinese" }),
106
+ Object.freeze({ value: "ono_anna", label: "Ono Anna \u2014 female, Japanese" }),
107
+ Object.freeze({ value: "sohee", label: "Sohee \u2014 female, Korean" })
108
+ ]);
109
+ var defaultQwenVoice = qwenVoices[0].value;
110
+ var defaultSettings = Object.freeze({
111
+ engine: "browser",
112
+ recognitionEngine: "browser",
113
+ recognitionProcessLocally: true,
114
+ recognitionAutoInstall: true,
115
+ voiceDetectionPreset: "natural",
116
+ announceAssistantMessages: true,
117
+ interruptSpeechOnUserMessage: false,
118
+ sendingMode: "manual",
119
+ autoSendDelaySeconds: 4,
120
+ assistantSpeechDelaySeconds: 3,
121
+ mode: "speaker",
122
+ lang: "pt-BR",
123
+ recognitionLang: "pt-BR",
124
+ voice: "",
125
+ rate: 1
126
+ });
127
+ function normalizeSettings(value) {
128
+ const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
129
+ return {
130
+ engine: ["browser", "say", "qwen-http"].includes(source.engine) ? source.engine : defaultSettings.engine,
131
+ recognitionEngine: ["browser", "whisper-http", "qwen-http"].includes(source.recognitionEngine) ? source.recognitionEngine : defaultSettings.recognitionEngine,
132
+ recognitionProcessLocally: typeof source.recognitionProcessLocally === "boolean" ? source.recognitionProcessLocally : defaultSettings.recognitionProcessLocally,
133
+ recognitionAutoInstall: typeof source.recognitionAutoInstall === "boolean" ? source.recognitionAutoInstall : defaultSettings.recognitionAutoInstall,
134
+ voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset) ? source.voiceDetectionPreset : defaultSettings.voiceDetectionPreset,
135
+ announceAssistantMessages: typeof source.announceAssistantMessages === "boolean" ? source.announceAssistantMessages : defaultSettings.announceAssistantMessages,
136
+ interruptSpeechOnUserMessage: typeof source.interruptSpeechOnUserMessage === "boolean" ? source.interruptSpeechOnUserMessage : defaultSettings.interruptSpeechOnUserMessage,
137
+ sendingMode: ["manual", "automatic"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
138
+ autoSendDelaySeconds: Number.isInteger(source.autoSendDelaySeconds) && source.autoSendDelaySeconds >= 2 && source.autoSendDelaySeconds <= 10 ? source.autoSendDelaySeconds : defaultSettings.autoSendDelaySeconds,
139
+ assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >= 1 && source.assistantSpeechDelaySeconds <= 10 ? source.assistantSpeechDelaySeconds : defaultSettings.assistantSpeechDelaySeconds,
140
+ mode: ["speaker", "headphones"].includes(source.mode) ? source.mode : defaultSettings.mode,
141
+ lang: typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.lang,
142
+ recognitionLang: source.recognitionLang === "auto" || typeof source.recognitionLang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.recognitionLang) ? source.recognitionLang : typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.recognitionLang,
143
+ voice: typeof source.voice === "string" && source.voice.length <= 200 && !source.voice.includes("\0") ? source.voice : "",
144
+ rate: Number.isFinite(source.rate) && source.rate >= 0.1 && source.rate <= 3 ? source.rate : 1
145
+ };
146
+ }
147
+
148
+ // src/core/coordinator.ts
149
+ var message = (error) => error?.message || String(error);
150
+ function cancellable(promise, signal) {
151
+ return new Promise((resolve, reject) => {
152
+ const abort = () => reject(Object.assign(new Error("Cancelled"), { name: "AbortError" }));
153
+ if (signal.aborted) {
154
+ abort();
155
+ return;
156
+ }
157
+ signal.addEventListener("abort", abort, { once: true });
158
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
159
+ });
160
+ }
161
+ var VoiceCoordinator = class {
162
+ constructor({ recognition, engines, meter, composer, settings = {} }) {
163
+ Object.assign(this, { recognition, engines, meter, composer });
164
+ this.listeners = /* @__PURE__ */ new Set();
165
+ this.transcript = new TranscriptDraft();
166
+ this.epoch = 0;
167
+ this.speechEpoch = 0;
168
+ this.controlEpoch = 0;
169
+ this.queue = [];
170
+ this.consumed = /* @__PURE__ */ new Map();
171
+ this.unfinished = /* @__PURE__ */ new Set();
172
+ this.suppressed = /* @__PURE__ */ new Set();
173
+ this.inputTail = Promise.resolve();
174
+ this.speechBarrier = Promise.resolve();
175
+ this.disposed = false;
176
+ this.inputReleaseError = null;
177
+ this.speechStopError = null;
178
+ this.snapshot = {
179
+ conversation: false,
180
+ listening: false,
181
+ recognizing: false,
182
+ speaking: false,
183
+ paused: false,
184
+ starting: false,
185
+ error: null,
186
+ activeMessageId: null,
187
+ autoSendAt: null,
188
+ capabilities: {},
189
+ settings: normalizeSettings(settings)
190
+ };
191
+ this.autoSendTimer = null;
192
+ this.autoSendDraft = null;
193
+ this.assistantSpeechTimer = null;
194
+ this.assistantSpeechNotBefore = 0;
195
+ this.recognition.lang = this.snapshot.settings.recognitionLang;
196
+ this.getSnapshot = () => this.snapshot;
197
+ this.subscribe = (listener) => {
198
+ if (this.disposed) return () => {
199
+ };
200
+ this.listeners.add(listener);
201
+ return () => this.listeners.delete(listener);
202
+ };
203
+ }
204
+ patch(next) {
205
+ this.snapshot = { ...this.snapshot, ...next };
206
+ for (const listener of this.listeners) {
207
+ try {
208
+ listener();
209
+ } catch {
210
+ }
211
+ }
212
+ }
213
+ clearError() {
214
+ if (!this.disposed) this.patch({ error: null });
215
+ }
216
+ replaceRecognition(recognition) {
217
+ if (this.disposed || !recognition) return;
218
+ this.recognition = recognition;
219
+ Object.assign(recognition, {
220
+ lang: this.snapshot.settings.recognitionLang,
221
+ processLocally: this.snapshot.settings.recognitionProcessLocally,
222
+ autoInstallLocalPack: this.snapshot.settings.recognitionAutoInstall,
223
+ voiceDetectionPreset: this.snapshot.settings.voiceDetectionPreset
224
+ });
225
+ this.patch({ capabilities: { ...this.snapshot.capabilities, recognition: void 0 } });
226
+ }
227
+ explainRecognition() {
228
+ throw new Error(
229
+ (this.snapshot.capabilities.capture?.reason || this.snapshot.capabilities.recognition?.reason || "Local speech recognition is unavailable.") + " Open Settings \u2192 Live Voice to check language and engine availability."
230
+ );
231
+ }
232
+ updateSettings(next) {
233
+ if (this.disposed) return;
234
+ const settings = normalizeSettings({ ...this.snapshot.settings, ...next });
235
+ Object.assign(this.recognition, {
236
+ lang: settings.recognitionLang,
237
+ processLocally: settings.recognitionProcessLocally,
238
+ autoInstallLocalPack: settings.recognitionAutoInstall,
239
+ voiceDetectionPreset: settings.voiceDetectionPreset
240
+ });
241
+ this.patch({ settings, error: null });
242
+ if (settings.sendingMode !== "automatic") this.cancelAutoSend();
243
+ if (Object.hasOwn(next, "announceAssistantMessages") && !settings.announceAssistantMessages) {
244
+ this.queue = [];
245
+ this.assistantSpeechNotBefore = 0;
246
+ this._cancelAssistantSpeechTimer();
247
+ }
248
+ if (Object.hasOwn(next, "assistantSpeechDelaySeconds") && this.assistantSpeechNotBefore > 0) {
249
+ this.assistantSpeechNotBefore = Date.now() + settings.assistantSpeechDelaySeconds * 1e3;
250
+ this._cancelAssistantSpeechTimer();
251
+ this._drain();
252
+ }
253
+ }
254
+ async refreshCapabilities() {
255
+ const request = this.capabilityRequest = (this.capabilityRequest || 0) + 1;
256
+ const caps = {};
257
+ const lang = this.snapshot.settings.recognitionLang;
258
+ Object.assign(this.recognition, {
259
+ lang,
260
+ processLocally: this.snapshot.settings.recognitionProcessLocally,
261
+ autoInstallLocalPack: this.snapshot.settings.recognitionAutoInstall
262
+ });
263
+ const probes = Object.entries(this.engines).map(([id2, engine]) => [
264
+ id2,
265
+ () => engine.capability?.() ?? engine.getCapabilities()
266
+ ]);
267
+ probes.push(["recognition", () => this.recognition.capability({ lang })]);
268
+ probes.push(["capture", () => this.meter.capability()]);
269
+ await Promise.all(
270
+ probes.map(async ([id2, probe]) => {
271
+ try {
272
+ caps[id2] = await probe();
273
+ } catch (error) {
274
+ caps[id2] = { supported: false, reason: message(error) };
275
+ }
276
+ if (!this.disposed && request === this.capabilityRequest && lang === this.snapshot.settings.recognitionLang) {
277
+ this.patch({ capabilities: { ...this.snapshot.capabilities, [id2]: caps[id2] } });
278
+ }
279
+ })
280
+ );
281
+ return caps;
282
+ }
283
+ _input(task) {
284
+ const result = this.inputTail.then(task);
285
+ this.inputTail = result.catch(() => {
286
+ });
287
+ return result;
288
+ }
289
+ async _releaseInput() {
290
+ const results = await Promise.allSettled([
291
+ Promise.resolve().then(() => this.recognition.stop()),
292
+ Promise.resolve().then(() => this.meter.stop())
293
+ ]);
294
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason);
295
+ if (errors.length) throw new AggregateError(errors, errors.map(message).join("; "));
296
+ }
297
+ startDictation() {
298
+ return this.startListening(false);
299
+ }
300
+ startConversation() {
301
+ return this.startListening(true);
302
+ }
303
+ startListening(conversation = this.snapshot.conversation) {
304
+ if (this.disposed) return Promise.resolve();
305
+ this.patch({ error: null });
306
+ const stopped = this.stopSpeech(false);
307
+ return this._startInput(conversation, stopped);
308
+ }
309
+ _startInput(conversation, prerequisite = Promise.resolve()) {
310
+ if (this.disposed) return Promise.resolve();
311
+ const epoch = ++this.epoch;
312
+ this.inputController?.abort();
313
+ const controller = new AbortController();
314
+ this.inputController = controller;
315
+ this.patch({ starting: true, listening: false, recognizing: false, conversation });
316
+ const valid = () => !this.disposed && epoch === this.epoch;
317
+ return this._input(async () => {
318
+ if (!valid()) return;
319
+ try {
320
+ await prerequisite;
321
+ if (!valid()) return;
322
+ if (this.speechStopError) throw this.speechStopError;
323
+ await this._releaseInput();
324
+ if (!valid()) return;
325
+ const lang = this.snapshot.settings.recognitionLang;
326
+ this.recognition.lang = lang;
327
+ const capability = await cancellable(
328
+ this.recognition.capability({ lang }),
329
+ controller.signal
330
+ );
331
+ if (!valid()) return;
332
+ if (!capability.supported)
333
+ throw new Error(capability.reason || "Local browser recognition is unavailable.");
334
+ let started;
335
+ try {
336
+ started = await this.meter.start({ signal: controller.signal });
337
+ } catch (error) {
338
+ if (valid())
339
+ this.patch({
340
+ capabilities: {
341
+ ...this.snapshot.capabilities,
342
+ capture: {
343
+ supported: false,
344
+ permission: error?.name === "NotAllowedError" ? "denied" : "error",
345
+ reason: error?.name === "NotAllowedError" ? "Microphone permission was denied. Allow it in browser settings, then refresh availability." : `Microphone capture failed: ${message(error)}`
346
+ }
347
+ }
348
+ });
349
+ throw error;
350
+ }
351
+ if (!valid()) {
352
+ await this._releaseInput();
353
+ return;
354
+ }
355
+ if (!started) throw new Error("Microphone metering could not start.");
356
+ this.transcript.reset();
357
+ await this.recognition.start({
358
+ lang,
359
+ signal: controller.signal,
360
+ onResult: (result) => {
361
+ if (valid()) this.onResult(result);
362
+ },
363
+ onActivity: (active) => {
364
+ if (!valid()) return;
365
+ if (active) {
366
+ this.cancelAutoSend();
367
+ this.assistantSpeechNotBefore = Infinity;
368
+ this._cancelAssistantSpeechTimer();
369
+ } else
370
+ this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1e3;
371
+ this.patch({ recognizing: active });
372
+ if (active && this.snapshot.settings.mode === "headphones") void this.pauseSpeech();
373
+ if (!active) this._drain();
374
+ },
375
+ onError: (error) => {
376
+ if (!valid()) return;
377
+ const ended = this.endConversation();
378
+ this.patch({ error: message(error) });
379
+ void ended;
380
+ }
381
+ });
382
+ if (!valid()) {
383
+ await this._releaseInput();
384
+ return;
385
+ }
386
+ this.patch({ listening: true, starting: false });
387
+ this._drain();
388
+ } catch (error) {
389
+ try {
390
+ await this._releaseInput();
391
+ } catch (cleanup) {
392
+ error = new AggregateError([error, cleanup], message(error) + "; " + message(cleanup));
393
+ }
394
+ if (valid()) {
395
+ this.queue = [];
396
+ this.patch({
397
+ starting: false,
398
+ listening: false,
399
+ conversation: false,
400
+ recognizing: false,
401
+ error: message(error)
402
+ });
403
+ }
404
+ }
405
+ });
406
+ }
407
+ onResult({ final = "", interim = "" }) {
408
+ if (this.disposed || !this.snapshot.listening && !this.snapshot.starting) return;
409
+ if (this.snapshot.speaking && !this.snapshot.paused) {
410
+ if (this.snapshot.settings.mode === "speaker") return;
411
+ if (final || interim) void this.pauseSpeech();
412
+ }
413
+ if (final) {
414
+ const next = this.transcript.update(this.composer.getDraft(), final, true);
415
+ this.composer.setDraft(next);
416
+ if (this.snapshot.settings.sendingMode === "automatic") this.scheduleAutoSend(next);
417
+ }
418
+ if (interim) {
419
+ this.cancelAutoSend();
420
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), interim));
421
+ }
422
+ if (!interim && this.snapshot.recognizing)
423
+ this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1e3;
424
+ this.patch({ recognizing: !!interim });
425
+ this._drain();
426
+ }
427
+ scheduleAutoSend(draft) {
428
+ this.cancelAutoSend();
429
+ if (!draft.trim() || typeof this.composer.submit !== "function") return;
430
+ const delay = this.snapshot.settings.autoSendDelaySeconds * 1e3;
431
+ this.autoSendDraft = draft;
432
+ this.patch({ autoSendAt: Date.now() + delay });
433
+ this.autoSendTimer = setTimeout(() => {
434
+ this.autoSendTimer = null;
435
+ const expected = this.autoSendDraft;
436
+ this.autoSendDraft = null;
437
+ this.patch({ autoSendAt: null });
438
+ if (!this.disposed && this.snapshot.settings.sendingMode === "automatic" && expected === this.composer.getDraft()) {
439
+ try {
440
+ this.composer.submit();
441
+ this.transcript.reset();
442
+ this.recognition.reset?.();
443
+ } catch (error) {
444
+ this.patch({ error: message(error) });
445
+ }
446
+ }
447
+ }, delay);
448
+ }
449
+ cancelAutoSend() {
450
+ if (this.autoSendTimer !== null) clearTimeout(this.autoSendTimer);
451
+ this.autoSendTimer = null;
452
+ this.autoSendDraft = null;
453
+ if (this.snapshot?.autoSendAt !== null) this.patch({ autoSendAt: null });
454
+ }
455
+ composerChanged(draft) {
456
+ if (this.autoSendDraft !== null && draft !== this.autoSendDraft) this.cancelAutoSend();
457
+ }
458
+ stopListening() {
459
+ this.cancelAutoSend();
460
+ ++this.epoch;
461
+ this.inputController?.abort();
462
+ this.patch({ listening: false, recognizing: false, starting: false });
463
+ return this._input(async () => {
464
+ try {
465
+ await this._releaseInput();
466
+ this.inputReleaseError = null;
467
+ } catch (error) {
468
+ this.inputReleaseError = error;
469
+ this.patch({ error: message(error) });
470
+ }
471
+ this.transcript.reset();
472
+ });
473
+ }
474
+ async cancelDictation() {
475
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
476
+ await this.stopListening();
477
+ }
478
+ async endConversation() {
479
+ this._cancelAssistantSpeechTimer();
480
+ this.assistantSpeechNotBefore = 0;
481
+ this.patch({ conversation: false });
482
+ await Promise.all([this.stopListening(), this.stopSpeech(false)]);
483
+ }
484
+ _suppressPending() {
485
+ for (const id2 of this.unfinished) this.suppressed.add(id2);
486
+ if (this.snapshot.activeMessageId !== null) this.suppressed.add(this.snapshot.activeMessageId);
487
+ for (const item of this.queue) this.suppressed.add(item.id);
488
+ this.queue = [];
489
+ }
490
+ async stopSpeech(resumeListening = true) {
491
+ const epoch = ++this.speechEpoch;
492
+ ++this.controlEpoch;
493
+ this._suppressPending();
494
+ this.patch({ speaking: false, paused: false, activeMessageId: null });
495
+ const stopped = this.speechBarrier.then(async () => {
496
+ const results = await Promise.allSettled(
497
+ Object.values(this.engines).map((engine) => Promise.resolve().then(() => engine.stop()))
498
+ );
499
+ const failed = results.find((result) => result.status === "rejected");
500
+ this.speechStopError = failed?.reason ?? null;
501
+ if (failed) throw failed.reason;
502
+ });
503
+ this.speechBarrier = stopped.catch(() => {
504
+ });
505
+ try {
506
+ await stopped;
507
+ } catch (error) {
508
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
509
+ return;
510
+ }
511
+ if (epoch === this.speechEpoch && !this.disposed && resumeListening && this.snapshot.conversation && !this.snapshot.listening && !this.snapshot.starting)
512
+ await this._startInput(true);
513
+ }
514
+ async pauseSpeech() {
515
+ if (this.disposed || !this.snapshot.speaking || this.snapshot.paused) return;
516
+ const epoch = this.speechEpoch;
517
+ const control = ++this.controlEpoch;
518
+ try {
519
+ const result = await this.engines[this.snapshot.settings.engine].pause();
520
+ if (epoch === this.speechEpoch && control === this.controlEpoch && this.snapshot.speaking && result !== false)
521
+ this.patch({ paused: true });
522
+ } catch (error) {
523
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
524
+ }
525
+ }
526
+ async resumeSpeech() {
527
+ if (this.disposed || !this.snapshot.paused) return;
528
+ const epoch = this.speechEpoch;
529
+ const control = ++this.controlEpoch;
530
+ try {
531
+ if (this.snapshot.settings.mode === "speaker") {
532
+ await this.stopListening();
533
+ if (this.inputReleaseError) throw this.inputReleaseError;
534
+ }
535
+ if (epoch !== this.speechEpoch || control !== this.controlEpoch) return;
536
+ const result = await this.engines[this.snapshot.settings.engine].resume();
537
+ if (epoch === this.speechEpoch && control === this.controlEpoch && result !== false)
538
+ this.patch({ paused: false });
539
+ } catch (error) {
540
+ if (epoch === this.speechEpoch) this.patch({ error: message(error) });
541
+ }
542
+ }
543
+ async speak(text, messageId = null) {
544
+ if (this.disposed) return;
545
+ const stopped = this.stopSpeech(false);
546
+ const epoch = this.speechEpoch;
547
+ await stopped;
548
+ if (!this.disposed && epoch === this.speechEpoch && !this.speechStopError)
549
+ return this.play(text, messageId);
550
+ }
551
+ async play(text, messageId) {
552
+ if (this.disposed || !text.trim()) return;
553
+ if (this.snapshot.speaking) {
554
+ this.queue.push({ text, id: messageId });
555
+ return;
556
+ }
557
+ const epoch = ++this.speechEpoch;
558
+ const engine = this.engines[this.snapshot.settings.engine];
559
+ if (!engine) {
560
+ this.queue = [];
561
+ this.patch({ error: "Speech engine unavailable." });
562
+ return;
563
+ }
564
+ this.patch({ speaking: true, paused: false, activeMessageId: messageId, error: null });
565
+ let failed = false;
566
+ try {
567
+ await this.speechBarrier;
568
+ if (this.speechStopError) throw this.speechStopError;
569
+ if (epoch !== this.speechEpoch || this.disposed) return;
570
+ if (this.snapshot.settings.mode === "speaker") {
571
+ await this.stopListening();
572
+ if (this.inputReleaseError) throw this.inputReleaseError;
573
+ }
574
+ if (epoch !== this.speechEpoch || this.disposed) return;
575
+ await engine.speak(text, {
576
+ voice: this.snapshot.settings.voice || void 0,
577
+ rate: this.snapshot.settings.rate
578
+ });
579
+ } catch (error) {
580
+ if (epoch === this.speechEpoch) {
581
+ failed = true;
582
+ this._suppressPending();
583
+ if (error.name !== "AbortError") this.patch({ error: message(error) });
584
+ }
585
+ } finally {
586
+ if (epoch === this.speechEpoch && !this.disposed) {
587
+ this.patch({ speaking: false, paused: false, activeMessageId: null });
588
+ if (!failed && this.queue.length) this._drain();
589
+ else if (this.snapshot.conversation && !this.snapshot.listening && !this.snapshot.starting)
590
+ await this._startInput(true);
591
+ }
592
+ }
593
+ }
594
+ _cancelAssistantSpeechTimer() {
595
+ if (this.assistantSpeechTimer !== null) clearTimeout(this.assistantSpeechTimer);
596
+ this.assistantSpeechTimer = null;
597
+ }
598
+ _drain() {
599
+ if (this.disposed || !this.snapshot.conversation || !this.snapshot.settings.announceAssistantMessages || this.snapshot.speaking || this.snapshot.recognizing || this.snapshot.starting || !this.queue.length)
600
+ return;
601
+ const wait = this.assistantSpeechNotBefore - Date.now();
602
+ if (wait > 0) {
603
+ if (Number.isFinite(wait) && this.assistantSpeechTimer === null)
604
+ this.assistantSpeechTimer = setTimeout(() => {
605
+ this.assistantSpeechTimer = null;
606
+ this._drain();
607
+ }, wait);
608
+ return;
609
+ }
610
+ this._cancelAssistantSpeechTimer();
611
+ this.assistantSpeechNotBefore = 0;
612
+ const next = this.queue.shift();
613
+ void this.play(next.text, next.id);
614
+ }
615
+ /** Baselines survive conversation toggles; cancelled message IDs remain suppressed. */
616
+ observeMessage(id2, text, { complete = false, baseline = false } = {}) {
617
+ if (this.disposed) return;
618
+ if (complete) this.unfinished.delete(id2);
619
+ else this.unfinished.add(id2);
620
+ if (baseline || !this.snapshot.conversation || !this.snapshot.settings.announceAssistantMessages || this.suppressed.has(id2)) {
621
+ this.consumed.set(id2, text.length);
622
+ return;
623
+ }
624
+ const offset = this.consumed.get(id2) || 0;
625
+ if (text.length < offset) {
626
+ this.consumed.set(id2, text.length);
627
+ return;
628
+ }
629
+ const remaining = text.slice(offset);
630
+ const boundary = complete ? remaining.length : Math.max(
631
+ remaining.lastIndexOf(". "),
632
+ remaining.lastIndexOf("? "),
633
+ remaining.lastIndexOf("! "),
634
+ remaining.lastIndexOf("\n")
635
+ ) + 1;
636
+ if (boundary <= 0) return;
637
+ const chunk = remaining.slice(0, boundary).trim();
638
+ this.consumed.set(id2, offset + boundary);
639
+ if (chunk) {
640
+ this.queue.push({ text: chunk, id: id2 });
641
+ this._drain();
642
+ }
643
+ }
644
+ async dispose() {
645
+ if (this.disposed) return;
646
+ this.cancelAutoSend();
647
+ this._cancelAssistantSpeechTimer();
648
+ this.disposed = true;
649
+ this.listeners.clear();
650
+ await this.endConversation();
651
+ }
652
+ };
653
+
654
+ // src/core/ownership.ts
655
+ var VoiceOwnership = class {
656
+ constructor() {
657
+ this.epoch = 0;
658
+ this.tail = Promise.resolve();
659
+ this.closed = false;
660
+ }
661
+ run(owner, owners, action) {
662
+ const epoch = ++this.epoch;
663
+ let outcome;
664
+ const acquired = this.tail.then(async () => {
665
+ if (this.closed || epoch !== this.epoch || owner.disposed) return;
666
+ await Promise.all(
667
+ owners.filter((other) => other !== owner).map((other) => other.endConversation())
668
+ );
669
+ if (this.closed || epoch !== this.epoch || owner.disposed) return;
670
+ outcome = Promise.resolve(action());
671
+ outcome.catch(() => {
672
+ });
673
+ });
674
+ this.tail = acquired.catch(() => {
675
+ });
676
+ return acquired.then(() => outcome);
677
+ }
678
+ cancel() {
679
+ ++this.epoch;
680
+ }
681
+ close() {
682
+ this.closed = true;
683
+ this.cancel();
684
+ }
685
+ };
686
+
687
+ // src/core/microphone.ts
688
+ var MicrophoneMeter = class {
689
+ constructor(globals = globalThis) {
690
+ this.g = globals;
691
+ this.epoch = 0;
692
+ this.current = null;
693
+ this.stream = this.context = this.source = this.analyser = this.samples = null;
694
+ }
695
+ async capability() {
696
+ const secure = this.g.isSecureContext === true || ["localhost", "127.0.0.1", "::1"].includes(this.g.location?.hostname);
697
+ if (!secure)
698
+ return {
699
+ supported: false,
700
+ permission: "unavailable",
701
+ reason: "Microphone capture requires a secure or loopback page."
702
+ };
703
+ if (typeof this.g.navigator?.mediaDevices?.getUserMedia !== "function")
704
+ return {
705
+ supported: false,
706
+ permission: "unavailable",
707
+ reason: "This browser does not expose microphone capture."
708
+ };
709
+ const AudioContext = this.g.AudioContext || this.g.webkitAudioContext;
710
+ if (typeof AudioContext !== "function")
711
+ return {
712
+ supported: false,
713
+ permission: "unavailable",
714
+ reason: "This browser does not expose Web Audio for the live waveform."
715
+ };
716
+ let permission = "prompt";
717
+ try {
718
+ const status = await this.g.navigator.permissions?.query?.({ name: "microphone" });
719
+ if (["granted", "denied", "prompt"].includes(status?.state)) permission = status.state;
720
+ } catch {
721
+ }
722
+ return permission === "denied" ? {
723
+ supported: false,
724
+ permission,
725
+ reason: "Microphone permission is denied. Allow it in browser settings, then refresh availability."
726
+ } : { supported: true, permission };
727
+ }
728
+ async start({ signal } = {}) {
729
+ this.stop();
730
+ if (signal?.aborted) return false;
731
+ const job = {
732
+ signal,
733
+ stream: null,
734
+ context: null,
735
+ source: null,
736
+ analyser: null,
737
+ samples: null
738
+ };
739
+ const cancelled3 = new Promise((resolve) => {
740
+ job.cancelled = resolve;
741
+ });
742
+ job.cancel = () => {
743
+ if (this.current === job) this.stop();
744
+ };
745
+ this.current = job;
746
+ signal?.addEventListener("abort", job.cancel, { once: true });
747
+ if (signal?.aborted) {
748
+ job.cancel();
749
+ return false;
750
+ }
751
+ const valid = () => this.current === job;
752
+ const capture = async () => {
753
+ try {
754
+ const stream = await this.g.navigator.mediaDevices.getUserMedia({
755
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
756
+ });
757
+ job.stream = stream;
758
+ if (!valid()) {
759
+ this._dispose(job);
760
+ return false;
761
+ }
762
+ this.stream = stream;
763
+ const AudioContext = this.g.AudioContext || this.g.webkitAudioContext;
764
+ job.context = new AudioContext();
765
+ job.analyser = job.context.createAnalyser();
766
+ job.analyser.fftSize = 256;
767
+ job.source = job.context.createMediaStreamSource(stream);
768
+ job.source.connect(job.analyser);
769
+ job.samples = new Float32Array(job.analyser.fftSize);
770
+ this.context = job.context;
771
+ this.source = job.source;
772
+ this.analyser = job.analyser;
773
+ this.samples = job.samples;
774
+ await job.context.resume();
775
+ return valid();
776
+ } catch (error) {
777
+ if (!valid()) {
778
+ this._dispose(job);
779
+ return false;
780
+ }
781
+ this.current = null;
782
+ this._clear();
783
+ this._dispose(job);
784
+ throw error;
785
+ }
786
+ };
787
+ return Promise.race([capture(), cancelled3]);
788
+ }
789
+ level() {
790
+ if (!this.analyser || !this.samples) return 0;
791
+ this.analyser.getFloatTimeDomainData(this.samples);
792
+ return Math.min(
793
+ 1,
794
+ Math.sqrt(this.samples.reduce((s, x) => s + x * x, 0) / this.samples.length) * 5
795
+ );
796
+ }
797
+ _clear() {
798
+ this.stream = this.context = this.source = this.analyser = this.samples = null;
799
+ }
800
+ _dispose(job) {
801
+ job.signal?.removeEventListener("abort", job.cancel);
802
+ const stream = job.stream, source = job.source, context = job.context;
803
+ job.stream = job.source = job.context = job.analyser = job.samples = null;
804
+ if (stream) {
805
+ for (const track of stream.getTracks()) {
806
+ try {
807
+ track.stop();
808
+ } catch {
809
+ }
810
+ }
811
+ }
812
+ try {
813
+ source?.disconnect();
814
+ } catch {
815
+ }
816
+ try {
817
+ if (context && context.state !== "closed") Promise.resolve(context.close()).catch(() => {
818
+ });
819
+ } catch {
820
+ }
821
+ }
822
+ async release() {
823
+ return this.stop();
824
+ }
825
+ async stop() {
826
+ ++this.epoch;
827
+ const job = this.current;
828
+ this.current = null;
829
+ this._clear();
830
+ if (!job) return;
831
+ job.cancelled(false);
832
+ this._dispose(job);
833
+ }
834
+ };
835
+
836
+ // src/engines/speaking/browser.ts
837
+ var abortError = () => Object.assign(new Error("Speech playback was cancelled."), { name: "AbortError" });
838
+ var BrowserSpeakingEngine = class {
839
+ constructor({ globals = globalThis, lang = "pt-BR" } = {}) {
840
+ this.globals = globals;
841
+ this.lang = lang;
842
+ this.current = null;
843
+ }
844
+ voices() {
845
+ try {
846
+ return Array.from(this.globals.speechSynthesis?.getVoices?.() ?? []).filter(
847
+ (v) => v.localService === true
848
+ );
849
+ } catch {
850
+ return [];
851
+ }
852
+ }
853
+ capability() {
854
+ const supported = typeof this.globals.SpeechSynthesisUtterance === "function" && typeof this.globals.speechSynthesis?.speak === "function" && typeof this.globals.speechSynthesis?.cancel === "function" && this.voices().length > 0;
855
+ return {
856
+ supported,
857
+ local: true,
858
+ voices: this.voices().map((voice) => ({
859
+ name: voice.name,
860
+ voiceURI: voice.voiceURI,
861
+ lang: voice.lang
862
+ })),
863
+ pause: typeof this.globals.speechSynthesis?.pause === "function",
864
+ resume: typeof this.globals.speechSynthesis?.resume === "function",
865
+ ...supported ? {} : {
866
+ reason: "Local browser speech synthesis is unavailable. Enable an installed local system voice, refresh the voice list, or choose another local speaking engine. Remote voices are not allowed."
867
+ }
868
+ };
869
+ }
870
+ async speak(text, { voice, rate = 1, signal } = {}) {
871
+ this.stop();
872
+ if (signal?.aborted) throw abortError();
873
+ if (typeof text !== "string") throw new TypeError("Speech text must be a string.");
874
+ if (!Number.isFinite(rate) || rate < 0.1 || rate > 10)
875
+ throw new RangeError("Speech rate must be between 0.1 and 10.");
876
+ const capability = this.capability();
877
+ if (!capability.supported) throw new Error(capability.reason);
878
+ const voices = this.voices();
879
+ const chosen = voice == null ? voices.find((v) => v.lang?.toLowerCase() === this.lang.toLowerCase()) ?? voices[0] : voices.find(
880
+ (v) => typeof voice === "string" ? v.voiceURI === voice || v.name === voice : v === voice
881
+ );
882
+ if (!chosen)
883
+ throw new Error(
884
+ "The selected voice is not an available local browser voice. Choose a voice from voices()."
885
+ );
886
+ if (!text.trim()) return;
887
+ const utterance = new this.globals.SpeechSynthesisUtterance(text);
888
+ utterance.voice = chosen;
889
+ utterance.lang = chosen.lang || this.lang;
890
+ utterance.rate = rate;
891
+ return new Promise((resolve, reject) => {
892
+ const finish = (error) => {
893
+ if (this.current !== job) return;
894
+ this.current = null;
895
+ utterance.onend = null;
896
+ utterance.onerror = null;
897
+ signal?.removeEventListener("abort", cancel);
898
+ error ? reject(error) : resolve();
899
+ };
900
+ const cancel = () => {
901
+ if (this.current === job) this.stop();
902
+ };
903
+ const job = { finish, utterance };
904
+ this.current = job;
905
+ utterance.onend = () => finish();
906
+ utterance.onerror = (event) => finish(
907
+ Object.assign(
908
+ new Error("Browser speech synthesis failed: " + (event.error || "unknown error")),
909
+ { code: event.error }
910
+ )
911
+ );
912
+ signal?.addEventListener("abort", cancel, { once: true });
913
+ if (signal?.aborted) {
914
+ cancel();
915
+ return;
916
+ }
917
+ try {
918
+ this.globals.speechSynthesis.resume?.();
919
+ this.globals.speechSynthesis.speak(utterance);
920
+ } catch (error) {
921
+ finish(error);
922
+ }
923
+ });
924
+ }
925
+ async stop() {
926
+ if (!this.current) return;
927
+ this.current.finish(abortError());
928
+ try {
929
+ this.globals.speechSynthesis.cancel();
930
+ } catch {
931
+ }
932
+ }
933
+ pause() {
934
+ if (!this.current || typeof this.globals.speechSynthesis.pause !== "function") return false;
935
+ this.globals.speechSynthesis.pause();
936
+ return true;
937
+ }
938
+ resume() {
939
+ if (!this.current || typeof this.globals.speechSynthesis.resume !== "function") return false;
940
+ this.globals.speechSynthesis.resume();
941
+ return true;
942
+ }
943
+ };
944
+
945
+ // src/engines/speaking/say-client.ts
946
+ var CHANNEL = "/api";
947
+ var cancelled = () => Object.assign(new Error("Speech was cancelled."), { name: "AbortError" });
948
+ var SayClientEngine = class {
949
+ constructor({ rpc }) {
950
+ this.rpc = rpc;
951
+ this.clientId = globalThis.crypto.randomUUID();
952
+ this.current = null;
953
+ }
954
+ async request(endpoint, payload = {}, signal) {
955
+ const result = await this.rpc.call(CHANNEL, `dsh-live-voice/${endpoint}`, payload, signal);
956
+ if (!result?.ok) {
957
+ if (signal?.aborted || result?.error?.code === "cancelled") throw cancelled();
958
+ throw Object.assign(new Error(result?.error?.message || "Host speech is unavailable."), {
959
+ code: result?.error?.code
960
+ });
961
+ }
962
+ return result.value;
963
+ }
964
+ async capability() {
965
+ try {
966
+ return { ...await this.request("capabilities"), local: true, location: "host" };
967
+ } catch (error) {
968
+ return {
969
+ supported: false,
970
+ local: true,
971
+ location: "host",
972
+ reason: `macOS say connection failed: ${error.message}`
973
+ };
974
+ }
975
+ }
976
+ async speak(text, { voice, rate = 1, signal } = {}) {
977
+ if (typeof text !== "string") throw new TypeError("Speech text must be a string.");
978
+ if (!Number.isFinite(rate) || rate < 0.1 || rate > 10)
979
+ throw new RangeError("Speech rate must be between 0.1 and 10.");
980
+ if (signal?.aborted) throw cancelled();
981
+ const previous = this.current;
982
+ previous?.abort.abort();
983
+ const operation = { operationId: globalThis.crypto.randomUUID(), abort: new AbortController() };
984
+ this.current = operation;
985
+ const cancel = () => operation.abort.abort();
986
+ signal?.addEventListener("abort", cancel, { once: true });
987
+ if (signal?.aborted) cancel();
988
+ try {
989
+ if (!text.trim()) return;
990
+ await this.request(
991
+ "speak",
992
+ {
993
+ clientId: this.clientId,
994
+ operationId: operation.operationId,
995
+ text,
996
+ ...voice === void 0 ? {} : { voice },
997
+ rate: Math.round(rate * 175)
998
+ },
999
+ operation.abort.signal
1000
+ );
1001
+ if (operation.abort.signal.aborted) throw cancelled();
1002
+ } finally {
1003
+ signal?.removeEventListener("abort", cancel);
1004
+ if (this.current === operation) this.current = null;
1005
+ }
1006
+ }
1007
+ async stop() {
1008
+ const operation = this.current;
1009
+ if (!operation) return;
1010
+ this.current = null;
1011
+ const stopping = this.request("stop", {
1012
+ clientId: this.clientId,
1013
+ operationId: operation.operationId
1014
+ });
1015
+ operation.abort.abort();
1016
+ await stopping;
1017
+ }
1018
+ async control(endpoint) {
1019
+ const operation = this.current;
1020
+ if (!operation) return false;
1021
+ const value = await this.request(endpoint, {
1022
+ clientId: this.clientId,
1023
+ operationId: operation.operationId
1024
+ });
1025
+ return value.applied;
1026
+ }
1027
+ pause() {
1028
+ return this.control("pause");
1029
+ }
1030
+ resume() {
1031
+ return this.control("resume");
1032
+ }
1033
+ };
1034
+
1035
+ // src/engines/recognition/browser.ts
1036
+ var abortError2 = () => Object.assign(new Error("Speech recognition was cancelled."), { name: "AbortError" });
1037
+ var localReason = "Local browser speech recognition is unavailable for this language. Install the local language pack, or explicitly turn off local-only processing in Settings if you permit the browser recognition service to process audio.";
1038
+ var notify = (callback, value) => {
1039
+ try {
1040
+ callback?.(value);
1041
+ } catch {
1042
+ }
1043
+ };
1044
+ var BrowserRecognitionEngine = class {
1045
+ constructor({
1046
+ globals = globalThis,
1047
+ lang = "pt-BR",
1048
+ processLocally = true,
1049
+ autoInstallLocalPack = true,
1050
+ onResult,
1051
+ onActivity,
1052
+ onError,
1053
+ maxRestarts = 3,
1054
+ restartDelayMs = 100
1055
+ } = {}) {
1056
+ if (!Number.isInteger(maxRestarts) || maxRestarts < 0 || maxRestarts > 100)
1057
+ throw new RangeError("maxRestarts must be an integer between 0 and 100.");
1058
+ if (!Number.isFinite(restartDelayMs) || restartDelayMs < 0)
1059
+ throw new RangeError("restartDelayMs must be nonnegative.");
1060
+ Object.assign(this, {
1061
+ globals,
1062
+ lang,
1063
+ processLocally,
1064
+ autoInstallLocalPack,
1065
+ onResult,
1066
+ onActivity,
1067
+ onError,
1068
+ maxRestarts,
1069
+ restartDelayMs
1070
+ });
1071
+ this.session = null;
1072
+ }
1073
+ get active() {
1074
+ return this.session !== null;
1075
+ }
1076
+ get Recognition() {
1077
+ try {
1078
+ const standard = this.globals?.SpeechRecognition;
1079
+ return typeof standard === "function" ? standard : this.globals?.webkitSpeechRecognition;
1080
+ } catch {
1081
+ return void 0;
1082
+ }
1083
+ }
1084
+ async capability({ lang = this.lang, processLocally = this.processLocally } = {}) {
1085
+ try {
1086
+ const Recognition = this.Recognition;
1087
+ if (typeof Recognition !== "function") throw new Error();
1088
+ const probe = new Recognition();
1089
+ if (typeof probe.start !== "function" || typeof probe.abort !== "function") throw new Error();
1090
+ if (!processLocally)
1091
+ return {
1092
+ supported: true,
1093
+ local: false,
1094
+ reason: "Browser recognition service may process audio remotely."
1095
+ };
1096
+ if (typeof Recognition.available !== "function" || !("processLocally" in probe))
1097
+ throw new Error();
1098
+ probe.processLocally = true;
1099
+ if (probe.processLocally !== true) throw new Error();
1100
+ let availability = await Recognition.available({ langs: [lang], processLocally: true });
1101
+ if (availability === "downloadable" && this.autoInstallLocalPack) {
1102
+ const installed = await Recognition.install?.({ langs: [lang], processLocally: true });
1103
+ if (installed === true)
1104
+ availability = await Recognition.available({ langs: [lang], processLocally: true });
1105
+ else
1106
+ return {
1107
+ supported: false,
1108
+ local: true,
1109
+ availability,
1110
+ reason: `The browser could not install the local ${lang} language pack. Disable local processing to use the browser recognition service, or try again later.`
1111
+ };
1112
+ }
1113
+ if (availability !== "available")
1114
+ return {
1115
+ supported: false,
1116
+ local: true,
1117
+ availability,
1118
+ reason: `Local recognition for ${lang}: ${availability}. ${availability === "downloadable" ? "The language pack is not installed; enable automatic installation or browser-service recognition." : availability === "downloading" ? "The browser is still installing the language pack." : "The browser cannot currently provide on-device recognition for this language."}`
1119
+ };
1120
+ return { supported: true, local: true };
1121
+ } catch {
1122
+ return {
1123
+ supported: false,
1124
+ local: processLocally,
1125
+ reason: processLocally ? localReason : "Browser speech recognition is unavailable in this browser."
1126
+ };
1127
+ }
1128
+ }
1129
+ async installLocalPack({ lang = this.lang } = {}) {
1130
+ const Recognition = this.Recognition;
1131
+ if (typeof Recognition?.install !== "function")
1132
+ throw new Error("This browser cannot install local speech-recognition language packs.");
1133
+ const result = await Recognition.install({ langs: [lang], processLocally: true });
1134
+ if (result !== true) throw new Error(`Local recognition for ${lang} could not be installed.`);
1135
+ return this.capability({ lang, processLocally: true });
1136
+ }
1137
+ async start({
1138
+ lang = this.lang,
1139
+ signal,
1140
+ onResult = this.onResult,
1141
+ onActivity = this.onActivity,
1142
+ onError = this.onError
1143
+ } = {}) {
1144
+ this.stop();
1145
+ if (signal?.aborted) throw abortError2();
1146
+ const session = {
1147
+ lang,
1148
+ signal,
1149
+ onResult,
1150
+ onActivity,
1151
+ onError,
1152
+ restarts: 0,
1153
+ timer: null,
1154
+ recognition: null,
1155
+ speaking: false
1156
+ };
1157
+ this.session = session;
1158
+ const cancelled3 = new Promise((resolve, reject) => {
1159
+ session.rejectCancelled = reject;
1160
+ });
1161
+ session.cancel = () => {
1162
+ if (this.session === session) this.stop();
1163
+ };
1164
+ signal?.addEventListener("abort", session.cancel, { once: true });
1165
+ const capability = await Promise.race([
1166
+ this.capability({ lang, processLocally: this.processLocally }),
1167
+ cancelled3
1168
+ ]);
1169
+ if (this.session !== session || signal?.aborted) {
1170
+ session.cancel();
1171
+ throw abortError2();
1172
+ }
1173
+ if (!capability.supported) {
1174
+ this.stop();
1175
+ throw new Error(capability.reason);
1176
+ }
1177
+ try {
1178
+ this._begin(session);
1179
+ if (this.session !== session) throw abortError2();
1180
+ } catch (error) {
1181
+ if (this.session === session) this.stop();
1182
+ throw error;
1183
+ }
1184
+ }
1185
+ _activity(session, active) {
1186
+ if (session.speaking === active) return;
1187
+ session.speaking = active;
1188
+ notify(session.onActivity, active);
1189
+ }
1190
+ _detach(recognition) {
1191
+ if (!recognition) return;
1192
+ for (const name of ["onresult", "onerror", "onend", "onspeechstart", "onspeechend"])
1193
+ recognition[name] = null;
1194
+ }
1195
+ _fail(session, error) {
1196
+ if (this.session !== session) return;
1197
+ this.stop();
1198
+ notify(session.onError, error);
1199
+ }
1200
+ _begin(session) {
1201
+ if (this.session !== session) return;
1202
+ const recognition = new this.Recognition();
1203
+ if (this.processLocally) {
1204
+ if (!("processLocally" in recognition)) throw new Error(localReason);
1205
+ recognition.processLocally = true;
1206
+ if (recognition.processLocally !== true) throw new Error(localReason);
1207
+ } else if ("processLocally" in recognition) recognition.processLocally = false;
1208
+ recognition.lang = session.lang;
1209
+ recognition.continuous = true;
1210
+ recognition.interimResults = true;
1211
+ session.recognition = recognition;
1212
+ const finals = /* @__PURE__ */ new Set();
1213
+ const valid = () => this.session === session && session.recognition === recognition;
1214
+ recognition.onspeechstart = () => {
1215
+ if (!valid()) return;
1216
+ session.restarts = 0;
1217
+ this._activity(session, true);
1218
+ };
1219
+ recognition.onspeechend = () => {
1220
+ if (valid()) this._activity(session, false);
1221
+ };
1222
+ recognition.onresult = (event) => {
1223
+ if (!valid()) return;
1224
+ const interim = [], final = [];
1225
+ for (let i = 0; i < event.results.length; i++) {
1226
+ const result = event.results[i];
1227
+ const text = result[0]?.transcript ?? "";
1228
+ if (text.trim()) session.restarts = 0;
1229
+ if (result.isFinal) {
1230
+ if (!finals.has(i)) {
1231
+ finals.add(i);
1232
+ final.push(text);
1233
+ }
1234
+ } else interim.push(text);
1235
+ }
1236
+ notify(session.onResult, { interim: interim.join(" "), final: final.join(" ") });
1237
+ };
1238
+ recognition.onerror = (event) => {
1239
+ if (!valid()) return;
1240
+ const error = Object.assign(
1241
+ new Error("Local browser recognition failed: " + (event.error || "unknown error")),
1242
+ { code: event.error }
1243
+ );
1244
+ if (event.error === "no-speech") return;
1245
+ else this._fail(session, error);
1246
+ };
1247
+ recognition.onend = () => {
1248
+ if (!valid()) return;
1249
+ this._detach(recognition);
1250
+ session.recognition = null;
1251
+ this._activity(session, false);
1252
+ if (this.session !== session) return;
1253
+ if (session.restarts >= this.maxRestarts) {
1254
+ this._fail(
1255
+ session,
1256
+ Object.assign(
1257
+ new Error(
1258
+ "Local browser recognition stopped repeatedly. Restart listening manually or choose another local engine."
1259
+ ),
1260
+ { code: "restart-limit" }
1261
+ )
1262
+ );
1263
+ return;
1264
+ }
1265
+ session.restarts++;
1266
+ session.timer = (this.globals.setTimeout ?? globalThis.setTimeout)(
1267
+ () => {
1268
+ session.timer = null;
1269
+ if (this.session !== session) return;
1270
+ try {
1271
+ this._begin(session);
1272
+ } catch (error) {
1273
+ this._fail(session, error);
1274
+ }
1275
+ },
1276
+ Math.min(3e4, this.restartDelayMs * 2 ** Math.min(session.restarts - 1, 20))
1277
+ );
1278
+ };
1279
+ recognition.start();
1280
+ }
1281
+ reset() {
1282
+ const session = this.session;
1283
+ const recognition = session?.recognition;
1284
+ if (!session || !recognition) return false;
1285
+ this._detach(recognition);
1286
+ session.recognition = null;
1287
+ try {
1288
+ recognition.abort();
1289
+ } catch {
1290
+ }
1291
+ this._activity(session, false);
1292
+ if (this.session !== session || session.signal?.aborted) return false;
1293
+ try {
1294
+ this._begin(session);
1295
+ return true;
1296
+ } catch (error) {
1297
+ this._fail(session, error);
1298
+ return false;
1299
+ }
1300
+ }
1301
+ async stop() {
1302
+ const session = this.session;
1303
+ if (!session) return;
1304
+ this.session = null;
1305
+ session.rejectCancelled(abortError2());
1306
+ session.signal?.removeEventListener("abort", session.cancel);
1307
+ if (session.timer !== null)
1308
+ (this.globals.clearTimeout ?? globalThis.clearTimeout)(session.timer);
1309
+ this._detach(session.recognition);
1310
+ try {
1311
+ session.recognition?.abort();
1312
+ } catch {
1313
+ }
1314
+ this._activity(session, false);
1315
+ }
1316
+ };
1317
+
1318
+ // src/engines/recognition/whisper-http.ts
1319
+ var ROUTE = "/api/dsh-live-voice/whisper";
1320
+ var id = () => globalThis.crypto.randomUUID();
1321
+ var abortError3 = () => Object.assign(new Error("Whisper recognition was cancelled."), { name: "AbortError" });
1322
+ function encodeMonoPcm16Wav(samples, inputRate) {
1323
+ const ratio = inputRate / 16e3, length = Math.floor(samples.length / ratio), out = new Int16Array(length);
1324
+ for (let i = 0; i < length; i++) {
1325
+ const start = Math.floor(i * ratio), end = Math.max(start + 1, Math.floor((i + 1) * ratio));
1326
+ let sum = 0;
1327
+ for (let j = start; j < end && j < samples.length; j++) sum += samples[j];
1328
+ const value = Math.max(-1, Math.min(1, sum / (end - start)));
1329
+ out[i] = value < 0 ? value * 32768 : value * 32767;
1330
+ }
1331
+ const buffer = new ArrayBuffer(44 + out.byteLength), view = new DataView(buffer), text = (at, s) => {
1332
+ for (let i = 0; i < s.length; i++) view.setUint8(at + i, s.charCodeAt(i));
1333
+ };
1334
+ text(0, "RIFF");
1335
+ view.setUint32(4, 36 + out.byteLength, true);
1336
+ text(8, "WAVE");
1337
+ text(12, "fmt ");
1338
+ view.setUint32(16, 16, true);
1339
+ view.setUint16(20, 1, true);
1340
+ view.setUint16(22, 1, true);
1341
+ view.setUint32(24, 16e3, true);
1342
+ view.setUint32(28, 32e3, true);
1343
+ view.setUint16(32, 2, true);
1344
+ view.setUint16(34, 16, true);
1345
+ text(36, "data");
1346
+ view.setUint32(40, out.byteLength, true);
1347
+ new Int16Array(buffer, 44).set(out);
1348
+ return buffer;
1349
+ }
1350
+ var WhisperHttpRecognitionEngine = class {
1351
+ constructor({ globals = globalThis, meter, voiceDetectionPreset = "natural" } = {}) {
1352
+ this.g = globals;
1353
+ this.meter = meter;
1354
+ this.session = null;
1355
+ this.lang = "pt-BR";
1356
+ this.voiceDetectionPreset = voiceDetectionPreset;
1357
+ this.route = ROUTE;
1358
+ }
1359
+ get segmentation() {
1360
+ return voiceDetectionPresets[this.voiceDetectionPreset] || voiceDetectionPresets.natural;
1361
+ }
1362
+ async capability() {
1363
+ try {
1364
+ const response = await this.g.fetch(this.route + "/capabilities", {
1365
+ credentials: "same-origin"
1366
+ }), json = await response.json();
1367
+ return json?.ok ? json.value : {
1368
+ supported: false,
1369
+ local: true,
1370
+ streaming: false,
1371
+ reason: json?.error?.message || "Whisper HTTP host is unavailable."
1372
+ };
1373
+ } catch (error) {
1374
+ return {
1375
+ supported: false,
1376
+ local: true,
1377
+ streaming: false,
1378
+ reason: "Whisper HTTP host connection failed: " + error.message
1379
+ };
1380
+ }
1381
+ }
1382
+ async start({ lang = this.lang, signal, onResult, onActivity, onError } = {}) {
1383
+ await this.stop();
1384
+ if (signal?.aborted) throw abortError3();
1385
+ const context = this.meter?.context, source = this.meter?.source;
1386
+ if (!context || !source || typeof context.createScriptProcessor !== "function")
1387
+ throw new Error("This browser cannot capture PCM audio for Whisper HTTP.");
1388
+ const processor = context.createScriptProcessor(4096, 1, 1), gain = context.createGain?.();
1389
+ if (gain) {
1390
+ gain.gain.value = 0;
1391
+ processor.connect(gain);
1392
+ gain.connect(context.destination);
1393
+ } else processor.connect(context.destination);
1394
+ const session = {
1395
+ operation: id(),
1396
+ processor,
1397
+ gain,
1398
+ chunks: [],
1399
+ samples: 0,
1400
+ voiced: false,
1401
+ silence: 0,
1402
+ inflight: /* @__PURE__ */ new Set(),
1403
+ onResult,
1404
+ onActivity,
1405
+ onError,
1406
+ lang,
1407
+ signal
1408
+ };
1409
+ this.session = session;
1410
+ const valid = () => this.session === session && !signal?.aborted;
1411
+ const submit = async () => {
1412
+ if (!session.voiced || session.samples < context.sampleRate * 0.25) {
1413
+ session.chunks = [];
1414
+ session.samples = 0;
1415
+ session.voiced = false;
1416
+ session.silence = 0;
1417
+ return;
1418
+ }
1419
+ const samples = new Float32Array(session.samples);
1420
+ let at = 0;
1421
+ for (const chunk of session.chunks) {
1422
+ samples.set(chunk, at);
1423
+ at += chunk.length;
1424
+ }
1425
+ session.chunks = [];
1426
+ session.samples = 0;
1427
+ session.voiced = false;
1428
+ session.silence = 0;
1429
+ const request = new AbortController();
1430
+ session.inflight.add(request);
1431
+ try {
1432
+ const response = await this.g.fetch(this.route + "/transcribe", {
1433
+ method: "POST",
1434
+ credentials: "same-origin",
1435
+ headers: {
1436
+ "content-type": "audio/wav",
1437
+ "x-dlv-client-id": session.operation,
1438
+ "x-dlv-operation-id": id(),
1439
+ "x-dlv-language": lang
1440
+ },
1441
+ body: encodeMonoPcm16Wav(samples, context.sampleRate),
1442
+ signal: request.signal
1443
+ });
1444
+ const json = await response.json();
1445
+ if (!response.ok || !json?.ok)
1446
+ throw new Error(json?.error?.message || "HTTP transcription failed.");
1447
+ if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: "" });
1448
+ } catch (error) {
1449
+ if (error.name !== "AbortError" && valid()) onError?.(error);
1450
+ } finally {
1451
+ session.inflight.delete(request);
1452
+ }
1453
+ };
1454
+ processor.onaudioprocess = (event) => {
1455
+ if (!valid()) return;
1456
+ const data = new Float32Array(event.inputBuffer.getChannelData(0)), rms = Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length);
1457
+ if (rms > 0.012) {
1458
+ session.voiced = true;
1459
+ session.silence = 0;
1460
+ onActivity?.(true);
1461
+ } else if (session.voiced) {
1462
+ session.silence += data.length;
1463
+ onActivity?.(false);
1464
+ }
1465
+ session.chunks.push(data);
1466
+ session.samples += data.length;
1467
+ if (session.voiced && session.silence > context.sampleRate * (this.segmentation.silenceMs / 1e3) || session.samples > context.sampleRate * 20)
1468
+ void submit();
1469
+ };
1470
+ source.connect(processor);
1471
+ session.abort = () => this.stop();
1472
+ signal?.addEventListener("abort", session.abort, { once: true });
1473
+ }
1474
+ async stop() {
1475
+ const session = this.session;
1476
+ if (!session) return;
1477
+ this.session = null;
1478
+ session.signal?.removeEventListener("abort", session.abort);
1479
+ session.processor.onaudioprocess = null;
1480
+ try {
1481
+ this.meter?.source?.disconnect(session.processor);
1482
+ } catch {
1483
+ }
1484
+ try {
1485
+ session.processor.disconnect();
1486
+ session.gain?.disconnect();
1487
+ } catch {
1488
+ }
1489
+ for (const request of session.inflight) request.abort();
1490
+ session.inflight.clear();
1491
+ }
1492
+ };
1493
+
1494
+ // src/engines/recognition/qwen-http.ts
1495
+ var QwenHttpRecognitionEngine = class extends WhisperHttpRecognitionEngine {
1496
+ constructor(options = {}) {
1497
+ super(options);
1498
+ this.route = "/api/dsh-live-voice/qwen";
1499
+ }
1500
+ async capability() {
1501
+ const capture = await this.meter?.capability?.();
1502
+ if (capture?.supported === false) return capture;
1503
+ try {
1504
+ const response = await this.g.fetch(this.route + "/capabilities?kind=asr", {
1505
+ credentials: "same-origin"
1506
+ }), json = await response.json();
1507
+ return response.ok && json?.ok ? json.value : {
1508
+ supported: false,
1509
+ local: true,
1510
+ location: "host",
1511
+ reason: json?.error?.message || "Qwen capability check failed."
1512
+ };
1513
+ } catch (error) {
1514
+ return {
1515
+ supported: false,
1516
+ local: true,
1517
+ location: "host",
1518
+ reason: "Qwen speech server check failed: " + (error?.message || error)
1519
+ };
1520
+ }
1521
+ }
1522
+ };
1523
+
1524
+ // src/engines/speaking/qwen-http.ts
1525
+ var BASE = "/api/dsh-live-voice/qwen";
1526
+ var cancelled2 = () => Object.assign(new Error("Speech was cancelled."), { name: "AbortError" });
1527
+ var QwenHttpSpeakingEngine = class {
1528
+ constructor({ globals = globalThis, lang = "pt-BR" } = {}) {
1529
+ this.g = globals;
1530
+ this.lang = lang;
1531
+ this.current = null;
1532
+ }
1533
+ async capability() {
1534
+ try {
1535
+ const response = await this.g.fetch(BASE + "/capabilities?kind=tts", {
1536
+ credentials: "same-origin"
1537
+ }), json = await response.json();
1538
+ if (!response.ok || !json?.ok)
1539
+ throw new Error(json?.error?.message || "Qwen capability check failed.");
1540
+ return { ...json.value, pause: true, resume: true };
1541
+ } catch (error) {
1542
+ return {
1543
+ supported: false,
1544
+ local: true,
1545
+ location: "host",
1546
+ pause: true,
1547
+ resume: true,
1548
+ reason: "Qwen speech server check failed: " + (error?.message || error)
1549
+ };
1550
+ }
1551
+ }
1552
+ async speak(text, { rate = 1, signal, lang = this.lang, voice = "aiden" } = {}) {
1553
+ if (typeof text !== "string") throw new TypeError("Speech text must be a string.");
1554
+ if (!Number.isFinite(rate) || rate < 0.1 || rate > 3)
1555
+ throw new RangeError("Speech rate must be between 0.1 and 3.");
1556
+ if (signal?.aborted) throw cancelled2();
1557
+ await this.stop();
1558
+ if (!text.trim()) return;
1559
+ const operation = { abort: new AbortController(), audio: null, url: null };
1560
+ this.current = operation;
1561
+ const cancel = () => operation.abort.abort();
1562
+ signal?.addEventListener("abort", cancel, { once: true });
1563
+ if (signal?.aborted) cancel();
1564
+ try {
1565
+ const response = await this.g.fetch(BASE + "/speech", {
1566
+ method: "POST",
1567
+ credentials: "same-origin",
1568
+ headers: { "content-type": "application/json" },
1569
+ body: JSON.stringify({ text, lang, voice }),
1570
+ signal: operation.abort.signal
1571
+ });
1572
+ if (!response.ok) {
1573
+ let body;
1574
+ try {
1575
+ body = await response.json();
1576
+ } catch {
1577
+ }
1578
+ throw new Error(body?.error?.message || `Qwen synthesis failed (${response.status}).`);
1579
+ }
1580
+ if (operation.abort.signal.aborted) throw cancelled2();
1581
+ const blob = await response.blob();
1582
+ operation.url = this.g.URL.createObjectURL(blob);
1583
+ const audio = operation.audio = new this.g.Audio(operation.url);
1584
+ audio.playbackRate = rate;
1585
+ await new Promise((resolve, reject) => {
1586
+ const done = () => {
1587
+ cleanup();
1588
+ resolve();
1589
+ }, failed = () => {
1590
+ cleanup();
1591
+ reject(new Error("The browser could not play Qwen speech audio."));
1592
+ }, aborted = () => {
1593
+ cleanup();
1594
+ audio.pause();
1595
+ reject(cancelled2());
1596
+ }, cleanup = () => {
1597
+ audio.removeEventListener("ended", done);
1598
+ audio.removeEventListener("error", failed);
1599
+ operation.abort.signal.removeEventListener("abort", aborted);
1600
+ };
1601
+ audio.addEventListener("ended", done, { once: true });
1602
+ audio.addEventListener("error", failed, { once: true });
1603
+ operation.abort.signal.addEventListener("abort", aborted, { once: true });
1604
+ Promise.resolve(audio.play()).catch(failed);
1605
+ });
1606
+ } catch (error) {
1607
+ if (operation.abort.signal.aborted && error?.name !== "AbortError") throw cancelled2();
1608
+ throw error;
1609
+ } finally {
1610
+ signal?.removeEventListener("abort", cancel);
1611
+ if (operation.url) this.g.URL.revokeObjectURL(operation.url);
1612
+ if (this.current === operation) this.current = null;
1613
+ }
1614
+ }
1615
+ async stop() {
1616
+ const operation = this.current;
1617
+ if (!operation) return;
1618
+ this.current = null;
1619
+ operation.abort.abort();
1620
+ operation.audio?.pause();
1621
+ if (operation.url) {
1622
+ this.g.URL.revokeObjectURL(operation.url);
1623
+ operation.url = null;
1624
+ }
1625
+ }
1626
+ pause() {
1627
+ if (!this.current?.audio || this.current.audio.paused) return false;
1628
+ this.current.audio.pause();
1629
+ return true;
1630
+ }
1631
+ async resume() {
1632
+ if (!this.current?.audio || !this.current.audio.paused) return false;
1633
+ await this.current.audio.play();
1634
+ return true;
1635
+ }
1636
+ };
1637
+
1638
+ // src/client/whisper-settings.ts
1639
+ var BASE2 = "/api/dsh-live-voice/whisper";
1640
+ var UNLOADED = "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.";
1641
+ async function whisperSettingsRequest(path, { method = "GET", config, signal } = {}, fetchImpl = globalThis.fetch) {
1642
+ const response = await fetchImpl(BASE2 + path, {
1643
+ method,
1644
+ credentials: "same-origin",
1645
+ signal,
1646
+ headers: config ? { "content-type": "application/json" } : void 0,
1647
+ body: config ? JSON.stringify(config) : void 0
1648
+ });
1649
+ if (response.status === 401 || response.status === 403)
1650
+ throw new Error("Sign in to DSH to manage Whisper settings.");
1651
+ if (response.status === 404 || response.status === 405) throw new Error(UNLOADED);
1652
+ let body;
1653
+ try {
1654
+ body = await response.json();
1655
+ } catch {
1656
+ throw new Error(UNLOADED);
1657
+ }
1658
+ if (typeof body?.ok !== "boolean") throw new Error(UNLOADED);
1659
+ if (!response.ok || !body.ok)
1660
+ throw new Error(body.error?.message || "Whisper settings request failed.");
1661
+ return body.value;
1662
+ }
1663
+ function createWhisperSettings(React2) {
1664
+ const h = React2.createElement;
1665
+ return function WhisperSettings({ controller }) {
1666
+ const [draft, setDraft] = React2.useState({
1667
+ url: "http://127.0.0.1:8080/inference",
1668
+ healthUrl: "/health",
1669
+ timeoutMs: 3e4
1670
+ });
1671
+ const [busy, setBusy] = React2.useState(true), [loaded, setLoaded] = React2.useState(false), [error, setError] = React2.useState(""), [message2, setMessage] = React2.useState("");
1672
+ const active = React2.useRef(null);
1673
+ async function run(action) {
1674
+ active.current?.abort();
1675
+ const abort = new AbortController();
1676
+ active.current = abort;
1677
+ setBusy(true);
1678
+ setError("");
1679
+ setMessage("");
1680
+ try {
1681
+ if (action === "load") {
1682
+ const value = await whisperSettingsRequest("/config", { signal: abort.signal });
1683
+ if (!abort.signal.aborted) {
1684
+ setDraft(value);
1685
+ setLoaded(true);
1686
+ }
1687
+ } else if (action === "save") {
1688
+ await controller.endConversation?.();
1689
+ const value = await whisperSettingsRequest("/config", {
1690
+ method: "PUT",
1691
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
1692
+ signal: abort.signal
1693
+ });
1694
+ if (!abort.signal.aborted) {
1695
+ setDraft(value);
1696
+ setMessage("Saved on the DSH host. Active host transcription requests were cancelled.");
1697
+ await controller.refreshCapabilities?.();
1698
+ }
1699
+ } else {
1700
+ const value = await whisperSettingsRequest("/test", {
1701
+ method: "POST",
1702
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
1703
+ signal: abort.signal
1704
+ });
1705
+ if (!abort.signal.aborted) {
1706
+ if (!value.supported) throw new Error(value.reason || "Whisper health check failed.");
1707
+ setMessage(
1708
+ "Connection successful. Health endpoint responded; transcription was not tested. Unsaved edits have not been applied."
1709
+ );
1710
+ }
1711
+ }
1712
+ } catch (reason) {
1713
+ if (!abort.signal.aborted) setError(reason.message || String(reason));
1714
+ } finally {
1715
+ if (!abort.signal.aborted) setBusy(false);
1716
+ }
1717
+ }
1718
+ React2.useEffect(() => {
1719
+ void run("load");
1720
+ return () => active.current?.abort();
1721
+ }, []);
1722
+ function field(label, key, type = "text") {
1723
+ return h(
1724
+ "label",
1725
+ null,
1726
+ label,
1727
+ h("input", {
1728
+ type,
1729
+ value: draft[key],
1730
+ disabled: busy || !loaded,
1731
+ autoComplete: "off",
1732
+ ...type === "number" ? { min: 100, max: 3e5, step: 1 } : {},
1733
+ onChange: (event) => {
1734
+ setDraft({ ...draft, [key]: event.target.value });
1735
+ setMessage("Unsaved changes");
1736
+ setError("");
1737
+ }
1738
+ })
1739
+ );
1740
+ }
1741
+ return h(
1742
+ React2.Fragment,
1743
+ null,
1744
+ h(
1745
+ "p",
1746
+ null,
1747
+ "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."
1748
+ ),
1749
+ field("Endpoint URL", "url"),
1750
+ field("Health URL or path", "healthUrl"),
1751
+ field("Request timeout (ms)", "timeoutMs", "number"),
1752
+ h(
1753
+ "div",
1754
+ { className: "dlv-settings-actions" },
1755
+ h(
1756
+ "button",
1757
+ { type: "button", disabled: busy || !loaded, onClick: () => run("save") },
1758
+ "Save Whisper settings"
1759
+ ),
1760
+ h(
1761
+ "button",
1762
+ { type: "button", disabled: busy || !loaded, onClick: () => run("test") },
1763
+ "Test connection"
1764
+ ),
1765
+ h(
1766
+ "button",
1767
+ { type: "button", disabled: busy, onClick: () => run("load") },
1768
+ "Reload saved settings"
1769
+ )
1770
+ ),
1771
+ busy ? h("p", { role: "status" }, "Contacting DSH host\u2026") : null,
1772
+ message2 ? h("p", { role: "status" }, message2) : null,
1773
+ error ? h("p", { role: "alert" }, error) : null
1774
+ );
1775
+ };
1776
+ }
1777
+
1778
+ // src/client/qwen-settings.ts
1779
+ var BASE3 = "/api/dsh-live-voice/qwen";
1780
+ var UNLOADED2 = "Qwen settings routes are not loaded. A normal DSH server restart is required to load updated plugin routes; refreshing this page alone is not enough.";
1781
+ async function qwenSettingsRequest(path, { method = "GET", config, signal } = {}, fetchImpl = globalThis.fetch) {
1782
+ const response = await fetchImpl(BASE3 + path, {
1783
+ method,
1784
+ credentials: "same-origin",
1785
+ signal,
1786
+ headers: config ? { "content-type": "application/json" } : void 0,
1787
+ body: config ? JSON.stringify(config) : void 0
1788
+ });
1789
+ if (response.status === 401 || response.status === 403)
1790
+ throw new Error("Sign in to DSH to manage Qwen settings.");
1791
+ if (response.status === 404 || response.status === 405) throw new Error(UNLOADED2);
1792
+ let body;
1793
+ try {
1794
+ body = await response.json();
1795
+ } catch {
1796
+ throw new Error(UNLOADED2);
1797
+ }
1798
+ if (typeof body?.ok !== "boolean") throw new Error(UNLOADED2);
1799
+ if (!response.ok || !body.ok)
1800
+ throw new Error(body.error?.message || "Qwen settings request failed.");
1801
+ return body.value;
1802
+ }
1803
+ function createQwenSettings(React2) {
1804
+ const h = React2.createElement;
1805
+ return function QwenSettings({ controller }) {
1806
+ const [draft, setDraft] = React2.useState({
1807
+ baseUrl: "http://127.0.0.1:8080/",
1808
+ timeoutMs: 3e5
1809
+ });
1810
+ const [busy, setBusy] = React2.useState(true), [loaded, setLoaded] = React2.useState(false), [error, setError] = React2.useState(""), [message2, setMessage] = React2.useState("");
1811
+ const active = React2.useRef(null);
1812
+ async function run(action) {
1813
+ active.current?.abort();
1814
+ const abort = new AbortController();
1815
+ active.current = abort;
1816
+ setBusy(true);
1817
+ setError("");
1818
+ setMessage("");
1819
+ try {
1820
+ if (action === "load") {
1821
+ const value = await qwenSettingsRequest("/config", { signal: abort.signal });
1822
+ if (!abort.signal.aborted) {
1823
+ setDraft(value);
1824
+ setLoaded(true);
1825
+ }
1826
+ } else if (action === "save") {
1827
+ await controller.endConversation?.();
1828
+ const value = await qwenSettingsRequest("/config", {
1829
+ method: "PUT",
1830
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
1831
+ signal: abort.signal
1832
+ });
1833
+ if (!abort.signal.aborted) {
1834
+ setDraft(value);
1835
+ setMessage("Saved on the DSH host. Active Qwen requests were cancelled.");
1836
+ await controller.refreshCapabilities?.();
1837
+ }
1838
+ } else {
1839
+ const value = await qwenSettingsRequest("/test", {
1840
+ method: "POST",
1841
+ config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
1842
+ signal: abort.signal
1843
+ });
1844
+ if (!abort.signal.aborted) {
1845
+ if (!value.supported) throw new Error(value.reason || "Qwen health check failed.");
1846
+ setMessage(
1847
+ "Connection successful. Both Qwen ASR and TTS are loaded. Unsaved edits have not been applied."
1848
+ );
1849
+ }
1850
+ }
1851
+ } catch (reason) {
1852
+ if (!abort.signal.aborted) setError(reason.message || String(reason));
1853
+ } finally {
1854
+ if (!abort.signal.aborted) setBusy(false);
1855
+ }
1856
+ }
1857
+ React2.useEffect(() => {
1858
+ void run("load");
1859
+ return () => active.current?.abort();
1860
+ }, []);
1861
+ const field = (label, key, type = "text") => h(
1862
+ "label",
1863
+ null,
1864
+ label,
1865
+ h("input", {
1866
+ type,
1867
+ value: draft[key],
1868
+ disabled: busy || !loaded,
1869
+ autoComplete: "off",
1870
+ ...type === "number" ? { min: 1e3, max: 6e5, step: 1 } : {},
1871
+ onChange: (event) => {
1872
+ setDraft({ ...draft, [key]: event.target.value });
1873
+ setMessage("Unsaved changes");
1874
+ setError("");
1875
+ }
1876
+ })
1877
+ );
1878
+ return h(
1879
+ React2.Fragment,
1880
+ null,
1881
+ h(
1882
+ "p",
1883
+ null,
1884
+ "Host-wide settings for the Qwen3 ASR + TTS server. Enter any HTTP or HTTPS base URL reachable from the DSH host. The browser accesses it through authenticated DSH routes."
1885
+ ),
1886
+ field("Qwen API base URL", "baseUrl"),
1887
+ field("Request timeout (ms)", "timeoutMs", "number"),
1888
+ h(
1889
+ "div",
1890
+ { className: "dlv-settings-actions" },
1891
+ h(
1892
+ "button",
1893
+ { type: "button", disabled: busy || !loaded, onClick: () => run("save") },
1894
+ "Save Qwen settings"
1895
+ ),
1896
+ h(
1897
+ "button",
1898
+ { type: "button", disabled: busy || !loaded, onClick: () => run("test") },
1899
+ "Test Qwen server"
1900
+ ),
1901
+ h(
1902
+ "button",
1903
+ { type: "button", disabled: busy, onClick: () => run("load") },
1904
+ "Reload saved settings"
1905
+ )
1906
+ ),
1907
+ busy ? h("p", { role: "status" }, "Contacting DSH host\u2026") : null,
1908
+ message2 ? h("p", { role: "status" }, message2) : null,
1909
+ error ? h("p", { role: "alert" }, error) : null
1910
+ );
1911
+ };
1912
+ }
1913
+
1914
+ // src/client/components.ts
1915
+ function createComponents(React2) {
1916
+ const h = React2.createElement;
1917
+ const WhisperSettings = createWhisperSettings(React2);
1918
+ const QwenSettings = createQwenSettings(React2);
1919
+ function useController(controller) {
1920
+ const subscribe = React2.useCallback((listener) => controller.subscribe(listener), [controller]);
1921
+ const read = React2.useCallback(() => controller.getSnapshot(), [controller]);
1922
+ return React2.useSyncExternalStore(subscribe, read, read);
1923
+ }
1924
+ function Icon({ name }) {
1925
+ const common = {
1926
+ viewBox: "0 0 24 24",
1927
+ fill: "none",
1928
+ stroke: "currentColor",
1929
+ strokeWidth: 1.8,
1930
+ strokeLinecap: "round",
1931
+ strokeLinejoin: "round",
1932
+ "aria-hidden": true
1933
+ };
1934
+ const paths = {
1935
+ mic: "M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5M6 10v2a6 6 0 0 0 12 0v-2M12 18v4M8 22h8",
1936
+ speaker: "M3 9h4l6-5v16l-6-5H3V9M17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14",
1937
+ close: "M6 6l12 12M18 6L6 18",
1938
+ stop: "M6 6h12v12H6z",
1939
+ pause: "M8 5v14M16 5v14",
1940
+ play: "M7 4l13 8-13 8z",
1941
+ send: "M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3",
1942
+ speakerOff: "M3 9h4l6-5v16l-6-5H3V9M17 9l5 6M22 9l-5 6"
1943
+ };
1944
+ return h("svg", common, h("path", { d: paths[name] || paths.mic }));
1945
+ }
1946
+ function Button({
1947
+ label,
1948
+ icon,
1949
+ visibleLabel,
1950
+ title = label,
1951
+ className = "dlv-pill-button",
1952
+ ...props
1953
+ }) {
1954
+ return h(
1955
+ "button",
1956
+ {
1957
+ ...props,
1958
+ type: "button",
1959
+ className: "dlv-icon-button " + className,
1960
+ title,
1961
+ "aria-label": label
1962
+ },
1963
+ h(Icon, { name: icon }),
1964
+ visibleLabel ? h("span", { className: "dlv-toggle-state", "aria-hidden": true }, visibleLabel) : null
1965
+ );
1966
+ }
1967
+ function useActions(controller) {
1968
+ const [error, setError] = React2.useState("");
1969
+ const alive = React2.useRef(true);
1970
+ React2.useEffect(() => {
1971
+ alive.current = true;
1972
+ return () => {
1973
+ alive.current = false;
1974
+ };
1975
+ }, []);
1976
+ const invoke = (name, ...args) => {
1977
+ setError("");
1978
+ try {
1979
+ Promise.resolve(controller[name](...args)).catch((reason) => {
1980
+ if (alive.current) setError(reason instanceof Error ? reason.message : String(reason));
1981
+ });
1982
+ } catch (reason) {
1983
+ setError(reason instanceof Error ? reason.message : String(reason));
1984
+ }
1985
+ };
1986
+ return [invoke, error, () => setError("")];
1987
+ }
1988
+ function ErrorText({ error, onDismiss }) {
1989
+ return error ? h(
1990
+ "div",
1991
+ { className: "dlv-error", role: "alert" },
1992
+ String(error),
1993
+ onDismiss ? h(
1994
+ "button",
1995
+ { type: "button", "aria-label": "Dismiss voice error", onClick: onDismiss },
1996
+ "Dismiss"
1997
+ ) : null
1998
+ ) : null;
1999
+ }
2000
+ function MicrophoneButtons({ controller }) {
2001
+ const state = useController(controller);
2002
+ const [invoke, error, clearError] = useActions(controller);
2003
+ const busy = state.conversation || state.listening || state.starting || state.recognizing;
2004
+ if (busy) return null;
2005
+ const recognition = state.capabilities?.recognition;
2006
+ const capture = state.capabilities?.capture;
2007
+ const pending = !recognition || !capture;
2008
+ const unavailable = recognition?.supported === false || capture?.supported === false;
2009
+ const reason = capture?.supported === false ? capture.reason : recognition?.reason;
2010
+ return h(
2011
+ React2.Fragment,
2012
+ null,
2013
+ h(Button, {
2014
+ className: "dlv-mic",
2015
+ icon: "mic",
2016
+ label: pending ? "Checking microphone availability" : unavailable ? reason || "Speech recognition unavailable" : "Start voice conversation",
2017
+ disabled: pending,
2018
+ onClick: () => unavailable ? invoke("explainRecognition") : invoke("startConversation")
2019
+ }),
2020
+ h(ErrorText, { error, onDismiss: clearError })
2021
+ );
2022
+ }
2023
+ function Waveform({ controller, enabled }) {
2024
+ const ref = React2.useRef(null);
2025
+ React2.useEffect(() => {
2026
+ const canvas = ref.current;
2027
+ const context = canvas?.getContext("2d");
2028
+ if (!context) return void 0;
2029
+ let frame = 0;
2030
+ let disposed = false;
2031
+ let width = 1;
2032
+ let height = 40;
2033
+ let ratio = 1;
2034
+ const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
2035
+ function resize() {
2036
+ const bounds = canvas.getBoundingClientRect();
2037
+ width = Math.max(1, bounds.width);
2038
+ height = Math.max(1, bounds.height || 40);
2039
+ ratio = Math.max(1, window.devicePixelRatio || 1);
2040
+ canvas.width = Math.round(width * ratio);
2041
+ canvas.height = Math.round(height * ratio);
2042
+ }
2043
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
2044
+ observer?.observe(canvas);
2045
+ window.addEventListener("resize", resize);
2046
+ resize();
2047
+ function draw(time) {
2048
+ if (disposed) return;
2049
+ if (ratio !== Math.max(1, window.devicePixelRatio || 1)) resize();
2050
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
2051
+ context.clearRect(0, 0, width, height);
2052
+ const raw = Number(controller.meter?.level?.() ?? 0);
2053
+ const level = enabled && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0;
2054
+ const color = getComputedStyle(canvas).color;
2055
+ for (let layer = 0; layer < 3; layer += 1) {
2056
+ context.beginPath();
2057
+ context.strokeStyle = layer === 1 ? "#38bdf8" : color;
2058
+ context.globalAlpha = 0.4 + layer * 0.25;
2059
+ context.lineWidth = layer === 2 ? 2 : 1;
2060
+ const phase = motion?.matches ? 0 : time / (500 + layer * 170);
2061
+ for (let x = 0; x <= width; x += 2) {
2062
+ const envelope = Math.sin(Math.PI * x / width);
2063
+ const y = height / 2 + Math.sin(x / width * Math.PI * (4 + layer * 2) + phase) * envelope * level * height * (0.43 - layer * 0.08);
2064
+ if (x === 0) context.moveTo(x, y);
2065
+ else context.lineTo(x, y);
2066
+ }
2067
+ context.stroke();
2068
+ }
2069
+ context.globalAlpha = 1;
2070
+ frame = window.requestAnimationFrame(draw);
2071
+ }
2072
+ frame = window.requestAnimationFrame(draw);
2073
+ return () => {
2074
+ disposed = true;
2075
+ window.cancelAnimationFrame(frame);
2076
+ observer?.disconnect();
2077
+ window.removeEventListener("resize", resize);
2078
+ };
2079
+ }, [controller, enabled]);
2080
+ return h("canvas", { ref, className: "dlv-wave", "aria-hidden": true });
2081
+ }
2082
+ function RecordingBar({ controller }) {
2083
+ const state = useController(controller);
2084
+ const [now, setNow] = React2.useState(Date.now());
2085
+ React2.useEffect(() => {
2086
+ if (!state.autoSendAt) return;
2087
+ setNow(Date.now());
2088
+ const timer = setInterval(() => setNow(Date.now()), 200);
2089
+ return () => clearInterval(timer);
2090
+ }, [state.autoSendAt]);
2091
+ const [invoke, error, clearError] = useActions(controller);
2092
+ const capture = state.starting || state.listening || state.recognizing;
2093
+ if (!state.conversation && !capture && !state.speaking && !state.paused && !state.error && !error)
2094
+ return null;
2095
+ const remaining = state.autoSendAt ? Math.max(1, Math.ceil((state.autoSendAt - now) / 1e3)) : null;
2096
+ const status = remaining ? `Sending in ${remaining}\u2026` : state.starting ? "Starting microphone\u2026" : state.paused ? "Speech paused" : state.speaking ? "Speaking" : state.recognizing ? "Recognizing speech\u2026" : state.listening ? "Listening \u2014 waiting for speech" : state.conversation ? "Conversation idle" : "Voice ready";
2097
+ return h(
2098
+ "div",
2099
+ { className: "dlv-bar-wrap" },
2100
+ h(
2101
+ "div",
2102
+ { className: "dlv-pill", role: "group", "aria-label": "Voice controls" },
2103
+ capture && !state.conversation ? h(Button, {
2104
+ label: "Cancel dictation",
2105
+ icon: "close",
2106
+ onClick: () => invoke("cancelDictation")
2107
+ }) : null,
2108
+ state.conversation ? h(Button, {
2109
+ label: "End voice conversation",
2110
+ icon: "close",
2111
+ onClick: () => invoke("endConversation")
2112
+ }) : null,
2113
+ h(Waveform, { controller, enabled: Boolean(state.listening) }),
2114
+ h("span", { className: "dlv-status", role: "status", "aria-live": "polite" }, status),
2115
+ h(Button, {
2116
+ className: "dlv-live-toggle",
2117
+ label: "Automatic sending",
2118
+ title: `Automatic sending: ${state.settings.sendingMode === "automatic" ? "on" : "off"}`,
2119
+ icon: "send",
2120
+ visibleLabel: state.settings.sendingMode === "automatic" ? "ON" : "OFF",
2121
+ role: "switch",
2122
+ "aria-checked": state.settings.sendingMode === "automatic",
2123
+ onClick: () => invoke("updateSettings", {
2124
+ sendingMode: state.settings.sendingMode === "automatic" ? "manual" : "automatic"
2125
+ })
2126
+ }),
2127
+ h(Button, {
2128
+ className: "dlv-live-toggle",
2129
+ label: "Automatic assistant speech",
2130
+ title: `Automatic assistant speech: ${state.settings.announceAssistantMessages !== false ? "on" : "off"}`,
2131
+ icon: state.settings.announceAssistantMessages !== false ? "speaker" : "speakerOff",
2132
+ visibleLabel: state.settings.announceAssistantMessages !== false ? "ON" : "OFF",
2133
+ role: "switch",
2134
+ "aria-checked": state.settings.announceAssistantMessages !== false,
2135
+ onClick: () => invoke("updateSettings", {
2136
+ announceAssistantMessages: state.settings.announceAssistantMessages === false
2137
+ })
2138
+ }),
2139
+ remaining ? h(Button, {
2140
+ label: "Cancel automatic send",
2141
+ icon: "close",
2142
+ onClick: () => invoke("cancelAutoSend")
2143
+ }) : null,
2144
+ state.conversation && !capture ? h(Button, {
2145
+ label: "Take microphone",
2146
+ icon: "mic",
2147
+ onClick: () => invoke("startConversation")
2148
+ }) : null,
2149
+ capture ? h(Button, {
2150
+ label: "Stop listening",
2151
+ icon: "stop",
2152
+ onClick: () => invoke("stopListening")
2153
+ }) : null,
2154
+ state.speaking && !state.paused && state.capabilities[state.settings.engine]?.pause ? h(Button, {
2155
+ label: "Pause speech",
2156
+ icon: "pause",
2157
+ onClick: () => invoke("pauseSpeech")
2158
+ }) : null,
2159
+ state.paused && state.capabilities[state.settings.engine]?.resume ? h(Button, {
2160
+ label: "Resume speech",
2161
+ icon: "play",
2162
+ onClick: () => invoke("resumeSpeech")
2163
+ }) : null,
2164
+ state.speaking || state.paused ? h(Button, {
2165
+ label: "Stop all speech",
2166
+ icon: "stop",
2167
+ onClick: () => invoke("stopSpeech")
2168
+ }) : null
2169
+ ),
2170
+ h(ErrorText, {
2171
+ error: error || state.error,
2172
+ onDismiss: () => {
2173
+ clearError();
2174
+ controller.clearError();
2175
+ }
2176
+ })
2177
+ );
2178
+ }
2179
+ function SpeakButton({ active = false, disabled = false, label, onClick }) {
2180
+ return h(Button, {
2181
+ className: "dlv-speaker",
2182
+ label: label || (active ? "Stop speaking" : "Speak message"),
2183
+ icon: active ? "stop" : "speaker",
2184
+ "aria-pressed": Boolean(active),
2185
+ disabled,
2186
+ onClick
2187
+ });
2188
+ }
2189
+ function SettingsPanel({ controller, onClose }) {
2190
+ const state = useController(controller);
2191
+ const [invoke, error, clearError] = useActions(controller);
2192
+ const settings = state.settings || {};
2193
+ const capabilities = state.capabilities || {};
2194
+ const field = (label, key, options) => h(
2195
+ "label",
2196
+ { key },
2197
+ label,
2198
+ h(
2199
+ "select",
2200
+ {
2201
+ value: settings[key] || options[0].value,
2202
+ onChange: (event) => invoke(
2203
+ "updateSettings",
2204
+ key === "engine" ? { engine: event.target.value, voice: "" } : { [key]: event.target.value }
2205
+ )
2206
+ },
2207
+ options.map(
2208
+ (option) => h(
2209
+ "option",
2210
+ { key: option.value, value: option.value, disabled: option.disabled },
2211
+ option.label
2212
+ )
2213
+ )
2214
+ )
2215
+ );
2216
+ const card = (title, children, open = false) => h(
2217
+ "details",
2218
+ { className: "dlv-settings-card", open },
2219
+ h("summary", null, title),
2220
+ h("div", { className: "dlv-settings-card-body" }, ...children)
2221
+ );
2222
+ const subcard = (title, children, open = false) => h(
2223
+ "details",
2224
+ { className: "dlv-settings-subcard", open },
2225
+ h("summary", null, title),
2226
+ h("div", { className: "dlv-settings-subcard-body" }, ...children)
2227
+ );
2228
+ return h(
2229
+ "section",
2230
+ { className: "dlv-settings", "aria-label": "Live Voice settings" },
2231
+ h("h3", null, "Live Voice"),
2232
+ onClose ? h(Button, { label: "Close voice settings", icon: "close", onClick: onClose }) : null,
2233
+ h(
2234
+ "details",
2235
+ { className: "dlv-settings-card" },
2236
+ h("summary", null, "Speech output"),
2237
+ h(
2238
+ "div",
2239
+ { className: "dlv-settings-card-body" },
2240
+ field("Speech engine", "engine", [
2241
+ {
2242
+ value: "qwen-http",
2243
+ label: "Qwen3 TTS \u2014 local MLX server",
2244
+ disabled: capabilities["qwen-http"]?.supported === false
2245
+ },
2246
+ {
2247
+ value: "say",
2248
+ label: "macOS say \u2014 host audio",
2249
+ disabled: capabilities.say?.supported === false
2250
+ },
2251
+ {
2252
+ value: "browser",
2253
+ label: "Browser speech \u2014 device audio",
2254
+ disabled: capabilities.browser?.supported === false
2255
+ }
2256
+ ]),
2257
+ settings.engine === "browser" ? field("Local browser voice", "voice", [
2258
+ { value: "", label: "Automatic local voice" },
2259
+ ...(capabilities.browser?.voices || []).map((voice) => ({
2260
+ value: voice.voiceURI || voice.name,
2261
+ label: `${voice.name} \u2014 ${voice.lang || "unknown language"}`
2262
+ }))
2263
+ ]) : settings.engine === "say" ? h(
2264
+ "label",
2265
+ null,
2266
+ "macOS say voice (empty uses system default)",
2267
+ h("input", {
2268
+ type: "text",
2269
+ value: settings.voice || "",
2270
+ onChange: (event) => invoke("updateSettings", { voice: event.target.value }),
2271
+ autoComplete: "off"
2272
+ })
2273
+ ) : null,
2274
+ settings.engine === "qwen-http" ? h(
2275
+ React2.Fragment,
2276
+ null,
2277
+ field("Qwen voice", "voice", qwenVoices),
2278
+ h(
2279
+ "small",
2280
+ null,
2281
+ `Aiden is used by default. These preset voices are not native Brazilian Portuguese voices.`
2282
+ ),
2283
+ subcard("Qwen server connection", [
2284
+ h(QwenSettings, { key: "qwen-output-settings", controller })
2285
+ ])
2286
+ ) : null,
2287
+ h(
2288
+ "label",
2289
+ null,
2290
+ "Speech rate",
2291
+ h("input", {
2292
+ type: "number",
2293
+ min: 0.1,
2294
+ max: 3,
2295
+ step: 0.1,
2296
+ value: settings.rate ?? 1,
2297
+ onChange: (event) => {
2298
+ const rate = Number(event.target.value);
2299
+ if (Number.isFinite(rate) && rate >= 0.1 && rate <= 3)
2300
+ invoke("updateSettings", { rate });
2301
+ }
2302
+ }),
2303
+ h("small", null, "Relative speed: 1 is normal.")
2304
+ ),
2305
+ h(
2306
+ "p",
2307
+ null,
2308
+ "Qwen synthesis runs on the DSH host and the generated WAV plays in this browser. macOS say plays on the host; Browser speech plays on this device."
2309
+ )
2310
+ )
2311
+ ),
2312
+ h(
2313
+ "details",
2314
+ { className: "dlv-settings-card" },
2315
+ h("summary", null, "Speech recognition"),
2316
+ h(
2317
+ "div",
2318
+ { className: "dlv-settings-card-body" },
2319
+ h(
2320
+ "label",
2321
+ null,
2322
+ "Recognition engine",
2323
+ h(
2324
+ "select",
2325
+ {
2326
+ value: settings.recognitionEngine,
2327
+ onChange: (event) => invoke(
2328
+ "updateSettings",
2329
+ event.target.value === "browser" && settings.recognitionLang === "auto" ? { recognitionEngine: "browser", recognitionLang: "pt-BR" } : { recognitionEngine: event.target.value }
2330
+ )
2331
+ },
2332
+ h("option", { value: "qwen-http" }, "Qwen3 ASR \u2014 local MLX server"),
2333
+ h("option", { value: "browser" }, "Browser SpeechRecognition"),
2334
+ h("option", { value: "whisper-http" }, "Whisper HTTP \u2014 DSH host")
2335
+ )
2336
+ ),
2337
+ field("Recognition language", "recognitionLang", [
2338
+ ...settings.recognitionEngine !== "browser" ? [{ value: "auto", label: "Automatic \u2014 detect language" }] : [],
2339
+ { value: "pt-BR", label: "Portugu\xEAs (Brasil)" },
2340
+ { value: "en-US", label: "English (United States)" }
2341
+ ]),
2342
+ settings.recognitionEngine === "browser" ? h(
2343
+ React2.Fragment,
2344
+ null,
2345
+ h(
2346
+ "label",
2347
+ { className: "dlv-check" },
2348
+ h("input", {
2349
+ type: "checkbox",
2350
+ checked: settings.recognitionProcessLocally !== false,
2351
+ onChange: (event) => invoke("updateSettings", { recognitionProcessLocally: event.target.checked })
2352
+ }),
2353
+ " Process recognition locally on this device"
2354
+ ),
2355
+ settings.recognitionProcessLocally !== false ? h(
2356
+ "label",
2357
+ { className: "dlv-check" },
2358
+ h("input", {
2359
+ type: "checkbox",
2360
+ checked: settings.recognitionAutoInstall !== false,
2361
+ onChange: (event) => invoke("updateSettings", {
2362
+ recognitionAutoInstall: event.target.checked
2363
+ })
2364
+ }),
2365
+ " Automatically install this browser language pack when needed"
2366
+ ) : h(
2367
+ "p",
2368
+ { role: "status" },
2369
+ "Browser-service recognition is enabled. The browser may send microphone audio to its recognition service."
2370
+ )
2371
+ ) : h(
2372
+ "p",
2373
+ { role: "status" },
2374
+ settings.recognitionEngine === "qwen-http" ? "Audio is segmented into complete WAV utterances and sent through authenticated DSH to the host-local Qwen3 ASR model." : "Audio is segmented into complete WAV utterances, sent through authenticated DSH, and processed by loopback whisper.cpp HTTP."
2375
+ ),
2376
+ settings.recognitionEngine === "whisper-http" ? subcard("Connection settings", [h(WhisperSettings, { key: "settings", controller })]) : null,
2377
+ settings.recognitionEngine === "qwen-http" && settings.engine !== "qwen-http" ? subcard("Qwen server connection", [
2378
+ h(QwenSettings, { key: "qwen-recognition-settings", controller })
2379
+ ]) : null,
2380
+ h("p", null, "Provider settings change with the selected recognition engine."),
2381
+ usesPluginVoiceDetection(settings.recognitionEngine) ? h(
2382
+ "details",
2383
+ { className: "dlv-settings-subcard", "aria-label": "Silence detection settings" },
2384
+ h("summary", null, "Silence detection"),
2385
+ h(
2386
+ "div",
2387
+ { className: "dlv-settings-subcard-body" },
2388
+ h(
2389
+ "p",
2390
+ null,
2391
+ "Controls how long a pause must last before captured speech is sent for recognition."
2392
+ ),
2393
+ h(
2394
+ "div",
2395
+ {
2396
+ className: "dlv-preset-group",
2397
+ role: "radiogroup",
2398
+ "aria-label": "Pause before sending"
2399
+ },
2400
+ Object.entries(voiceDetectionPresets).map(
2401
+ ([value, preset]) => h(
2402
+ "label",
2403
+ { key: value, className: "dlv-preset" },
2404
+ h("input", {
2405
+ type: "radio",
2406
+ name: "dlv-vad-preset",
2407
+ value,
2408
+ checked: (settings.voiceDetectionPreset || "natural") === value,
2409
+ onChange: () => invoke("updateSettings", { voiceDetectionPreset: value })
2410
+ }),
2411
+ h(
2412
+ "span",
2413
+ null,
2414
+ h("strong", null, preset.label),
2415
+ h("small", null, preset.description)
2416
+ )
2417
+ )
2418
+ )
2419
+ ),
2420
+ h(
2421
+ "p",
2422
+ { className: "dlv-vad-summary" },
2423
+ "Pause before sending: " + (voiceDetectionPresets[settings.voiceDetectionPreset]?.silenceMs || voiceDetectionPresets.natural.silenceMs) + " ms"
2424
+ )
2425
+ )
2426
+ ) : null
2427
+ )
2428
+ ),
2429
+ card(
2430
+ "Conversation",
2431
+ [
2432
+ h(
2433
+ "label",
2434
+ { key: "announce", className: "dlv-check" },
2435
+ h("input", {
2436
+ type: "checkbox",
2437
+ checked: settings.announceAssistantMessages !== false,
2438
+ onChange: (event) => invoke("updateSettings", { announceAssistantMessages: event.target.checked })
2439
+ }),
2440
+ " Automatically speak new assistant messages"
2441
+ ),
2442
+ h(
2443
+ "p",
2444
+ { key: "policy" },
2445
+ "During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking."
2446
+ ),
2447
+ h(
2448
+ "label",
2449
+ { key: "interrupt-message", className: "dlv-check" },
2450
+ h("input", {
2451
+ type: "checkbox",
2452
+ checked: settings.interruptSpeechOnUserMessage === true,
2453
+ onChange: (event) => invoke("updateSettings", { interruptSpeechOnUserMessage: event.target.checked })
2454
+ }),
2455
+ " Stop assistant speech when I send a message"
2456
+ ),
2457
+ h(
2458
+ "p",
2459
+ { key: "interrupt-message-description", className: "dlv-setting-description" },
2460
+ settings.interruptSpeechOnUserMessage ? "Sending or steering a new user message stops current or paused assistant speech." : "Sending another message does not stop the assistant audio you are already hearing."
2461
+ ),
2462
+ field("Listening mode", "mode", [
2463
+ { value: "speaker", label: "Speakers \u2014 gated listening" },
2464
+ { value: "headphones", label: "Headphones \u2014 open microphone" }
2465
+ ]),
2466
+ h(
2467
+ "p",
2468
+ { key: "mode-description", className: "dlv-setting-description" },
2469
+ settings.mode === "headphones" ? "Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose." : "Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt."
2470
+ ),
2471
+ h(
2472
+ "label",
2473
+ { key: "speech-delay" },
2474
+ "Assistant response delay",
2475
+ h(
2476
+ "select",
2477
+ {
2478
+ value: String(settings.assistantSpeechDelaySeconds || 3),
2479
+ onChange: (event) => invoke("updateSettings", {
2480
+ assistantSpeechDelaySeconds: Number(event.target.value)
2481
+ })
2482
+ },
2483
+ [1, 2, 3, 4, 5, 6, 8, 10].map(
2484
+ (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2485
+ )
2486
+ ),
2487
+ h(
2488
+ "small",
2489
+ null,
2490
+ "After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait."
2491
+ )
2492
+ ),
2493
+ field("Sending mode", "sendingMode", [
2494
+ { value: "manual", label: "Manual \u2014 review and send" },
2495
+ { value: "automatic", label: "Automatic \u2014 send after silence" }
2496
+ ]),
2497
+ settings.sendingMode === "automatic" ? h(
2498
+ "label",
2499
+ { key: "delay" },
2500
+ "Send after silence",
2501
+ h(
2502
+ "select",
2503
+ {
2504
+ value: String(settings.autoSendDelaySeconds || 4),
2505
+ onChange: (event) => invoke("updateSettings", {
2506
+ autoSendDelaySeconds: Number(event.target.value)
2507
+ })
2508
+ },
2509
+ [2, 3, 4, 5, 6, 8, 10].map(
2510
+ (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2511
+ )
2512
+ ),
2513
+ h(
2514
+ "small",
2515
+ null,
2516
+ "Countdown starts after a final recognized phrase. New speech or edits cancel it."
2517
+ )
2518
+ ) : h(
2519
+ "p",
2520
+ { key: "manual", className: "dlv-setting-description" },
2521
+ "Recognized text stays in the composer until you use the normal DSH Send control."
2522
+ )
2523
+ ],
2524
+ false
2525
+ ),
2526
+ capabilities.capture?.supported === false ? h("p", { role: "status" }, `Microphone: ${capabilities.capture.reason}`) : capabilities.capture?.permission === "prompt" ? h(
2527
+ "p",
2528
+ { role: "status" },
2529
+ "Microphone permission will be requested only when you start dictation or a voice conversation."
2530
+ ) : null,
2531
+ capabilities.recognition?.supported === false ? h("p", { role: "status" }, capabilities.recognition.reason) : null,
2532
+ ...["qwen-http", "say", "browser"].filter((id2) => capabilities[id2]?.supported === false).map(
2533
+ (id2) => h(
2534
+ "p",
2535
+ { key: id2, role: "status" },
2536
+ `${id2 === "qwen-http" ? "Qwen3 local" : id2 === "say" ? "macOS say" : "Browser speech"}: ${capabilities[id2].reason}`
2537
+ )
2538
+ ),
2539
+ h(
2540
+ "div",
2541
+ { className: "dlv-settings-actions" },
2542
+ h(
2543
+ "button",
2544
+ {
2545
+ type: "button",
2546
+ disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
2547
+ onClick: () => invoke("speak", "DSH Live Voice. The selected speech output is working.")
2548
+ },
2549
+ state.speaking ? "Testing speech\u2026" : "Test selected speech output"
2550
+ ),
2551
+ state.speaking || state.paused ? h("button", { type: "button", onClick: () => invoke("stopSpeech") }, "Stop speech test") : null,
2552
+ h(
2553
+ "button",
2554
+ { type: "button", onClick: () => invoke("refreshCapabilities") },
2555
+ "Refresh available engines"
2556
+ )
2557
+ ),
2558
+ h(ErrorText, {
2559
+ error: error || state.error,
2560
+ onDismiss: () => {
2561
+ clearError();
2562
+ controller.clearError();
2563
+ }
2564
+ })
2565
+ );
2566
+ }
2567
+ return { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel };
2568
+ }
2569
+
2570
+ // src/client/styles.ts
2571
+ var styles = `
2572
+ .dlv-icon-button{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;flex-shrink:0;cursor:pointer;background:transparent;color:var(--dsw-alias-label-secondary);padding:0;font:inherit}
2573
+ .dlv-icon-button svg{display:block;width:20px;height:20px}
2574
+ .dlv-mic{width:30px;height:30px;border:1px solid var(--dsw-alias-border-l1);border-radius:50%}
2575
+ .dlv-mic:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
2576
+ .dlv-speaker{width:28px;height:28px;border:0;border-radius:28px;padding:5px;color:var(--dsw-alias-label-tertiary)}
2577
+ .dlv-speaker:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
2578
+ .dlv-speaker[aria-pressed=true]{color:var(--dsw-alias-label-primary)}
2579
+ .dlv-icon-button:disabled{opacity:.4;cursor:default}
2580
+ .dlv-icon-button:focus-visible,.dlv-settings :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}
2581
+ .dlv-bar-wrap{width:100%;min-width:0}
2582
+ .dlv-pill{display:flex;align-items:center;box-sizing:border-box;gap:10px;min-height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18)}
2583
+ .dlv-pill-button{width:34px;height:34px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
2584
+ .dlv-pill-button:hover{background:var(--dsw-alias-bg-layer-2)}
2585
+ .dlv-live-toggle{width:auto;min-width:58px;height:34px;padding:0 9px;gap:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:17px;color:var(--dsw-alias-label-primary)}
2586
+ .dlv-live-toggle svg{width:18px;height:18px}
2587
+ .dlv-toggle-state{min-width:22px;font-size:10px;font-weight:700;line-height:1;letter-spacing:.04em;text-align:left}
2588
+ .dlv-live-toggle:hover{background:var(--dsw-alias-bg-layer-2)}
2589
+ .dlv-live-toggle[aria-checked=false]{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l1);opacity:.72}
2590
+ .dlv-live-toggle[aria-checked=true]{background:var(--dsw-alias-bg-layer-2)}
2591
+ .dlv-wave{display:block;flex:1 1 180px;min-width:30px;width:100%;height:40px;color:var(--dsw-alias-label-primary)}
2592
+ .dlv-status{flex:1 1 120px;min-width:0;font-size:13px;line-height:1.4;color:var(--dsw-alias-label-secondary)}
2593
+ .dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
2594
+ .dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
2595
+ .dlv-settings h3,.dlv-settings p{margin:0}
2596
+ .dlv-settings-group,.dlv-settings-card{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0}.dlv-settings-group{display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card>summary,.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:12px}.dlv-settings-card>summary::-webkit-details-marker,.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-card>summary:after,.dlv-settings-subcard>summary:after{content:"\u203A";transform:rotate(90deg);transition:transform .15s}.dlv-settings-card:not([open])>summary:after,.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-card-body{display:grid;gap:12px;padding:0 12px 12px}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{padding:10px}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
2597
+ .dlv-settings label{display:grid;gap:6px;font-size:14px}.dlv-settings label.dlv-check{display:flex;align-items:center;gap:8px}.dlv-settings label.dlv-check input{width:auto}
2598
+ .dlv-settings :is(input,select){box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit}
2599
+ .dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
2600
+ .dlv-preset-group{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
2601
+ .dlv-settings-actions{display:flex;flex-wrap:wrap;gap:8px}.dlv-settings-actions button{padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}.dlv-settings-actions button:disabled{opacity:.45;cursor:default}
2602
+ @media(max-width:480px){.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
2603
+ `;
2604
+
2605
+ // src/client/chat.ts
2606
+ function assistantMessages(snapshot) {
2607
+ const nodes = snapshot?.nodes?.values?.() || [];
2608
+ return [...nodes].filter(
2609
+ (node) => (node.kind === "assistant-step" || node.kind === "assistant") && node.visibility !== "hidden"
2610
+ ).map((node) => {
2611
+ const data = node.data;
2612
+ return {
2613
+ id: String(data.turn) + ":" + String(data.step),
2614
+ messageId: data.finalNode?.messageId,
2615
+ turn: data.turn,
2616
+ step: data.step,
2617
+ complete: data.status !== "running",
2618
+ interrupted: data.status === "interrupted",
2619
+ text: (data.blocks || []).filter((block) => block.kind === "text").map((block) => block.text || "").join("")
2620
+ };
2621
+ }).sort((a, b) => a.turn - b.turn || a.step - b.step);
2622
+ }
2623
+ function latestUserSequence(snapshot) {
2624
+ return Math.max(
2625
+ -1,
2626
+ ...[...snapshot?.nodes?.values?.() || []].filter((node) => node.kind === "user" || node.kind === "steering").map((node) => Number(node.anchorSeq ?? node.data?.seq ?? -1))
2627
+ );
2628
+ }
2629
+ function addressedTurn(messages, messageId) {
2630
+ const addressed = messages.find((message2) => String(message2.messageId) === String(messageId));
2631
+ if (!addressed) return { text: "", ids: [] };
2632
+ return { text: addressed.text, id: addressed.id };
2633
+ }
2634
+
2635
+ // src/client/index.ts
2636
+ var inject = ["slots", "connection", "uiConversation"];
2637
+ function apply(ctx) {
2638
+ const e = import_react.default.createElement;
2639
+ const { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel } = createComponents(import_react.default);
2640
+ const controllers = /* @__PURE__ */ new Map();
2641
+ const retiring = /* @__PURE__ */ new Set();
2642
+ let disposed = false;
2643
+ const ownership = new VoiceOwnership();
2644
+ const recognitionFor = (settings, meter) => settings.recognitionEngine === "qwen-http" ? new QwenHttpRecognitionEngine({
2645
+ meter,
2646
+ voiceDetectionPreset: settings.voiceDetectionPreset
2647
+ }) : settings.recognitionEngine === "whisper-http" ? new WhisperHttpRecognitionEngine({
2648
+ meter,
2649
+ voiceDetectionPreset: settings.voiceDetectionPreset
2650
+ }) : new BrowserRecognitionEngine({
2651
+ processLocally: settings.recognitionProcessLocally,
2652
+ autoInstallLocalPack: settings.recognitionAutoInstall
2653
+ });
2654
+ const run = (controller, promise) => Promise.resolve(promise).catch((error) => {
2655
+ if (!disposed && !controller.disposed)
2656
+ controller.patch({ error: error?.message || String(error) });
2657
+ });
2658
+ function retire(entry) {
2659
+ if (entry.closed) return;
2660
+ entry.closed = true;
2661
+ entry.request++;
2662
+ entry.composers.clear();
2663
+ entry.unsubscribe?.();
2664
+ entry.unsubscribe = null;
2665
+ entry.chatListeners.clear();
2666
+ if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
2667
+ const done = run(entry.controller, entry.controller.dispose());
2668
+ const barrier = { endConversation: () => done };
2669
+ retiring.add(barrier);
2670
+ void done.finally(() => retiring.delete(barrier));
2671
+ }
2672
+ function get(sessionId) {
2673
+ const key = String(sessionId);
2674
+ if (controllers.has(key)) return controllers.get(key);
2675
+ const entry = {
2676
+ key,
2677
+ draft: "",
2678
+ pendingDraft: void 0,
2679
+ composers: /* @__PURE__ */ new Map(),
2680
+ refs: 0,
2681
+ buttons: 0,
2682
+ request: 0,
2683
+ closed: false
2684
+ };
2685
+ let settings = {};
2686
+ try {
2687
+ settings = normalizeSettings(
2688
+ JSON.parse(localStorage.getItem("dsh-live-voice.settings") || "{}")
2689
+ );
2690
+ } catch {
2691
+ settings = normalizeSettings(null);
2692
+ }
2693
+ const engineBrowser = new BrowserSpeakingEngine({ lang: settings.lang || "pt-BR" });
2694
+ const engineSay = new SayClientEngine({ rpc: ctx.connection.rpc });
2695
+ const engineQwen = new QwenHttpSpeakingEngine({ lang: settings.lang || "pt-BR" });
2696
+ const meter = new MicrophoneMeter();
2697
+ const recognition = recognitionFor(settings, meter);
2698
+ entry.controller = new VoiceCoordinator({
2699
+ recognition,
2700
+ engines: { browser: engineBrowser, say: engineSay, "qwen-http": engineQwen },
2701
+ meter,
2702
+ composer: {
2703
+ getDraft: () => entry.draft,
2704
+ submit: () => {
2705
+ const owner = [...entry.composers.values()].at(-1);
2706
+ owner?.actions.submit?.();
2707
+ },
2708
+ setDraft: (text) => {
2709
+ if (disposed || entry.closed) return;
2710
+ const owner = [...entry.composers.values()].at(-1);
2711
+ if (!owner) return;
2712
+ entry.draft = text;
2713
+ entry.pendingDraft = text;
2714
+ owner.actions.setDraft(text);
2715
+ }
2716
+ },
2717
+ settings
2718
+ });
2719
+ const controller = entry.controller;
2720
+ entry.chat = ctx.uiConversation.binding(sessionId).target("chat");
2721
+ entry.chatListeners = /* @__PURE__ */ new Set();
2722
+ entry.subscribeChat = (listener) => {
2723
+ if (entry.closed) return () => {
2724
+ };
2725
+ entry.chatListeners.add(listener);
2726
+ return () => entry.chatListeners.delete(listener);
2727
+ };
2728
+ entry.readChat = entry.chat.getSnapshot.bind(entry.chat);
2729
+ const refresh = (baseline = false) => {
2730
+ if (disposed || entry.closed) return;
2731
+ const snapshot = entry.readChat();
2732
+ const userSeq = latestUserSequence(snapshot);
2733
+ if (!baseline && userSeq > entry.userSeq && controller.getSnapshot().settings.interruptSpeechOnUserMessage && (controller.getSnapshot().speaking || controller.getSnapshot().paused))
2734
+ run(controller, controller.stopSpeech());
2735
+ entry.userSeq = Math.max(entry.userSeq ?? -1, userSeq);
2736
+ for (const message2 of assistantMessages(snapshot))
2737
+ controller.observeMessage(message2.id, message2.text, {
2738
+ complete: message2.complete,
2739
+ baseline: baseline || message2.interrupted
2740
+ });
2741
+ };
2742
+ refresh(true);
2743
+ entry.unsubscribe = entry.chat.subscribe(() => {
2744
+ refresh();
2745
+ for (const listener of entry.chatListeners) listener();
2746
+ });
2747
+ const update = controller.updateSettings.bind(controller);
2748
+ controller.updateSettings = (next) => {
2749
+ if (disposed || entry.closed) return;
2750
+ update(next);
2751
+ engineBrowser.lang = controller.getSnapshot().settings.lang;
2752
+ engineQwen.lang = controller.getSnapshot().settings.lang;
2753
+ try {
2754
+ localStorage.setItem(
2755
+ "dsh-live-voice.settings",
2756
+ JSON.stringify(controller.getSnapshot().settings)
2757
+ );
2758
+ } catch {
2759
+ }
2760
+ run(controller, controller.refreshCapabilities());
2761
+ };
2762
+ for (const method of ["stopListening", "cancelDictation", "endConversation", "stopSpeech"]) {
2763
+ const original = controller[method].bind(controller);
2764
+ controller[method] = (...args) => {
2765
+ entry.request++;
2766
+ return original(...args);
2767
+ };
2768
+ }
2769
+ for (const method of ["startDictation", "startConversation", "speak"]) {
2770
+ const original = controller[method].bind(controller);
2771
+ controller[method] = (...args) => {
2772
+ if (disposed || entry.closed || !entry.refs) return Promise.resolve();
2773
+ if (method !== "speak" && !entry.composers.size) return Promise.resolve();
2774
+ const request = ++entry.request;
2775
+ return ownership.run(
2776
+ controller,
2777
+ [...controllers.values()].map((other) => other.controller).concat([...retiring]),
2778
+ () => {
2779
+ if (disposed || entry.closed || !entry.refs || request !== entry.request) return;
2780
+ if (method !== "speak" && !entry.composers.size) return;
2781
+ return original(...args);
2782
+ }
2783
+ );
2784
+ };
2785
+ }
2786
+ controllers.set(key, entry);
2787
+ run(controller, controller.refreshCapabilities());
2788
+ return entry;
2789
+ }
2790
+ function useEntry(sessionId, kind) {
2791
+ const [entry, setEntry] = import_react.default.useState(null);
2792
+ import_react.default.useLayoutEffect(() => {
2793
+ if (disposed) return;
2794
+ const current = get(sessionId);
2795
+ current.refs++;
2796
+ if (kind === "buttons") current.buttons++;
2797
+ setEntry(current);
2798
+ return () => {
2799
+ current.refs--;
2800
+ if (kind === "buttons") current.buttons--;
2801
+ if (!current.refs) current.request++;
2802
+ queueMicrotask(() => {
2803
+ if (!current.refs) retire(current);
2804
+ });
2805
+ };
2806
+ }, [sessionId, kind]);
2807
+ return entry?.key === String(sessionId) && !entry.closed ? entry : null;
2808
+ }
2809
+ function useComposer(entry, props) {
2810
+ const subscribedInput = props.useInput?.((value) => value);
2811
+ const input = subscribedInput ?? props.input;
2812
+ const token = import_react.default.useRef({});
2813
+ import_react.default.useLayoutEffect(() => {
2814
+ if (!entry || entry.closed || disposed) return;
2815
+ return () => {
2816
+ entry.composers.delete(token.current);
2817
+ if (!entry.composers.size) {
2818
+ entry.request++;
2819
+ queueMicrotask(() => {
2820
+ if (!entry.closed && !entry.composers.size)
2821
+ run(entry.controller, entry.controller.endConversation());
2822
+ });
2823
+ }
2824
+ };
2825
+ }, [entry]);
2826
+ import_react.default.useLayoutEffect(() => {
2827
+ if (!entry || entry.closed || disposed) return;
2828
+ if (!input || typeof props.inputActions?.setDraft !== "function") return;
2829
+ entry.composers.set(token.current, { actions: props.inputActions });
2830
+ }, [entry, input, props.inputActions]);
2831
+ import_react.default.useLayoutEffect(() => {
2832
+ if (!entry || entry.closed || disposed || !input) return;
2833
+ const published = typeof input.draft === "string" ? input.draft : "";
2834
+ if (entry.pendingDraft === void 0) entry.draft = published;
2835
+ else if (published === entry.pendingDraft) {
2836
+ entry.draft = published;
2837
+ entry.pendingDraft = void 0;
2838
+ }
2839
+ entry.controller.composerChanged(entry.draft);
2840
+ });
2841
+ }
2842
+ function Buttons(props) {
2843
+ const entry = useEntry(props.sessionId, "buttons");
2844
+ useComposer(entry, props);
2845
+ return entry ? e(MicrophoneButtons, { controller: entry.controller }) : null;
2846
+ }
2847
+ function Settings() {
2848
+ const [controller, setController] = import_react.default.useState(null);
2849
+ import_react.default.useEffect(() => {
2850
+ let settings;
2851
+ try {
2852
+ settings = normalizeSettings(
2853
+ JSON.parse(localStorage.getItem("dsh-live-voice.settings") || "{}")
2854
+ );
2855
+ } catch {
2856
+ settings = normalizeSettings(null);
2857
+ }
2858
+ const browser = new BrowserSpeakingEngine({ lang: settings.lang });
2859
+ const qwen = new QwenHttpSpeakingEngine({ lang: settings.lang });
2860
+ const meter = new MicrophoneMeter();
2861
+ const recognition = recognitionFor(settings, meter);
2862
+ const c = new VoiceCoordinator({
2863
+ recognition,
2864
+ engines: {
2865
+ browser,
2866
+ say: new SayClientEngine({ rpc: ctx.connection.rpc }),
2867
+ "qwen-http": qwen
2868
+ },
2869
+ meter,
2870
+ composer: { getDraft: () => "", setDraft: () => {
2871
+ } },
2872
+ settings
2873
+ });
2874
+ const speak = c.speak.bind(c);
2875
+ c.speak = (...args) => ownership.run(
2876
+ c,
2877
+ [...controllers.values()].map((entry) => entry.controller).concat([...retiring]),
2878
+ () => speak(...args)
2879
+ );
2880
+ const update = c.updateSettings.bind(c);
2881
+ let settingsRevision = 0;
2882
+ c.updateSettings = async (next) => {
2883
+ const revision = ++settingsRevision;
2884
+ const previousEngine = c.getSnapshot().settings.recognitionEngine;
2885
+ update(next);
2886
+ browser.lang = c.getSnapshot().settings.lang;
2887
+ qwen.lang = c.getSnapshot().settings.lang;
2888
+ const settings2 = c.getSnapshot().settings;
2889
+ if (previousEngine !== settings2.recognitionEngine)
2890
+ c.replaceRecognition(recognitionFor(settings2, c.meter));
2891
+ localStorage.setItem("dsh-live-voice.settings", JSON.stringify(settings2));
2892
+ run(c, c.refreshCapabilities());
2893
+ const active = [...controllers.values()];
2894
+ await Promise.allSettled([
2895
+ c.endConversation(),
2896
+ ...active.map((entry) => entry.controller.endConversation())
2897
+ ]);
2898
+ if (revision !== settingsRevision || disposed || c.disposed) return;
2899
+ for (const entry of controllers.values()) {
2900
+ if (entry.closed) continue;
2901
+ if (entry.controller.getSnapshot().settings.recognitionEngine !== settings2.recognitionEngine)
2902
+ entry.controller.replaceRecognition(recognitionFor(settings2, entry.controller.meter));
2903
+ entry.controller.updateSettings(settings2);
2904
+ run(entry.controller, entry.controller.refreshCapabilities());
2905
+ }
2906
+ };
2907
+ const refresh = () => run(c, c.refreshCapabilities());
2908
+ document.addEventListener("dsh-live-voice:capabilitieschanged", refresh);
2909
+ setController(c);
2910
+ refresh();
2911
+ return () => {
2912
+ document.removeEventListener("dsh-live-voice:capabilitieschanged", refresh);
2913
+ void c.dispose();
2914
+ };
2915
+ }, []);
2916
+ return controller ? e(SettingsPanel, { controller }) : null;
2917
+ }
2918
+ ctx.slots.inject(
2919
+ "settings.section",
2920
+ () => ctx.slots.register(
2921
+ { name: "settings.section", id: "dsh-live-voice", order: 65, label: "Live Voice" },
2922
+ Settings
2923
+ )
2924
+ );
2925
+ function Dock(props) {
2926
+ const entry = useEntry(props.sessionId, "dock");
2927
+ useComposer(entry, props);
2928
+ return entry ? e(RecordingBar, { controller: entry.controller }) : null;
2929
+ }
2930
+ function ActionView({ entry, messageId }) {
2931
+ const snapshot = import_react.default.useSyncExternalStore(
2932
+ entry.controller.subscribe,
2933
+ entry.controller.getSnapshot
2934
+ );
2935
+ const chat = import_react.default.useSyncExternalStore(entry.subscribeChat, entry.readChat);
2936
+ const message2 = addressedTurn(assistantMessages(chat), messageId);
2937
+ const capability = snapshot.capabilities[snapshot.settings.engine];
2938
+ const active = snapshot.speaking && message2.id === snapshot.activeMessageId;
2939
+ const unavailable = capability?.supported !== true;
2940
+ return e(SpeakButton, {
2941
+ active,
2942
+ disabled: !message2.text.trim() || !active && unavailable,
2943
+ label: unavailable ? capability?.reason || "Checking speech output\u2026" : void 0,
2944
+ onClick: () => run(
2945
+ entry.controller,
2946
+ active ? entry.controller.stopSpeech() : entry.controller.speak(message2.text, message2.id)
2947
+ )
2948
+ });
2949
+ }
2950
+ function Action(props) {
2951
+ const entry = useEntry(props.sessionId, "action");
2952
+ return entry ? e(ActionView, { entry, messageId: props.messageId }) : null;
2953
+ }
2954
+ ctx.effect(() => {
2955
+ const style = document.createElement("style");
2956
+ style.dataset.plugin = "dsh-live-voice";
2957
+ style.textContent = styles;
2958
+ document.head.appendChild(style);
2959
+ return () => style.remove();
2960
+ });
2961
+ for (const [name, id2, order, component] of [
2962
+ ["conversation.input.right", "live-voice-controls", 6, Buttons],
2963
+ ["conversation.input.dock", "live-voice-status", -100, Dock],
2964
+ ["conversation.chat.assistant-actions", "live-voice-speak", 5, Action]
2965
+ ])
2966
+ ctx.slots.inject(
2967
+ name,
2968
+ () => ctx.slots.register({ name, id: id2, order, label: "DSH Live Voice" }, component)
2969
+ );
2970
+ ctx.effect(() => {
2971
+ const stop = () => {
2972
+ ownership.cancel();
2973
+ for (const entry of controllers.values())
2974
+ run(entry.controller, entry.controller.endConversation());
2975
+ };
2976
+ const onKey = (event) => {
2977
+ if (disposed || event.defaultPrevented || !event.ctrlKey || !event.shiftKey || event.code !== "Space" || event.repeat)
2978
+ return;
2979
+ const candidates = [...controllers.values()].filter(
2980
+ (entry) => entry.buttons > 0 && entry.composers.size > 0
2981
+ );
2982
+ if (candidates.length !== 1) return;
2983
+ event.preventDefault();
2984
+ const c = candidates[0].controller;
2985
+ run(
2986
+ c,
2987
+ c.getSnapshot().listening || c.getSnapshot().starting ? c.stopListening() : c.startDictation()
2988
+ );
2989
+ };
2990
+ const refreshCapabilities = () => {
2991
+ for (const entry of controllers.values())
2992
+ run(entry.controller, entry.controller.refreshCapabilities());
2993
+ document.dispatchEvent(new Event("dsh-live-voice:capabilitieschanged"));
2994
+ };
2995
+ const visibilityChanged = () => {
2996
+ if (document.visibilityState === "visible") refreshCapabilities();
2997
+ };
2998
+ window.speechSynthesis?.addEventListener?.("voiceschanged", refreshCapabilities);
2999
+ navigator.mediaDevices?.addEventListener?.("devicechange", refreshCapabilities);
3000
+ document.addEventListener("visibilitychange", visibilityChanged);
3001
+ document.addEventListener("keydown", onKey);
3002
+ window.addEventListener("pagehide", stop);
3003
+ return () => {
3004
+ disposed = true;
3005
+ ownership.close();
3006
+ window.speechSynthesis?.removeEventListener?.("voiceschanged", refreshCapabilities);
3007
+ navigator.mediaDevices?.removeEventListener?.("devicechange", refreshCapabilities);
3008
+ document.removeEventListener("visibilitychange", visibilityChanged);
3009
+ document.removeEventListener("keydown", onKey);
3010
+ window.removeEventListener("pagehide", stop);
3011
+ for (const entry of controllers.values()) retire(entry);
3012
+ };
3013
+ });
3014
+ }
3015
+ return module.exports;}});