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/README.md +1 -1
- package/lib/asr/asr.worker.js +1 -1
- package/lib/asr/manifest.json +1 -1
- package/lib/client.js +295 -51
- package/lib/server.js +2 -2
- package/lib/vad/manifest.json +4 -4
- package/lib/vad/vad.capture.js +12 -0
- package/lib/vad/vad.worker.js +1 -1
- package/package.json +2 -2
- package/src/app/ARCHITECTURE.md +2 -0
- package/src/app/client/apply.tsx +39 -11
- package/src/modules/conversation/models/AttendeeClient.ts +3 -1
- package/src/modules/conversation/models/meeting.ts +15 -8
- package/src/modules/core/ARCHITECTURE.md +2 -0
- package/src/modules/core/coordinator.ts +199 -24
- package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +12 -0
- package/src/modules/recognition/vad/SileroVadRuntime.ts +43 -3
- package/src/modules/recognition/vad/vad.capture.ts +12 -0
- package/src/modules/recording/RecordingRecognition.ts +59 -4
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.
|
|
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.
|
|
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));
|
package/lib/vad/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"pluginVersion": "0.7.
|
|
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":
|
|
40
|
-
"sha256": "
|
|
39
|
+
"bytes": 1694,
|
|
40
|
+
"sha256": "d24df69dc6326d8c1b37437b0bef459a3a67405237108a3c32868a87c3fa13c4"
|
|
41
41
|
},
|
|
42
42
|
"vad.worker.js": {
|
|
43
43
|
"bytes": 79337,
|
|
44
|
-
"sha256": "
|
|
44
|
+
"sha256": "6b83547e105d6a61b37f3647687021afcab2162d707071e7bffd9a6ffecade58"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
}
|
package/lib/vad/vad.capture.js
CHANGED
|
@@ -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++) {
|
package/lib/vad/vad.worker.js
CHANGED
|
@@ -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.
|
|
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.
|
|
4
|
-
"dshTestedVersion": "0.2.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",
|
package/src/app/ARCHITECTURE.md
CHANGED
|
@@ -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.
|
package/src/app/client/apply.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
177
|
-
//
|
|
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.
|