dsh-live-voice 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +1 -1
- package/lib/client.js +795 -207
- package/lib/server.js +18 -3
- package/package.json +2 -1
- package/scripts/build.ts +1 -1
- package/src/client/chat.ts +7 -0
- package/src/client/components.ts +453 -190
- package/src/client/index.ts +111 -11
- package/src/client/qwen-settings.ts +1 -1
- package/src/client/styles.ts +11 -7
- package/src/core/coordinator.ts +209 -26
- package/src/core/filters.ts +63 -0
- package/src/core/microphone.ts +7 -1
- package/src/core/ownership.ts +1 -1
- package/src/core/settings.ts +102 -3
- package/src/engines/qwen-http-host.ts +3 -9
- package/src/engines/recognition/browser.ts +23 -0
- package/src/engines/speaking/qwen-http.ts +6 -1
package/src/client/components.ts
CHANGED
|
@@ -25,12 +25,15 @@ export function createComponents(React) {
|
|
|
25
25
|
};
|
|
26
26
|
const paths = {
|
|
27
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
|
+
micOff:
|
|
29
|
+
'M9 9v3a3 3 0 0 0 5.12 2.12M15 9V5a3 3 0 0 0-5.64-1.42M6 10v2a6 6 0 0 0 9.5 4.88M18 10v2a6 6 0 0 1-.5 2.4M12 18v4M8 22h8M3 3l18 18',
|
|
28
30
|
speaker: 'M3 9h4l6-5v16l-6-5H3V9M17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14',
|
|
29
31
|
close: 'M6 6l12 12M18 6L6 18',
|
|
30
32
|
stop: 'M6 6h12v12H6z',
|
|
31
33
|
pause: 'M8 5v14M16 5v14',
|
|
32
34
|
play: 'M7 4l13 8-13 8z',
|
|
33
35
|
send: 'M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3',
|
|
36
|
+
queue: 'M5 6h14M5 12h10M5 18h6M18 15v6M15 18h6',
|
|
34
37
|
speakerOff: 'M3 9h4l6-5v16l-6-5H3V9M17 9l5 6M22 9l-5 6',
|
|
35
38
|
};
|
|
36
39
|
return h('svg', common, h('path', { d: paths[name] || paths.mic }));
|
|
@@ -189,7 +192,7 @@ export function createComponents(React) {
|
|
|
189
192
|
}, [controller, enabled]);
|
|
190
193
|
return h('canvas', { ref, className: 'dlv-wave', 'aria-hidden': true });
|
|
191
194
|
}
|
|
192
|
-
function RecordingBar({ controller }) {
|
|
195
|
+
function RecordingBar({ controller, questionOnly = false, overlay = false, overlayStyle }) {
|
|
193
196
|
const state = useController(controller);
|
|
194
197
|
const [now, setNow] = React.useState(Date.now());
|
|
195
198
|
React.useEffect(() => {
|
|
@@ -200,6 +203,7 @@ export function createComponents(React) {
|
|
|
200
203
|
}, [state.autoSendAt]);
|
|
201
204
|
const [invoke, error, clearError] = useActions(controller);
|
|
202
205
|
const capture = state.starting || state.listening || state.recognizing;
|
|
206
|
+
if (questionOnly && !state.answeringQuestion) return null;
|
|
203
207
|
if (
|
|
204
208
|
!state.conversation &&
|
|
205
209
|
!capture &&
|
|
@@ -212,9 +216,13 @@ export function createComponents(React) {
|
|
|
212
216
|
const remaining = state.autoSendAt
|
|
213
217
|
? Math.max(1, Math.ceil((state.autoSendAt - now) / 1000))
|
|
214
218
|
: null;
|
|
215
|
-
const status =
|
|
216
|
-
?
|
|
217
|
-
: state.
|
|
219
|
+
const status = state.answeringQuestion && state.recognizing
|
|
220
|
+
? 'Recognizing answer…'
|
|
221
|
+
: state.answeringQuestion && state.listening
|
|
222
|
+
? 'Listening for your answer…'
|
|
223
|
+
: remaining
|
|
224
|
+
? `Sending in ${remaining}…`
|
|
225
|
+
: state.starting
|
|
218
226
|
? 'Starting microphone…'
|
|
219
227
|
: state.paused
|
|
220
228
|
? 'Speech paused'
|
|
@@ -229,7 +237,10 @@ export function createComponents(React) {
|
|
|
229
237
|
: 'Voice ready';
|
|
230
238
|
return h(
|
|
231
239
|
'div',
|
|
232
|
-
{
|
|
240
|
+
{
|
|
241
|
+
className: overlay ? 'dlv-bar-wrap dlv-question-overlay' : 'dlv-bar-wrap',
|
|
242
|
+
style: overlay ? overlayStyle : undefined,
|
|
243
|
+
},
|
|
233
244
|
h(
|
|
234
245
|
'div',
|
|
235
246
|
{ className: 'dlv-pill', role: 'group', 'aria-label': 'Voice controls' },
|
|
@@ -251,25 +262,46 @@ export function createComponents(React) {
|
|
|
251
262
|
h('span', { className: 'dlv-status', role: 'status', 'aria-live': 'polite' }, status),
|
|
252
263
|
h(Button, {
|
|
253
264
|
className: 'dlv-live-toggle',
|
|
254
|
-
label: 'Automatic
|
|
255
|
-
title: `Automatic
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
label: 'Automatic delivery mode',
|
|
266
|
+
title: `Automatic delivery: ${
|
|
267
|
+
state.settings.sendingMode === 'steer'
|
|
268
|
+
? 'send to the running agent'
|
|
269
|
+
: state.settings.sendingMode === 'queue'
|
|
270
|
+
? 'queue'
|
|
271
|
+
: 'off'
|
|
272
|
+
}`,
|
|
273
|
+
icon: state.settings.sendingMode === 'queue' ? 'queue' : 'send',
|
|
274
|
+
visibleLabel:
|
|
275
|
+
state.settings.sendingMode === 'steer'
|
|
276
|
+
? 'SEND'
|
|
277
|
+
: state.settings.sendingMode === 'queue'
|
|
278
|
+
? 'QUEUE'
|
|
279
|
+
: 'OFF',
|
|
280
|
+
'aria-label': `Automatic delivery: ${state.settings.sendingMode || 'manual'}`,
|
|
281
|
+
'aria-pressed': ['queue', 'steer'].includes(state.settings.sendingMode),
|
|
282
|
+
'data-mode': state.settings.sendingMode || 'manual',
|
|
260
283
|
onClick: () =>
|
|
261
284
|
invoke('updateSettings', {
|
|
262
|
-
sendingMode: state.settings.sendingMode
|
|
285
|
+
sendingMode: !['queue', 'steer'].includes(state.settings.sendingMode)
|
|
286
|
+
? 'queue'
|
|
287
|
+
: state.settings.sendingMode === 'queue'
|
|
288
|
+
? 'steer'
|
|
289
|
+
: 'manual',
|
|
263
290
|
}),
|
|
264
291
|
}),
|
|
265
292
|
h(Button, {
|
|
266
|
-
className: 'dlv-live-toggle'
|
|
293
|
+
className: `dlv-live-toggle${state.speechSegmentsRemaining > 0 ? ' dlv-live-toggle-expanded' : ''}`,
|
|
267
294
|
label: 'Automatic assistant speech',
|
|
268
295
|
title: `Automatic assistant speech: ${
|
|
269
296
|
state.settings.announceAssistantMessages !== false ? 'on' : 'off'
|
|
270
|
-
}`,
|
|
297
|
+
}${state.speechSegmentsRemaining > 0 ? ` — ${state.speechSegmentsRemaining} speech segment${state.speechSegmentsRemaining === 1 ? '' : 's'} remaining` : ''}`,
|
|
271
298
|
icon: state.settings.announceAssistantMessages !== false ? 'speaker' : 'speakerOff',
|
|
272
|
-
visibleLabel:
|
|
299
|
+
visibleLabel:
|
|
300
|
+
state.settings.announceAssistantMessages === false
|
|
301
|
+
? 'OFF'
|
|
302
|
+
: state.speechSegmentsRemaining > 0
|
|
303
|
+
? String(state.speechSegmentsRemaining)
|
|
304
|
+
: 'ON',
|
|
273
305
|
role: 'switch',
|
|
274
306
|
'aria-checked': state.settings.announceAssistantMessages !== false,
|
|
275
307
|
onClick: () =>
|
|
@@ -293,9 +325,14 @@ export function createComponents(React) {
|
|
|
293
325
|
: null,
|
|
294
326
|
capture
|
|
295
327
|
? h(Button, {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
328
|
+
className: 'dlv-live-toggle dlv-mic-state',
|
|
329
|
+
label: state.muted ? 'Resume listening' : 'Ignore composer input',
|
|
330
|
+
title: `Microphone input: ${state.muted ? 'ignoring' : 'listening'}`,
|
|
331
|
+
icon: state.muted ? 'micOff' : 'mic',
|
|
332
|
+
visibleLabel: state.muted ? 'IGNORING' : 'LISTENING',
|
|
333
|
+
'aria-pressed': Boolean(state.muted),
|
|
334
|
+
'data-muted': state.muted ? 'true' : 'false',
|
|
335
|
+
onClick: () => invoke(state.muted ? 'resumeListeningInput' : 'muteListening'),
|
|
299
336
|
})
|
|
300
337
|
: null,
|
|
301
338
|
state.speaking && !state.paused && state.capabilities[state.settings.engine]?.pause
|
|
@@ -344,6 +381,59 @@ export function createComponents(React) {
|
|
|
344
381
|
const [invoke, error, clearError] = useActions(controller);
|
|
345
382
|
const settings = state.settings || {};
|
|
346
383
|
const capabilities = state.capabilities || {};
|
|
384
|
+
const tabsId = React.useId();
|
|
385
|
+
const tabs = [
|
|
386
|
+
{ id: 'conversation', label: 'Conversation' },
|
|
387
|
+
{ id: 'speech', label: 'Speech' },
|
|
388
|
+
{ id: 'recognition', label: 'Speech recognition' },
|
|
389
|
+
];
|
|
390
|
+
const tabRefs = React.useRef([]);
|
|
391
|
+
const [activeTab, setActiveTab] = React.useState('conversation');
|
|
392
|
+
const activateTab = (index) => {
|
|
393
|
+
const tab = tabs[index];
|
|
394
|
+
if (!tab) return;
|
|
395
|
+
setActiveTab(tab.id);
|
|
396
|
+
tabRefs.current[index]?.focus();
|
|
397
|
+
};
|
|
398
|
+
const handleTabKeyDown = (event, index) => {
|
|
399
|
+
let nextIndex;
|
|
400
|
+
if (event.key === 'ArrowRight') nextIndex = (index + 1) % tabs.length;
|
|
401
|
+
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + tabs.length) % tabs.length;
|
|
402
|
+
else if (event.key === 'Home') nextIndex = 0;
|
|
403
|
+
else if (event.key === 'End') nextIndex = tabs.length - 1;
|
|
404
|
+
else return;
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
activateTab(nextIndex);
|
|
407
|
+
};
|
|
408
|
+
const [audioDevices, setAudioDevices] = React.useState([]);
|
|
409
|
+
React.useEffect(() => {
|
|
410
|
+
let active = true;
|
|
411
|
+
const refresh = async () => {
|
|
412
|
+
try {
|
|
413
|
+
const devices = await globalThis.navigator?.mediaDevices?.enumerateDevices?.();
|
|
414
|
+
if (active) setAudioDevices(Array.from(devices || []));
|
|
415
|
+
} catch {
|
|
416
|
+
if (active) setAudioDevices([]);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
void refresh();
|
|
420
|
+
globalThis.navigator?.mediaDevices?.addEventListener?.('devicechange', refresh);
|
|
421
|
+
return () => {
|
|
422
|
+
active = false;
|
|
423
|
+
globalThis.navigator?.mediaDevices?.removeEventListener?.('devicechange', refresh);
|
|
424
|
+
};
|
|
425
|
+
}, []);
|
|
426
|
+
const deviceOptions = (kind, fallback) => [
|
|
427
|
+
{ value: '', label: 'System default' },
|
|
428
|
+
...audioDevices
|
|
429
|
+
.filter(
|
|
430
|
+
(device) => device.kind === kind && device.deviceId && device.deviceId !== 'default',
|
|
431
|
+
)
|
|
432
|
+
.map((device, index) => ({
|
|
433
|
+
value: device.deviceId,
|
|
434
|
+
label: device.label || `${fallback} ${index + 1}`,
|
|
435
|
+
})),
|
|
436
|
+
];
|
|
347
437
|
const field = (label, key, options) =>
|
|
348
438
|
h(
|
|
349
439
|
'label',
|
|
@@ -370,12 +460,17 @@ export function createComponents(React) {
|
|
|
370
460
|
),
|
|
371
461
|
),
|
|
372
462
|
);
|
|
373
|
-
const
|
|
463
|
+
const panel = (id, children) =>
|
|
374
464
|
h(
|
|
375
|
-
'
|
|
376
|
-
{
|
|
377
|
-
|
|
378
|
-
|
|
465
|
+
'div',
|
|
466
|
+
{
|
|
467
|
+
id: `${tabsId}-panel-${id}`,
|
|
468
|
+
className: 'dlv-settings-panel',
|
|
469
|
+
role: 'tabpanel',
|
|
470
|
+
'aria-labelledby': `${tabsId}-tab-${id}`,
|
|
471
|
+
hidden: activeTab !== id,
|
|
472
|
+
},
|
|
473
|
+
...children,
|
|
379
474
|
);
|
|
380
475
|
const subcard = (title, children, open = false) =>
|
|
381
476
|
h(
|
|
@@ -392,9 +487,41 @@ export function createComponents(React) {
|
|
|
392
487
|
? h(Button, { label: 'Close voice settings', icon: 'close', onClick: onClose })
|
|
393
488
|
: null,
|
|
394
489
|
h(
|
|
395
|
-
'
|
|
396
|
-
{ className: 'dlv-settings-
|
|
397
|
-
|
|
490
|
+
'div',
|
|
491
|
+
{ className: 'dlv-settings-tabs', role: 'tablist', 'aria-label': 'Live Voice settings' },
|
|
492
|
+
tabs.map((tab, index) => {
|
|
493
|
+
const selected = activeTab === tab.id;
|
|
494
|
+
return h(
|
|
495
|
+
'button',
|
|
496
|
+
{
|
|
497
|
+
key: tab.id,
|
|
498
|
+
ref: (element) => {
|
|
499
|
+
tabRefs.current[index] = element;
|
|
500
|
+
},
|
|
501
|
+
id: `${tabsId}-tab-${tab.id}`,
|
|
502
|
+
type: 'button',
|
|
503
|
+
role: 'tab',
|
|
504
|
+
className: 'dlv-settings-tab',
|
|
505
|
+
'aria-selected': selected,
|
|
506
|
+
'aria-controls': `${tabsId}-panel-${tab.id}`,
|
|
507
|
+
'data-active': selected ? 'true' : undefined,
|
|
508
|
+
tabIndex: selected ? 0 : -1,
|
|
509
|
+
onClick: () => setActiveTab(tab.id),
|
|
510
|
+
onKeyDown: (event) => handleTabKeyDown(event, index),
|
|
511
|
+
},
|
|
512
|
+
tab.label,
|
|
513
|
+
);
|
|
514
|
+
}),
|
|
515
|
+
),
|
|
516
|
+
h(
|
|
517
|
+
'div',
|
|
518
|
+
{
|
|
519
|
+
id: `${tabsId}-panel-speech`,
|
|
520
|
+
className: 'dlv-settings-panel',
|
|
521
|
+
role: 'tabpanel',
|
|
522
|
+
'aria-labelledby': `${tabsId}-tab-speech`,
|
|
523
|
+
hidden: activeTab !== 'speech',
|
|
524
|
+
},
|
|
398
525
|
h(
|
|
399
526
|
'div',
|
|
400
527
|
{ className: 'dlv-settings-card-body' },
|
|
@@ -415,27 +542,27 @@ export function createComponents(React) {
|
|
|
415
542
|
disabled: capabilities.browser?.supported === false,
|
|
416
543
|
},
|
|
417
544
|
]),
|
|
545
|
+
settings.engine !== 'say'
|
|
546
|
+
? field('Output device', 'outputDeviceId', deviceOptions('audiooutput', 'Audio output'))
|
|
547
|
+
: h('small', null, 'macOS say uses the output selected on the DSH host.'),
|
|
418
548
|
settings.engine === 'browser'
|
|
419
|
-
?
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
})),
|
|
425
|
-
])
|
|
426
|
-
: settings.engine === 'say'
|
|
427
|
-
? h(
|
|
428
|
-
'label',
|
|
549
|
+
? h(
|
|
550
|
+
React.Fragment,
|
|
551
|
+
null,
|
|
552
|
+
h(
|
|
553
|
+
'small',
|
|
429
554
|
null,
|
|
430
|
-
'
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
555
|
+
'Browser speech synthesis may ignore the selected output device; this browser API normally follows the system default.',
|
|
556
|
+
),
|
|
557
|
+
field('Local browser voice', 'voice', [
|
|
558
|
+
{ value: '', label: 'Automatic local voice' },
|
|
559
|
+
...(capabilities.browser?.voices || []).map((voice) => ({
|
|
560
|
+
value: voice.voiceURI || voice.name,
|
|
561
|
+
label: `${voice.name} — ${voice.lang || 'unknown language'}`,
|
|
562
|
+
})),
|
|
563
|
+
]),
|
|
564
|
+
)
|
|
565
|
+
: null,
|
|
439
566
|
settings.engine === 'qwen-http'
|
|
440
567
|
? h(
|
|
441
568
|
React.Fragment,
|
|
@@ -451,6 +578,49 @@ export function createComponents(React) {
|
|
|
451
578
|
]),
|
|
452
579
|
)
|
|
453
580
|
: null,
|
|
581
|
+
subcard('Filtering', [
|
|
582
|
+
h(
|
|
583
|
+
'label',
|
|
584
|
+
{ key: 'output-code-filter', className: 'dlv-check' },
|
|
585
|
+
h('input', {
|
|
586
|
+
type: 'checkbox',
|
|
587
|
+
checked: settings.outputCodeFilterEnabled !== false,
|
|
588
|
+
onChange: (event) =>
|
|
589
|
+
invoke('updateSettings', { outputCodeFilterEnabled: event.target.checked }),
|
|
590
|
+
}),
|
|
591
|
+
' Filter Markdown code blocks before speaking',
|
|
592
|
+
),
|
|
593
|
+
h(
|
|
594
|
+
'label',
|
|
595
|
+
{ key: 'output-code-lines' },
|
|
596
|
+
'Read code blocks up to this many lines',
|
|
597
|
+
h('input', {
|
|
598
|
+
type: 'number',
|
|
599
|
+
min: 0,
|
|
600
|
+
max: 100,
|
|
601
|
+
value: settings.outputCodeMaxLines ?? 5,
|
|
602
|
+
disabled: settings.outputCodeFilterEnabled === false,
|
|
603
|
+
onChange: (event) => {
|
|
604
|
+
const value = Number(event.target.value);
|
|
605
|
+
if (Number.isInteger(value) && value >= 0 && value <= 100)
|
|
606
|
+
invoke('updateSettings', { outputCodeMaxLines: value });
|
|
607
|
+
},
|
|
608
|
+
}),
|
|
609
|
+
),
|
|
610
|
+
h(
|
|
611
|
+
'label',
|
|
612
|
+
{ key: 'output-code-notice' },
|
|
613
|
+
'Replacement phrase for larger code blocks',
|
|
614
|
+
h('input', {
|
|
615
|
+
type: 'text',
|
|
616
|
+
maxLength: 300,
|
|
617
|
+
value: settings.outputCodeNotice || 'Look the code on out conversation',
|
|
618
|
+
disabled: settings.outputCodeFilterEnabled === false,
|
|
619
|
+
onChange: (event) =>
|
|
620
|
+
invoke('updateSettings', { outputCodeNotice: event.target.value }),
|
|
621
|
+
}),
|
|
622
|
+
),
|
|
623
|
+
]),
|
|
454
624
|
h(
|
|
455
625
|
'label',
|
|
456
626
|
null,
|
|
@@ -474,12 +644,52 @@ export function createComponents(React) {
|
|
|
474
644
|
null,
|
|
475
645
|
'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
646
|
),
|
|
647
|
+
...['qwen-http', 'say', 'browser']
|
|
648
|
+
.filter((id) => capabilities[id]?.supported === false)
|
|
649
|
+
.map((id) =>
|
|
650
|
+
h(
|
|
651
|
+
'p',
|
|
652
|
+
{ key: id, role: 'status' },
|
|
653
|
+
`${id === 'qwen-http' ? 'Qwen3 local' : id === 'say' ? 'macOS say' : 'Browser speech'}: ${capabilities[id].reason}`,
|
|
654
|
+
),
|
|
655
|
+
),
|
|
656
|
+
h(
|
|
657
|
+
'div',
|
|
658
|
+
{ className: 'dlv-settings-actions' },
|
|
659
|
+
h(
|
|
660
|
+
'button',
|
|
661
|
+
{
|
|
662
|
+
type: 'button',
|
|
663
|
+
disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
|
|
664
|
+
onClick: () =>
|
|
665
|
+
invoke('speak', 'DSH Live Voice. The selected speech output is working.'),
|
|
666
|
+
},
|
|
667
|
+
state.speaking ? 'Testing speech…' : 'Test selected speech output',
|
|
668
|
+
),
|
|
669
|
+
state.speaking || state.paused
|
|
670
|
+
? h(
|
|
671
|
+
'button',
|
|
672
|
+
{ type: 'button', onClick: () => invoke('stopSpeech') },
|
|
673
|
+
'Stop speech test',
|
|
674
|
+
)
|
|
675
|
+
: null,
|
|
676
|
+
h(
|
|
677
|
+
'button',
|
|
678
|
+
{ type: 'button', onClick: () => invoke('refreshCapabilities') },
|
|
679
|
+
'Refresh available engines',
|
|
680
|
+
),
|
|
681
|
+
),
|
|
477
682
|
),
|
|
478
683
|
),
|
|
479
684
|
h(
|
|
480
|
-
'
|
|
481
|
-
{
|
|
482
|
-
|
|
685
|
+
'div',
|
|
686
|
+
{
|
|
687
|
+
id: `${tabsId}-panel-recognition`,
|
|
688
|
+
className: 'dlv-settings-panel',
|
|
689
|
+
role: 'tabpanel',
|
|
690
|
+
'aria-labelledby': `${tabsId}-tab-recognition`,
|
|
691
|
+
hidden: activeTab !== 'recognition',
|
|
692
|
+
},
|
|
483
693
|
h(
|
|
484
694
|
'div',
|
|
485
695
|
{ className: 'dlv-settings-card-body' },
|
|
@@ -504,6 +714,14 @@ export function createComponents(React) {
|
|
|
504
714
|
h('option', { value: 'whisper-http' }, 'Whisper HTTP — DSH host'),
|
|
505
715
|
),
|
|
506
716
|
),
|
|
717
|
+
field('Input device', 'inputDeviceId', deviceOptions('audioinput', 'Microphone')),
|
|
718
|
+
settings.recognitionEngine === 'browser'
|
|
719
|
+
? h(
|
|
720
|
+
'small',
|
|
721
|
+
null,
|
|
722
|
+
'Browser SpeechRecognition may use the browser or system default microphone instead of this selection.',
|
|
723
|
+
)
|
|
724
|
+
: null,
|
|
507
725
|
field('Recognition language', 'recognitionLang', [
|
|
508
726
|
...(settings.recognitionEngine !== 'browser'
|
|
509
727
|
? [{ value: 'auto', label: 'Automatic — detect language' }]
|
|
@@ -562,6 +780,97 @@ export function createComponents(React) {
|
|
|
562
780
|
])
|
|
563
781
|
: null,
|
|
564
782
|
h('p', null, 'Provider settings change with the selected recognition engine.'),
|
|
783
|
+
subcard('Voice commands', [
|
|
784
|
+
h(
|
|
785
|
+
'label',
|
|
786
|
+
{ key: 'voice-commands-enabled', className: 'dlv-check' },
|
|
787
|
+
h('input', {
|
|
788
|
+
type: 'checkbox',
|
|
789
|
+
checked: settings.voiceCommandsEnabled !== false,
|
|
790
|
+
onChange: (event) =>
|
|
791
|
+
invoke('updateSettings', { voiceCommandsEnabled: event.target.checked }),
|
|
792
|
+
}),
|
|
793
|
+
' Enable exact voice commands',
|
|
794
|
+
),
|
|
795
|
+
h(
|
|
796
|
+
'small',
|
|
797
|
+
{ key: 'voice-command-help' },
|
|
798
|
+
'Separate phrases with commas. Matching ignores capitalization, accents, punctuation, and extra spaces. The entire final chunk must match.',
|
|
799
|
+
),
|
|
800
|
+
...[
|
|
801
|
+
['Send to running agent', 'voiceCommandSend', 'send, send message'],
|
|
802
|
+
['Put in queue', 'voiceCommandQueue', 'queue, queue message'],
|
|
803
|
+
['End conversation', 'voiceCommandEnd', 'end, end conversation'],
|
|
804
|
+
['Mute composer input', 'voiceCommandMute', 'mute, stop listening'],
|
|
805
|
+
['Resume composer input', 'voiceCommandResume', 'resume, start listening'],
|
|
806
|
+
[
|
|
807
|
+
'Stop assistant speech',
|
|
808
|
+
'voiceCommandStopSpeaking',
|
|
809
|
+
'stop talking, stop speaking, shut up',
|
|
810
|
+
],
|
|
811
|
+
['Clear composer', 'voiceCommandClear', 'clear all, clear message'],
|
|
812
|
+
].map(([label, key, fallback]) =>
|
|
813
|
+
h(
|
|
814
|
+
'label',
|
|
815
|
+
{ key },
|
|
816
|
+
label,
|
|
817
|
+
h('textarea', {
|
|
818
|
+
rows: 2,
|
|
819
|
+
maxLength: 1000,
|
|
820
|
+
defaultValue: settings[key] || fallback,
|
|
821
|
+
disabled: settings.voiceCommandsEnabled === false,
|
|
822
|
+
onBlur: (event) => invoke('updateSettings', { [key]: event.target.value }),
|
|
823
|
+
}),
|
|
824
|
+
),
|
|
825
|
+
),
|
|
826
|
+
]),
|
|
827
|
+
subcard('Filtering', [
|
|
828
|
+
h(
|
|
829
|
+
'label',
|
|
830
|
+
{ key: 'recognition-filter', className: 'dlv-check' },
|
|
831
|
+
h('input', {
|
|
832
|
+
type: 'checkbox',
|
|
833
|
+
checked: settings.recognitionFilterEnabled !== false,
|
|
834
|
+
onChange: (event) =>
|
|
835
|
+
invoke('updateSettings', { recognitionFilterEnabled: event.target.checked }),
|
|
836
|
+
}),
|
|
837
|
+
' Ignore short final transcription chunks',
|
|
838
|
+
),
|
|
839
|
+
h(
|
|
840
|
+
'label',
|
|
841
|
+
{ key: 'recognition-minimum-words' },
|
|
842
|
+
'Minimum words per final chunk',
|
|
843
|
+
h('input', {
|
|
844
|
+
type: 'number',
|
|
845
|
+
min: 1,
|
|
846
|
+
max: 20,
|
|
847
|
+
value: settings.recognitionMinimumWords ?? 2,
|
|
848
|
+
disabled: settings.recognitionFilterEnabled === false,
|
|
849
|
+
onChange: (event) => {
|
|
850
|
+
const value = Number(event.target.value);
|
|
851
|
+
if (Number.isInteger(value) && value >= 1 && value <= 20)
|
|
852
|
+
invoke('updateSettings', { recognitionMinimumWords: value });
|
|
853
|
+
},
|
|
854
|
+
}),
|
|
855
|
+
h(
|
|
856
|
+
'small',
|
|
857
|
+
null,
|
|
858
|
+
'Final chunks with fewer words are ignored before they reach the composer or automatic delivery.',
|
|
859
|
+
),
|
|
860
|
+
),
|
|
861
|
+
]),
|
|
862
|
+
capabilities.capture?.supported === false
|
|
863
|
+
? h('p', { role: 'status' }, `Microphone: ${capabilities.capture.reason}`)
|
|
864
|
+
: capabilities.capture?.permission === 'prompt'
|
|
865
|
+
? h(
|
|
866
|
+
'p',
|
|
867
|
+
{ role: 'status' },
|
|
868
|
+
'Microphone permission will be requested only when you start dictation or a voice conversation.',
|
|
869
|
+
)
|
|
870
|
+
: null,
|
|
871
|
+
capabilities.recognition?.supported === false
|
|
872
|
+
? h('p', { role: 'status' }, capabilities.recognition.reason)
|
|
873
|
+
: null,
|
|
565
874
|
usesPluginVoiceDetection(settings.recognitionEngine)
|
|
566
875
|
? h(
|
|
567
876
|
'details',
|
|
@@ -615,156 +924,110 @@ export function createComponents(React) {
|
|
|
615
924
|
: null,
|
|
616
925
|
),
|
|
617
926
|
),
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
927
|
+
panel('conversation', [
|
|
928
|
+
h(
|
|
929
|
+
'label',
|
|
930
|
+
{ key: 'announce', className: 'dlv-check' },
|
|
931
|
+
h('input', {
|
|
932
|
+
type: 'checkbox',
|
|
933
|
+
checked: settings.announceAssistantMessages !== false,
|
|
934
|
+
onChange: (event) =>
|
|
935
|
+
invoke('updateSettings', { announceAssistantMessages: event.target.checked }),
|
|
936
|
+
}),
|
|
937
|
+
' Automatically speak new assistant messages',
|
|
938
|
+
),
|
|
939
|
+
h(
|
|
940
|
+
'p',
|
|
941
|
+
{ key: 'policy' },
|
|
942
|
+
'During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking.',
|
|
943
|
+
),
|
|
944
|
+
h(
|
|
945
|
+
'label',
|
|
946
|
+
{ key: 'interrupt-message', className: 'dlv-check' },
|
|
947
|
+
h('input', {
|
|
948
|
+
type: 'checkbox',
|
|
949
|
+
checked: settings.interruptSpeechOnUserMessage === true,
|
|
950
|
+
onChange: (event) =>
|
|
951
|
+
invoke('updateSettings', { interruptSpeechOnUserMessage: event.target.checked }),
|
|
952
|
+
}),
|
|
953
|
+
' Stop assistant speech when I send a message',
|
|
954
|
+
),
|
|
955
|
+
h(
|
|
956
|
+
'p',
|
|
957
|
+
{ key: 'interrupt-message-description', className: 'dlv-setting-description' },
|
|
958
|
+
settings.interruptSpeechOnUserMessage
|
|
959
|
+
? 'Sending or steering a new user message stops current or paused assistant speech.'
|
|
960
|
+
: 'Sending another message does not stop the assistant audio you are already hearing.',
|
|
961
|
+
),
|
|
962
|
+
field('Listening mode', 'mode', [
|
|
963
|
+
{ value: 'speaker', label: 'Speakers — gated listening' },
|
|
964
|
+
{ value: 'headphones', label: 'Headphones — open microphone' },
|
|
965
|
+
]),
|
|
966
|
+
h(
|
|
967
|
+
'p',
|
|
968
|
+
{ key: 'mode-description', className: 'dlv-setting-description' },
|
|
969
|
+
settings.mode === 'headphones'
|
|
970
|
+
? 'Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose.'
|
|
971
|
+
: 'Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt.',
|
|
972
|
+
),
|
|
973
|
+
h(
|
|
974
|
+
'label',
|
|
975
|
+
{ key: 'speech-delay' },
|
|
976
|
+
'Assistant response delay',
|
|
637
977
|
h(
|
|
638
|
-
'
|
|
639
|
-
{
|
|
640
|
-
|
|
641
|
-
type: 'checkbox',
|
|
642
|
-
checked: settings.interruptSpeechOnUserMessage === true,
|
|
978
|
+
'select',
|
|
979
|
+
{
|
|
980
|
+
value: String(settings.assistantSpeechDelaySeconds || 3),
|
|
643
981
|
onChange: (event) =>
|
|
644
|
-
invoke('updateSettings', {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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.',
|
|
982
|
+
invoke('updateSettings', {
|
|
983
|
+
assistantSpeechDelaySeconds: Number(event.target.value),
|
|
984
|
+
}),
|
|
985
|
+
},
|
|
986
|
+
[1, 2, 3, 4, 5, 6, 8, 10].map((seconds) =>
|
|
987
|
+
h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
|
|
988
|
+
),
|
|
665
989
|
),
|
|
666
990
|
h(
|
|
667
|
-
'
|
|
668
|
-
|
|
669
|
-
'
|
|
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
|
-
),
|
|
991
|
+
'small',
|
|
992
|
+
null,
|
|
993
|
+
'After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait.',
|
|
688
994
|
),
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
h(
|
|
712
|
-
'small',
|
|
713
|
-
null,
|
|
714
|
-
'Countdown starts after a final recognized phrase. New speech or edits cancel it.',
|
|
995
|
+
),
|
|
996
|
+
field('Sending mode', 'sendingMode', [
|
|
997
|
+
{ value: 'manual', label: 'Off — review and send manually' },
|
|
998
|
+
{ value: 'queue', label: 'Queue — automatically add after silence' },
|
|
999
|
+
{ value: 'steer', label: 'Steer — automatically send to the running agent' },
|
|
1000
|
+
]),
|
|
1001
|
+
settings.sendingMode !== 'manual'
|
|
1002
|
+
? h(
|
|
1003
|
+
'label',
|
|
1004
|
+
{ key: 'delay' },
|
|
1005
|
+
'Send after silence',
|
|
1006
|
+
h(
|
|
1007
|
+
'select',
|
|
1008
|
+
{
|
|
1009
|
+
value: String(settings.autoSendDelaySeconds || 4),
|
|
1010
|
+
onChange: (event) =>
|
|
1011
|
+
invoke('updateSettings', {
|
|
1012
|
+
autoSendDelaySeconds: Number(event.target.value),
|
|
1013
|
+
}),
|
|
1014
|
+
},
|
|
1015
|
+
[2, 3, 4, 5, 6, 8, 10].map((seconds) =>
|
|
1016
|
+
h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
|
|
715
1017
|
),
|
|
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
1018
|
),
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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.',
|
|
1019
|
+
h(
|
|
1020
|
+
'small',
|
|
1021
|
+
null,
|
|
1022
|
+
'Countdown starts after a final recognized phrase. New speech or edits cancel it.',
|
|
1023
|
+
),
|
|
732
1024
|
)
|
|
733
|
-
:
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
),
|
|
1025
|
+
: h(
|
|
1026
|
+
'p',
|
|
1027
|
+
{ key: 'manual', className: 'dlv-setting-description' },
|
|
1028
|
+
'Recognized text stays in the composer until you use the normal DSH Send control.',
|
|
1029
|
+
),
|
|
1030
|
+
]),
|
|
768
1031
|
h(ErrorText, {
|
|
769
1032
|
error: error || state.error,
|
|
770
1033
|
onDismiss: () => {
|