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.
Files changed (70) hide show
  1. package/README.md +48 -14
  2. package/lib/client.js +6474 -3396
  3. package/lib/server.js +29 -6
  4. package/package.json +9 -30
  5. package/src/app/AGENTS.md +1 -1
  6. package/src/app/ARCHITECTURE.md +1 -1
  7. package/src/app/client/apply.tsx +242 -35
  8. package/src/app/client/composerSelection.ts +92 -0
  9. package/src/app/client/i18n/catalogs/base.ts +35 -0
  10. package/src/app/client/i18n/catalogs/en.ts +43 -3
  11. package/src/app/client/i18n/catalogs/es.ts +51 -11
  12. package/src/app/client/i18n/catalogs/fr.ts +52 -11
  13. package/src/app/client/i18n/catalogs/hi.ts +50 -11
  14. package/src/app/client/i18n/catalogs/pt-BR.ts +52 -11
  15. package/src/app/client/i18n/catalogs/zh.ts +50 -11
  16. package/src/modules/AGENTS.md +1 -1
  17. package/src/modules/conversation/ARCHITECTURE.md +6 -2
  18. package/src/modules/conversation/components/AutoPlaybackToggle.tsx +31 -0
  19. package/src/modules/conversation/components/ConversationControls.tsx +23 -8
  20. package/src/modules/conversation/components/ConversationStatusBar.tsx +91 -93
  21. package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
  22. package/src/modules/conversation/components/MeetingControls.tsx +40 -0
  23. package/src/modules/conversation/components/MicrophoneButton.tsx +15 -2
  24. package/src/modules/conversation/components/PlaybackControls.tsx +20 -5
  25. package/src/modules/conversation/components/RecognitionBar.tsx +14 -0
  26. package/src/modules/conversation/components/ScrollingSpeechCaption.tsx +100 -0
  27. package/src/modules/conversation/components/SpeechStatusBar.tsx +99 -0
  28. package/src/modules/conversation/components/Waveform.tsx +8 -3
  29. package/src/modules/conversation/components/conversationStatus.ts +1 -3
  30. package/src/modules/conversation/components/createConversationComponents.tsx +1 -1
  31. package/src/modules/conversation/components/index.ts +1 -0
  32. package/src/modules/conversation/components/speechCaption.ts +27 -0
  33. package/src/modules/conversation/models/chat.ts +15 -2
  34. package/src/modules/conversation/models/meeting.ts +256 -0
  35. package/src/modules/conversation/models/meetingTranscript.ts +63 -0
  36. package/src/modules/core/ARCHITECTURE.md +1 -1
  37. package/src/modules/core/coordinator.ts +353 -81
  38. package/src/modules/core/developerExtension.ts +26 -0
  39. package/src/modules/core/diagnostics.ts +153 -0
  40. package/src/modules/core/filters.ts +20 -15
  41. package/src/modules/core/markdownSpeech.ts +154 -0
  42. package/src/modules/core/settings.ts +17 -13
  43. package/src/modules/core/sharedAudio.ts +88 -0
  44. package/src/modules/core/speechDefaults.ts +21 -0
  45. package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
  46. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
  47. package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
  48. package/src/modules/settings/components/SettingsHeader.tsx +1 -1
  49. package/src/modules/settings/models/settingsStorage.ts +11 -3
  50. package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
  51. package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
  52. package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -1
  53. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +12 -1
  54. package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
  55. package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
  56. package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
  57. package/src/shared/design-system/icons/icons.ts +6 -0
  58. package/src/styles/index.ts +13 -3
  59. package/CHANGELOG.md +0 -240
  60. package/DEVELOPMENT.md +0 -83
  61. package/PLAN.md +0 -296
  62. package/docs/ARCHITECTURE.md +0 -33
  63. package/docs/CHOOSING-AN-ENGINE.md +0 -120
  64. package/docs/CONFIGURATION.md +0 -163
  65. package/docs/REVIEW.md +0 -35
  66. package/docs/VOICE-LIFECYCLE.md +0 -63
  67. package/scripts/build.ts +0 -62
  68. package/scripts/check-dist.mjs +0 -38
  69. package/scripts/preview-ui.ts +0 -31
  70. 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: true,
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: 3,
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: "Look the code on out conversation",
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: Number.isInteger(source.autoSendDelaySeconds) && source.autoSendDelaySeconds >= 2 && source.autoSendDelaySeconds <= 10 ? source.autoSendDelaySeconds : defaultSettings.autoSendDelaySeconds,
102
- assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >= 1 && source.assistantSpeechDelaySeconds <= 10 ? source.assistantSpeechDelaySeconds : defaultSettings.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.trim() : defaultSettings.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.3.2",
4
- "dshTestedVersion": "0.2.0-rc.2",
3
+ "version": "0.4.1",
4
+ "dshTestedVersion": "0.2.1-alpha.2",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "typecheck": "tsc --noEmit",
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
- "build": "npm run typecheck && node --experimental-strip-types scripts/build.ts",
10
- "check:dist": "node scripts/check-dist.mjs",
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](../../AGENTS.md) for product, validation, and authorization rules.
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.
@@ -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](../../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.
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.
@@ -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
- if (mode === 'steer') {
197
- // InputActions deliberately exposes no delivery-mode argument. Dispatch the
198
- // DSH accelerated composer gesture instead: Ctrl/Cmd+Enter resolves to the
199
- // opposite of the normal busy-enter policy, which is direct steering when
200
- // the ordinary action queues. Never fall back to inputActions.submit() here:
201
- // it would silently turn an explicit steer request into a queued message.
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
- owner.actions.setDraft(text);
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(() => publishVoiceContext(entry));
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 refresh = (baseline = false) => {
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: baseline || message.interrupted,
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: true,
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 ? <ConversationControls controller={entry.controller} /> : null;
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
- const active = [...controllers.values()];
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
- for (const entry of controllers.values()) {
534
- if (entry.closed) continue;
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 ? <ConversationStatusBar controller={entry.controller} /> : null;
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
- for (const entry of controllers.values())
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
  }