dsh-live-voice 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +15 -8
  2. package/lib/client.js +5959 -3222
  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 +140 -16
  8. package/src/app/client/composerSelection.ts +92 -0
  9. package/src/app/client/i18n/catalogs/base.ts +33 -0
  10. package/src/app/client/i18n/catalogs/en.ts +36 -0
  11. package/src/app/client/i18n/catalogs/es.ts +36 -0
  12. package/src/app/client/i18n/catalogs/fr.ts +37 -0
  13. package/src/app/client/i18n/catalogs/hi.ts +35 -0
  14. package/src/app/client/i18n/catalogs/pt-BR.ts +37 -0
  15. package/src/app/client/i18n/catalogs/zh.ts +35 -0
  16. package/src/modules/AGENTS.md +1 -1
  17. package/src/modules/conversation/ARCHITECTURE.md +4 -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 +72 -94
  21. package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
  22. package/src/modules/conversation/components/MeetingControls.tsx +64 -0
  23. package/src/modules/conversation/components/MicrophoneButton.tsx +6 -1
  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 +0 -2
  30. package/src/modules/conversation/components/index.ts +1 -0
  31. package/src/modules/conversation/components/speechCaption.ts +27 -0
  32. package/src/modules/conversation/models/chat.ts +14 -2
  33. package/src/modules/conversation/models/meeting.ts +140 -0
  34. package/src/modules/conversation/models/meetingTranscript.ts +56 -0
  35. package/src/modules/core/ARCHITECTURE.md +1 -1
  36. package/src/modules/core/coordinator.ts +204 -51
  37. package/src/modules/core/developerExtension.ts +26 -0
  38. package/src/modules/core/diagnostics.ts +150 -0
  39. package/src/modules/core/filters.ts +20 -15
  40. package/src/modules/core/markdownSpeech.ts +154 -0
  41. package/src/modules/core/settings.ts +17 -13
  42. package/src/modules/core/sharedAudio.ts +82 -0
  43. package/src/modules/core/speechDefaults.ts +21 -0
  44. package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
  45. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
  46. package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
  47. package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
  48. package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
  49. package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
  50. package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
  51. package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
  52. package/src/shared/design-system/icons/icons.ts +4 -0
  53. package/src/styles/index.ts +11 -3
  54. package/CHANGELOG.md +0 -240
  55. package/DEVELOPMENT.md +0 -83
  56. package/PLAN.md +0 -296
  57. package/docs/ARCHITECTURE.md +0 -33
  58. package/docs/CHOOSING-AN-ENGINE.md +0 -120
  59. package/docs/CONFIGURATION.md +0 -163
  60. package/docs/REVIEW.md +0 -35
  61. package/docs/VOICE-LIFECYCLE.md +0 -63
  62. package/scripts/build.ts +0 -62
  63. package/scripts/check-dist.mjs +0 -38
  64. package/scripts/preview-ui.ts +0 -31
  65. 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.0",
