dsh-live-voice 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.js CHANGED
@@ -2052,7 +2052,7 @@ var VAD_ASSET_TYPES = {
2052
2052
  };
2053
2053
  var MAX_VAD_ASSET_BYTES = 32 * 1024 * 1024;
2054
2054
  function registerVadAssetRoutes(ctx, options = {}) {
2055
- const version = options.version ?? (true ? "0.7.0" : "test");
2055
+ const version = options.version ?? (true ? "0.7.2" : "test");
2056
2056
  if (!/^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,127}$/.test(version))
2057
2057
  throw new Error("Invalid VAD asset version");
2058
2058
  const directory = options.directory ?? fileURLToPath(new URL("./vad/", import.meta.url));
@@ -2146,7 +2146,7 @@ import { lstat as lstat2, readFile as readFile5 } from "node:fs/promises";
2146
2146
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2147
2147
  import { join as join8 } from "node:path";
2148
2148
  function registerBrowserAsrAssets(ctx, options = {}) {
2149
- const version = options.version ?? (true ? "0.7.0" : "test");
2149
+ const version = options.version ?? (true ? "0.7.2" : "test");
2150
2150
  if (!/^[a-zA-Z0-9][a-zA-Z0-9.+-]{0,127}$/.test(version)) throw new Error("invalid-version");
2151
2151
  const base = "/api/dsh-live-voice/asr/" + encodeURIComponent(version) + "/";
2152
2152
  const directory = options.directory ?? fileURLToPath2(new URL("./asr/", import.meta.url));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "pluginVersion": "0.7.0",
3
+ "pluginVersion": "0.7.2",
4
4
  "dependencies": {
5
5
  "@ricky0123/vad-web": "0.0.31",
6
6
  "onnxruntime-web": "1.22.0"
@@ -36,12 +36,12 @@
36
36
  "sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f"
37
37
  },
38
38
  "vad.capture.js": {
39
- "bytes": 1254,
40
- "sha256": "da75b304c7a67d2538b96c0310cb9d4d991a46ca495f246b19ab70ce71b9f26b"
39
+ "bytes": 1694,
40
+ "sha256": "d24df69dc6326d8c1b37437b0bef459a3a67405237108a3c32868a87c3fa13c4"
41
41
  },
42
42
  "vad.worker.js": {
43
43
  "bytes": 79337,
44
- "sha256": "16ae4a3a3d4fa13ad7e65e8a7ce220e816448c4b9899ebaa53a2ad4c287eecbb"
44
+ "sha256": "6b83547e105d6a61b37f3647687021afcab2162d707071e7bffd9a6ffecade58"
45
45
  }
46
46
  }
47
47
  }
@@ -1,11 +1,22 @@
1
1
  // src/modules/recognition/vad/vad.capture.ts
