dsh-live-voice 0.2.1 → 0.2.3
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 +43 -1
- package/README.md +29 -21
- package/lib/client.js +457 -60
- package/lib/server.js +2 -0
- package/package.json +3 -2
- package/scripts/build.ts +9 -1
- package/src/client/components.ts +171 -23
- package/src/client/index.ts +68 -30
- package/src/client/releases.ts +117 -0
- package/src/client/styles.ts +3 -1
- package/src/core/coordinator.ts +54 -7
- package/src/core/settings.ts +12 -0
- package/src/engines/recognition/browser.ts +38 -0
- package/src/engines/recognition/whisper-http.ts +80 -29
package/lib/server.js
CHANGED
|
@@ -460,7 +460,9 @@ var defaultSettings = Object.freeze({
|
|
|
460
460
|
recognitionProcessLocally: true,
|
|
461
461
|
recognitionAutoInstall: true,
|
|
462
462
|
voiceDetectionPreset: "natural",
|
|
463
|
+
recognitionMaxUtteranceSeconds: 60,
|
|
463
464
|
microphoneEnabled: true,
|
|
465
|
+
holdToTalkEnabled: true,
|
|
464
466
|
announceAssistantMessages: true,
|
|
465
467
|
interruptSpeechOnUserMessage: false,
|
|
466
468
|
sendingMode: "manual",
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live-voice",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"dshTestedVersion": "0.1.6-alpha.2",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"scripts": {
|
|
6
7
|
"typecheck": "tsc --noEmit",
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
"setup:hooks": "git config core.hooksPath .githooks",
|
|
14
15
|
"prepack": "npm run build"
|
|
15
16
|
},
|
|
16
|
-
"description": "Local-first voice
|
|
17
|
+
"description": "Local-first hands-free AI voice assistant plugin for DeepSeek Harness (DSH), with speech-to-text, text-to-speech, voice commands, and local speech engines.",
|
|
17
18
|
"keywords": [
|
|
18
19
|
"dsh",
|
|
19
20
|
"dsh-plugin",
|
package/scripts/build.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import { build, context, type BuildOptions } from 'esbuild';
|
|
3
|
-
import { rm, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { rm, mkdir, readFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
const packageMetadata = JSON.parse(await readFile('package.json', 'utf8'));
|
|
6
|
+
const versionDefine = {
|
|
7
|
+
__DLV_VERSION__: JSON.stringify(packageMetadata.version),
|
|
8
|
+
__DLV_TESTED_DSH_VERSION__: JSON.stringify(packageMetadata.dshTestedVersion),
|
|
9
|
+
};
|
|
4
10
|
|
|
5
11
|
const client: BuildOptions = {
|
|
6
12
|
entryPoints: ['src/client/index.ts'],
|
|
@@ -10,6 +16,7 @@ const client: BuildOptions = {
|
|
|
10
16
|
platform: 'browser',
|
|
11
17
|
target: ['es2022'],
|
|
12
18
|
external: ['react', 'react-dom'],
|
|
19
|
+
define: versionDefine,
|
|
13
20
|
banner: {
|
|
14
21
|
js: 'window.__ModuleLoader__.load({id:"dsh-live-voice",factory:(require)=>{var module={exports:{}};var exports=module.exports;',
|
|
15
22
|
},
|
|
@@ -35,6 +42,7 @@ const tests: BuildOptions = {
|
|
|
35
42
|
platform: 'node',
|
|
36
43
|
target: ['node22'],
|
|
37
44
|
packages: 'external',
|
|
45
|
+
define: versionDefine,
|
|
38
46
|
logLevel: 'silent',
|
|
39
47
|
};
|
|
40
48
|
const watch = process.argv.includes('--watch');
|
package/src/client/components.ts
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
import { createWhisperSettings } from './whisper-settings.ts';
|
|
3
3
|
import { createQwenSettings } from './qwen-settings.ts';
|
|
4
4
|
import { qwenVoices, usesPluginVoiceDetection, voiceDetectionPresets } from '../core/settings.ts';
|
|
5
|
+
import {
|
|
6
|
+
checkLatestRelease,
|
|
7
|
+
CURRENT_VERSION,
|
|
8
|
+
DSH_BADGE_URL,
|
|
9
|
+
hasNewerRelease,
|
|
10
|
+
LIVE_VOICE_BADGE_URL,
|
|
11
|
+
RELEASES_URL,
|
|
12
|
+
REPOSITORY_URL,
|
|
13
|
+
TESTED_DSH_RELEASE_URL,
|
|
14
|
+
TESTED_DSH_VERSION,
|
|
15
|
+
} from './releases.ts';
|
|
5
16
|
|
|
6
17
|
// UI only: the controller owns capture, recognition, playback and policy.
|
|
7
18
|
export function createComponents(React) {
|
|
@@ -216,25 +227,26 @@ export function createComponents(React) {
|
|
|
216
227
|
const remaining = state.autoSendAt
|
|
217
228
|
? Math.max(1, Math.ceil((state.autoSendAt - now) / 1000))
|
|
218
229
|
: null;
|
|
219
|
-
const status =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
230
|
+
const status =
|
|
231
|
+
state.answeringQuestion && state.recognizing
|
|
232
|
+
? 'Recognizing answer…'
|
|
233
|
+
: state.answeringQuestion && state.listening
|
|
234
|
+
? 'Listening for your answer…'
|
|
235
|
+
: remaining
|
|
236
|
+
? `Sending in ${remaining}…`
|
|
237
|
+
: state.starting
|
|
238
|
+
? 'Starting microphone…'
|
|
239
|
+
: state.paused
|
|
240
|
+
? 'Speech paused'
|
|
241
|
+
: state.speaking
|
|
242
|
+
? 'Speaking'
|
|
243
|
+
: state.recognizing
|
|
244
|
+
? 'Recognizing speech…'
|
|
245
|
+
: state.listening
|
|
246
|
+
? 'Listening — waiting for speech'
|
|
247
|
+
: state.conversation
|
|
248
|
+
? 'Conversation idle'
|
|
249
|
+
: 'Voice ready';
|
|
238
250
|
return h(
|
|
239
251
|
'div',
|
|
240
252
|
{
|
|
@@ -382,6 +394,17 @@ export function createComponents(React) {
|
|
|
382
394
|
const settings = state.settings || {};
|
|
383
395
|
const capabilities = state.capabilities || {};
|
|
384
396
|
const tabsId = React.useId();
|
|
397
|
+
const [latestRelease, setLatestRelease] = React.useState(null);
|
|
398
|
+
React.useEffect(() => {
|
|
399
|
+
let active = true;
|
|
400
|
+
void checkLatestRelease().then((result) => {
|
|
401
|
+
if (active) setLatestRelease(result.release);
|
|
402
|
+
});
|
|
403
|
+
return () => {
|
|
404
|
+
active = false;
|
|
405
|
+
};
|
|
406
|
+
}, []);
|
|
407
|
+
const updateAvailable = hasNewerRelease(latestRelease);
|
|
385
408
|
const tabs = [
|
|
386
409
|
{ id: 'conversation', label: 'Conversation' },
|
|
387
410
|
{ id: 'speech', label: 'Speech' },
|
|
@@ -482,7 +505,73 @@ export function createComponents(React) {
|
|
|
482
505
|
return h(
|
|
483
506
|
'section',
|
|
484
507
|
{ className: 'dlv-settings', 'aria-label': 'Live Voice settings' },
|
|
485
|
-
h(
|
|
508
|
+
h(
|
|
509
|
+
'div',
|
|
510
|
+
{ className: 'dlv-settings-heading' },
|
|
511
|
+
h('h3', null, 'Live Voice'),
|
|
512
|
+
h(
|
|
513
|
+
'div',
|
|
514
|
+
{ className: 'dlv-version-badges', 'aria-label': 'Version information' },
|
|
515
|
+
h(
|
|
516
|
+
'a',
|
|
517
|
+
{
|
|
518
|
+
className: 'dlv-shields-badge',
|
|
519
|
+
href: RELEASES_URL,
|
|
520
|
+
target: '_blank',
|
|
521
|
+
rel: 'noreferrer',
|
|
522
|
+
'aria-label': `DSH Live Voice v${CURRENT_VERSION}. Open releases`,
|
|
523
|
+
title: `DSH Live Voice v${CURRENT_VERSION}`,
|
|
524
|
+
},
|
|
525
|
+
h('img', { src: LIVE_VOICE_BADGE_URL, alt: '' }),
|
|
526
|
+
h('span', null, `v${CURRENT_VERSION}`),
|
|
527
|
+
),
|
|
528
|
+
h(
|
|
529
|
+
'a',
|
|
530
|
+
{
|
|
531
|
+
className: 'dlv-shields-badge',
|
|
532
|
+
href: TESTED_DSH_RELEASE_URL,
|
|
533
|
+
target: '_blank',
|
|
534
|
+
rel: 'noreferrer',
|
|
535
|
+
'aria-label': `Compatible with DSH v${TESTED_DSH_VERSION}. Open release`,
|
|
536
|
+
title: `Compatible with DSH v${TESTED_DSH_VERSION}`,
|
|
537
|
+
},
|
|
538
|
+
h('img', { src: DSH_BADGE_URL, alt: '' }),
|
|
539
|
+
h('span', null, `v${TESTED_DSH_VERSION}`),
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
h('span', { className: 'dlv-heading-divider', 'aria-hidden': true }),
|
|
543
|
+
updateAvailable
|
|
544
|
+
? h(
|
|
545
|
+
'a',
|
|
546
|
+
{
|
|
547
|
+
className: 'dlv-version-badge dlv-update-badge',
|
|
548
|
+
href: latestRelease.url,
|
|
549
|
+
target: '_blank',
|
|
550
|
+
rel: 'noreferrer',
|
|
551
|
+
'aria-label': `Update available: ${latestRelease.tag}. Open release`,
|
|
552
|
+
title: `Update available: ${latestRelease.tag}`,
|
|
553
|
+
},
|
|
554
|
+
h('span', { className: 'dlv-update-icon', 'aria-hidden': true }, '↑'),
|
|
555
|
+
'Update available',
|
|
556
|
+
)
|
|
557
|
+
: null,
|
|
558
|
+
updateAvailable
|
|
559
|
+
? h('span', { className: 'dlv-heading-divider', 'aria-hidden': true })
|
|
560
|
+
: null,
|
|
561
|
+
h(
|
|
562
|
+
'a',
|
|
563
|
+
{
|
|
564
|
+
className: 'dlv-version-badge dlv-star-badge',
|
|
565
|
+
href: REPOSITORY_URL,
|
|
566
|
+
target: '_blank',
|
|
567
|
+
rel: 'noreferrer',
|
|
568
|
+
'aria-label': 'Star DSH Live Voice on GitHub',
|
|
569
|
+
title: 'Star DSH Live Voice on GitHub',
|
|
570
|
+
},
|
|
571
|
+
h('span', { className: 'dlv-star-icon', 'aria-hidden': true }, '★'),
|
|
572
|
+
'Star Us on GitHub',
|
|
573
|
+
),
|
|
574
|
+
),
|
|
486
575
|
onClose
|
|
487
576
|
? h(Button, { label: 'Close voice settings', icon: 'close', onClick: onClose })
|
|
488
577
|
: null,
|
|
@@ -709,11 +798,32 @@ export function createComponents(React) {
|
|
|
709
798
|
: { recognitionEngine: event.target.value },
|
|
710
799
|
),
|
|
711
800
|
},
|
|
712
|
-
h('option', { value: '
|
|
713
|
-
h('option', { value: '
|
|
714
|
-
h('option', { value: 'whisper-http' }, 'Whisper
|
|
801
|
+
h('option', { value: 'browser' }, 'Browser SpeechRecognition — Default option'),
|
|
802
|
+
h('option', { value: 'qwen-http' }, 'Qwen3 ASR — HTTP API'),
|
|
803
|
+
h('option', { value: 'whisper-http' }, 'Whisper — HTTP API'),
|
|
804
|
+
h(
|
|
805
|
+
'optgroup',
|
|
806
|
+
{ label: 'Coming Soon — vote on repo issues' },
|
|
807
|
+
h('option', { value: 'webgpu', disabled: true }, 'Browser WebGPU Inference — Soon'),
|
|
808
|
+
h(
|
|
809
|
+
'option',
|
|
810
|
+
{ value: 'sherpa-onnx', disabled: true },
|
|
811
|
+
'sherpa-onnx Streaming — Soon',
|
|
812
|
+
),
|
|
813
|
+
h('option', { value: 'parakeet', disabled: true }, 'NVIDIA Parakeet — Soon'),
|
|
814
|
+
h('option', { value: 'voxtral', disabled: true }, 'Voxtral Realtime — Soon'),
|
|
815
|
+
),
|
|
715
816
|
),
|
|
716
817
|
),
|
|
818
|
+
h(
|
|
819
|
+
'small',
|
|
820
|
+
{ className: 'dlv-recognition-engine-description' },
|
|
821
|
+
settings.recognitionEngine === 'qwen-http'
|
|
822
|
+
? 'HTTP API at the configured base URL (default: http://127.0.0.1:8080/). Compatible with POST /v1/audio/transcriptions.'
|
|
823
|
+
: settings.recognitionEngine === 'whisper-http'
|
|
824
|
+
? 'HTTP API at the configured DSH host URL (default: http://127.0.0.1:8080/inference). Audio uses the authenticated DSH host transcription route.'
|
|
825
|
+
: 'Uses the browser SpeechRecognition API. This is the default option.',
|
|
826
|
+
),
|
|
717
827
|
field('Input device', 'inputDeviceId', deviceOptions('audioinput', 'Microphone')),
|
|
718
828
|
settings.recognitionEngine === 'browser'
|
|
719
829
|
? h(
|
|
@@ -884,6 +994,28 @@ export function createComponents(React) {
|
|
|
884
994
|
null,
|
|
885
995
|
'Controls how long a pause must last before captured speech is sent for recognition.',
|
|
886
996
|
),
|
|
997
|
+
h(
|
|
998
|
+
'label',
|
|
999
|
+
{ className: 'dlv-setting-field' },
|
|
1000
|
+
'Maximum continuous speech (seconds)',
|
|
1001
|
+
h('input', {
|
|
1002
|
+
type: 'number',
|
|
1003
|
+
min: 10,
|
|
1004
|
+
max: 300,
|
|
1005
|
+
step: 1,
|
|
1006
|
+
value: settings.recognitionMaxUtteranceSeconds ?? 60,
|
|
1007
|
+
onChange: (event) => {
|
|
1008
|
+
const value = Number(event.target.value);
|
|
1009
|
+
if (Number.isInteger(value) && value >= 10 && value <= 300)
|
|
1010
|
+
invoke('updateSettings', { recognitionMaxUtteranceSeconds: value });
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
h(
|
|
1014
|
+
'small',
|
|
1015
|
+
null,
|
|
1016
|
+
'If speech never pauses, start a new transcription chunk after this duration. Default: 60 seconds.',
|
|
1017
|
+
),
|
|
1018
|
+
),
|
|
887
1019
|
h(
|
|
888
1020
|
'div',
|
|
889
1021
|
{
|
|
@@ -959,6 +1091,22 @@ export function createComponents(React) {
|
|
|
959
1091
|
? 'Sending or steering a new user message stops current or paused assistant speech.'
|
|
960
1092
|
: 'Sending another message does not stop the assistant audio you are already hearing.',
|
|
961
1093
|
),
|
|
1094
|
+
h(
|
|
1095
|
+
'label',
|
|
1096
|
+
{ key: 'hold-to-talk', className: 'dlv-check' },
|
|
1097
|
+
h('input', {
|
|
1098
|
+
type: 'checkbox',
|
|
1099
|
+
checked: settings.holdToTalkEnabled !== false,
|
|
1100
|
+
onChange: (event) =>
|
|
1101
|
+
invoke('updateSettings', { holdToTalkEnabled: event.target.checked }),
|
|
1102
|
+
}),
|
|
1103
|
+
' Hold Control to talk',
|
|
1104
|
+
),
|
|
1105
|
+
h(
|
|
1106
|
+
'p',
|
|
1107
|
+
{ key: 'hold-to-talk-description', className: 'dlv-setting-description' },
|
|
1108
|
+
'While a composer is open, hold Control anywhere on the page to capture speech. Release it to flush queued transcription, wait the configured send delay, queue the message, and close voice capture. Press Escape while holding to cancel.',
|
|
1109
|
+
),
|
|
962
1110
|
field('Listening mode', 'mode', [
|
|
963
1111
|
{ value: 'speaker', label: 'Speakers — gated listening' },
|
|
964
1112
|
{ value: 'headphones', label: 'Headphones — open microphone' },
|
package/src/client/index.ts
CHANGED
|
@@ -13,7 +13,12 @@ import { QwenHttpRecognitionEngine } from '../engines/recognition/qwen-http.ts';
|
|
|
13
13
|
import { QwenHttpSpeakingEngine } from '../engines/speaking/qwen-http.ts';
|
|
14
14
|
import { createComponents } from './components.ts';
|
|
15
15
|
import { styles } from './styles.ts';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
assistantMessages,
|
|
18
|
+
addressedTurn,
|
|
19
|
+
latestUserSequence,
|
|
20
|
+
pendingQuestionSpeech,
|
|
21
|
+
} from './chat.ts';
|
|
17
22
|
|
|
18
23
|
export const inject = ['slots', 'connection', 'uiConversation', 'uiSession'];
|
|
19
24
|
export function apply(ctx) {
|
|
@@ -27,16 +32,27 @@ export function apply(ctx) {
|
|
|
27
32
|
// composer should inherit the user's explicit choice to remain in voice mode.
|
|
28
33
|
let voiceModeActive = false;
|
|
29
34
|
const ownership = new VoiceOwnership();
|
|
35
|
+
const recognitionSettingKeys = new Set([
|
|
36
|
+
'recognitionEngine',
|
|
37
|
+
'recognitionProcessLocally',
|
|
38
|
+
'recognitionAutoInstall',
|
|
39
|
+
'voiceDetectionPreset',
|
|
40
|
+
'recognitionMaxUtteranceSeconds',
|
|
41
|
+
]);
|
|
42
|
+
const changesRecognition = (next) =>
|
|
43
|
+
Object.keys(next).some((key) => recognitionSettingKeys.has(key));
|
|
30
44
|
const recognitionFor = (settings, meter) =>
|
|
31
45
|
settings.recognitionEngine === 'qwen-http'
|
|
32
46
|
? new QwenHttpRecognitionEngine({
|
|
33
47
|
meter,
|
|
34
48
|
voiceDetectionPreset: settings.voiceDetectionPreset,
|
|
49
|
+
maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds,
|
|
35
50
|
})
|
|
36
51
|
: settings.recognitionEngine === 'whisper-http'
|
|
37
52
|
? new WhisperHttpRecognitionEngine({
|
|
38
53
|
meter,
|
|
39
54
|
voiceDetectionPreset: settings.voiceDetectionPreset,
|
|
55
|
+
maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds,
|
|
40
56
|
})
|
|
41
57
|
: new BrowserRecognitionEngine({
|
|
42
58
|
processLocally: settings.recognitionProcessLocally,
|
|
@@ -116,7 +132,8 @@ export function apply(ctx) {
|
|
|
116
132
|
if (capture.index < capture.interaction.questions.length) {
|
|
117
133
|
capture.answering = false;
|
|
118
134
|
const text = pendingQuestionSpeech(capture.interaction, capture.index);
|
|
119
|
-
if (text)
|
|
135
|
+
if (text)
|
|
136
|
+
run(entry.controller, entry.controller.speak(text, capture.interaction.key));
|
|
120
137
|
} else {
|
|
121
138
|
entry.questionCapture = null;
|
|
122
139
|
entry.controller.patch({ answeringQuestion: false });
|
|
@@ -228,7 +245,7 @@ export function apply(ctx) {
|
|
|
228
245
|
return original(...args);
|
|
229
246
|
};
|
|
230
247
|
}
|
|
231
|
-
for (const method of ['startDictation', 'startConversation', 'speak']) {
|
|
248
|
+
for (const method of ['startDictation', 'startHoldToTalk', 'startConversation', 'speak']) {
|
|
232
249
|
const original = controller[method].bind(controller);
|
|
233
250
|
controller[method] = (...args) => {
|
|
234
251
|
if (disposed || entry.closed || !entry.refs) return Promise.resolve();
|
|
@@ -351,13 +368,11 @@ export function apply(ctx) {
|
|
|
351
368
|
let settingsRevision = 0;
|
|
352
369
|
c.updateSettings = async (next) => {
|
|
353
370
|
const revision = ++settingsRevision;
|
|
354
|
-
const previousEngine = c.getSnapshot().settings.recognitionEngine;
|
|
355
371
|
update(next);
|
|
356
372
|
browser.lang = c.getSnapshot().settings.lang;
|
|
357
373
|
qwen.lang = c.getSnapshot().settings.lang;
|
|
358
374
|
const settings = c.getSnapshot().settings;
|
|
359
|
-
if (
|
|
360
|
-
c.replaceRecognition(recognitionFor(settings, c.meter));
|
|
375
|
+
if (changesRecognition(next)) c.replaceRecognition(recognitionFor(settings, c.meter));
|
|
361
376
|
localStorage.setItem('dsh-live-voice.settings', JSON.stringify(settings));
|
|
362
377
|
run(c, c.refreshCapabilities());
|
|
363
378
|
const active = [...controllers.values()];
|
|
@@ -368,9 +383,7 @@ export function apply(ctx) {
|
|
|
368
383
|
if (revision !== settingsRevision || disposed || c.disposed) return;
|
|
369
384
|
for (const entry of controllers.values()) {
|
|
370
385
|
if (entry.closed) continue;
|
|
371
|
-
if (
|
|
372
|
-
entry.controller.getSnapshot().settings.recognitionEngine !== settings.recognitionEngine
|
|
373
|
-
)
|
|
386
|
+
if (changesRecognition(next))
|
|
374
387
|
entry.controller.replaceRecognition(recognitionFor(settings, entry.controller.meter));
|
|
375
388
|
entry.applySettings(settings);
|
|
376
389
|
run(entry.controller, entry.controller.refreshCapabilities());
|
|
@@ -488,28 +501,49 @@ export function apply(ctx) {
|
|
|
488
501
|
for (const entry of controllers.values())
|
|
489
502
|
run(entry.controller, entry.controller.endConversation());
|
|
490
503
|
};
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
disposed ||
|
|
494
|
-
event.defaultPrevented ||
|
|
495
|
-
!event.ctrlKey ||
|
|
496
|
-
!event.shiftKey ||
|
|
497
|
-
event.code !== 'Space' ||
|
|
498
|
-
event.repeat
|
|
499
|
-
)
|
|
500
|
-
return;
|
|
504
|
+
let holdToTalk = null;
|
|
505
|
+
const candidate = () => {
|
|
501
506
|
const candidates = [...controllers.values()].filter(
|
|
502
507
|
(entry) => entry.buttons > 0 && entry.composers.size > 0,
|
|
503
508
|
);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
509
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
510
|
+
};
|
|
511
|
+
const releaseHoldToTalk = () => {
|
|
512
|
+
const entry = holdToTalk;
|
|
513
|
+
holdToTalk = null;
|
|
514
|
+
if (!entry || entry.closed) return;
|
|
515
|
+
run(entry.controller, entry.controller.releaseHoldToTalk());
|
|
516
|
+
};
|
|
517
|
+
const onKeyDown = (event) => {
|
|
518
|
+
if (disposed || event.defaultPrevented || event.repeat) return;
|
|
519
|
+
if (event.key === 'Escape' && holdToTalk) {
|
|
520
|
+
const entry = holdToTalk;
|
|
521
|
+
holdToTalk = null;
|
|
522
|
+
event.preventDefault();
|
|
523
|
+
run(entry.controller, entry.controller.cancelDictation());
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (event.ctrlKey && event.shiftKey && event.code === 'Space') {
|
|
527
|
+
const entry = candidate();
|
|
528
|
+
if (!entry) return;
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
const c = entry.controller;
|
|
531
|
+
run(
|
|
532
|
+
c,
|
|
533
|
+
c.getSnapshot().listening || c.getSnapshot().starting
|
|
534
|
+
? c.stopListening()
|
|
535
|
+
: c.startDictation(),
|
|
536
|
+
);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (event.key !== 'Control' || event.altKey || event.metaKey || event.shiftKey) return;
|
|
540
|
+
const entry = candidate();
|
|
541
|
+
if (!entry || !entry.controller.getSnapshot().settings.holdToTalkEnabled) return;
|
|
542
|
+
holdToTalk = entry;
|
|
543
|
+
run(entry.controller, entry.controller.startHoldToTalk());
|
|
544
|
+
};
|
|
545
|
+
const onKeyUp = (event) => {
|
|
546
|
+
if (event.key === 'Control') releaseHoldToTalk();
|
|
513
547
|
};
|
|
514
548
|
const refreshCapabilities = () => {
|
|
515
549
|
for (const entry of controllers.values())
|
|
@@ -522,7 +556,9 @@ export function apply(ctx) {
|
|
|
522
556
|
window.speechSynthesis?.addEventListener?.('voiceschanged', refreshCapabilities);
|
|
523
557
|
navigator.mediaDevices?.addEventListener?.('devicechange', refreshCapabilities);
|
|
524
558
|
document.addEventListener('visibilitychange', visibilityChanged);
|
|
525
|
-
document.addEventListener('keydown',
|
|
559
|
+
document.addEventListener('keydown', onKeyDown);
|
|
560
|
+
document.addEventListener('keyup', onKeyUp);
|
|
561
|
+
window.addEventListener('blur', releaseHoldToTalk);
|
|
526
562
|
window.addEventListener('pagehide', stop);
|
|
527
563
|
return () => {
|
|
528
564
|
disposed = true;
|
|
@@ -530,7 +566,9 @@ export function apply(ctx) {
|
|
|
530
566
|
window.speechSynthesis?.removeEventListener?.('voiceschanged', refreshCapabilities);
|
|
531
567
|
navigator.mediaDevices?.removeEventListener?.('devicechange', refreshCapabilities);
|
|
532
568
|
document.removeEventListener('visibilitychange', visibilityChanged);
|
|
533
|
-
document.removeEventListener('keydown',
|
|
569
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
570
|
+
document.removeEventListener('keyup', onKeyUp);
|
|
571
|
+
window.removeEventListener('blur', releaseHoldToTalk);
|
|
534
572
|
window.removeEventListener('pagehide', stop);
|
|
535
573
|
for (const entry of controllers.values()) retire(entry);
|
|
536
574
|
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
declare const __DLV_VERSION__: string;
|
|
2
|
+
declare const __DLV_TESTED_DSH_VERSION__: string;
|
|
3
|
+
|
|
4
|
+
// Both values are injected from package.json by scripts/build.ts.
|
|
5
|
+
export const CURRENT_VERSION = __DLV_VERSION__;
|
|
6
|
+
export const TESTED_DSH_VERSION = __DLV_TESTED_DSH_VERSION__;
|
|
7
|
+
export const REPOSITORY_URL = 'https://github.com/victorwads/dsh-live-voice';
|
|
8
|
+
export const RELEASES_URL = `${REPOSITORY_URL}/releases`;
|
|
9
|
+
export const LATEST_RELEASE_API_URL =
|
|
10
|
+
'https://api.github.com/repos/victorwads/dsh-live-voice/releases/latest';
|
|
11
|
+
export const RELEASE_CHECK_STORAGE_KEY = 'dsh-live-voice.latest-release-check';
|
|
12
|
+
export const RELEASE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export const LIVE_VOICE_BADGE_URL = 'https://cdn.simpleicons.org/npm/white';
|
|
15
|
+
export const DSH_BADGE_URL = 'https://cdn.simpleicons.org/deepseek/white';
|
|
16
|
+
export const TESTED_DSH_RELEASE_URL = `https://github.com/deepseek-ai/deepseek-harness/releases/tag/v${TESTED_DSH_VERSION}`;
|
|
17
|
+
|
|
18
|
+
function versionParts(version) {
|
|
19
|
+
const normalized = String(version || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^v/i, '')
|
|
22
|
+
.split('+', 1)[0];
|
|
23
|
+
const [core = '', prerelease = ''] = normalized.split('-', 2);
|
|
24
|
+
const numbers = core.split('.').map((part) => {
|
|
25
|
+
const match = part.match(/^\d+/);
|
|
26
|
+
return match ? Number(match[0]) : 0;
|
|
27
|
+
});
|
|
28
|
+
while (numbers.length < 3) numbers.push(0);
|
|
29
|
+
return { numbers, prerelease: prerelease ? prerelease.split('.') : [] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareIdentifier(left, right) {
|
|
33
|
+
const leftNumber = /^\d+$/.test(left) ? Number(left) : null;
|
|
34
|
+
const rightNumber = /^\d+$/.test(right) ? Number(right) : null;
|
|
35
|
+
if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
|
|
36
|
+
if (leftNumber !== null) return -1;
|
|
37
|
+
if (rightNumber !== null) return 1;
|
|
38
|
+
return left.localeCompare(right);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function compareVersions(left, right) {
|
|
42
|
+
const a = versionParts(left);
|
|
43
|
+
const b = versionParts(right);
|
|
44
|
+
for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index++) {
|
|
45
|
+
const difference = (a.numbers[index] || 0) - (b.numbers[index] || 0);
|
|
46
|
+
if (difference) return Math.sign(difference);
|
|
47
|
+
}
|
|
48
|
+
if (!a.prerelease.length && !b.prerelease.length) return 0;
|
|
49
|
+
if (!a.prerelease.length) return 1;
|
|
50
|
+
if (!b.prerelease.length) return -1;
|
|
51
|
+
for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
|
|
52
|
+
if (a.prerelease[index] === undefined) return -1;
|
|
53
|
+
if (b.prerelease[index] === undefined) return 1;
|
|
54
|
+
const difference = compareIdentifier(a.prerelease[index], b.prerelease[index]);
|
|
55
|
+
if (difference) return difference;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readCached(storage, now) {
|
|
61
|
+
try {
|
|
62
|
+
const cached = JSON.parse(storage?.getItem(RELEASE_CHECK_STORAGE_KEY) || 'null');
|
|
63
|
+
if (
|
|
64
|
+
cached &&
|
|
65
|
+
Number.isFinite(cached.checkedAt) &&
|
|
66
|
+
now - cached.checkedAt >= 0 &&
|
|
67
|
+
now - cached.checkedAt < RELEASE_CHECK_INTERVAL_MS
|
|
68
|
+
)
|
|
69
|
+
return cached;
|
|
70
|
+
} catch {}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeCached(storage, value) {
|
|
75
|
+
try {
|
|
76
|
+
storage?.setItem(RELEASE_CHECK_STORAGE_KEY, JSON.stringify(value));
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function checkLatestRelease({
|
|
81
|
+
fetchImpl = globalThis.window?.fetch?.bind(globalThis.window),
|
|
82
|
+
storage = globalThis.window?.localStorage,
|
|
83
|
+
now = Date.now(),
|
|
84
|
+
} = {}) {
|
|
85
|
+
const cached = readCached(storage, now);
|
|
86
|
+
if (cached) return cached;
|
|
87
|
+
|
|
88
|
+
// Record the attempt before requesting so a failing public endpoint is not retried on every mount.
|
|
89
|
+
const attempted = { checkedAt: now, release: null };
|
|
90
|
+
writeCached(storage, attempted);
|
|
91
|
+
if (typeof fetchImpl !== 'function') return attempted;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetchImpl(LATEST_RELEASE_API_URL, {
|
|
95
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) return attempted;
|
|
98
|
+
const body = await response.json();
|
|
99
|
+
if (typeof body?.tag_name !== 'string' || !body.tag_name.trim()) return attempted;
|
|
100
|
+
const release = {
|
|
101
|
+
tag: body.tag_name.trim(),
|
|
102
|
+
url:
|
|
103
|
+
typeof body.html_url === 'string' && body.html_url.startsWith('https://github.com/')
|
|
104
|
+
? body.html_url
|
|
105
|
+
: RELEASES_URL,
|
|
106
|
+
};
|
|
107
|
+
const result = { checkedAt: now, release };
|
|
108
|
+
writeCached(storage, result);
|
|
109
|
+
return result;
|
|
110
|
+
} catch {
|
|
111
|
+
return attempted;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function hasNewerRelease(release, currentVersion = CURRENT_VERSION) {
|
|
116
|
+
return Boolean(release?.tag && compareVersions(release.tag, currentVersion) > 0);
|
|
117
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -29,12 +29,14 @@ export const styles = `
|
|
|
29
29
|
.dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
|
|
30
30
|
.dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
|
|
31
31
|
.dlv-settings h3,.dlv-settings p{margin:0}
|
|
32
|
+
.dlv-settings-heading{display:flex;align-items:center;gap:8px;min-width:0}.dlv-settings-heading h3{white-space:nowrap}.dlv-heading-divider{height:1px;min-width:12px;flex:1;background:var(--dsw-alias-border-l1)}.dlv-settings-heading .dlv-star-badge{flex:none}
|
|
33
|
+
.dlv-version-badges{display:flex;flex:none;gap:5px;align-items:center}.dlv-shields-badge{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;height:20px;gap:4px;padding:3px 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:650;line-height:12px;text-decoration:none}.dlv-shields-badge img{display:block;width:12px;height:12px;max-width:100%}.dlv-shields-badge:focus-visible{border-radius:4px;outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}.dlv-version-badge{display:inline-flex;align-items:center;min-height:24px;box-sizing:border-box;padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;line-height:16px;text-decoration:none}.dlv-version-badges a.dlv-version-badge:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}.dlv-star-badge{padding:4px 10px;color:var(--dsw-alias-label-primary)}.dlv-update-icon{margin-right:4px;font-size:14px;line-height:1}.dlv-star-icon{margin-right:4px;color:#f59e0b;font-size:14px;line-height:1;text-shadow:0 0 8px rgba(245,158,11,.28)}.dlv-version-badges a.dlv-star-badge:hover{border-color:#f59e0b;background:rgba(245,158,11,.08)}.dlv-update-badge{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
|
|
32
34
|
.dlv-settings-tabs{border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;gap:22px;overflow-x:auto}.dlv-settings-tab{position:relative;flex:none;padding:7px 1px 9px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}.dlv-settings-tab:hover,.dlv-settings-tab[data-active=true]{color:var(--dsw-alias-label-primary)}.dlv-settings-tab[data-active=true]:after,.dlv-settings-tab:focus-visible:after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;background:var(--dsw-alias-label-primary)}.dlv-settings-tab:focus-visible{border-radius:2px;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary)}.dlv-settings-panel{min-width:0;padding-top:2px}.dlv-settings-panel[hidden]{display:none}.dlv-settings-group{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0;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-body{display:grid;gap:12px;padding:12px 0}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:10px}.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-subcard>summary:after{content:"›";transform:rotate(90deg);transition:transform .15s}.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
|
|
33
35
|
.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}
|
|
34
36
|
.dlv-settings :is(input,select,textarea){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}
|
|
35
37
|
.dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
|
|
36
38
|
.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}
|
|
37
39
|
.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}
|
|
38
|
-
@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}}
|
|
40
|
+
@media(max-width:480px){.dlv-settings-heading{gap:6px}.dlv-heading-divider{min-width:4px}.dlv-settings-heading .dlv-star-badge{padding:3px 6px;font-size:10px}.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
|
|
39
41
|
`;
|
|
40
42
|
export default styles;
|