4
+ "dshTestedVersion": "0.2.1-alpha.1",
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,43 @@ 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
+ const diagnosticListeners = new Set();
58
+ const notifyDiagnostics = () => {
59
+ for (const listener of diagnosticListeners) {
60
+ try {
61
+ listener();
62
+ } catch {
63
+ /* Observers never own voice behavior. */
64
+ }
65
+ }
66
+ };
67
+ ctx.effect(() => {
68
+ const unpublish = publishDiagnosticSource(window, {
69
+ version: 1,
70
+ read: ({ includeContent = false } = {}) => ({
71
+ application: { disposed, voiceModeActive, retiring: retiring.size },
72
+ sessions: Object.fromEntries(
73
+ [...controllers].map(([id, entry]) => [
74
+ id,
75
+ readCoordinatorDiagnostics(entry.controller, includeContent),
76
+ ]),
77
+ ),
78
+ settingsControllers: [...settingsControllers].map((controller) =>
79
+ readCoordinatorDiagnostics(controller, includeContent),
80
+ ),
81
+ }),
82
+ subscribe: (listener) => {
83
+ diagnosticListeners.add(listener);
84
+ return () => {
85
+ diagnosticListeners.delete(listener);
86
+ };
87
+ },
88
+ });
89
+ return () => {
90
+ unpublish();
91
+ diagnosticListeners.clear();
92
+ };
93
+ }, 'dsh-live-voice: diagnostic bridge');
45
94
  const publishVoiceContext = (
46
95
  entry,
47
96
  active = entry?.controller?.getSnapshot().conversation === true,
@@ -146,10 +195,15 @@ export function apply(ctx) {
146
195
  entry.questionCapture = null;
147
196
  entry.controller.patch({ answeringQuestion: false });
148
197
  publishVoiceContext(entry, false);
198
+ entry.unsubscribeMeetingMicrophone?.();
149
199
  entry.chatListeners.clear();
150
200
  if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
201
+ notifyDiagnostics();
151
202
  // Keep teardown in the hardware handoff barrier, not in the session registry.
152
- const done = run(entry.controller, entry.controller.dispose());
203
+ const done = run(
204
+ entry.controller,
205
+ Promise.all([entry.controller.dispose(), entry.meeting?.dispose()]),
206
+ );
153
207
  const barrier = { endConversation: () => done };
154
208
  retiring.add(barrier);
155
209
  void done.finally(() => retiring.delete(barrier));
@@ -185,21 +239,29 @@ export function apply(ctx) {
185
239
  const meter = new MicrophoneMeter();
186
240
  const recognition = recognitionFor(settings, meter);
187
241
  entry.controller = new VoiceCoordinator({
242
+ persistSettings: (next) => savePreferences(entry.controller, next),
188
243
  recognition,
189
244
  engines: { browser: engineBrowser, say: engineSay, 'qwen-http': engineQwen },
190
245
  meter,
191
246
  composer: {
192
247
  getDraft: () => entry.draft,
248
+ appendFinal: (text, startedAt) => {
249
+ if (entry.meeting?.getSnapshot().shared.listening) {
250
+ entry.meeting.transcript.append('microphone', text, startedAt);
251
+ return entry.draft;
252
+ }
253
+ return null;
254
+ },
193
255
  submit: (mode = 'queue') => {
194
256
  const owner = [...entry.composers.values()].at(-1);
195
257
  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?.();
258
+ const busyEnter = entry.controller.getSnapshot().settings.dshBusyEnterBehavior;
259
+ if (mode !== busyEnter || busyEnter === 'steer') {
260
+ // InputActions.submit() always queues; it does not emulate normal Enter.
261
+ // Use an actual Enter gesture for steering-default composers, adding Ctrl
262
+ // only for the opposite mode. Read the preference at delivery time.
263
+ // Never fall back to normal submit: that would deliver the wrong mode.
264
+ owner.submitAccelerated?.(mode !== busyEnter);
203
265
  return;
204
266
  }
205
267
  owner.actions.submit?.();
@@ -237,16 +299,23 @@ export function apply(ctx) {
237
299
  if (entry.pendingDraft === undefined) entry.staleDrafts.clear();
238
300
  entry.staleDrafts.add(entry.draft);
239
301
  if (entry.publishedDraft !== undefined) entry.staleDrafts.add(entry.publishedDraft);
302
+ const previous = entry.draft;
240
303
  entry.draft = text;
241
304
  entry.pendingDraft = text;
242
305
  entry.pendingRevision = entry.publishedRevision;
243
- owner.actions.setDraft(text);
306
+ const append = text.length > previous.length && text.startsWith(previous);
307
+ if (append) preserveComposerSelection(() => owner.actions.setDraft(text));
308
+ else owner.actions.setDraft(text);
244
309
  },
245
310
  },
246
311
  settings,
247
312
  });
248
313
  const controller = entry.controller;
249
- entry.unsubscribeVoiceContext = controller.subscribe(() => publishVoiceContext(entry));
314
+ entry.unsubscribeVoiceContext = controller.subscribe(() => {
315
+ publishVoiceContext(entry);
316
+ notifyDiagnostics();
317
+ });
318
+ notifyDiagnostics();
250
319
  publishVoiceContext(entry);
251
320
  entry.chat = ctx.uiConversation.binding(sessionId).target('chat');
252
321
  entry.chatListeners = new Set();
@@ -363,6 +432,34 @@ export function apply(ctx) {
363
432
  );
364
433
  };
365
434
  }
435
+ entry.meeting = new MeetingController({
436
+ composer: entry.controller.composer,
437
+ settings: () => entry.controller.getSnapshot().settings,
438
+ translate: (key) => t('dsh-live-voice.meeting.' + key),
439
+ createSource: (source, settings) => {
440
+ const meter = source === 'shared' ? new SharedAudioMeter() : new MicrophoneMeter();
441
+ return { meter, engine: recognitionFor(settings, meter) };
442
+ },
443
+ });
444
+ entry.startMeetingSource = (source) => {
445
+ try {
446
+ const request = requestSharedAudio();
447
+ request.catch(() => {});
448
+ void entry.meeting.start(source, request);
449
+ } catch {
450
+ entry.meeting.patch(source, { error: t('dsh-live-voice.meeting.failed') });
451
+ }
452
+ };
453
+ let microphoneActive = false;
454
+ const unsubscribeMeetingMicrophone = controller.subscribe(() => {
455
+ const state = controller.getSnapshot();
456
+ const next = Boolean((state.listening || state.starting) && !state.muted);
457
+ if (next !== microphoneActive) {
458
+ microphoneActive = next;
459
+ entry.meeting.transcript.setActive('microphone', next);
460
+ }
461
+ });
462
+ entry.unsubscribeMeetingMicrophone = unsubscribeMeetingMicrophone;
366
463
  controllers.set(key, entry);
367
464
  void preferences.ready.catch(() => {
368
465
  if (!entry.closed)
@@ -415,7 +512,7 @@ export function apply(ctx) {
415
512
  if (!input || typeof props.inputActions?.setDraft !== 'function') return;
416
513
  entry.composers.set(token.current, {
417
514
  actions: props.inputActions,
418
- submitAccelerated: () => {
515
+ submitAccelerated: (accelerated = true) => {
419
516
  const active = document.activeElement;
420
517
  const editor =
421
518
  active?.nodeType === 1 && active.isContentEditable
@@ -429,7 +526,7 @@ export function apply(ctx) {
429
526
  new KeyboardEventCtor('keydown', {
430
527
  key: 'Enter',
431
528
  code: 'Enter',
432
- ctrlKey: true,
529
+ ctrlKey: accelerated,
433
530
  bubbles: true,
434
531
  cancelable: true,
435
532
  }),
@@ -467,7 +564,19 @@ export function apply(ctx) {
467
564
  function Buttons(props) {
468
565
  const entry = useEntry(props.sessionId, 'buttons');
469
566
  useComposer(entry, props);
470
- return entry ? <ConversationControls controller={entry.controller} /> : null;
567
+ return entry ? (
568
+ <>
569
+ <MeetingToggle
570
+ meeting={entry.meeting}
571
+ onToggle={() => {
572
+ const value = entry.meeting.getSnapshot().shared;
573
+ if (value.starting || value.listening) void entry.meeting.stop('shared');
574
+ else entry.startMeetingSource('shared');
575
+ }}
576
+ />
577
+ <ConversationControls controller={entry.controller} />
578
+ </>
579
+ ) : null;
471
580
  }
472
581
  function Settings() {
473
582
  const [controller, setController] = React.useState(null);
@@ -478,6 +587,7 @@ export function apply(ctx) {
478
587
  const meter = new MicrophoneMeter();
479
588
  const recognition = recognitionFor(settings, meter);
480
589
  const c = new VoiceCoordinator({
590
+ persistSettings: (next) => savePreferences(c, next),
481
591
  recognition,
482
592
  engines: {
483
593
  browser,
@@ -510,6 +620,8 @@ export function apply(ctx) {
510
620
  const update = c.updateSettings.bind(c);
511
621
  c.applySavedSettings = update;
512
622
  settingsControllers.add(c);
623
+ const unsubscribeDiagnostics = c.subscribe(notifyDiagnostics);
624
+ notifyDiagnostics();
513
625
  void preferences.ready.catch(() =>
514
626
  c.patch({ error: t('dsh-live-voice.settings.persistence.loadError') }),
515
627
  );
@@ -527,7 +639,9 @@ export function apply(ctx) {
527
639
  const [savedSettings] = await Promise.all([
528
640
  saved,
529
641
  c.endConversation(),
530
- ...active.map((entry) => entry.controller.endConversation()),
642
+ ...active.map((entry) =>
643
+ Promise.all([entry.controller.endConversation(), entry.meeting.end()]),
644
+ ),
531
645
  ]);
532
646
  if (revision !== settingsRevision || disposed || c.disposed) return;
533
647
  for (const entry of controllers.values()) {
@@ -546,7 +660,9 @@ export function apply(ctx) {
546
660
  refresh();
547
661
  return () => {
548
662
  document.removeEventListener('dsh-live-voice:capabilitieschanged', refresh);
663
+ unsubscribeDiagnostics();
549
664
  settingsControllers.delete(c);
665
+ notifyDiagnostics();
550
666
  void c.dispose();
551
667
  };
552
668
  }, []);
@@ -556,7 +672,13 @@ export function apply(ctx) {
556
672
  function Dock(props) {
557
673
  const entry = useEntry(props.sessionId, 'dock');
558
674
  useComposer(entry, props);
559
- return entry ? <ConversationStatusBar controller={entry.controller} /> : null;
675
+ return entry ? (
676
+ <div className="dlv-bar-stack">
677
+ <SpeechStatusBar controller={entry.controller} />
678
+ <MeetingBars meeting={entry.meeting} />
679
+ <ConversationStatusBar controller={entry.controller} includeSpeech={false} />
680
+ </div>
681
+ ) : null;
560
682
  }
561
683
  function QuestionStatusView({ entry }) {
562
684
  const snapshot = React.useSyncExternalStore(
@@ -647,8 +769,10 @@ export function apply(ctx) {
647
769
  ctx.effect(() => {
648
770
  const stop = () => {
649
771
  ownership.cancel();
650
- for (const entry of controllers.values())
772
+ for (const entry of controllers.values()) {
651
773
  run(entry.controller, entry.controller.endConversation());
774
+ void entry.meeting.end();
775
+ }
652
776
  };
653
777
  let holdToTalk = null;
654
778
  const candidate = () => {
@@ -0,0 +1,92 @@
1
+ /** Preserve browser selection around DSH's synchronous, whole-draft editor write.
2
+ * Selection changes are published back through the editor's normal selectionchange bridge.
3
+ * Capture offsets, not live Ranges: setDraft replaces the underlying text nodes.
4
+ */
5
+ export function preserveComposerSelection(write: () => void, doc: Document = document) {
6
+ const active = doc.activeElement as HTMLElement | null;
7
+ const selection = doc.getSelection();
8
+ const editable = active?.closest<HTMLElement>('[contenteditable="true"]');
9
+ const ownsSelection =
10
+ editable &&
11
+ selection?.anchorNode &&
12
+ selection.focusNode &&
13
+ editable.contains(selection.anchorNode) &&
14
+ editable.contains(selection.focusNode);
15
+ const offset = (node: Node, at: number) => {
16
+ const range = doc.createRange();
17
+ range.selectNodeContents(editable!);
18
+ range.setEnd(node, at);
19
+ return range.toString().length;
20
+ };
21
+ const anchor = ownsSelection ? offset(selection!.anchorNode!, selection!.anchorOffset) : null;
22
+ const focus = ownsSelection ? offset(selection!.focusNode!, selection!.focusOffset) : null;
23
+ const outside =
24
+ !ownsSelection && selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
25
+ const input = active as HTMLInputElement | HTMLTextAreaElement | null;
26
+ const inputSelection =
27
+ input && typeof input.selectionStart === 'number'
28
+ ? {
29
+ start: input.selectionStart,
30
+ end: input.selectionEnd!,
31
+ direction: input.selectionDirection!,
32
+ }
33
+ : null;
34
+ const scrollTop = active?.scrollTop,
35
+ scrollLeft = active?.scrollLeft;
36
+ const bookmark = (node: Node, at: number) => {
37
+ const path: number[] = [];
38
+ let child = node;
39
+ while (child !== editable && child.parentNode) {
40
+ path.unshift(Array.prototype.indexOf.call(child.parentNode.childNodes, child));
41
+ child = child.parentNode;
42
+ }
43
+ return { path, at, type: node.nodeType, text: node.textContent };
44
+ };
45
+ const anchorMark = ownsSelection
46
+ ? bookmark(selection!.anchorNode!, selection!.anchorOffset)
47
+ : null;
48
+ const focusMark = ownsSelection ? bookmark(selection!.focusNode!, selection!.focusOffset) : null;
49
+ write();
50
+ if (active?.isConnected && doc.activeElement !== active) active.focus({ preventScroll: true });
51
+ if (ownsSelection && editable!.isConnected) {
52
+ const point = (at: number): [Node, number] => {
53
+ const walker = doc.createTreeWalker(editable!, 4);
54
+ let node: Node | null;
55
+ let last: Node = editable!;
56
+ while ((node = walker.nextNode())) {
57
+ last = node;
58
+ const length = node.textContent?.length ?? 0;
59
+ if (at <= length) return [node, at];
60
+ at -= length;
61
+ }
62
+ return [last, last === editable ? last.childNodes.length : (last.textContent?.length ?? 0)];
63
+ };
64
+ const restorePoint = (mark: NonNullable<typeof anchorMark>, at: number): [Node, number] => {
65
+ let node: Node | undefined = editable!;
66
+ for (const index of mark.path) node = node?.childNodes[index];
67
+ // Preserve paragraph/empty-line boundaries when the rebuilt structure matches.
68
+ if (
69
+ node &&
70
+ node.nodeType === mark.type &&
71
+ (node.nodeType !== 3 || node.textContent?.startsWith(mark.text ?? '')) &&
72
+ mark.at <= (node.nodeType === 3 ? node.textContent!.length : node.childNodes.length)
73
+ )
74
+ return [node, mark.at];
75
+ return point(at);
76
+ };
77
+ const [a, ao] = restorePoint(anchorMark!, anchor!);
78
+ const [f, fo] = restorePoint(focusMark!, focus!);
79
+ selection!.setBaseAndExtent(a, ao, f, fo);
80
+ // Let Lexical adopt the restored DOM selection instead of retaining selectEnd().
81
+ doc.dispatchEvent(new doc.defaultView!.Event('selectionchange'));
82
+ } else if (inputSelection && active?.isConnected) {
83
+ input!.setSelectionRange(inputSelection.start, inputSelection.end, inputSelection.direction);
84
+ } else if (outside?.startContainer.isConnected && outside.endContainer.isConnected) {
85
+ selection?.removeAllRanges();
86
+ selection?.addRange(outside);
87
+ }
88
+ if (active?.isConnected) {
89
+ active.scrollTop = scrollTop!;
90
+ active.scrollLeft = scrollLeft!;
91
+ }
92
+ }
@@ -23,12 +23,14 @@ export interface LiveVoiceTranslation extends Translation {
23
23
  'dsh-live-voice.commons.input.listening': string;
24
24
  'dsh-live-voice.commons.input.listeningBadge': string;
25
25
  'dsh-live-voice.commons.manual': string;
26
+ 'dsh-live-voice.commons.milliseconds': string;
26
27
  'dsh-live-voice.commons.off': string;
27
28
  'dsh-live-voice.commons.on': string;
28
29
  'dsh-live-voice.commons.pluginName': string;
29
30
  'dsh-live-voice.commons.queue': string;
30
31
  'dsh-live-voice.commons.repository.starLabel': string;
31
32
  'dsh-live-voice.commons.repository.starLink': string;
33
+ 'dsh-live-voice.commons.second': string;
32
34
  'dsh-live-voice.commons.seconds': string;
33
35
  'dsh-live-voice.commons.send': string;
34
36
  'dsh-live-voice.commons.status.ready': string;
@@ -43,6 +45,24 @@ export interface LiveVoiceTranslation extends Translation {
43
45
  'dsh-live-voice.commons.version.label': string;
44
46
  'dsh-live-voice.commons.version.link': string;
45
47
  'dsh-live-voice.commons.version.title': string;
48
+ 'dsh-live-voice.meeting.failed': string;
49
+ 'dsh-live-voice.meeting.help': string;
50
+ 'dsh-live-voice.meeting.httpRequired': string;
51
+ 'dsh-live-voice.meeting.inactive': string;
52
+ 'dsh-live-voice.meeting.listening': string;
53
+ 'dsh-live-voice.meeting.microphone': string;
54
+ 'dsh-live-voice.meeting.noAudio': string;
55
+ 'dsh-live-voice.meeting.processing': string;
56
+ 'dsh-live-voice.meeting.shared': string;
57
+ 'dsh-live-voice.meeting.start': string;
58
+ 'dsh-live-voice.meeting.startShared': string;
59
+ 'dsh-live-voice.meeting.starting': string;
60
+ 'dsh-live-voice.meeting.stop': string;
61
+ 'dsh-live-voice.meeting.stopShared': string;
62
+ 'dsh-live-voice.meeting.timestamps': string;
63
+ 'dsh-live-voice.meeting.timestampsBadge': string;
64
+ 'dsh-live-voice.meeting.title': string;
65
+ 'dsh-live-voice.meeting.unavailable': string;
46
66
  'dsh-live-voice.recognition.autoSend.countdownHelp': string;
47
67
  'dsh-live-voice.recognition.browser.autoInstallPack': string;
48
68
  'dsh-live-voice.recognition.browser.help': string;
@@ -131,6 +151,10 @@ export interface LiveVoiceTranslation extends Translation {
131
151
  'dsh-live-voice.settings.autoSend.countdown': string;
132
152
  'dsh-live-voice.settings.autoSend.delay': string;
133
153
  'dsh-live-voice.settings.close': string;
154
+ 'dsh-live-voice.settings.delivery.dshEnterHelp': string;
155
+ 'dsh-live-voice.settings.delivery.dshEnterQuestion': string;
156
+ 'dsh-live-voice.settings.delivery.dshEnterQueue': string;
157
+ 'dsh-live-voice.settings.delivery.dshEnterSteer': string;
134
158
  'dsh-live-voice.settings.delivery.label': string;
135
159
  'dsh-live-voice.settings.delivery.manualLabel': string;
136
160
  'dsh-live-voice.settings.delivery.queueLabel': string;
@@ -153,6 +177,8 @@ export interface LiveVoiceTranslation extends Translation {
153
177
  'dsh-live-voice.speak.agentContext.help': string;
154
178
  'dsh-live-voice.speak.agentContext.label': string;
155
179
  'dsh-live-voice.speak.agentContext.restore': string;
180
+ 'dsh-live-voice.speak.autoPlayback.disable': string;
181
+ 'dsh-live-voice.speak.autoPlayback.enable': string;
156
182
  'dsh-live-voice.speak.autoPlayback.enabled': string;
157
183
  'dsh-live-voice.speak.autoPlayback.help': string;
158
184
  'dsh-live-voice.speak.autoPlayback.label': string;
@@ -164,6 +190,11 @@ export interface LiveVoiceTranslation extends Translation {
164
190
  'dsh-live-voice.speak.browser.name': string;
165
191
  'dsh-live-voice.speak.browser.outputHelp': string;
166
192
  'dsh-live-voice.speak.browser.voice': string;
193
+ 'dsh-live-voice.speak.captions.approximate': string;
194
+ 'dsh-live-voice.speak.captions.loading': string;
195
+ 'dsh-live-voice.speak.captions.position': string;
196
+ 'dsh-live-voice.speak.captions.progress': string;
197
+ 'dsh-live-voice.speak.captions.title': string;
167
198
  'dsh-live-voice.speak.engine.label': string;
168
199
  'dsh-live-voice.speak.engine.playbackHelp': string;
169
200
  'dsh-live-voice.speak.filters.code.enabled': string;
@@ -186,6 +217,7 @@ export interface LiveVoiceTranslation extends Translation {
186
217
  'dsh-live-voice.speak.playback.message': string;
187
218
  'dsh-live-voice.speak.playback.next': string;
188
219
  'dsh-live-voice.speak.playback.pause': string;
220
+ 'dsh-live-voice.speak.playback.previous': string;
189
221
  'dsh-live-voice.speak.playback.resume': string;
190
222
  'dsh-live-voice.speak.playback.stop': string;
191
223
  'dsh-live-voice.speak.playback.stopAll': string;
@@ -215,6 +247,7 @@ export interface LiveVoiceTranslation extends Translation {
215
247
  'dsh-live-voice.speak.rate.label': string;
216
248
  'dsh-live-voice.speak.responseDelay.help': string;
217
249
  'dsh-live-voice.speak.responseDelay.label': string;
250
+ 'dsh-live-voice.speak.responseDelay.none': string;
218
251
  'dsh-live-voice.speak.segmentGap.help': string;
219
252
  'dsh-live-voice.speak.segmentGap.label': string;
220
253
  'dsh-live-voice.speak.status.paused': string;