2
2
  var VadCaptureProcessor = class extends AudioWorkletProcessor {
3
3
  stopped = false;
4
+ paused = false;
4
5
  pcm = new Float32Array(2048);
5
6
  at = 0;
6
7
  constructor() {
7
8
  super();
8
9
  this.port.onmessage = (event) => {
10
+ if (event.data?.type === "resume") this.paused = false;
11
+ if (event.data?.type === "flush") {
12
+ if (event.data.pause) this.paused = true;
13
+ if (this.at) {
14
+ const pcm = this.pcm.slice(0, this.at);
15
+ this.port.postMessage({ pcm, sampleRate }, [pcm.buffer]);
16
+ this.at = 0;
17
+ }
18
+ this.port.postMessage({ type: "flushed" });
19
+ }
9
20
  if (event.data?.type === "stop") {
10
21
  this.stopped = true;
11
22
  if (event.data.drain && this.at) {
@@ -20,6 +31,7 @@ var VadCaptureProcessor = class extends AudioWorkletProcessor {
20
31
  process(inputs, outputs) {
21
32
  for (const channels of outputs) for (const channel of channels) channel.fill(0);
22
33
  if (this.stopped) return false;
34
+ if (this.paused) return true;
23
35
  const input = inputs[0];
24
36
  if (!input?.length) return true;
25
37
  for (let i = 0; i < input[0].length; i++) {
@@ -1632,7 +1632,7 @@ var vo = Ke;
1632
1632
  Object.defineProperty(I.versions, "web", { value: $t, enumerable: true });
1633
1633
 
1634
1634
  // src/modules/recognition/vad/assets.ts
1635
- var sileroVadAssetBase = () => "/api/dsh-live-voice/vad/" + encodeURIComponent(true ? "0.7.0" : "test") + "/";
1635
+ var sileroVadAssetBase = () => "/api/dsh-live-voice/vad/" + encodeURIComponent(true ? "0.7.2" : "test") + "/";
1636
1636
 
1637
1637
  // src/modules/recognition/vad/StreamingResampler.ts
1638
1638
  var StreamingResampler = class {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-live-voice",
3
- "version": "0.7.0",
4
- "dshTestedVersion": "0.2.2-alpha.2",
3
+ "version": "0.7.2",
4
+ "dshTestedVersion": "0.2.2-rc.1",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "node --experimental-strip-types ../../scripts/build.ts --package live-voice",
@@ -3,6 +3,8 @@
3
3
  `src/app` is the composition boundary between DSH and the voice-domain modules. Client and server entry points remain separate; host-only Node dependencies must never enter the browser bundle.
4
4
 
5
5
  - `client/apply.tsx` injects DSH client services, creates session controllers and voice ownership, wires engines and settings, and disposes resources. `client/slotDefinitions.ts` and `client/registerSlots.tsx` own the established slot IDs, order, and registration. Preserve those contracts when changing placement. Microphone mode stop invalidates pending input ownership and application continuity without ending current playback; explicit takeover and full End retain their separate handoff/lifecycle semantics.
6
+ - Retained session entries re-resolve the current DSH chat target on committed slot attachment. A replaced target releases its old subscription and republishes through stable readers/listeners without resetting session speech memory; detached-session admission still prevents background playback.
7
+ - Composer detachment parks eligible recording-backed microphone controllers in the existing session registry rather than retiring them. Their capture clock pauses, recognition requests continue, and final results stay on the originating controller. Returning uses the existing ownership barrier before resuming capture and delivering queued results against the latest draft. Explicit stop clears parked intent; plugin disposal retires both mounted and parked entries. This memory is page-lifetime only, not reload recovery.
6
8
  - `server/apply.ts` composes host engines and providers. `server/registerRoutes.ts` registers authenticated same-origin routes; do not bypass their validation or expose host access directly to browser code.
7
9
  - `client/transcriptionLeaveGuard.ts` owns one activity-scoped native `beforeunload` listener across microphone, meeting sources and Settings test capture. It covers acquisition, listening and unsettled transcription, not playback or voice-mode preference alone. Cancelling the browser dialog does not stop capture; existing `pagehide` cleanup runs only after leaving. Idle and disposed apps remove the listener. Browser wording, user-activation requirements and mobile/forced-close limitations apply; this is not refresh recovery.
8
10
  - `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.
@@ -457,7 +457,12 @@ export function apply(ctx) {
457
457
  entry.speechState.checkpoint = entry.controller.checkpointSpeech();
458
458
  entry.speechAttached = false;
459
459
  ++entry.speechRequest;
460
- const stopped = entry.controller.suspendSpeech();
460
+ const preserveInput =
461
+ entry.controller.snapshot.conversation &&
462
+ entry.controller.snapshot.listening &&
463
+ typeof entry.controller.recognition.pauseForNavigation === 'function';
464
+ entry.parkedInput = preserveInput;
465
+ const stopped = entry.controller.suspendSpeech(preserveInput);
461
466
  entry.speechRelease = Promise.resolve(stopped).then(() => {
462
467
  if (entry.controller.speechStopError) throw entry.controller.speechStopError;
463
468
  if (entry.controller.inputReleaseError) throw entry.controller.inputReleaseError;
@@ -478,6 +483,13 @@ export function apply(ctx) {
478
483
  await entry.speechRelease;
479
484
  if (disposed || entry.closed || !entry.speechAttached || request !== entry.speechRequest)
480
485
  return;
486
+ if (entry.parkedInput) {
487
+ const resumed = await entry.controller.resumeInputFromNavigation();
488
+ if (disposed || entry.closed || !entry.speechAttached || request !== entry.speechRequest)
489
+ return;
490
+ if (!resumed) throw new Error('The paused microphone recording could not resume.');
491
+ entry.parkedInput = false;
492
+ }
481
493
  entry.controller.restoreSpeech(entry.speechState.checkpoint);
482
494
  entry.speechState.checkpoint = null;
483
495
  entry.refreshSpeech(!entry.speechState.attached);
@@ -488,6 +500,7 @@ export function apply(ctx) {
488
500
  function retire(entry) {
489
501
  if (entry.closed) return;
490
502
  suspendSessionSpeech(entry);
503
+ if (entry.parkedInput && !disposed) return;
491
504
  entry.closed = true;
492
505
  if (meetingEntry === entry) {
493
506
  void meeting.attendee.stop();
@@ -677,7 +690,8 @@ export function apply(ctx) {
677
690
  entry.chatListeners.add(listener);
678
691
  return () => entry.chatListeners.delete(listener);
679
692
  };
680
- entry.readChat = entry.chat.getSnapshot.bind(entry.chat);
693
+ // Stable React store readers must follow a replacement DSH session binding.
694
+ entry.readChat = () => entry.chat.getSnapshot();
681
695
  const refresh = (reset = false) => {
682
696
  if (disposed || entry.closed) return;
683
697
  const snapshot = entry.readChat();
@@ -716,10 +730,22 @@ export function apply(ctx) {
716
730
  };
717
731
  entry.refreshSpeech = refresh;
718
732
  refresh();
719
- entry.unsubscribe = entry.chat.subscribe(() => {
733
+ const publishChat = () => {
720
734
  refresh();
721
735
  for (const listener of entry.chatListeners) listener();
722
- });
736
+ };
737
+ entry.unsubscribe = entry.chat.subscribe(publishChat);
738
+ entry.reconnectChat = () => {
739
+ if (disposed || entry.closed) return;
740
+ const chat = ctx.uiConversation.binding(sessionId).target('chat');
741
+ if (chat === entry.chat) return;
742
+ entry.unsubscribe?.();
743
+ entry.chat = chat;
744
+ entry.unsubscribe = chat.subscribe(publishChat);
745
+ // Subscribe first: activation may materialize the target synchronously.
746
+ // Existing speech memory and detached-session admission rules still apply.
747
+ publishChat();
748
+ };
723
749
  // DSH 0.1.6 may omit this legacy store. Keep the remaining voice controls
724
750
  // available while structured-question integration is unavailable.
725
751
  const pendingInteractions = ctx.uiSession.pendingInteractions;
@@ -753,7 +779,10 @@ export function apply(ctx) {
753
779
  const before = controller.getSnapshot();
754
780
  update(next);
755
781
  const settings = controller.getSnapshot().settings;
756
- if ([...recognitionSettingKeys].some((key) => before.settings[key] !== settings[key])) {
782
+ if (
783
+ !entry.parkedInput &&
784
+ [...recognitionSettingKeys].some((key) => before.settings[key] !== settings[key])
785
+ ) {
757
786
  if (!entry.restoreInput) entry.inputRestoreRequest = entry.request;
758
787
  entry.restoreInput ||= Boolean(before.listening || before.starting);
759
788
  const revision = (entry.inputSettingsRevision = (entry.inputSettingsRevision || 0) + 1);
@@ -802,6 +831,7 @@ export function apply(ctx) {
802
831
  controller[method] = (...args) => {
803
832
  // Internal handoffs release resources, not the parked navigation cursor.
804
833
  if (method === 'endConversation' && args[0] === true && !entry.speechAttached) {
834
+ if (entry.parkedInput) return entry.speechRelease ?? Promise.resolve();
805
835
  controller.patch({ conversation: false });
806
836
  return Promise.all([
807
837
  controller.stopListening(),
@@ -823,6 +853,7 @@ export function apply(ctx) {
823
853
  }
824
854
  }
825
855
  entry.request++;
856
+ if (method !== 'stopSpeech') entry.parkedInput = false;
826
857
  if (
827
858
  (method === 'endConversation' && args[0] !== true) ||
828
859
  method === 'stopConversationInput'
@@ -909,6 +940,7 @@ export function apply(ctx) {
909
940
  // Allocation and subscriptions happen only for committed mounts. Suspended
910
941
  // or abandoned renders never acquire a session, probe engines, or retain it.
911
942
  const current = get(sessionId);
943
+ current.reconnectChat();
912
944
  current.refs++;
913
945
  if (kind === 'buttons') current.buttons++;
914
946
  setEntry(current);
@@ -937,7 +969,6 @@ export function apply(ctx) {
937
969
  entry.composers.delete(token.current);
938
970
  if (!entry.composers.size) suspendSessionSpeech(entry);
939
971
  if (meetingEntry === entry && !entry.composers.size) {
940
- void meeting.attendee.stop();
941
972
  meeting.composerReplaced();
942
973
  meetingEntry = null;
943
974
  meeting.transcript.setActive('microphone', false);
@@ -952,10 +983,7 @@ export function apply(ctx) {
952
983
  if (!entry || entry.closed || disposed) return;
953
984
  if (!input || typeof props.inputActions?.setDraft !== 'function') return;
954
985
  if (meetingEntry !== entry) {
955
- if (meetingEntry) {
956
- void meeting.attendee.stop();
957
- suspendSessionSpeech(meetingEntry);
958
- }
986
+ if (meetingEntry) suspendSessionSpeech(meetingEntry);
959
987
  meeting.composerReplaced();
960
988
  meetingEntry = entry;
961
989
  entry.controller.setExternalSpeechActivity(sharedSpeechActive);
@@ -991,7 +1019,7 @@ export function apply(ctx) {
991
1019
  const attachSpeech = !entry.speechAttached;
992
1020
  if (attachSpeech) entry.speechAttached = true;
993
1021
  const inputReady =
994
- voiceModeActive && !entry.controller.getSnapshot().conversation
1022
+ !entry.parkedInput && voiceModeActive && !entry.controller.getSnapshot().conversation
995
1023
  ? entry.controller.startConversation()
996
1024
  : Promise.resolve();
997
1025
  run(
@@ -623,7 +623,9 @@ export class AttendeeClient {
623
623
  this.queues.set(source, work);
624
624
  }
625
625
  async audio(event, generation) {
626
- if (this.canDeliver && !this.canDeliver()) throw new Error('no-composer');
626
+ // Capture is application-owned and can outlive a composer during chat navigation.
627
+ // MeetingTranscript has no active destination in that gap, so results are dropped
628
+ // rather than ending the receiver or being written into a stale composer.
627
629
  const source = 'attendee:' + event.botId + ':' + event.participantId;
628
630
  let row = this.rows.get(source);
629
631
  if (!row) {
@@ -173,14 +173,17 @@ export class MeetingController {
173
173
  for (const input of this.inputs.values()) input.cancelAutoSend();
174
174
  }
175
175
  composerReplaced() {
176
- // Capture follows navigation, but a recognition operation must not deliver
177
- // an utterance begun for the previous composer into its replacement.
176
+ // Recording/capture is application-owned and outlives any one chat composer.
177
+ // Keep the audio clock and writer, but never deliver a final whose speech began
178
+ // before the replacement composer was committed.
179
+ for (const [source, job] of this.jobs) {
180
+ const state = this.snapshot[source];
181
+ job.discardResults =
182
+ (Number.isSafeInteger(state?.pendingTranscriptionRequests)
183
+ ? state.pendingTranscriptionRequests
184
+ : state?.pending || 0) || (state?.recognizing ? 1 : 0);
185
+ }
178
186
  this.cancelDelivery();
179
- const job = this.jobs.get('shared');
180
- if (!job || !job.recognitionAbort) return;
181
- job.recognitionAbort.abort();
182
- if (this.sharedTrackLive(job)) this.queueRecognitionRecovery('shared', job);
183
- else void this.stop('shared').catch(() => {});
184
187
  }
185
188
  toggleTimestamps() {
186
189
  const enabled = !this.snapshot.timestamps;
@@ -206,7 +209,7 @@ export class MeetingController {
206
209
  }
207
210
  this.patch(null, { active: true });
208
211
  const abort = new AbortController();
209
- const job = { abort };
212
+ const job = { abort, discardResults: 0 };
210
213
  this.jobs.set(source, job);
211
214
  const valid = () => this.jobs.get(source) === job && !abort.signal.aborted;
212
215
  this.patch(source, { starting: true, error: null });
@@ -291,6 +294,10 @@ export class MeetingController {
291
294
  signal: recognitionAbort.signal,
292
295
  onResult: ({ final, interim, startedAt, paragraphBefore }) => {
293
296
  if (!valid()) return;
297
+ if (job.discardResults > 0) {
298
+ job.discardResults--;
299
+ return;
300
+ }
294
301
  if (final?.trim()) job.retries = 0;
295
302
  const accepted = this.onSpeech?.(source, final || interim) !== false;
296
303
  if (accepted)
@@ -2,6 +2,8 @@
2
2
 
3
3
  `core` owns policy and reusable voice-domain state, not DSH slot/route registration or provider-specific presentation. `coordinator.ts` serializes input transitions, owns conversation state and speech queues, and invalidates stale async work with generations. `ownership.ts` arbitrates microphone and speaker use between sessions and Settings self-tests. `transcript.ts` enforces append-only final recognition text (interim hypotheses never own or rewrite composer content), `microphone.ts` manages capture and activity, `filters.ts` handles voice commands and speech text filtering, and `settings.ts` normalizes persisted values and defaults.
4
4
 
5
+ Recording-backed conversation input can be parked by `pauseInputForNavigation()` without aborting recognition or sealing its writer. Final recognition callbacks remain on the original coordinator and are queued while detached; `resumeInputFromNavigation()` delivers them only after composition reacquires hardware ownership and a current composer. Navigation generations reject obsolete resume completions. Stop/End/disposal clears that queue and releases input; native Browser recognition keeps its existing teardown path. Parked capture retains its start-time recognition configuration and does not retry acquisition in the background.
6
+
5
7
  Recording audit emits transition-only assistant wait start/end with bounded reason codes, queue/request counts and delay, playback admission, requested input gating, recognition/meter stop completion or failure, real engine playback start/end, cancellation and errors. Input-stop completion means adapter promises settled successfully, not independent physical microphone proof. Wait reasons never include text, message IDs, provider errors, or audio. Event audit does not change capture/recognition/playback policy.
6
8
 
7
9
  The `qwen/` subtree contains shared Qwen host configuration and its settings UI; recognition and speaking adapters live in their respective modules. Keep the client/host dependency graphs separate. Preserve the persisted `dsh-live-voice.settings` key and normalization compatibility. `startConversation()` enables input without cancelling playback; `stopConversationInput()` retires input without clearing accepted speech. Speaker-mode starts defer capture until playback finishes, while `takeMicrophone()` retains the explicit teardown-before-capture handoff. Full `endConversation()` remains the global lifecycle/End-command operation. Automatic speech admission depends on the assistant-speech preference, not microphone conversation mode; new segments and accepted queues continue with input closed. Distinguish capture, recognition, playback, and generation; do not conflate pausing with cancellation or resume obsolete speech after a new turn. Consult [voice lifecycle](../../../../../docs/VOICE-LIFECYCLE.md) for expected transitions.