dsh-live-voice 0.3.2 → 0.4.1
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/README.md +48 -14
- package/lib/client.js +6474 -3396
- package/lib/server.js +29 -6
- package/package.json +9 -30
- package/src/app/AGENTS.md +1 -1
- package/src/app/ARCHITECTURE.md +1 -1
- package/src/app/client/apply.tsx +242 -35
- package/src/app/client/composerSelection.ts +92 -0
- package/src/app/client/i18n/catalogs/base.ts +35 -0
- package/src/app/client/i18n/catalogs/en.ts +43 -3
- package/src/app/client/i18n/catalogs/es.ts +51 -11
- package/src/app/client/i18n/catalogs/fr.ts +52 -11
- package/src/app/client/i18n/catalogs/hi.ts +50 -11
- package/src/app/client/i18n/catalogs/pt-BR.ts +52 -11
- package/src/app/client/i18n/catalogs/zh.ts +50 -11
- package/src/modules/AGENTS.md +1 -1
- package/src/modules/conversation/ARCHITECTURE.md +6 -2
- package/src/modules/conversation/components/AutoPlaybackToggle.tsx +31 -0
- package/src/modules/conversation/components/ConversationControls.tsx +23 -8
- package/src/modules/conversation/components/ConversationStatusBar.tsx +91 -93
- package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
- package/src/modules/conversation/components/MeetingControls.tsx +40 -0
- package/src/modules/conversation/components/MicrophoneButton.tsx +15 -2
- package/src/modules/conversation/components/PlaybackControls.tsx +20 -5
- package/src/modules/conversation/components/RecognitionBar.tsx +14 -0
- package/src/modules/conversation/components/ScrollingSpeechCaption.tsx +100 -0
- package/src/modules/conversation/components/SpeechStatusBar.tsx +99 -0
- package/src/modules/conversation/components/Waveform.tsx +8 -3
- package/src/modules/conversation/components/conversationStatus.ts +1 -3
- package/src/modules/conversation/components/createConversationComponents.tsx +1 -1
- package/src/modules/conversation/components/index.ts +1 -0
- package/src/modules/conversation/components/speechCaption.ts +27 -0
- package/src/modules/conversation/models/chat.ts +15 -2
- package/src/modules/conversation/models/meeting.ts +256 -0
- package/src/modules/conversation/models/meetingTranscript.ts +63 -0
- package/src/modules/core/ARCHITECTURE.md +1 -1
- package/src/modules/core/coordinator.ts +353 -81
- package/src/modules/core/developerExtension.ts +26 -0
- package/src/modules/core/diagnostics.ts +153 -0
- package/src/modules/core/filters.ts +20 -15
- package/src/modules/core/markdownSpeech.ts +154 -0
- package/src/modules/core/settings.ts +17 -13
- package/src/modules/core/sharedAudio.ts +88 -0
- package/src/modules/core/speechDefaults.ts +21 -0
- package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
- package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
- package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
- package/src/modules/settings/components/SettingsHeader.tsx +1 -1
- package/src/modules/settings/models/settingsStorage.ts +11 -3
- package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
- package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
- package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -1
- package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +12 -1
- package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
- package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
- package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
- package/src/shared/design-system/icons/icons.ts +6 -0
- package/src/styles/index.ts +13 -3
- package/CHANGELOG.md +0 -240
- package/DEVELOPMENT.md +0 -83
- package/PLAN.md +0 -296
- package/docs/ARCHITECTURE.md +0 -33
- package/docs/CHOOSING-AN-ENGINE.md +0 -120
- package/docs/CONFIGURATION.md +0 -163
- package/docs/REVIEW.md +0 -35
- package/docs/VOICE-LIFECYCLE.md +0 -63
- package/scripts/build.ts +0 -62
- package/scripts/check-dist.mjs +0 -38
- package/scripts/preview-ui.ts +0 -31
- package/scripts/probe-browser.ts +0 -46
package/lib/server.js
CHANGED
|
@@ -4,6 +4,26 @@ import { homedir as homedir2 } from "node:os";
|
|
|
4
4
|
import { dirname as dirname2, join as join2 } from "node:path";
|
|
5
5
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
6
6
|
|
|
7
|
+
// src/modules/core/speechDefaults.ts
|
|
8
|
+
var notices = {
|
|
9
|
+
en: "You can see the code in our conversation history.",
|
|
10
|
+
pt: "Voc\xEA pode ver o c\xF3digo no hist\xF3rico da nossa conversa.",
|
|
11
|
+
es: "Puedes ver el c\xF3digo en el historial de nuestra conversaci\xF3n.",
|
|
12
|
+
fr: "Vous pouvez voir le code dans l\u2019historique de notre conversation.",
|
|
13
|
+
hi: "\u0906\u092A \u0939\u092E\u093E\u0930\u0940 \u092C\u093E\u0924\u091A\u0940\u0924 \u0915\u0947 \u0907\u0924\u093F\u0939\u093E\u0938 \u092E\u0947\u0902 \u0915\u094B\u0921 \u0926\u0947\u0916 \u0938\u0915\u0924\u0947 \u0939\u0948\u0902\u0964",
|
|
14
|
+
zh: "\u4F60\u53EF\u4EE5\u5728\u6211\u4EEC\u7684\u5BF9\u8BDD\u5386\u53F2\u4E2D\u67E5\u770B\u4EE3\u7801\u3002"
|
|
15
|
+
};
|
|
16
|
+
var defaults = /* @__PURE__ */ new Set([
|
|
17
|
+
...Object.values(notices),
|
|
18
|
+
"Look the code on out conversation",
|
|
19
|
+
"You can see the code on our conversation history.",
|
|
20
|
+
"You can see the code on our conversation history"
|
|
21
|
+
]);
|
|
22
|
+
function resolveCodeNotice(value, lang = "en") {
|
|
23
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
24
|
+
return !text || defaults.has(text) ? notices[String(lang).toLowerCase().split("-")[0]] || notices.en : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
// src/modules/core/settings.ts
|
|
8
28
|
var voiceDetectionPresets = Object.freeze({
|
|
9
29
|
short: Object.freeze({
|
|
@@ -38,6 +58,7 @@ var isQwenVoice = (value) => qwenVoices.some((voice) => voice.value === value);
|
|
|
38
58
|
var defaultAgentVoiceContext = `Live Voice output is active. Your entire user-facing response will be spoken aloud.
|
|
39
59
|
Be concise and conversational. Lead with the answer or next action. Avoid unnecessary repetition, long preambles, dense lists, raw code, paths, and verbose status narration.
|
|
40
60
|
Do not narrate routine tool activity by default. If the user explicitly asks you to keep them informed while working, provide brief spoken progress updates only at meaningful milestones.`;
|
|
61
|
+
var autoSendDelayOptions = Object.freeze([0.6, 0.8, 1, 2, 3, 4, 5, 6]);
|
|
41
62
|
var defaultSettings = Object.freeze({
|
|
42
63
|
engine: "browser",
|
|
43
64
|
recognitionEngine: "browser",
|
|
@@ -47,14 +68,15 @@ var defaultSettings = Object.freeze({
|
|
|
47
68
|
voiceDetectionCustomSilenceMs: 1e3,
|
|
48
69
|
recognitionMaxUtteranceSeconds: 60,
|
|
49
70
|
microphoneEnabled: true,
|
|
50
|
-
holdToTalkEnabled:
|
|
71
|
+
holdToTalkEnabled: false,
|
|
51
72
|
announceAssistantMessages: true,
|
|
52
73
|
agentVoiceContextEnabled: true,
|
|
53
74
|
agentVoiceContext: defaultAgentVoiceContext,
|
|
54
75
|
interruptSpeechOnUserMessage: false,
|
|
55
76
|
sendingMode: "manual",
|
|
77
|
+
dshBusyEnterBehavior: "queue",
|
|
56
78
|
autoSendDelaySeconds: 4,
|
|
57
|
-
assistantSpeechDelaySeconds:
|
|
79
|
+
assistantSpeechDelaySeconds: 0,
|
|
58
80
|
mode: "speaker",
|
|
59
81
|
lang: "pt-BR",
|
|
60
82
|
recognitionLang: "pt-BR",
|
|
@@ -73,7 +95,7 @@ var defaultSettings = Object.freeze({
|
|
|
73
95
|
voiceCommandClear: "clear all, clear message",
|
|
74
96
|
outputCodeFilterEnabled: true,
|
|
75
97
|
outputCodeMaxLines: 5,
|
|
76
|
-
outputCodeNotice:
|
|
98
|
+
outputCodeNotice: resolveCodeNotice(void 0, "pt-BR"),
|
|
77
99
|
rate: 1,
|
|
78
100
|
segmentGapMs: 400
|
|
79
101
|
});
|
|
@@ -97,9 +119,10 @@ function normalizeSettings(value) {
|
|
|
97
119
|
agentVoiceContextEnabled: typeof source.agentVoiceContextEnabled === "boolean" ? source.agentVoiceContextEnabled : defaultSettings.agentVoiceContextEnabled,
|
|
98
120
|
agentVoiceContext: typeof source.agentVoiceContext === "string" && source.agentVoiceContext.length <= 4e3 && !source.agentVoiceContext.includes("\0") ? source.agentVoiceContext.trim() : defaultSettings.agentVoiceContext,
|
|
99
121
|
interruptSpeechOnUserMessage: typeof source.interruptSpeechOnUserMessage === "boolean" ? source.interruptSpeechOnUserMessage : defaultSettings.interruptSpeechOnUserMessage,
|
|
122
|
+
dshBusyEnterBehavior: ["queue", "steer"].includes(source.dshBusyEnterBehavior) ? source.dshBusyEnterBehavior : defaultSettings.dshBusyEnterBehavior,
|
|
100
123
|
sendingMode: source.sendingMode === "automatic" ? "queue" : ["manual", "queue", "steer"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
|
|
101
|
-
autoSendDelaySeconds:
|
|
102
|
-
assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >=
|
|
124
|
+
autoSendDelaySeconds: autoSendDelayOptions.includes(source.autoSendDelaySeconds) ? source.autoSendDelaySeconds : defaultSettings.autoSendDelaySeconds,
|
|
125
|
+
assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >= 0 && source.assistantSpeechDelaySeconds <= 4 ? source.assistantSpeechDelaySeconds : defaultSettings.assistantSpeechDelaySeconds,
|
|
103
126
|
mode: ["speaker", "headphones"].includes(source.mode) ? source.mode : defaultSettings.mode,
|
|
104
127
|
lang: typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.lang,
|
|
105
128
|
recognitionLang: source.recognitionLang === "auto" || typeof source.recognitionLang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.recognitionLang) ? source.recognitionLang : typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.recognitionLang,
|
|
@@ -139,7 +162,7 @@ function normalizeSettings(value) {
|
|
|
139
162
|
),
|
|
140
163
|
outputCodeFilterEnabled: typeof source.outputCodeFilterEnabled === "boolean" ? source.outputCodeFilterEnabled : defaultSettings.outputCodeFilterEnabled,
|
|
141
164
|
outputCodeMaxLines: Number.isInteger(source.outputCodeMaxLines) && source.outputCodeMaxLines >= 0 && source.outputCodeMaxLines <= 100 ? source.outputCodeMaxLines : defaultSettings.outputCodeMaxLines,
|
|
142
|
-
outputCodeNotice: typeof source.outputCodeNotice === "string" && source.outputCodeNotice.trim() && source.outputCodeNotice.length <= 300 && !source.outputCodeNotice.includes("\0") ? source.outputCodeNotice.
|
|
165
|
+
outputCodeNotice: typeof source.outputCodeNotice === "string" && source.outputCodeNotice.trim() && source.outputCodeNotice.length <= 300 && !source.outputCodeNotice.includes("\0") ? resolveCodeNotice(source.outputCodeNotice, source.lang || defaultSettings.lang) : resolveCodeNotice(defaultSettings.outputCodeNotice, source.lang || defaultSettings.lang),
|
|
143
166
|
rate: Number.isFinite(source.rate) && source.rate >= 0.1 && source.rate <= 3 ? source.rate : 1,
|
|
144
167
|
segmentGapMs: Number.isFinite(source.segmentGapMs) && source.segmentGapMs >= 0 && source.segmentGapMs <= 2e3 ? Math.round(source.segmentGapMs) : 400
|
|
145
168
|
};
|
package/package.json
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live-voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"dshTestedVersion": "0.2.
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"dshTestedVersion": "0.2.1-alpha.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
7
|
+
"build": "node --experimental-strip-types ../../scripts/build.ts --package live-voice",
|
|
8
8
|
"test": "npm run build && node --test .test-dist/*.test.js",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"dev": "node --experimental-strip-types scripts/build.ts --watch",
|
|
12
|
-
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\"",
|
|
13
|
-
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"scripts/**/*.ts\"",
|
|
14
|
-
"setup:hooks": "git config core.hooksPath .githooks",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"dev": "node --experimental-strip-types ../../scripts/build.ts --package live-voice --watch",
|
|
15
11
|
"prepack": "npm run build",
|
|
16
12
|
"preview": "previewjs .",
|
|
17
13
|
"preview:check": "previewjs . --help"
|
|
@@ -62,12 +58,7 @@
|
|
|
62
58
|
"lib/",
|
|
63
59
|
"cordis.patch.yml",
|
|
64
60
|
"README.md",
|
|
65
|
-
"CHANGELOG.md",
|
|
66
|
-
"PLAN.md",
|
|
67
|
-
"DEVELOPMENT.md",
|
|
68
61
|
"HISTORY.md",
|
|
69
|
-
"docs/",
|
|
70
|
-
"scripts/",
|
|
71
62
|
"LICENSE"
|
|
72
63
|
],
|
|
73
64
|
"publishConfig": {
|
|
@@ -76,21 +67,8 @@
|
|
|
76
67
|
},
|
|
77
68
|
"repository": {
|
|
78
69
|
"type": "git",
|
|
79
|
-
"url": "git+https://github.com/victorwads/dsh-live-voice.git"
|
|
80
|
-
|
|
81
|
-
"devDependencies": {
|
|
82
|
-
"@previewjs/cli": "^1.30.1",
|
|
83
|
-
"@previewjs/plugin-react": "^17.1.0",
|
|
84
|
-
"@types/node": "26.6.2",
|
|
85
|
-
"@types/react": "19.3.0",
|
|
86
|
-
"@types/react-dom": "19.3.0",
|
|
87
|
-
"esbuild": "0.28.2",
|
|
88
|
-
"jsdom": "30.1.0",
|
|
89
|
-
"playwright-core": "1.63.0",
|
|
90
|
-
"prettier": "3.9.8",
|
|
91
|
-
"react": "19.3.0",
|
|
92
|
-
"react-dom": "19.3.0",
|
|
93
|
-
"typescript": "7.0.2"
|
|
70
|
+
"url": "git+https://github.com/victorwads/dsh-live-voice.git",
|
|
71
|
+
"directory": "packages/live-voice"
|
|
94
72
|
},
|
|
95
73
|
"main": "./lib/server.js",
|
|
96
74
|
"exports": {
|
|
@@ -115,6 +93,7 @@
|
|
|
115
93
|
},
|
|
116
94
|
"dependencies": {
|
|
117
95
|
"@wads.dev/i18n-react": "^0.0.1-alpha.0",
|
|
118
|
-
"@wads.dev/i18n-ts": "0.0.1-alpha.0"
|
|
96
|
+
"@wads.dev/i18n-ts": "0.0.1-alpha.0",
|
|
97
|
+
"marked": "^18.1.0"
|
|
119
98
|
}
|
|
120
99
|
}
|
package/src/app/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Application boundary
|
|
2
2
|
|
|
3
|
-
Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing composition roots, routes, slots, or language registration. Read the repository [AGENTS.md](
|
|
3
|
+
Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing composition roots, routes, slots, or language registration. Read the repository [AGENTS.md](../../../../AGENTS.md) for product, validation, and authorization rules.
|
|
4
4
|
|
|
5
5
|
- Inspect the actual DSH extension API before changing an integration point. Preserve established slot names, IDs, ordering, injected services, authenticated same-origin routes, and browser/host separation.
|
|
6
6
|
- Keep UI copy in the typed catalogs under `client/i18n/`; update every supported language in alphabetical key order. Use DSH `ctx.locale`, not an independent preference. UI locale is independent of recognition/synthesis languages and configured voice-command phrases.
|
package/src/app/ARCHITECTURE.md
CHANGED
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
- `client/i18n` owns the only UI translation tree: typed, alphabetized catalogs for every supported locale, runtime selection synchronized with `ctx.locale`, DSH registration, and the boundary wrapping slot components. UI language must not change STT/TTS language or user-defined command phrases.
|
|
8
8
|
- Styles are assembled from `src/styles/index.ts`; design-system primitives live under `src/shared/design-system` rather than here.
|
|
9
9
|
|
|
10
|
-
Read [the repository overview](
|
|
10
|
+
Read [the repository overview](../../../../docs/ARCHITECTURE.md) for dependency direction and stable contracts, and the relevant module architecture before editing feature behavior. Read [application agent guidance](AGENTS.md) for operational constraints.
|
package/src/app/client/apply.tsx
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { SpeechStatusBar } from '../../modules/conversation/components/SpeechStatusBar.js';
|
|
4
|
+
import { MeetingController } from '../../modules/conversation/models/meeting.js';
|
|
5
|
+
import { SharedAudioMeter, requestSharedAudio } from '../../modules/core/sharedAudio.js';
|
|
6
|
+
import {
|
|
7
|
+
MeetingToggle,
|
|
8
|
+
MeetingBars,
|
|
9
|
+
} from '../../modules/conversation/components/MeetingControls.js';
|
|
3
10
|
import { createPortal } from 'react-dom';
|
|
11
|
+
import { preserveComposerSelection } from './composerSelection.js';
|
|
4
12
|
import { MicrophoneMeter, VoiceCoordinator, VoiceOwnership } from '../../modules/core/index.js';
|
|
5
13
|
import {
|
|
6
14
|
BrowserRecognitionEngine,
|
|
@@ -22,6 +30,10 @@ import { registerLiveVoiceLocales } from './i18n/index.js';
|
|
|
22
30
|
import { registerConversationSlots, registerSettingsSlot } from './registerSlots.js';
|
|
23
31
|
import { styles } from '../../styles/index.js';
|
|
24
32
|
import { createSettingsClient } from '../../modules/settings/models/settingsStorage.js';
|
|
33
|
+
import {
|
|
34
|
+
publishDiagnosticSource,
|
|
35
|
+
readCoordinatorDiagnostics,
|
|
36
|
+
} from '../../modules/core/diagnostics.js';
|
|
25
37
|
import {
|
|
26
38
|
assistantMessages,
|
|
27
39
|
addressedTurn,
|
|
@@ -42,6 +54,80 @@ export function apply(ctx) {
|
|
|
42
54
|
// A route change may replace every conversation slot, but the next committed
|
|
43
55
|
// composer should inherit the user's explicit choice to remain in voice mode.
|
|
44
56
|
let voiceModeActive = false;
|
|
57
|
+
let meetingEntry = null;
|
|
58
|
+
let sharedSpeechActive = false;
|
|
59
|
+
const meeting = new MeetingController({
|
|
60
|
+
onActivity: (source, active) => {
|
|
61
|
+
if (source !== 'shared') return;
|
|
62
|
+
sharedSpeechActive = active;
|
|
63
|
+
meetingEntry?.controller.setExternalSpeechActivity(active);
|
|
64
|
+
meetingEntry?.controller.refreshDeliveryReadiness();
|
|
65
|
+
},
|
|
66
|
+
onSpeech: (source, text) =>
|
|
67
|
+
source !== 'shared' || meetingEntry?.controller.confirmExternalSpeech(text) !== false,
|
|
68
|
+
stopSpeech: () => meetingEntry?.controller.stopSpeech(),
|
|
69
|
+
composer: {
|
|
70
|
+
getDraft: () => meetingEntry?.draft || '',
|
|
71
|
+
setDraft: (text) => {
|
|
72
|
+
meetingEntry?.controller.cancelAutoSend();
|
|
73
|
+
meetingEntry?.controller.composer.setDraft(text);
|
|
74
|
+
},
|
|
75
|
+
submit: (mode) => {
|
|
76
|
+
meeting.cancelDelivery();
|
|
77
|
+
meetingEntry?.controller.cancelAutoSend();
|
|
78
|
+
meetingEntry?.controller.composer.submit(mode);
|
|
79
|
+
},
|
|
80
|
+
updatePlaybackSettings: (next) => meetingEntry?.controller.updateSettings(next),
|
|
81
|
+
handleQuestionResult: (result) => meetingEntry?.controller.composer.handleQuestionResult?.(result),
|
|
82
|
+
canAutoSend: () => !!meetingEntry && !meetingEntry.controller.getSnapshot().recognizing &&
|
|
83
|
+
!meetingEntry.controller.getSnapshot().pendingTranscriptions,
|
|
84
|
+
},
|
|
85
|
+
settings: () => preferences.getSnapshot(),
|
|
86
|
+
translate: (key) => t('dsh-live-voice.meeting.' + key),
|
|
87
|
+
createSource: (source, settings) => {
|
|
88
|
+
const meter = source === 'shared' ? new SharedAudioMeter() : new MicrophoneMeter();
|
|
89
|
+
return { meter, engine: recognitionFor(settings, meter) };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const unsubscribeMeetingDelivery = meeting.subscribe(() => meetingEntry?.controller.refreshDeliveryReadiness());
|
|
93
|
+
ctx.effect(() => () => unsubscribeMeetingDelivery(), 'dsh-live-voice: remove meeting delivery subscription');
|
|
94
|
+
const diagnosticListeners = new Set();
|
|
95
|
+
const notifyDiagnostics = () => {
|
|
96
|
+
for (const listener of diagnosticListeners) {
|
|
97
|
+
try {
|
|
98
|
+
listener();
|
|
99
|
+
} catch {
|
|
100
|
+
/* Observers never own voice behavior. */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
ctx.effect(() => {
|
|
105
|
+
const unpublish = publishDiagnosticSource(window, {
|
|
106
|
+
version: 1,
|
|
107
|
+
read: ({ includeContent = false } = {}) => ({
|
|
108
|
+
application: { disposed, voiceModeActive, retiring: retiring.size },
|
|
109
|
+
sessions: Object.fromEntries(
|
|
110
|
+
[...controllers].map(([id, entry]) => [
|
|
111
|
+
id,
|
|
112
|
+
readCoordinatorDiagnostics(entry.controller, includeContent),
|
|
113
|
+
]),
|
|
114
|
+
),
|
|
115
|
+
settingsControllers: [...settingsControllers].map((controller) =>
|
|
116
|
+
readCoordinatorDiagnostics(controller, includeContent),
|
|
117
|
+
),
|
|
118
|
+
}),
|
|
119
|
+
subscribe: (listener) => {
|
|
120
|
+
diagnosticListeners.add(listener);
|
|
121
|
+
return () => {
|
|
122
|
+
diagnosticListeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return () => {
|
|
127
|
+
unpublish();
|
|
128
|
+
diagnosticListeners.clear();
|
|
129
|
+
};
|
|
130
|
+
}, 'dsh-live-voice: diagnostic bridge');
|
|
45
131
|
const publishVoiceContext = (
|
|
46
132
|
entry,
|
|
47
133
|
active = entry?.controller?.getSnapshot().conversation === true,
|
|
@@ -68,6 +154,8 @@ export function apply(ctx) {
|
|
|
68
154
|
const ownership = new VoiceOwnership();
|
|
69
155
|
const recognitionSettingKeys = new Set([
|
|
70
156
|
'recognitionEngine',
|
|
157
|
+
'recognitionLang',
|
|
158
|
+
'inputDeviceId',
|
|
71
159
|
'recognitionProcessLocally',
|
|
72
160
|
'recognitionAutoInstall',
|
|
73
161
|
'voiceDetectionPreset',
|
|
@@ -111,11 +199,9 @@ export function apply(ctx) {
|
|
|
111
199
|
}
|
|
112
200
|
const unsubscribePreferences = preferences.subscribe((settings) => {
|
|
113
201
|
if (disposed) return;
|
|
202
|
+
meeting.applySettings(settings);
|
|
114
203
|
for (const entry of controllers.values()) {
|
|
115
204
|
if (entry.closed) continue;
|
|
116
|
-
const previous = entry.controller.getSnapshot().settings;
|
|
117
|
-
if ([...recognitionSettingKeys].some((key) => previous[key] !== settings[key]))
|
|
118
|
-
entry.controller.replaceRecognition(recognitionFor(settings, entry.controller.meter));
|
|
119
205
|
entry.applySettings(settings);
|
|
120
206
|
}
|
|
121
207
|
for (const c of settingsControllers) {
|
|
@@ -135,6 +221,11 @@ export function apply(ctx) {
|
|
|
135
221
|
function retire(entry) {
|
|
136
222
|
if (entry.closed) return;
|
|
137
223
|
entry.closed = true;
|
|
224
|
+
if (meetingEntry === entry) {
|
|
225
|
+
meeting.cancelDelivery();
|
|
226
|
+
meetingEntry = null;
|
|
227
|
+
meeting.transcript.setActive('microphone', false);
|
|
228
|
+
}
|
|
138
229
|
entry.request++;
|
|
139
230
|
entry.composers.clear();
|
|
140
231
|
entry.unsubscribe?.();
|
|
@@ -146,8 +237,10 @@ export function apply(ctx) {
|
|
|
146
237
|
entry.questionCapture = null;
|
|
147
238
|
entry.controller.patch({ answeringQuestion: false });
|
|
148
239
|
publishVoiceContext(entry, false);
|
|
240
|
+
entry.unsubscribeMeetingMicrophone?.();
|
|
149
241
|
entry.chatListeners.clear();
|
|
150
242
|
if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
|
|
243
|
+
notifyDiagnostics();
|
|
151
244
|
// Keep teardown in the hardware handoff barrier, not in the session registry.
|
|
152
245
|
const done = run(entry.controller, entry.controller.dispose());
|
|
153
246
|
const barrier = { endConversation: () => done };
|
|
@@ -185,21 +278,33 @@ export function apply(ctx) {
|
|
|
185
278
|
const meter = new MicrophoneMeter();
|
|
186
279
|
const recognition = recognitionFor(settings, meter);
|
|
187
280
|
entry.controller = new VoiceCoordinator({
|
|
281
|
+
persistSettings: (next) => savePreferences(entry.controller, next),
|
|
188
282
|
recognition,
|
|
189
283
|
engines: { browser: engineBrowser, say: engineSay, 'qwen-http': engineQwen },
|
|
190
284
|
meter,
|
|
191
285
|
composer: {
|
|
192
286
|
getDraft: () => entry.draft,
|
|
287
|
+
canAutoSend: () => !meeting.getSnapshot().shared.pending && !meeting.input().getSnapshot().recognizing,
|
|
288
|
+
appendFinal: (text, startedAt) => {
|
|
289
|
+
meeting.cancelDelivery();
|
|
290
|
+
if (meetingEntry === entry && (entry.meeting?.getSnapshot().shared.listening || entry.controller.getSnapshot().timestamps)) {
|
|
291
|
+
meeting.transcript.setSourceTimestamps('microphone', !!entry.controller.getSnapshot().timestamps);
|
|
292
|
+
entry.meeting.transcript.append('microphone', text, startedAt);
|
|
293
|
+
return entry.draft;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
},
|
|
193
297
|
submit: (mode = 'queue') => {
|
|
298
|
+
meeting.cancelDelivery();
|
|
194
299
|
const owner = [...entry.composers.values()].at(-1);
|
|
195
300
|
if (!owner) return;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
201
|
-
//
|
|
202
|
-
owner.submitAccelerated?.();
|
|
301
|
+
const busyEnter = entry.controller.getSnapshot().settings.dshBusyEnterBehavior;
|
|
302
|
+
if (mode !== busyEnter || busyEnter === 'steer') {
|
|
303
|
+
// InputActions.submit() always queues; it does not emulate normal Enter.
|
|
304
|
+
// Use an actual Enter gesture for steering-default composers, adding Ctrl
|
|
305
|
+
// only for the opposite mode. Read the preference at delivery time.
|
|
306
|
+
// Never fall back to normal submit: that would deliver the wrong mode.
|
|
307
|
+
owner.submitAccelerated?.(mode !== busyEnter);
|
|
203
308
|
return;
|
|
204
309
|
}
|
|
205
310
|
owner.actions.submit?.();
|
|
@@ -237,16 +342,23 @@ export function apply(ctx) {
|
|
|
237
342
|
if (entry.pendingDraft === undefined) entry.staleDrafts.clear();
|
|
238
343
|
entry.staleDrafts.add(entry.draft);
|
|
239
344
|
if (entry.publishedDraft !== undefined) entry.staleDrafts.add(entry.publishedDraft);
|
|
345
|
+
const previous = entry.draft;
|
|
240
346
|
entry.draft = text;
|
|
241
347
|
entry.pendingDraft = text;
|
|
242
348
|
entry.pendingRevision = entry.publishedRevision;
|
|
243
|
-
|
|
349
|
+
const append = text.length > previous.length && text.startsWith(previous);
|
|
350
|
+
if (append) preserveComposerSelection(() => owner.actions.setDraft(text));
|
|
351
|
+
else owner.actions.setDraft(text);
|
|
244
352
|
},
|
|
245
353
|
},
|
|
246
354
|
settings,
|
|
247
355
|
});
|
|
248
356
|
const controller = entry.controller;
|
|
249
|
-
entry.unsubscribeVoiceContext = controller.subscribe(() =>
|
|
357
|
+
entry.unsubscribeVoiceContext = controller.subscribe(() => {
|
|
358
|
+
publishVoiceContext(entry);
|
|
359
|
+
notifyDiagnostics();
|
|
360
|
+
});
|
|
361
|
+
notifyDiagnostics();
|
|
250
362
|
publishVoiceContext(entry);
|
|
251
363
|
entry.chat = ctx.uiConversation.binding(sessionId).target('chat');
|
|
252
364
|
entry.chatListeners = new Set();
|
|
@@ -256,9 +368,15 @@ export function apply(ctx) {
|
|
|
256
368
|
return () => entry.chatListeners.delete(listener);
|
|
257
369
|
};
|
|
258
370
|
entry.readChat = entry.chat.getSnapshot.bind(entry.chat);
|
|
259
|
-
const
|
|
371
|
+
const attachedAt = Date.now();
|
|
372
|
+
const observed = new Set();
|
|
373
|
+
let baselined = false;
|
|
374
|
+
const refresh = (forceBaseline = false) => {
|
|
260
375
|
if (disposed || entry.closed) return;
|
|
261
376
|
const snapshot = entry.readChat();
|
|
377
|
+
const baseline = forceBaseline || !baselined;
|
|
378
|
+
// Lazy Chat targets may publish an empty snapshot before loading history.
|
|
379
|
+
if ([...(snapshot?.nodes?.values?.() || [])].length) baselined = true;
|
|
262
380
|
const userSeq = latestUserSequence(snapshot);
|
|
263
381
|
if (
|
|
264
382
|
!baseline &&
|
|
@@ -268,11 +386,22 @@ export function apply(ctx) {
|
|
|
268
386
|
)
|
|
269
387
|
run(controller, controller.stopSpeech());
|
|
270
388
|
entry.userSeq = Math.max(entry.userSeq ?? -1, userSeq);
|
|
271
|
-
for (const message of assistantMessages(snapshot))
|
|
389
|
+
for (const message of assistantMessages(snapshot)) {
|
|
390
|
+
const historical =
|
|
391
|
+
!observed.has(message.id) &&
|
|
392
|
+
message.complete &&
|
|
393
|
+
Number.isFinite(message.time) &&
|
|
394
|
+
message.time < attachedAt;
|
|
272
395
|
controller.observeMessage(message.id, message.text, {
|
|
273
396
|
complete: message.complete,
|
|
274
|
-
baseline:
|
|
397
|
+
baseline:
|
|
398
|
+
forceBaseline ||
|
|
399
|
+
(baseline && (!Number.isFinite(message.time) || message.time < attachedAt)) ||
|
|
400
|
+
historical ||
|
|
401
|
+
message.interrupted,
|
|
275
402
|
});
|
|
403
|
+
observed.add(message.id);
|
|
404
|
+
}
|
|
276
405
|
};
|
|
277
406
|
refresh(true);
|
|
278
407
|
entry.unsubscribe = entry.chat.subscribe(() => {
|
|
@@ -309,7 +438,34 @@ export function apply(ctx) {
|
|
|
309
438
|
}
|
|
310
439
|
const update = controller.updateSettings.bind(controller);
|
|
311
440
|
entry.applySettings = (next) => {
|
|
441
|
+
const before = controller.getSnapshot();
|
|
312
442
|
update(next);
|
|
443
|
+
const settings = controller.getSnapshot().settings;
|
|
444
|
+
if ([...recognitionSettingKeys].some((key) => before.settings[key] !== settings[key])) {
|
|
445
|
+
if (!entry.restoreInput) entry.inputRestoreRequest = entry.request;
|
|
446
|
+
entry.restoreInput ||= Boolean(before.listening || before.starting);
|
|
447
|
+
const revision = (entry.inputSettingsRevision = (entry.inputSettingsRevision || 0) + 1);
|
|
448
|
+
entry.inputSettingsBarrier = (entry.inputSettingsBarrier || Promise.resolve())
|
|
449
|
+
.catch(() => {})
|
|
450
|
+
.then(async () => {
|
|
451
|
+
if (disposed || entry.closed || revision !== entry.inputSettingsRevision) return;
|
|
452
|
+
await VoiceCoordinator.prototype.stopListening.call(controller);
|
|
453
|
+
if (controller.inputReleaseError) throw controller.inputReleaseError;
|
|
454
|
+
if (disposed || entry.closed || revision !== entry.inputSettingsRevision) return;
|
|
455
|
+
controller.replaceRecognition(
|
|
456
|
+
recognitionFor(controller.getSnapshot().settings, controller.meter),
|
|
457
|
+
);
|
|
458
|
+
const restart = entry.restoreInput && entry.inputRestoreRequest === entry.request;
|
|
459
|
+
entry.restoreInput = false;
|
|
460
|
+
const current = controller.getSnapshot();
|
|
461
|
+
if (
|
|
462
|
+
restart &&
|
|
463
|
+
!(current.settings.mode === 'speaker' && current.speaking && !current.paused)
|
|
464
|
+
)
|
|
465
|
+
await controller._startInput(current.conversation);
|
|
466
|
+
});
|
|
467
|
+
run(controller, entry.inputSettingsBarrier);
|
|
468
|
+
}
|
|
313
469
|
publishVoiceContext(entry);
|
|
314
470
|
engineBrowser.lang = controller.getSnapshot().settings.lang;
|
|
315
471
|
engineQwen.lang = controller.getSnapshot().settings.lang;
|
|
@@ -363,6 +519,27 @@ export function apply(ctx) {
|
|
|
363
519
|
);
|
|
364
520
|
};
|
|
365
521
|
}
|
|
522
|
+
entry.meeting = meeting;
|
|
523
|
+
entry.startMeetingSource = (source) => {
|
|
524
|
+
try {
|
|
525
|
+
const request = requestSharedAudio();
|
|
526
|
+
request.catch(() => {});
|
|
527
|
+
void entry.meeting.start(source, request);
|
|
528
|
+
} catch {
|
|
529
|
+
entry.meeting.patch(source, { error: t('dsh-live-voice.meeting.failed') });
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
let microphoneActive = false;
|
|
533
|
+
const unsubscribeMeetingMicrophone = controller.subscribe(() => {
|
|
534
|
+
const state = controller.getSnapshot();
|
|
535
|
+
const next = Boolean(state.listening || state.starting);
|
|
536
|
+
if (meetingEntry === entry) entry.meeting.refreshDeliveryReadiness();
|
|
537
|
+
if (meetingEntry === entry && next !== microphoneActive) {
|
|
538
|
+
microphoneActive = next;
|
|
539
|
+
entry.meeting.transcript.setActive('microphone', next);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
entry.unsubscribeMeetingMicrophone = unsubscribeMeetingMicrophone;
|
|
366
543
|
controllers.set(key, entry);
|
|
367
544
|
void preferences.ready.catch(() => {
|
|
368
545
|
if (!entry.closed)
|
|
@@ -404,6 +581,11 @@ export function apply(ctx) {
|
|
|
404
581
|
if (!entry || entry.closed || disposed) return;
|
|
405
582
|
return () => {
|
|
406
583
|
entry.composers.delete(token.current);
|
|
584
|
+
if (meetingEntry === entry && !entry.composers.size) {
|
|
585
|
+
meeting.cancelDelivery();
|
|
586
|
+
meetingEntry = null;
|
|
587
|
+
meeting.transcript.setActive('microphone', false);
|
|
588
|
+
}
|
|
407
589
|
// Message-action mounts may outlive the composer; never retain its actions.
|
|
408
590
|
// Do not interpret a route-driven composer unmount as the user's request
|
|
409
591
|
// to leave voice mode. Retirement still releases the old controller; the
|
|
@@ -413,9 +595,19 @@ export function apply(ctx) {
|
|
|
413
595
|
React.useLayoutEffect(() => {
|
|
414
596
|
if (!entry || entry.closed || disposed) return;
|
|
415
597
|
if (!input || typeof props.inputActions?.setDraft !== 'function') return;
|
|
598
|
+
if (meetingEntry !== entry) {
|
|
599
|
+
meeting.cancelDelivery();
|
|
600
|
+
meetingEntry = entry;
|
|
601
|
+
entry.controller.setExternalSpeechActivity(sharedSpeechActive);
|
|
602
|
+
const state = entry.controller.getSnapshot();
|
|
603
|
+
meeting.transcript.setActive(
|
|
604
|
+
'microphone',
|
|
605
|
+
Boolean((state.listening || state.starting) && !state.muted),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
416
608
|
entry.composers.set(token.current, {
|
|
417
609
|
actions: props.inputActions,
|
|
418
|
-
submitAccelerated: () => {
|
|
610
|
+
submitAccelerated: (accelerated = true) => {
|
|
419
611
|
const active = document.activeElement;
|
|
420
612
|
const editor =
|
|
421
613
|
active?.nodeType === 1 && active.isContentEditable
|
|
@@ -429,7 +621,7 @@ export function apply(ctx) {
|
|
|
429
621
|
new KeyboardEventCtor('keydown', {
|
|
430
622
|
key: 'Enter',
|
|
431
623
|
code: 'Enter',
|
|
432
|
-
ctrlKey:
|
|
624
|
+
ctrlKey: accelerated,
|
|
433
625
|
bubbles: true,
|
|
434
626
|
cancelable: true,
|
|
435
627
|
}),
|
|
@@ -462,12 +654,25 @@ export function apply(ctx) {
|
|
|
462
654
|
entry.staleDrafts.clear();
|
|
463
655
|
}
|
|
464
656
|
entry.controller.composerChanged(entry.draft);
|
|
657
|
+
if (meetingEntry === entry) meeting.composerChanged(entry.draft);
|
|
465
658
|
});
|
|
466
659
|
}
|
|
467
660
|
function Buttons(props) {
|
|
468
661
|
const entry = useEntry(props.sessionId, 'buttons');
|
|
469
662
|
useComposer(entry, props);
|
|
470
|
-
return entry ?
|
|
663
|
+
return entry ? (
|
|
664
|
+
<div className="dlv-composer-toggles">
|
|
665
|
+
<MeetingToggle
|
|
666
|
+
meeting={entry.meeting}
|
|
667
|
+
onToggle={() => {
|
|
668
|
+
const value = entry.meeting.getSnapshot().shared;
|
|
669
|
+
if (value.starting || value.listening) void entry.meeting.stop('shared');
|
|
670
|
+
else entry.startMeetingSource('shared');
|
|
671
|
+
}}
|
|
672
|
+
/>
|
|
673
|
+
<ConversationControls controller={entry.controller} />
|
|
674
|
+
</div>
|
|
675
|
+
) : null;
|
|
471
676
|
}
|
|
472
677
|
function Settings() {
|
|
473
678
|
const [controller, setController] = React.useState(null);
|
|
@@ -478,6 +683,7 @@ export function apply(ctx) {
|
|
|
478
683
|
const meter = new MicrophoneMeter();
|
|
479
684
|
const recognition = recognitionFor(settings, meter);
|
|
480
685
|
const c = new VoiceCoordinator({
|
|
686
|
+
persistSettings: (next) => savePreferences(c, next),
|
|
481
687
|
recognition,
|
|
482
688
|
engines: {
|
|
483
689
|
browser,
|
|
@@ -510,6 +716,8 @@ export function apply(ctx) {
|
|
|
510
716
|
const update = c.updateSettings.bind(c);
|
|
511
717
|
c.applySavedSettings = update;
|
|
512
718
|
settingsControllers.add(c);
|
|
719
|
+
const unsubscribeDiagnostics = c.subscribe(notifyDiagnostics);
|
|
720
|
+
notifyDiagnostics();
|
|
513
721
|
void preferences.ready.catch(() =>
|
|
514
722
|
c.patch({ error: t('dsh-live-voice.settings.persistence.loadError') }),
|
|
515
723
|
);
|
|
@@ -523,22 +731,10 @@ export function apply(ctx) {
|
|
|
523
731
|
if (changesRecognition(next)) c.replaceRecognition(recognitionFor(settings, c.meter));
|
|
524
732
|
const saved = savePreferences(c, next);
|
|
525
733
|
run(c, c.refreshCapabilities());
|
|
526
|
-
|
|
527
|
-
const [savedSettings] = await Promise.all([
|
|
528
|
-
saved,
|
|
529
|
-
c.endConversation(),
|
|
530
|
-
...active.map((entry) => entry.controller.endConversation()),
|
|
531
|
-
]);
|
|
734
|
+
await saved;
|
|
532
735
|
if (revision !== settingsRevision || disposed || c.disposed) return;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (changesRecognition(next))
|
|
536
|
-
entry.controller.replaceRecognition(
|
|
537
|
-
recognitionFor(savedSettings, entry.controller.meter),
|
|
538
|
-
);
|
|
539
|
-
entry.applySettings(savedSettings);
|
|
540
|
-
run(entry.controller, entry.controller.refreshCapabilities());
|
|
541
|
-
}
|
|
736
|
+
// The shared preference subscriber reconfigures only affected resources.
|
|
737
|
+
// A voice/output/UI change must not end playback, meeting capture, or chats.
|
|
542
738
|
};
|
|
543
739
|
const refresh = () => run(c, c.refreshCapabilities());
|
|
544
740
|
document.addEventListener('dsh-live-voice:capabilitieschanged', refresh);
|
|
@@ -546,7 +742,9 @@ export function apply(ctx) {
|
|
|
546
742
|
refresh();
|
|
547
743
|
return () => {
|
|
548
744
|
document.removeEventListener('dsh-live-voice:capabilitieschanged', refresh);
|
|
745
|
+
unsubscribeDiagnostics();
|
|
549
746
|
settingsControllers.delete(c);
|
|
747
|
+
notifyDiagnostics();
|
|
550
748
|
void c.dispose();
|
|
551
749
|
};
|
|
552
750
|
}, []);
|
|
@@ -556,7 +754,13 @@ export function apply(ctx) {
|
|
|
556
754
|
function Dock(props) {
|
|
557
755
|
const entry = useEntry(props.sessionId, 'dock');
|
|
558
756
|
useComposer(entry, props);
|
|
559
|
-
return entry ?
|
|
757
|
+
return entry ? (
|
|
758
|
+
<div className="dlv-bar-stack">
|
|
759
|
+
<SpeechStatusBar controller={entry.controller} />
|
|
760
|
+
<MeetingBars meeting={entry.meeting} />
|
|
761
|
+
<ConversationStatusBar controller={entry.controller} includeSpeech={false} />
|
|
762
|
+
</div>
|
|
763
|
+
) : null;
|
|
560
764
|
}
|
|
561
765
|
function QuestionStatusView({ entry }) {
|
|
562
766
|
const snapshot = React.useSyncExternalStore(
|
|
@@ -647,8 +851,10 @@ export function apply(ctx) {
|
|
|
647
851
|
ctx.effect(() => {
|
|
648
852
|
const stop = () => {
|
|
649
853
|
ownership.cancel();
|
|
650
|
-
|
|
854
|
+
void meeting.end();
|
|
855
|
+
for (const entry of controllers.values()) {
|
|
651
856
|
run(entry.controller, entry.controller.endConversation());
|
|
857
|
+
}
|
|
652
858
|
};
|
|
653
859
|
let holdToTalk = null;
|
|
654
860
|
const candidate = () => {
|
|
@@ -720,6 +926,7 @@ export function apply(ctx) {
|
|
|
720
926
|
window.removeEventListener('blur', releaseHoldToTalk);
|
|
721
927
|
window.removeEventListener('pagehide', stop);
|
|
722
928
|
for (const entry of controllers.values()) retire(entry);
|
|
929
|
+
void meeting.dispose();
|
|
723
930
|
};
|
|
724
931
|
});
|
|
725
932
|
}
|