dsh-live-voice 0.0.1-developing → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,778 @@
1
+ // @ts-nocheck
2
+ import { createWhisperSettings } from './whisper-settings.ts';
3
+ import { createQwenSettings } from './qwen-settings.ts';
4
+ import { qwenVoices, usesPluginVoiceDetection, voiceDetectionPresets } from '../core/settings.ts';
5
+
6
+ // UI only: the controller owns capture, recognition, playback and policy.
7
+ export function createComponents(React) {
8
+ const h = React.createElement;
9
+ const WhisperSettings = createWhisperSettings(React);
10
+ const QwenSettings = createQwenSettings(React);
11
+ function useController(controller) {
12
+ const subscribe = React.useCallback((listener) => controller.subscribe(listener), [controller]);
13
+ const read = React.useCallback(() => controller.getSnapshot(), [controller]);
14
+ return React.useSyncExternalStore(subscribe, read, read);
15
+ }
16
+ function Icon({ name }) {
17
+ const common = {
18
+ viewBox: '0 0 24 24',
19
+ fill: 'none',
20
+ stroke: 'currentColor',
21
+ strokeWidth: 1.8,
22
+ strokeLinecap: 'round',
23
+ strokeLinejoin: 'round',
24
+ 'aria-hidden': true,
25
+ };
26
+ const paths = {
27
+ 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',
28
+ speaker: 'M3 9h4l6-5v16l-6-5H3V9M17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14',
29
+ close: 'M6 6l12 12M18 6L6 18',
30
+ stop: 'M6 6h12v12H6z',
31
+ pause: 'M8 5v14M16 5v14',
32
+ play: 'M7 4l13 8-13 8z',
33
+ send: 'M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3',
34
+ speakerOff: 'M3 9h4l6-5v16l-6-5H3V9M17 9l5 6M22 9l-5 6',
35
+ };
36
+ return h('svg', common, h('path', { d: paths[name] || paths.mic }));
37
+ }
38
+ function Button({
39
+ label,
40
+ icon,
41
+ visibleLabel,
42
+ title = label,
43
+ className = 'dlv-pill-button',
44
+ ...props
45
+ }) {
46
+ return h(
47
+ 'button',
48
+ {
49
+ ...props,
50
+ type: 'button',
51
+ className: 'dlv-icon-button ' + className,
52
+ title,
53
+ 'aria-label': label,
54
+ },
55
+ h(Icon, { name: icon }),
56
+ visibleLabel
57
+ ? h('span', { className: 'dlv-toggle-state', 'aria-hidden': true }, visibleLabel)
58
+ : null,
59
+ );
60
+ }
61
+ // Keep rejection handling local without swallowing controller-published errors.
62
+ function useActions(controller) {
63
+ const [error, setError] = React.useState('');
64
+ const alive = React.useRef(true);
65
+ React.useEffect(() => {
66
+ alive.current = true;
67
+ return () => {
68
+ alive.current = false;
69
+ };
70
+ }, []);
71
+ const invoke = (name, ...args) => {
72
+ setError('');
73
+ try {
74
+ Promise.resolve(controller[name](...args)).catch((reason) => {
75
+ if (alive.current) setError(reason instanceof Error ? reason.message : String(reason));
76
+ });
77
+ } catch (reason) {
78
+ setError(reason instanceof Error ? reason.message : String(reason));
79
+ }
80
+ };
81
+ return [invoke, error, () => setError('')];
82
+ }
83
+ function ErrorText({ error, onDismiss }) {
84
+ return error
85
+ ? h(
86
+ 'div',
87
+ { className: 'dlv-error', role: 'alert' },
88
+ String(error),
89
+ onDismiss
90
+ ? h(
91
+ 'button',
92
+ { type: 'button', 'aria-label': 'Dismiss voice error', onClick: onDismiss },
93
+ 'Dismiss',
94
+ )
95
+ : null,
96
+ )
97
+ : null;
98
+ }
99
+ function MicrophoneButtons({ controller }) {
100
+ const state = useController(controller);
101
+ const [invoke, error, clearError] = useActions(controller);
102
+ const busy = state.conversation || state.listening || state.starting || state.recognizing;
103
+ if (busy) return null;
104
+ const recognition = state.capabilities?.recognition;
105
+ const capture = state.capabilities?.capture;
106
+ const pending = !recognition || !capture;
107
+ const unavailable = recognition?.supported === false || capture?.supported === false;
108
+ const reason = capture?.supported === false ? capture.reason : recognition?.reason;
109
+ return h(
110
+ React.Fragment,
111
+ null,
112
+ h(Button, {
113
+ className: 'dlv-mic',
114
+ icon: 'mic',
115
+ label: pending
116
+ ? 'Checking microphone availability'
117
+ : unavailable
118
+ ? reason || 'Speech recognition unavailable'
119
+ : 'Start voice conversation',
120
+ disabled: pending,
121
+ onClick: () => (unavailable ? invoke('explainRecognition') : invoke('startConversation')),
122
+ }),
123
+ h(ErrorText, { error, onDismiss: clearError }),
124
+ );
125
+ }
126
+ function Waveform({ controller, enabled }) {
127
+ const ref = React.useRef(null);
128
+ React.useEffect(() => {
129
+ const canvas = ref.current;
130
+ const context = canvas?.getContext('2d');
131
+ if (!context) return undefined;
132
+ let frame = 0;
133
+ let disposed = false;
134
+ let width = 1;
135
+ let height = 40;
136
+ let ratio = 1;
137
+ const motion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
138
+ function resize() {
139
+ const bounds = canvas.getBoundingClientRect();
140
+ width = Math.max(1, bounds.width);
141
+ height = Math.max(1, bounds.height || 40);
142
+ ratio = Math.max(1, window.devicePixelRatio || 1);
143
+ canvas.width = Math.round(width * ratio);
144
+ canvas.height = Math.round(height * ratio);
145
+ }
146
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
147
+ observer?.observe(canvas);
148
+ window.addEventListener('resize', resize);
149
+ resize();
150
+ function draw(time) {
151
+ if (disposed) return;
152
+ if (ratio !== Math.max(1, window.devicePixelRatio || 1)) resize();
153
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
154
+ context.clearRect(0, 0, width, height);
155
+ const raw = Number(controller.meter?.level?.() ?? 0);
156
+ const level = enabled && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0;
157
+ const color = getComputedStyle(canvas).color;
158
+ // Zero input is flat: decorative motion must never imply microphone activity.
159
+ for (let layer = 0; layer < 3; layer += 1) {
160
+ context.beginPath();
161
+ context.strokeStyle = layer === 1 ? '#38bdf8' : color;
162
+ context.globalAlpha = 0.4 + layer * 0.25;
163
+ context.lineWidth = layer === 2 ? 2 : 1;
164
+ const phase = motion?.matches ? 0 : time / (500 + layer * 170);
165
+ for (let x = 0; x <= width; x += 2) {
166
+ const envelope = Math.sin((Math.PI * x) / width);
167
+ const y =
168
+ height / 2 +
169
+ Math.sin((x / width) * Math.PI * (4 + layer * 2) + phase) *
170
+ envelope *
171
+ level *
172
+ height *
173
+ (0.43 - layer * 0.08);
174
+ if (x === 0) context.moveTo(x, y);
175
+ else context.lineTo(x, y);
176
+ }
177
+ context.stroke();
178
+ }
179
+ context.globalAlpha = 1;
180
+ frame = window.requestAnimationFrame(draw);
181
+ }
182
+ frame = window.requestAnimationFrame(draw);
183
+ return () => {
184
+ disposed = true;
185
+ window.cancelAnimationFrame(frame);
186
+ observer?.disconnect();
187
+ window.removeEventListener('resize', resize);
188
+ };
189
+ }, [controller, enabled]);
190
+ return h('canvas', { ref, className: 'dlv-wave', 'aria-hidden': true });
191
+ }
192
+ function RecordingBar({ controller }) {
193
+ const state = useController(controller);
194
+ const [now, setNow] = React.useState(Date.now());
195
+ React.useEffect(() => {
196
+ if (!state.autoSendAt) return;
197
+ setNow(Date.now());
198
+ const timer = setInterval(() => setNow(Date.now()), 200);
199
+ return () => clearInterval(timer);
200
+ }, [state.autoSendAt]);
201
+ const [invoke, error, clearError] = useActions(controller);
202
+ const capture = state.starting || state.listening || state.recognizing;
203
+ if (
204
+ !state.conversation &&
205
+ !capture &&
206
+ !state.speaking &&
207
+ !state.paused &&
208
+ !state.error &&
209
+ !error
210
+ )
211
+ return null;
212
+ const remaining = state.autoSendAt
213
+ ? Math.max(1, Math.ceil((state.autoSendAt - now) / 1000))
214
+ : null;
215
+ const status = remaining
216
+ ? `Sending in ${remaining}…`
217
+ : state.starting
218
+ ? 'Starting microphone…'
219
+ : state.paused
220
+ ? 'Speech paused'
221
+ : state.speaking
222
+ ? 'Speaking'
223
+ : state.recognizing
224
+ ? 'Recognizing speech…'
225
+ : state.listening
226
+ ? 'Listening — waiting for speech'
227
+ : state.conversation
228
+ ? 'Conversation idle'
229
+ : 'Voice ready';
230
+ return h(
231
+ 'div',
232
+ { className: 'dlv-bar-wrap' },
233
+ h(
234
+ 'div',
235
+ { className: 'dlv-pill', role: 'group', 'aria-label': 'Voice controls' },
236
+ capture && !state.conversation
237
+ ? h(Button, {
238
+ label: 'Cancel dictation',
239
+ icon: 'close',
240
+ onClick: () => invoke('cancelDictation'),
241
+ })
242
+ : null,
243
+ state.conversation
244
+ ? h(Button, {
245
+ label: 'End voice conversation',
246
+ icon: 'close',
247
+ onClick: () => invoke('endConversation'),
248
+ })
249
+ : null,
250
+ h(Waveform, { controller, enabled: Boolean(state.listening) }),
251
+ h('span', { className: 'dlv-status', role: 'status', 'aria-live': 'polite' }, status),
252
+ h(Button, {
253
+ className: 'dlv-live-toggle',
254
+ label: 'Automatic sending',
255
+ title: `Automatic sending: ${state.settings.sendingMode === 'automatic' ? 'on' : 'off'}`,
256
+ icon: 'send',
257
+ visibleLabel: state.settings.sendingMode === 'automatic' ? 'ON' : 'OFF',
258
+ role: 'switch',
259
+ 'aria-checked': state.settings.sendingMode === 'automatic',
260
+ onClick: () =>
261
+ invoke('updateSettings', {
262
+ sendingMode: state.settings.sendingMode === 'automatic' ? 'manual' : 'automatic',
263
+ }),
264
+ }),
265
+ h(Button, {
266
+ className: 'dlv-live-toggle',
267
+ label: 'Automatic assistant speech',
268
+ title: `Automatic assistant speech: ${
269
+ state.settings.announceAssistantMessages !== false ? 'on' : 'off'
270
+ }`,
271
+ icon: state.settings.announceAssistantMessages !== false ? 'speaker' : 'speakerOff',
272
+ visibleLabel: state.settings.announceAssistantMessages !== false ? 'ON' : 'OFF',
273
+ role: 'switch',
274
+ 'aria-checked': state.settings.announceAssistantMessages !== false,
275
+ onClick: () =>
276
+ invoke('updateSettings', {
277
+ announceAssistantMessages: state.settings.announceAssistantMessages === false,
278
+ }),
279
+ }),
280
+ remaining
281
+ ? h(Button, {
282
+ label: 'Cancel automatic send',
283
+ icon: 'close',
284
+ onClick: () => invoke('cancelAutoSend'),
285
+ })
286
+ : null,
287
+ state.conversation && !capture
288
+ ? h(Button, {
289
+ label: 'Take microphone',
290
+ icon: 'mic',
291
+ onClick: () => invoke('startConversation'),
292
+ })
293
+ : null,
294
+ capture
295
+ ? h(Button, {
296
+ label: 'Stop listening',
297
+ icon: 'stop',
298
+ onClick: () => invoke('stopListening'),
299
+ })
300
+ : null,
301
+ state.speaking && !state.paused && state.capabilities[state.settings.engine]?.pause
302
+ ? h(Button, {
303
+ label: 'Pause speech',
304
+ icon: 'pause',
305
+ onClick: () => invoke('pauseSpeech'),
306
+ })
307
+ : null,
308
+ state.paused && state.capabilities[state.settings.engine]?.resume
309
+ ? h(Button, {
310
+ label: 'Resume speech',
311
+ icon: 'play',
312
+ onClick: () => invoke('resumeSpeech'),
313
+ })
314
+ : null,
315
+ state.speaking || state.paused
316
+ ? h(Button, {
317
+ label: 'Stop all speech',
318
+ icon: 'stop',
319
+ onClick: () => invoke('stopSpeech'),
320
+ })
321
+ : null,
322
+ ),
323
+ h(ErrorText, {
324
+ error: error || state.error,
325
+ onDismiss: () => {
326
+ clearError();
327
+ controller.clearError();
328
+ },
329
+ }),
330
+ );
331
+ }
332
+ function SpeakButton({ active = false, disabled = false, label, onClick }) {
333
+ return h(Button, {
334
+ className: 'dlv-speaker',
335
+ label: label || (active ? 'Stop speaking' : 'Speak message'),
336
+ icon: active ? 'stop' : 'speaker',
337
+ 'aria-pressed': Boolean(active),
338
+ disabled,
339
+ onClick,
340
+ });
341
+ }
342
+ function SettingsPanel({ controller, onClose }) {
343
+ const state = useController(controller);
344
+ const [invoke, error, clearError] = useActions(controller);
345
+ const settings = state.settings || {};
346
+ const capabilities = state.capabilities || {};
347
+ const field = (label, key, options) =>
348
+ h(
349
+ 'label',
350
+ { key },
351
+ label,
352
+ h(
353
+ 'select',
354
+ {
355
+ value: settings[key] || options[0].value,
356
+ onChange: (event) =>
357
+ invoke(
358
+ 'updateSettings',
359
+ key === 'engine'
360
+ ? { engine: event.target.value, voice: '' }
361
+ : { [key]: event.target.value },
362
+ ),
363
+ },
364
+ options.map((option) =>
365
+ h(
366
+ 'option',
367
+ { key: option.value, value: option.value, disabled: option.disabled },
368
+ option.label,
369
+ ),
370
+ ),
371
+ ),
372
+ );
373
+ const card = (title, children, open = false) =>
374
+ h(
375
+ 'details',
376
+ { className: 'dlv-settings-card', open },
377
+ h('summary', null, title),
378
+ h('div', { className: 'dlv-settings-card-body' }, ...children),
379
+ );
380
+ const subcard = (title, children, open = false) =>
381
+ h(
382
+ 'details',
383
+ { className: 'dlv-settings-subcard', open },
384
+ h('summary', null, title),
385
+ h('div', { className: 'dlv-settings-subcard-body' }, ...children),
386
+ );
387
+ return h(
388
+ 'section',
389
+ { className: 'dlv-settings', 'aria-label': 'Live Voice settings' },
390
+ h('h3', null, 'Live Voice'),
391
+ onClose
392
+ ? h(Button, { label: 'Close voice settings', icon: 'close', onClick: onClose })
393
+ : null,
394
+ h(
395
+ 'details',
396
+ { className: 'dlv-settings-card' },
397
+ h('summary', null, 'Speech output'),
398
+ h(
399
+ 'div',
400
+ { className: 'dlv-settings-card-body' },
401
+ field('Speech engine', 'engine', [
402
+ {
403
+ value: 'qwen-http',
404
+ label: 'Qwen3 TTS — local MLX server',
405
+ disabled: capabilities['qwen-http']?.supported === false,
406
+ },
407
+ {
408
+ value: 'say',
409
+ label: 'macOS say — host audio',
410
+ disabled: capabilities.say?.supported === false,
411
+ },
412
+ {
413
+ value: 'browser',
414
+ label: 'Browser speech — device audio',
415
+ disabled: capabilities.browser?.supported === false,
416
+ },
417
+ ]),
418
+ settings.engine === 'browser'
419
+ ? field('Local browser voice', 'voice', [
420
+ { value: '', label: 'Automatic local voice' },
421
+ ...(capabilities.browser?.voices || []).map((voice) => ({
422
+ value: voice.voiceURI || voice.name,
423
+ label: `${voice.name} — ${voice.lang || 'unknown language'}`,
424
+ })),
425
+ ])
426
+ : settings.engine === 'say'
427
+ ? h(
428
+ 'label',
429
+ null,
430
+ 'macOS say voice (empty uses system default)',
431
+ h('input', {
432
+ type: 'text',
433
+ value: settings.voice || '',
434
+ onChange: (event) => invoke('updateSettings', { voice: event.target.value }),
435
+ autoComplete: 'off',
436
+ }),
437
+ )
438
+ : null,
439
+ settings.engine === 'qwen-http'
440
+ ? h(
441
+ React.Fragment,
442
+ null,
443
+ field('Qwen voice', 'voice', qwenVoices),
444
+ h(
445
+ 'small',
446
+ null,
447
+ `Aiden is used by default. These preset voices are not native Brazilian Portuguese voices.`,
448
+ ),
449
+ subcard('Qwen server connection', [
450
+ h(QwenSettings, { key: 'qwen-output-settings', controller }),
451
+ ]),
452
+ )
453
+ : null,
454
+ h(
455
+ 'label',
456
+ null,
457
+ 'Speech rate',
458
+ h('input', {
459
+ type: 'number',
460
+ min: 0.1,
461
+ max: 3,
462
+ step: 0.1,
463
+ value: settings.rate ?? 1,
464
+ onChange: (event) => {
465
+ const rate = Number(event.target.value);
466
+ if (Number.isFinite(rate) && rate >= 0.1 && rate <= 3)
467
+ invoke('updateSettings', { rate });
468
+ },
469
+ }),
470
+ h('small', null, 'Relative speed: 1 is normal.'),
471
+ ),
472
+ h(
473
+ 'p',
474
+ null,
475
+ '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.',
476
+ ),
477
+ ),
478
+ ),
479
+ h(
480
+ 'details',
481
+ { className: 'dlv-settings-card' },
482
+ h('summary', null, 'Speech recognition'),
483
+ h(
484
+ 'div',
485
+ { className: 'dlv-settings-card-body' },
486
+ h(
487
+ 'label',
488
+ null,
489
+ 'Recognition engine',
490
+ h(
491
+ 'select',
492
+ {
493
+ value: settings.recognitionEngine,
494
+ onChange: (event) =>
495
+ invoke(
496
+ 'updateSettings',
497
+ event.target.value === 'browser' && settings.recognitionLang === 'auto'
498
+ ? { recognitionEngine: 'browser', recognitionLang: 'pt-BR' }
499
+ : { recognitionEngine: event.target.value },
500
+ ),
501
+ },
502
+ h('option', { value: 'qwen-http' }, 'Qwen3 ASR — local MLX server'),
503
+ h('option', { value: 'browser' }, 'Browser SpeechRecognition'),
504
+ h('option', { value: 'whisper-http' }, 'Whisper HTTP — DSH host'),
505
+ ),
506
+ ),
507
+ field('Recognition language', 'recognitionLang', [
508
+ ...(settings.recognitionEngine !== 'browser'
509
+ ? [{ value: 'auto', label: 'Automatic — detect language' }]
510
+ : []),
511
+ { value: 'pt-BR', label: 'Português (Brasil)' },
512
+ { value: 'en-US', label: 'English (United States)' },
513
+ ]),
514
+ settings.recognitionEngine === 'browser'
515
+ ? h(
516
+ React.Fragment,
517
+ null,
518
+ h(
519
+ 'label',
520
+ { className: 'dlv-check' },
521
+ h('input', {
522
+ type: 'checkbox',
523
+ checked: settings.recognitionProcessLocally !== false,
524
+ onChange: (event) =>
525
+ invoke('updateSettings', { recognitionProcessLocally: event.target.checked }),
526
+ }),
527
+ ' Process recognition locally on this device',
528
+ ),
529
+ settings.recognitionProcessLocally !== false
530
+ ? h(
531
+ 'label',
532
+ { className: 'dlv-check' },
533
+ h('input', {
534
+ type: 'checkbox',
535
+ checked: settings.recognitionAutoInstall !== false,
536
+ onChange: (event) =>
537
+ invoke('updateSettings', {
538
+ recognitionAutoInstall: event.target.checked,
539
+ }),
540
+ }),
541
+ ' Automatically install this browser language pack when needed',
542
+ )
543
+ : h(
544
+ 'p',
545
+ { role: 'status' },
546
+ 'Browser-service recognition is enabled. The browser may send microphone audio to its recognition service.',
547
+ ),
548
+ )
549
+ : h(
550
+ 'p',
551
+ { role: 'status' },
552
+ settings.recognitionEngine === 'qwen-http'
553
+ ? 'Audio is segmented into complete WAV utterances and sent through authenticated DSH to the host-local Qwen3 ASR model.'
554
+ : 'Audio is segmented into complete WAV utterances, sent through authenticated DSH, and processed by loopback whisper.cpp HTTP.',
555
+ ),
556
+ settings.recognitionEngine === 'whisper-http'
557
+ ? subcard('Connection settings', [h(WhisperSettings, { key: 'settings', controller })])
558
+ : null,
559
+ settings.recognitionEngine === 'qwen-http' && settings.engine !== 'qwen-http'
560
+ ? subcard('Qwen server connection', [
561
+ h(QwenSettings, { key: 'qwen-recognition-settings', controller }),
562
+ ])
563
+ : null,
564
+ h('p', null, 'Provider settings change with the selected recognition engine.'),
565
+ usesPluginVoiceDetection(settings.recognitionEngine)
566
+ ? h(
567
+ 'details',
568
+ { className: 'dlv-settings-subcard', 'aria-label': 'Silence detection settings' },
569
+ h('summary', null, 'Silence detection'),
570
+ h(
571
+ 'div',
572
+ { className: 'dlv-settings-subcard-body' },
573
+ h(
574
+ 'p',
575
+ null,
576
+ 'Controls how long a pause must last before captured speech is sent for recognition.',
577
+ ),
578
+ h(
579
+ 'div',
580
+ {
581
+ className: 'dlv-preset-group',
582
+ role: 'radiogroup',
583
+ 'aria-label': 'Pause before sending',
584
+ },
585
+ Object.entries(voiceDetectionPresets).map(([value, preset]) =>
586
+ h(
587
+ 'label',
588
+ { key: value, className: 'dlv-preset' },
589
+ h('input', {
590
+ type: 'radio',
591
+ name: 'dlv-vad-preset',
592
+ value,
593
+ checked: (settings.voiceDetectionPreset || 'natural') === value,
594
+ onChange: () => invoke('updateSettings', { voiceDetectionPreset: value }),
595
+ }),
596
+ h(
597
+ 'span',
598
+ null,
599
+ h('strong', null, preset.label),
600
+ h('small', null, preset.description),
601
+ ),
602
+ ),
603
+ ),
604
+ ),
605
+ h(
606
+ 'p',
607
+ { className: 'dlv-vad-summary' },
608
+ 'Pause before sending: ' +
609
+ (voiceDetectionPresets[settings.voiceDetectionPreset]?.silenceMs ||
610
+ voiceDetectionPresets.natural.silenceMs) +
611
+ ' ms',
612
+ ),
613
+ ),
614
+ )
615
+ : null,
616
+ ),
617
+ ),
618
+ card(
619
+ 'Conversation',
620
+ [
621
+ h(
622
+ 'label',
623
+ { key: 'announce', className: 'dlv-check' },
624
+ h('input', {
625
+ type: 'checkbox',
626
+ checked: settings.announceAssistantMessages !== false,
627
+ onChange: (event) =>
628
+ invoke('updateSettings', { announceAssistantMessages: event.target.checked }),
629
+ }),
630
+ ' Automatically speak new assistant messages',
631
+ ),
632
+ h(
633
+ 'p',
634
+ { key: 'policy' },
635
+ 'During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking.',
636
+ ),
637
+ h(
638
+ 'label',
639
+ { key: 'interrupt-message', className: 'dlv-check' },
640
+ h('input', {
641
+ type: 'checkbox',
642
+ checked: settings.interruptSpeechOnUserMessage === true,
643
+ onChange: (event) =>
644
+ invoke('updateSettings', { interruptSpeechOnUserMessage: event.target.checked }),
645
+ }),
646
+ ' Stop assistant speech when I send a message',
647
+ ),
648
+ h(
649
+ 'p',
650
+ { key: 'interrupt-message-description', className: 'dlv-setting-description' },
651
+ settings.interruptSpeechOnUserMessage
652
+ ? 'Sending or steering a new user message stops current or paused assistant speech.'
653
+ : 'Sending another message does not stop the assistant audio you are already hearing.',
654
+ ),
655
+ field('Listening mode', 'mode', [
656
+ { value: 'speaker', label: 'Speakers — gated listening' },
657
+ { value: 'headphones', label: 'Headphones — open microphone' },
658
+ ]),
659
+ h(
660
+ 'p',
661
+ { key: 'mode-description', className: 'dlv-setting-description' },
662
+ settings.mode === 'headphones'
663
+ ? 'Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose.'
664
+ : 'Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt.',
665
+ ),
666
+ h(
667
+ 'label',
668
+ { key: 'speech-delay' },
669
+ 'Assistant response delay',
670
+ h(
671
+ 'select',
672
+ {
673
+ value: String(settings.assistantSpeechDelaySeconds || 3),
674
+ onChange: (event) =>
675
+ invoke('updateSettings', {
676
+ assistantSpeechDelaySeconds: Number(event.target.value),
677
+ }),
678
+ },
679
+ [1, 2, 3, 4, 5, 6, 8, 10].map((seconds) =>
680
+ h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
681
+ ),
682
+ ),
683
+ h(
684
+ 'small',
685
+ null,
686
+ 'After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait.',
687
+ ),
688
+ ),
689
+ field('Sending mode', 'sendingMode', [
690
+ { value: 'manual', label: 'Manual — review and send' },
691
+ { value: 'automatic', label: 'Automatic — send after silence' },
692
+ ]),
693
+ settings.sendingMode === 'automatic'
694
+ ? h(
695
+ 'label',
696
+ { key: 'delay' },
697
+ 'Send after silence',
698
+ h(
699
+ 'select',
700
+ {
701
+ value: String(settings.autoSendDelaySeconds || 4),
702
+ onChange: (event) =>
703
+ invoke('updateSettings', {
704
+ autoSendDelaySeconds: Number(event.target.value),
705
+ }),
706
+ },
707
+ [2, 3, 4, 5, 6, 8, 10].map((seconds) =>
708
+ h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
709
+ ),
710
+ ),
711
+ h(
712
+ 'small',
713
+ null,
714
+ 'Countdown starts after a final recognized phrase. New speech or edits cancel it.',
715
+ ),
716
+ )
717
+ : h(
718
+ 'p',
719
+ { key: 'manual', className: 'dlv-setting-description' },
720
+ 'Recognized text stays in the composer until you use the normal DSH Send control.',
721
+ ),
722
+ ],
723
+ false,
724
+ ),
725
+ capabilities.capture?.supported === false
726
+ ? h('p', { role: 'status' }, `Microphone: ${capabilities.capture.reason}`)
727
+ : capabilities.capture?.permission === 'prompt'
728
+ ? h(
729
+ 'p',
730
+ { role: 'status' },
731
+ 'Microphone permission will be requested only when you start dictation or a voice conversation.',
732
+ )
733
+ : null,
734
+ capabilities.recognition?.supported === false
735
+ ? h('p', { role: 'status' }, capabilities.recognition.reason)
736
+ : null,
737
+ ...['qwen-http', 'say', 'browser']
738
+ .filter((id) => capabilities[id]?.supported === false)
739
+ .map((id) =>
740
+ h(
741
+ 'p',
742
+ { key: id, role: 'status' },
743
+ `${id === 'qwen-http' ? 'Qwen3 local' : id === 'say' ? 'macOS say' : 'Browser speech'}: ${capabilities[id].reason}`,
744
+ ),
745
+ ),
746
+ h(
747
+ 'div',
748
+ { className: 'dlv-settings-actions' },
749
+ h(
750
+ 'button',
751
+ {
752
+ type: 'button',
753
+ disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
754
+ onClick: () =>
755
+ invoke('speak', 'DSH Live Voice. The selected speech output is working.'),
756
+ },
757
+ state.speaking ? 'Testing speech…' : 'Test selected speech output',
758
+ ),
759
+ state.speaking || state.paused
760
+ ? h('button', { type: 'button', onClick: () => invoke('stopSpeech') }, 'Stop speech test')
761
+ : null,
762
+ h(
763
+ 'button',
764
+ { type: 'button', onClick: () => invoke('refreshCapabilities') },
765
+ 'Refresh available engines',
766
+ ),
767
+ ),
768
+ h(ErrorText, {
769
+ error: error || state.error,
770
+ onDismiss: () => {
771
+ clearError();
772
+ controller.clearError();
773
+ },
774
+ }),
775
+ );
776
+ }
777
+ return { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel };
778
+ }