dsh-live-voice 0.0.1-developing → 0.0.2
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/DEVELOPMENT.md +70 -0
- package/HISTORY.md +10 -85
- package/PLAN.md +244 -0
- package/README.md +88 -96
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2988 -0
- package/lib/server.js +1006 -0
- package/package.json +69 -6
- package/scripts/build.ts +50 -0
- package/scripts/check-dist.mjs +38 -0
- package/scripts/preview-ui.ts +31 -0
- package/scripts/probe-browser.ts +46 -0
- package/src/client/chat.ts +40 -0
- package/src/client/components.ts +778 -0
- package/src/client/index.ts +430 -0
- package/src/client/qwen-settings.ts +144 -0
- package/src/client/styles.ts +36 -0
- package/src/client/whisper-settings.ts +147 -0
- package/src/core/coordinator.ts +554 -0
- package/src/core/microphone.ts +171 -0
- package/src/core/ownership.ts +31 -0
- package/src/core/settings.ts +113 -0
- package/src/core/transcript.ts +48 -0
- package/src/engines/qwen-http-host.ts +246 -0
- package/src/engines/recognition/browser.ts +291 -0
- package/src/engines/recognition/qwen-http.ts +36 -0
- package/src/engines/recognition/whisper-http-host.ts +210 -0
- package/src/engines/recognition/whisper-http.ts +191 -0
- package/src/engines/speaking/browser.ts +136 -0
- package/src/engines/speaking/qwen-http.ts +119 -0
- package/src/engines/speaking/say-client.ts +96 -0
- package/src/engines/speaking/say.ts +271 -0
- package/src/server.ts +365 -0
- package/AGENTS.md +0 -52
|
@@ -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
|
+
}
|