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.
- package/README.md +15 -8
- package/lib/client.js +5959 -3222
- package/lib/server.js +29 -6
- package/package.json +9 -30
- package/src/app/AGENTS.md +1 -1
- package/src/app/ARCHITECTURE.md +1 -1
- package/src/app/client/apply.tsx +140 -16
- package/src/app/client/composerSelection.ts +92 -0
- package/src/app/client/i18n/catalogs/base.ts +33 -0
- package/src/app/client/i18n/catalogs/en.ts +36 -0
- package/src/app/client/i18n/catalogs/es.ts +36 -0
- package/src/app/client/i18n/catalogs/fr.ts +37 -0
- package/src/app/client/i18n/catalogs/hi.ts +35 -0
- package/src/app/client/i18n/catalogs/pt-BR.ts +37 -0
- package/src/app/client/i18n/catalogs/zh.ts +35 -0
- package/src/modules/AGENTS.md +1 -1
- package/src/modules/conversation/ARCHITECTURE.md +4 -2
- package/src/modules/conversation/components/AutoPlaybackToggle.tsx +31 -0
- package/src/modules/conversation/components/ConversationControls.tsx +23 -8
- package/src/modules/conversation/components/ConversationStatusBar.tsx +72 -94
- package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
- package/src/modules/conversation/components/MeetingControls.tsx +64 -0
- package/src/modules/conversation/components/MicrophoneButton.tsx +6 -1
- package/src/modules/conversation/components/PlaybackControls.tsx +20 -5
- package/src/modules/conversation/components/RecognitionBar.tsx +14 -0
- package/src/modules/conversation/components/ScrollingSpeechCaption.tsx +100 -0
- package/src/modules/conversation/components/SpeechStatusBar.tsx +99 -0
- package/src/modules/conversation/components/Waveform.tsx +8 -3
- package/src/modules/conversation/components/conversationStatus.ts +0 -2
- package/src/modules/conversation/components/index.ts +1 -0
- package/src/modules/conversation/components/speechCaption.ts +27 -0
- package/src/modules/conversation/models/chat.ts +14 -2
- package/src/modules/conversation/models/meeting.ts +140 -0
- package/src/modules/conversation/models/meetingTranscript.ts +56 -0
- package/src/modules/core/ARCHITECTURE.md +1 -1
- package/src/modules/core/coordinator.ts +204 -51
- package/src/modules/core/developerExtension.ts +26 -0
- package/src/modules/core/diagnostics.ts +150 -0
- package/src/modules/core/filters.ts +20 -15
- package/src/modules/core/markdownSpeech.ts +154 -0
- package/src/modules/core/settings.ts +17 -13
- package/src/modules/core/sharedAudio.ts +82 -0
- package/src/modules/core/speechDefaults.ts +21 -0
- package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
- package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
- package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
- package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
- package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
- package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
- package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
- package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
- package/src/shared/design-system/icons/icons.ts +4 -0
- package/src/styles/index.ts +11 -3
- package/CHANGELOG.md +0 -240
- package/DEVELOPMENT.md +0 -83
- package/PLAN.md +0 -296
- package/docs/ARCHITECTURE.md +0 -33
- package/docs/CHOOSING-AN-ENGINE.md +0 -120
- package/docs/CONFIGURATION.md +0 -163
- package/docs/REVIEW.md +0 -35
- package/docs/VOICE-LIFECYCLE.md +0 -63
- package/scripts/build.ts +0 -62
- package/scripts/check-dist.mjs +0 -38
- package/scripts/preview-ui.ts +0 -31
- package/scripts/probe-browser.ts +0 -46
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
filterSpeechOutput,
|
|
6
6
|
hasMinimumWords,
|
|
7
7
|
hasUnclosedCodeFence,
|
|
8
|
+
hasIncompleteSpeechMarkdown,
|
|
8
9
|
matchVoiceCommand,
|
|
9
10
|
splitSpeechOutput,
|
|
11
|
+
splitInitialSpeechItem,
|
|
10
12
|
} from './filters.ts';
|
|
11
13
|
|
|
12
14
|
const message = (error) => error?.message || String(error);
|
|
@@ -26,14 +28,16 @@ function cancellable(promise, signal) {
|
|
|
26
28
|
|
|
27
29
|
/** Session-owned policy. Input transitions are serialized; generations invalidate late work. */
|
|
28
30
|
export class VoiceCoordinator {
|
|
29
|
-
constructor({ recognition, engines, meter, composer, settings = {} }) {
|
|
30
|
-
Object.assign(this, { recognition, engines, meter, composer });
|
|
31
|
+
constructor({ recognition, engines, meter, composer, settings = {}, persistSettings }) {
|
|
32
|
+
Object.assign(this, { recognition, engines, meter, composer, persistSettings });
|
|
31
33
|
this.listeners = new Set();
|
|
32
34
|
this.transcript = new TranscriptDraft();
|
|
33
35
|
this.epoch = 0;
|
|
34
36
|
this.speechEpoch = 0;
|
|
35
37
|
this.controlEpoch = 0;
|
|
36
38
|
this.queue = [];
|
|
39
|
+
this.speechHistory = [];
|
|
40
|
+
this.speechCursor = -1;
|
|
37
41
|
this.prefetchController = new AbortController();
|
|
38
42
|
this.prefetchItems = new Set();
|
|
39
43
|
this.activeSpeechItem = null;
|
|
@@ -56,9 +60,16 @@ export class VoiceCoordinator {
|
|
|
56
60
|
starting: false,
|
|
57
61
|
error: null,
|
|
58
62
|
activeMessageId: null,
|
|
63
|
+
speechLoading: false,
|
|
64
|
+
speechText: null,
|
|
65
|
+
speechEngine: null,
|
|
59
66
|
answeringQuestion: false,
|
|
60
67
|
autoSendAt: null,
|
|
61
68
|
speechSegmentsRemaining: 0,
|
|
69
|
+
speechSegmentIndex: 0,
|
|
70
|
+
speechSegmentsTotal: 0,
|
|
71
|
+
speechHasPrevious: false,
|
|
72
|
+
speechRunActive: false,
|
|
62
73
|
capabilities: {},
|
|
63
74
|
settings: normalizeSettings(settings),
|
|
64
75
|
};
|
|
@@ -273,7 +284,32 @@ export class VoiceCoordinator {
|
|
|
273
284
|
throw new Error(capability.reason || 'Local browser recognition is unavailable.');
|
|
274
285
|
let started;
|
|
275
286
|
try {
|
|
276
|
-
|
|
287
|
+
const deviceId = this.snapshot.settings.inputDeviceId;
|
|
288
|
+
try {
|
|
289
|
+
started = await this.meter.start({ signal: controller.signal });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (
|
|
292
|
+
error?.name !== 'OverconstrainedError' ||
|
|
293
|
+
!deviceId ||
|
|
294
|
+
!valid() ||
|
|
295
|
+
controller.signal.aborted ||
|
|
296
|
+
this.snapshot.settings.inputDeviceId !== deviceId
|
|
297
|
+
)
|
|
298
|
+
throw error;
|
|
299
|
+
// Repair only the obsolete input preference, without ending this input operation.
|
|
300
|
+
VoiceCoordinator.prototype.updateSettings.call(this, { inputDeviceId: '' });
|
|
301
|
+
await cancellable(
|
|
302
|
+
Promise.resolve(this.persistSettings?.({ inputDeviceId: '' })),
|
|
303
|
+
controller.signal,
|
|
304
|
+
);
|
|
305
|
+
if (
|
|
306
|
+
!valid() ||
|
|
307
|
+
controller.signal.aborted ||
|
|
308
|
+
this.snapshot.settings.inputDeviceId !== ''
|
|
309
|
+
)
|
|
310
|
+
return;
|
|
311
|
+
started = await this.meter.start({ signal: controller.signal });
|
|
312
|
+
}
|
|
277
313
|
} catch (error) {
|
|
278
314
|
if (valid())
|
|
279
315
|
this.patch({
|
|
@@ -403,7 +439,7 @@ export class VoiceCoordinator {
|
|
|
403
439
|
}
|
|
404
440
|
}
|
|
405
441
|
|
|
406
|
-
onResult({ final = '', interim = '' }) {
|
|
442
|
+
onResult({ final = '', interim = '', startedAt }) {
|
|
407
443
|
if (this.disposed || (!this.snapshot.listening && !this.snapshot.starting)) return;
|
|
408
444
|
if (
|
|
409
445
|
this.snapshot.speaking &&
|
|
@@ -463,7 +499,7 @@ export class VoiceCoordinator {
|
|
|
463
499
|
} else if (this.snapshot.muted) return;
|
|
464
500
|
else {
|
|
465
501
|
const sendingMode = command === 'send' ? 'steer' : 'queue';
|
|
466
|
-
|
|
502
|
+
// Spoken delivery is a one-shot action, not an automatic-send preference change.
|
|
467
503
|
if (this.composer.getDraft().trim() && typeof this.composer.submit === 'function')
|
|
468
504
|
this.composer.submit(sendingMode);
|
|
469
505
|
}
|
|
@@ -484,8 +520,9 @@ export class VoiceCoordinator {
|
|
|
484
520
|
this.patch({ recognizing: false });
|
|
485
521
|
return;
|
|
486
522
|
}
|
|
487
|
-
const
|
|
488
|
-
this.composer.
|
|
523
|
+
const appended = this.composer.appendFinal?.(final, startedAt);
|
|
524
|
+
const next = appended ?? this.transcript.update(this.composer.getDraft(), final, true);
|
|
525
|
+
if (appended == null) this.composer.setDraft(next);
|
|
489
526
|
this.maybeScheduleAutoSend(next);
|
|
490
527
|
}
|
|
491
528
|
if (interim) {
|
|
@@ -586,8 +623,10 @@ export class VoiceCoordinator {
|
|
|
586
623
|
this.prefetchController.abort();
|
|
587
624
|
this.prefetchController = new AbortController();
|
|
588
625
|
for (const item of this.prefetchItems) {
|
|
589
|
-
|
|
590
|
-
|
|
626
|
+
if (!this.speechHistory.includes(item)) {
|
|
627
|
+
item.prepared?.dispose?.();
|
|
628
|
+
item.prepared = null;
|
|
629
|
+
}
|
|
591
630
|
item.preparing = null;
|
|
592
631
|
}
|
|
593
632
|
this.prefetchItems.clear();
|
|
@@ -610,7 +649,10 @@ export class VoiceCoordinator {
|
|
|
610
649
|
.prepare(item.text, { ...options, signal })
|
|
611
650
|
.then((prepared) => {
|
|
612
651
|
item.preparing = null;
|
|
613
|
-
if (
|
|
652
|
+
if (
|
|
653
|
+
signal.aborted ||
|
|
654
|
+
(!this.queue.includes(item) && !this.speechHistory.includes(item))
|
|
655
|
+
) {
|
|
614
656
|
prepared.dispose?.();
|
|
615
657
|
this.prefetchItems.delete(item);
|
|
616
658
|
} else item.prepared = prepared;
|
|
@@ -623,10 +665,46 @@ export class VoiceCoordinator {
|
|
|
623
665
|
}
|
|
624
666
|
}
|
|
625
667
|
|
|
668
|
+
_releaseSpeechHistory() {
|
|
669
|
+
for (const item of this.speechHistory) item.prepared?.dispose?.();
|
|
670
|
+
this.speechHistory = [];
|
|
671
|
+
this.speechCursor = -1;
|
|
672
|
+
}
|
|
673
|
+
getSpeechHistory() {
|
|
674
|
+
const groups = [];
|
|
675
|
+
for (const item of [...this.speechHistory, ...this.queue]) {
|
|
676
|
+
let group = groups.at(-1);
|
|
677
|
+
if (!group || group.messageId !== item.id) {
|
|
678
|
+
group = { messageId: item.id, segments: [] };
|
|
679
|
+
groups.push(group);
|
|
680
|
+
}
|
|
681
|
+
if (!group.segments.includes(item)) group.segments.push(item);
|
|
682
|
+
}
|
|
683
|
+
return groups;
|
|
684
|
+
}
|
|
626
685
|
_syncSpeechSegments() {
|
|
627
686
|
const speechSegmentsRemaining = this.queue.length + (this.snapshot.speaking ? 1 : 0);
|
|
628
|
-
|
|
629
|
-
|
|
687
|
+
const speechHasPrevious = this.speechCursor > 0;
|
|
688
|
+
const items = [...new Set([...this.speechHistory, ...this.queue])];
|
|
689
|
+
const speechSegmentsTotal = items.length;
|
|
690
|
+
const speechSegmentIndex = this.speechCursor >= 0 ? this.speechCursor + 1 : 0;
|
|
691
|
+
const speechRunActive =
|
|
692
|
+
speechSegmentsRemaining > 0 ||
|
|
693
|
+
this.speechHistory.some((item) => !item.manual && this.unfinished.has(item.id));
|
|
694
|
+
if (
|
|
695
|
+
speechSegmentsRemaining !== this.snapshot.speechSegmentsRemaining ||
|
|
696
|
+
speechHasPrevious !== this.snapshot.speechHasPrevious ||
|
|
697
|
+
speechRunActive !== this.snapshot.speechRunActive ||
|
|
698
|
+
speechSegmentsTotal !== this.snapshot.speechSegmentsTotal ||
|
|
699
|
+
speechSegmentIndex !== this.snapshot.speechSegmentIndex
|
|
700
|
+
)
|
|
701
|
+
this.patch({
|
|
702
|
+
speechSegmentsRemaining,
|
|
703
|
+
speechHasPrevious,
|
|
704
|
+
speechRunActive,
|
|
705
|
+
speechSegmentsTotal,
|
|
706
|
+
speechSegmentIndex,
|
|
707
|
+
});
|
|
630
708
|
}
|
|
631
709
|
|
|
632
710
|
_suppressPending() {
|
|
@@ -644,7 +722,15 @@ export class VoiceCoordinator {
|
|
|
644
722
|
++this.controlEpoch;
|
|
645
723
|
this._cancelSpeechPrefetch();
|
|
646
724
|
this._suppressPending();
|
|
647
|
-
this.
|
|
725
|
+
this._releaseSpeechHistory();
|
|
726
|
+
this.patch({
|
|
727
|
+
speaking: false,
|
|
728
|
+
paused: false,
|
|
729
|
+
activeMessageId: null,
|
|
730
|
+
speechLoading: false,
|
|
731
|
+
speechText: null,
|
|
732
|
+
speechEngine: null,
|
|
733
|
+
});
|
|
648
734
|
this._syncSpeechSegments();
|
|
649
735
|
const stopped = this.speechBarrier.then(async () => {
|
|
650
736
|
const results = await Promise.allSettled(
|
|
@@ -671,50 +757,57 @@ export class VoiceCoordinator {
|
|
|
671
757
|
)
|
|
672
758
|
await this._startInput(true);
|
|
673
759
|
}
|
|
760
|
+
async previousSpeechSegment() {
|
|
761
|
+
if (this.speechCursor <= 0 || this.disposed) return;
|
|
762
|
+
const replay = this.speechHistory.slice(this.speechCursor - 1);
|
|
763
|
+
this.queue = [...replay, ...this.queue.filter((item) => !replay.includes(item))];
|
|
764
|
+
await this._navigateSpeech();
|
|
765
|
+
}
|
|
674
766
|
async skipSpeechSegment() {
|
|
675
|
-
|
|
676
|
-
// streaming segments remain eligible for playback.
|
|
677
|
-
if (this.disposed || this.queue.length === 0) return;
|
|
678
|
-
// During the intentional inter-segment gap there is no active audio. In
|
|
679
|
-
// that case Next discards the imminent segment and immediately drains the
|
|
680
|
-
// following one, so the persistent button remains useful.
|
|
767
|
+
if (this.disposed || !this.queue.length) return;
|
|
681
768
|
if (!this.snapshot.speaking && !this.snapshot.paused) {
|
|
682
769
|
const skipped = this.queue.shift();
|
|
683
|
-
skipped
|
|
684
|
-
this.
|
|
685
|
-
this._cancelAssistantSpeechTimer();
|
|
686
|
-
this.assistantSpeechNotBefore = 0;
|
|
687
|
-
this._syncSpeechSegments();
|
|
688
|
-
this._prefetchSpeech();
|
|
689
|
-
this._drain();
|
|
690
|
-
return;
|
|
770
|
+
if (!this.speechHistory.includes(skipped)) this.speechHistory.push(skipped);
|
|
771
|
+
this.speechCursor = this.speechHistory.indexOf(skipped);
|
|
691
772
|
}
|
|
773
|
+
await this._navigateSpeech();
|
|
774
|
+
}
|
|
775
|
+
async _navigateSpeech() {
|
|
776
|
+
const engine = this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine];
|
|
692
777
|
const epoch = ++this.speechEpoch;
|
|
693
778
|
++this.controlEpoch;
|
|
694
779
|
this._cancelAssistantSpeechTimer();
|
|
695
780
|
this.assistantSpeechNotBefore = 0;
|
|
696
|
-
this.
|
|
697
|
-
this.patch({ speaking: false, paused: false, activeMessageId: null });
|
|
781
|
+
this.patch({ speaking: false, paused: false, speechLoading: false });
|
|
698
782
|
this._syncSpeechSegments();
|
|
699
|
-
const stopped = this.speechBarrier.then(() =>
|
|
700
|
-
Promise.resolve().then(() => this.engines[this.snapshot.settings.engine]?.stop()),
|
|
701
|
-
);
|
|
783
|
+
const stopped = this.speechBarrier.then(() => engine?.stop());
|
|
702
784
|
this.speechBarrier = stopped.catch(() => {});
|
|
703
785
|
try {
|
|
704
786
|
await stopped;
|
|
705
787
|
} catch (error) {
|
|
788
|
+
this.speechStopError = error;
|
|
706
789
|
if (epoch === this.speechEpoch) this.patch({ error: message(error) });
|
|
707
790
|
return;
|
|
708
791
|
}
|
|
709
792
|
if (epoch !== this.speechEpoch || this.disposed) return;
|
|
710
793
|
this._drain();
|
|
711
794
|
}
|
|
795
|
+
getSpeechProgress() {
|
|
796
|
+
return this.engines[this.snapshot.speechEngine]?.getPlaybackProgress?.() ?? null;
|
|
797
|
+
}
|
|
712
798
|
async pauseSpeech() {
|
|
713
|
-
if (this.disposed ||
|
|
799
|
+
if (this.disposed || this.snapshot.paused) return;
|
|
800
|
+
if (!this.snapshot.speaking && this.snapshot.speechRunActive) {
|
|
801
|
+
this._cancelAssistantSpeechTimer();
|
|
802
|
+
this.patch({ paused: true });
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (!this.snapshot.speaking) return;
|
|
714
806
|
const epoch = this.speechEpoch;
|
|
715
807
|
const control = ++this.controlEpoch;
|
|
716
808
|
try {
|
|
717
|
-
const result =
|
|
809
|
+
const result =
|
|
810
|
+
await this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine].pause();
|
|
718
811
|
if (result !== false) this._cancelSpeechPrefetch();
|
|
719
812
|
if (
|
|
720
813
|
epoch === this.speechEpoch &&
|
|
@@ -729,6 +822,12 @@ export class VoiceCoordinator {
|
|
|
729
822
|
}
|
|
730
823
|
async resumeSpeech() {
|
|
731
824
|
if (this.disposed || !this.snapshot.paused) return;
|
|
825
|
+
if (!this.snapshot.speaking) {
|
|
826
|
+
this.patch({ paused: false });
|
|
827
|
+
this.assistantSpeechNotBefore = 0;
|
|
828
|
+
this._drain();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
732
831
|
const epoch = this.speechEpoch;
|
|
733
832
|
const control = ++this.controlEpoch;
|
|
734
833
|
try {
|
|
@@ -737,7 +836,8 @@ export class VoiceCoordinator {
|
|
|
737
836
|
if (this.inputReleaseError) throw this.inputReleaseError;
|
|
738
837
|
}
|
|
739
838
|
if (epoch !== this.speechEpoch || control !== this.controlEpoch) return;
|
|
740
|
-
const result =
|
|
839
|
+
const result =
|
|
840
|
+
await this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine].resume();
|
|
741
841
|
if (epoch === this.speechEpoch && control === this.controlEpoch && result !== false) {
|
|
742
842
|
this.patch({ paused: false });
|
|
743
843
|
this._prefetchSpeech();
|
|
@@ -756,9 +856,13 @@ export class VoiceCoordinator {
|
|
|
756
856
|
filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
|
|
757
857
|
codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
|
|
758
858
|
codeBlockNotice: this.snapshot.settings.outputCodeNotice,
|
|
859
|
+
lang: this.snapshot.settings.lang,
|
|
759
860
|
});
|
|
760
|
-
const
|
|
761
|
-
if (!
|
|
861
|
+
const initial = segments.shift();
|
|
862
|
+
if (!initial) return;
|
|
863
|
+
const parts = splitInitialSpeechItem(initial);
|
|
864
|
+
const first = parts.shift();
|
|
865
|
+
segments.unshift(...parts);
|
|
762
866
|
this.queue.push(...segments.map((segment) => ({ text: segment, id: messageId, manual: true })));
|
|
763
867
|
this._prefetchSpeech();
|
|
764
868
|
this._syncSpeechSegments();
|
|
@@ -772,15 +876,30 @@ export class VoiceCoordinator {
|
|
|
772
876
|
this._syncSpeechSegments();
|
|
773
877
|
return;
|
|
774
878
|
}
|
|
879
|
+
const item =
|
|
880
|
+
this.activeSpeechItem?.text === text
|
|
881
|
+
? this.activeSpeechItem
|
|
882
|
+
: { text, id: messageId, manual };
|
|
883
|
+
if (!this.speechHistory.includes(item)) this.speechHistory.push(item);
|
|
884
|
+
this.speechCursor = this.speechHistory.indexOf(item);
|
|
775
885
|
const epoch = ++this.speechEpoch;
|
|
776
|
-
const engine = this.engines[this.snapshot.settings.engine];
|
|
886
|
+
const engine = item.engine || this.engines[this.snapshot.settings.engine];
|
|
887
|
+
item.engine = engine;
|
|
777
888
|
if (!engine) {
|
|
778
889
|
this.queue = [];
|
|
779
890
|
this.patch({ error: 'Speech engine unavailable.' });
|
|
780
891
|
return;
|
|
781
892
|
}
|
|
782
893
|
// Reserve playback synchronously so incoming stream chunks cannot overtake gating.
|
|
783
|
-
this.patch({
|
|
894
|
+
this.patch({
|
|
895
|
+
speaking: true,
|
|
896
|
+
paused: false,
|
|
897
|
+
activeMessageId: messageId,
|
|
898
|
+
speechLoading: true,
|
|
899
|
+
speechText: text,
|
|
900
|
+
speechEngine: this.snapshot.settings.engine,
|
|
901
|
+
error: null,
|
|
902
|
+
});
|
|
784
903
|
this._syncSpeechSegments();
|
|
785
904
|
let failed = false;
|
|
786
905
|
try {
|
|
@@ -792,20 +911,30 @@ export class VoiceCoordinator {
|
|
|
792
911
|
if (this.inputReleaseError) throw this.inputReleaseError;
|
|
793
912
|
}
|
|
794
913
|
if (epoch !== this.speechEpoch || this.disposed) return;
|
|
795
|
-
const queued =
|
|
914
|
+
const queued = item;
|
|
796
915
|
if (queued?.preparing) await queued.preparing;
|
|
797
916
|
if (epoch !== this.speechEpoch || this.disposed) return;
|
|
798
917
|
if (queued?.prepareError) throw queued.prepareError;
|
|
799
918
|
const options = {
|
|
919
|
+
onPlaybackStart: () => {
|
|
920
|
+
if (epoch === this.speechEpoch && !this.disposed) this.patch({ speechLoading: false });
|
|
921
|
+
},
|
|
800
922
|
voice: this.snapshot.settings.voice || undefined,
|
|
801
923
|
rate: this.snapshot.settings.rate,
|
|
802
924
|
outputDeviceId: this.snapshot.settings.outputDeviceId,
|
|
803
925
|
};
|
|
926
|
+
if (!queued.prepared && typeof engine.prepare === 'function') {
|
|
927
|
+
const prepared = await engine.prepare(text, options);
|
|
928
|
+
if (epoch !== this.speechEpoch || this.disposed) {
|
|
929
|
+
prepared.dispose?.();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
queued.prepared = prepared;
|
|
933
|
+
}
|
|
804
934
|
if (queued?.prepared && typeof engine.playPrepared === 'function') {
|
|
805
935
|
const prepared = queued.prepared;
|
|
806
|
-
queued.prepared = null;
|
|
807
936
|
this.prefetchItems.delete(queued);
|
|
808
|
-
await engine.playPrepared(prepared, options);
|
|
937
|
+
await engine.playPrepared(prepared, { ...options, retainPrepared: true });
|
|
809
938
|
} else await engine.speak(text, options);
|
|
810
939
|
} catch (error) {
|
|
811
940
|
if (epoch === this.speechEpoch) {
|
|
@@ -815,7 +944,21 @@ export class VoiceCoordinator {
|
|
|
815
944
|
}
|
|
816
945
|
} finally {
|
|
817
946
|
if (epoch === this.speechEpoch && !this.disposed) {
|
|
818
|
-
this.patch({
|
|
947
|
+
this.patch({
|
|
948
|
+
speaking: false,
|
|
949
|
+
paused: false,
|
|
950
|
+
activeMessageId: null,
|
|
951
|
+
speechLoading: false,
|
|
952
|
+
speechText: this.queue.length || this.unfinished.has(messageId) ? text : null,
|
|
953
|
+
speechEngine:
|
|
954
|
+
this.queue.length || this.unfinished.has(messageId) ? this.snapshot.speechEngine : null,
|
|
955
|
+
});
|
|
956
|
+
if (
|
|
957
|
+
failed ||
|
|
958
|
+
(!this.queue.length &&
|
|
959
|
+
!this.speechHistory.some((item) => !item.manual && this.unfinished.has(item.id)))
|
|
960
|
+
)
|
|
961
|
+
this._releaseSpeechHistory();
|
|
819
962
|
this._syncSpeechSegments();
|
|
820
963
|
if (!failed && this.queue.length) {
|
|
821
964
|
this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.segmentGapMs;
|
|
@@ -839,6 +982,7 @@ export class VoiceCoordinator {
|
|
|
839
982
|
(!this.snapshot.conversation && !this.queue[0]?.manual) ||
|
|
840
983
|
(!this.snapshot.settings.announceAssistantMessages && !this.queue[0]?.manual) ||
|
|
841
984
|
this.snapshot.speaking ||
|
|
985
|
+
this.snapshot.paused ||
|
|
842
986
|
this.snapshot.recognizing ||
|
|
843
987
|
this.snapshot.starting ||
|
|
844
988
|
(!this.queue[0]?.manual &&
|
|
@@ -862,7 +1006,7 @@ export class VoiceCoordinator {
|
|
|
862
1006
|
this._prefetchSpeech();
|
|
863
1007
|
void this.play(next.text, next.id, next.manual === true).finally(() => {
|
|
864
1008
|
if (this.activeSpeechItem === next) this.activeSpeechItem = null;
|
|
865
|
-
|
|
1009
|
+
// Audio remains owned by the playback run until completion.
|
|
866
1010
|
this.prefetchItems.delete(next);
|
|
867
1011
|
});
|
|
868
1012
|
}
|
|
@@ -871,6 +1015,16 @@ export class VoiceCoordinator {
|
|
|
871
1015
|
if (this.disposed) return;
|
|
872
1016
|
if (complete) this.unfinished.delete(id);
|
|
873
1017
|
else this.unfinished.add(id);
|
|
1018
|
+
if (
|
|
1019
|
+
complete &&
|
|
1020
|
+
!this.snapshot.speaking &&
|
|
1021
|
+
!this.queue.length &&
|
|
1022
|
+
this.speechHistory.some((item) => item.id === id)
|
|
1023
|
+
) {
|
|
1024
|
+
this._releaseSpeechHistory();
|
|
1025
|
+
this.patch({ speechText: null, speechEngine: null });
|
|
1026
|
+
this._syncSpeechSegments();
|
|
1027
|
+
}
|
|
874
1028
|
if (
|
|
875
1029
|
baseline ||
|
|
876
1030
|
!this.snapshot.conversation ||
|
|
@@ -888,20 +1042,19 @@ export class VoiceCoordinator {
|
|
|
888
1042
|
const remaining = text.slice(offset);
|
|
889
1043
|
const boundary = complete ? remaining.length : remaining.lastIndexOf('\n') + 1;
|
|
890
1044
|
if (boundary <= 0) return;
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
hasUnclosedCodeFence(remaining.slice(0, boundary)) &&
|
|
894
|
-
!complete
|
|
895
|
-
)
|
|
896
|
-
return;
|
|
1045
|
+
if (!complete && hasIncompleteSpeechMarkdown(remaining.slice(0, boundary))) return;
|
|
1046
|
+
if (hasUnclosedCodeFence(remaining.slice(0, boundary)) && !complete) return;
|
|
897
1047
|
const chunk = filterSpeechOutput(remaining.slice(0, boundary).trim(), {
|
|
898
1048
|
filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
|
|
899
1049
|
codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
|
|
900
1050
|
codeBlockNotice: this.snapshot.settings.outputCodeNotice,
|
|
1051
|
+
lang: this.snapshot.settings.lang,
|
|
901
1052
|
}).trim();
|
|
902
1053
|
this.consumed.set(id, offset + boundary);
|
|
903
1054
|
if (chunk) {
|
|
904
|
-
this.queue.
|
|
1055
|
+
const initial = !this.snapshot.speaking && !this.queue.some((item) => item.prepared);
|
|
1056
|
+
const parts = initial ? splitInitialSpeechItem(chunk) : [chunk];
|
|
1057
|
+
this.queue.push(...parts.map((text) => ({ text, id })));
|
|
905
1058
|
this._prefetchSpeech();
|
|
906
1059
|
this._syncSpeechSegments();
|
|
907
1060
|
this._drain();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
export const DEVELOPER_KEY = Symbol.for('dsh-live-voice.developer.v1');
|
|
3
|
+
export const DEVELOPER_CHANGED = 'dsh-live-voice:developer-changed';
|
|
4
|
+
export interface DeveloperExtension {
|
|
5
|
+
version: 1;
|
|
6
|
+
label(): string;
|
|
7
|
+
component: ComponentType;
|
|
8
|
+
}
|
|
9
|
+
export function readDeveloperExtension(target: any = globalThis.window): DeveloperExtension | null {
|
|
10
|
+
const extension = target?.[DEVELOPER_KEY];
|
|
11
|
+
return extension?.version === 1 ? extension : null;
|
|
12
|
+
}
|
|
13
|
+
export function subscribeDeveloperExtension(listener: () => void, target: any = globalThis.window) {
|
|
14
|
+
target?.addEventListener(DEVELOPER_CHANGED, listener);
|
|
15
|
+
return () => target?.removeEventListener(DEVELOPER_CHANGED, listener);
|
|
16
|
+
}
|
|
17
|
+
export function publishDeveloperExtension(target: any, extension: DeveloperExtension) {
|
|
18
|
+
const ChangedEvent = target.Event ?? Event;
|
|
19
|
+
target[DEVELOPER_KEY] = extension;
|
|
20
|
+
target.dispatchEvent(new ChangedEvent(DEVELOPER_CHANGED));
|
|
21
|
+
return () => {
|
|
22
|
+
if (target[DEVELOPER_KEY] !== extension) return;
|
|
23
|
+
delete target[DEVELOPER_KEY];
|
|
24
|
+
target.dispatchEvent(new ChangedEvent(DEVELOPER_CHANGED));
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Versioned read-only boundary; debugger UI is never imported by the runtime. */
|
|
2
|
+
export const DIAGNOSTIC_KEY = Symbol.for('dsh-live-voice.diagnostics.v1');
|
|
3
|
+
export const DIAGNOSTIC_CHANGED = 'dsh-live-voice:diagnostics-changed';
|
|
4
|
+
export type DiagnosticValue =
|
|
5
|
+
null | boolean | number | string | DiagnosticValue[] | { [key: string]: DiagnosticValue };
|
|
6
|
+
export interface DiagnosticSource {
|
|
7
|
+
version: 1;
|
|
8
|
+
read(options?: { includeContent?: boolean }): DiagnosticValue;
|
|
9
|
+
subscribe(listener: () => void): () => void;
|
|
10
|
+
}
|
|
11
|
+
export function publishDiagnosticSource(target: any, source: DiagnosticSource) {
|
|
12
|
+
target[DIAGNOSTIC_KEY] = source;
|
|
13
|
+
const ChangedEvent = target.Event ?? Event;
|
|
14
|
+
target.dispatchEvent?.(new ChangedEvent(DIAGNOSTIC_CHANGED));
|
|
15
|
+
return () => {
|
|
16
|
+
if (target[DIAGNOSTIC_KEY] !== source) return;
|
|
17
|
+
delete target[DIAGNOSTIC_KEY];
|
|
18
|
+
target.dispatchEvent?.(new ChangedEvent(DIAGNOSTIC_CHANGED));
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const number = (value: unknown) =>
|
|
22
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
23
|
+
/** Explicit projection excludes audio, functions, resources and free-form errors. */
|
|
24
|
+
export function readCoordinatorDiagnostics(
|
|
25
|
+
controller: any,
|
|
26
|
+
includeContent = false,
|
|
27
|
+
): DiagnosticValue {
|
|
28
|
+
const state = controller.getSnapshot();
|
|
29
|
+
const recognition = controller.recognition?.session;
|
|
30
|
+
const settings: Record<string, DiagnosticValue> = {};
|
|
31
|
+
const publicSettings = new Set([
|
|
32
|
+
'engine',
|
|
33
|
+
'recognitionEngine',
|
|
34
|
+
'voiceDetectionPreset',
|
|
35
|
+
'sendingMode',
|
|
36
|
+
'dshBusyEnterBehavior',
|
|
37
|
+
'mode',
|
|
38
|
+
'lang',
|
|
39
|
+
'recognitionLang',
|
|
40
|
+
]);
|
|
41
|
+
for (const [key, value] of Object.entries(state.settings ?? {})) {
|
|
42
|
+
settings[key] =
|
|
43
|
+
typeof value === 'string'
|
|
44
|
+
? includeContent || publicSettings.has(key)
|
|
45
|
+
? value
|
|
46
|
+
: { characters: value.length }
|
|
47
|
+
: typeof value === 'boolean' || typeof value === 'number'
|
|
48
|
+
? value
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
const item = (entry: any): DiagnosticValue => ({
|
|
52
|
+
messageId: entry.id == null ? null : String(entry.id),
|
|
53
|
+
manual: entry.manual === true,
|
|
54
|
+
characters: entry.text?.length ?? 0,
|
|
55
|
+
...(includeContent ? { text: String(entry.text ?? '') } : {}),
|
|
56
|
+
preparing: Boolean(entry.preparing),
|
|
57
|
+
prepared: Boolean(entry.prepared),
|
|
58
|
+
failed: Boolean(entry.prepareError),
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
settings,
|
|
62
|
+
lifecycle: {
|
|
63
|
+
disposed: Boolean(controller.disposed),
|
|
64
|
+
conversation: Boolean(state.conversation),
|
|
65
|
+
},
|
|
66
|
+
composer: {
|
|
67
|
+
characters: controller.composer?.getDraft?.()?.length ?? 0,
|
|
68
|
+
answeringQuestion: Boolean(state.answeringQuestion),
|
|
69
|
+
...(includeContent ? { draft: controller.composer?.getDraft?.() ?? '' } : {}),
|
|
70
|
+
},
|
|
71
|
+
capture: {
|
|
72
|
+
starting: Boolean(state.starting),
|
|
73
|
+
listening: Boolean(state.listening),
|
|
74
|
+
muted: Boolean(state.muted),
|
|
75
|
+
generation: number(controller.epoch),
|
|
76
|
+
level: number(controller.meter?.level?.()),
|
|
77
|
+
releaseFailed: Boolean(controller.inputReleaseError),
|
|
78
|
+
},
|
|
79
|
+
vad: {
|
|
80
|
+
source: state.settings?.recognitionEngine === 'browser' ? 'native' : 'plugin',
|
|
81
|
+
activity: Boolean(state.recognizing),
|
|
82
|
+
threshold: recognition?.chunks ? 0.012 : null,
|
|
83
|
+
silenceMs:
|
|
84
|
+
recognition?.chunks && controller.meter?.context?.sampleRate
|
|
85
|
+
? (recognition.silence / controller.meter.context.sampleRate) * 1000
|
|
86
|
+
: null,
|
|
87
|
+
},
|
|
88
|
+
chunker: {
|
|
89
|
+
available: Boolean(recognition?.chunks),
|
|
90
|
+
bufferedSamples: number(recognition?.samples),
|
|
91
|
+
bufferedChunks: recognition?.chunks?.length ?? null,
|
|
92
|
+
containsSpeech: recognition?.voiced ?? null,
|
|
93
|
+
},
|
|
94
|
+
recognition: {
|
|
95
|
+
pendingTranscriptions: number(state.pendingTranscriptions),
|
|
96
|
+
activeRequest: Boolean(recognition?.activeRequest),
|
|
97
|
+
queue: (recognition?.transcriptionQueue ?? []).map((chunk: any) => ({
|
|
98
|
+
samples: (chunk.samples ?? chunk).length,
|
|
99
|
+
durationMs: controller.meter?.context?.sampleRate
|
|
100
|
+
? ((chunk.samples ?? chunk).length / controller.meter.context.sampleRate) * 1000
|
|
101
|
+
: null,
|
|
102
|
+
})),
|
|
103
|
+
},
|
|
104
|
+
delivery: {
|
|
105
|
+
autoSendAt: number(state.autoSendAt),
|
|
106
|
+
countdownActive: controller.autoSendTimer != null,
|
|
107
|
+
holdToTalkRelease: Boolean(controller.holdToTalkRelease),
|
|
108
|
+
},
|
|
109
|
+
speech: {
|
|
110
|
+
speaking: Boolean(state.speaking),
|
|
111
|
+
paused: Boolean(state.paused),
|
|
112
|
+
activeMessageId: state.activeMessageId == null ? null : String(state.activeMessageId),
|
|
113
|
+
generation: number(controller.speechEpoch),
|
|
114
|
+
controlGeneration: number(controller.controlEpoch),
|
|
115
|
+
remaining: number(state.speechSegmentsRemaining),
|
|
116
|
+
notBefore: number(controller.assistantSpeechNotBefore),
|
|
117
|
+
waitingForActivity: controller.assistantSpeechNotBefore === Infinity,
|
|
118
|
+
stopFailed: Boolean(controller.speechStopError),
|
|
119
|
+
queue: (controller.queue ?? []).map(item),
|
|
120
|
+
activeItem: controller.activeSpeechItem ? item(controller.activeSpeechItem) : null,
|
|
121
|
+
prefetchCount: controller.prefetchItems?.size ?? 0,
|
|
122
|
+
consumed: Object.fromEntries(controller.consumed ?? []),
|
|
123
|
+
unfinished: [...(controller.unfinished ?? [])].map(String),
|
|
124
|
+
suppressed: [...(controller.suppressed ?? [])].map(String),
|
|
125
|
+
},
|
|
126
|
+
interruption: {
|
|
127
|
+
transcriptConfirmed: Boolean(controller.interruptionTranscriptConfirmed),
|
|
128
|
+
pausedSpeech: Boolean(controller.interruptionPausedSpeech),
|
|
129
|
+
timerActive: controller.interruptionTimer != null,
|
|
130
|
+
},
|
|
131
|
+
capabilities: Object.fromEntries(
|
|
132
|
+
Object.entries(state.capabilities ?? {}).map(([key, cap]: [string, any]) => [
|
|
133
|
+
key,
|
|
134
|
+
cap
|
|
135
|
+
? {
|
|
136
|
+
supported: cap.supported === true,
|
|
137
|
+
local: cap.local ?? null,
|
|
138
|
+
pause: cap.pause ?? null,
|
|
139
|
+
resume: cap.resume ?? null,
|
|
140
|
+
hasReason: Boolean(cap.reason),
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
]),
|
|
144
|
+
),
|
|
145
|
+
error: {
|
|
146
|
+
present: Boolean(state.error),
|
|
147
|
+
...(includeContent ? { message: state.error ?? null } : {}),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
+
import { markdownSpeech } from './markdownSpeech.js';
|
|
3
|
+
export { hasIncompleteSpeechMarkdown } from './markdownSpeech.js';
|
|
2
4
|
const words = (text) =>
|
|
3
5
|
String(text || '')
|
|
4
6
|
.trim()
|
|
@@ -41,23 +43,26 @@ export function splitSpeechOutput(text, options = {}) {
|
|
|
41
43
|
.filter(Boolean);
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** Split only the initial item at sentence punctuation or colons, never commas. */
|
|
47
|
+
export function splitInitialSpeechItem(text) {
|
|
48
|
+
const result = [];
|
|
49
|
+
let start = 0;
|
|
50
|
+
// Require whitespace/end after punctuation to preserve decimals, URLs and file names.
|
|
51
|
+
for (const match of text.matchAll(/[.!?:]+(?:["”’)]*)?(?=\s|$)/gu)) {
|
|
52
|
+
const end = match.index + match[0].length;
|
|
53
|
+
const part = text.slice(start, end).trim();
|
|
54
|
+
if (part) result.push(part);
|
|
55
|
+
start = end;
|
|
56
|
+
}
|
|
57
|
+
const tail = text.slice(start).trim();
|
|
58
|
+
if (tail) result.push(tail);
|
|
59
|
+
return result.length ? result : [text];
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
export function hasUnclosedCodeFence(text) {
|
|
45
63
|
return (String(text || '').match(/```/g) || []).length % 2 === 1;
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
export function filterSpeechOutput(
|
|
49
|
-
text,
|
|
50
|
-
{
|
|
51
|
-
filterCodeBlocks = true,
|
|
52
|
-
codeBlockMaxLines = 5,
|
|
53
|
-
codeBlockNotice = 'Look the code on out conversation',
|
|
54
|
-
} = {},
|
|
55
|
-
) {
|
|
56
|
-
const source = String(text || '');
|
|
57
|
-
if (!filterCodeBlocks) return source;
|
|
58
|
-
return source.replace(/```[^\n]*\n?([\s\S]*?)```/g, (block, body) => {
|
|
59
|
-
const normalized = body.replace(/\n$/, '');
|
|
60
|
-
const lineCount = normalized ? normalized.split(/\r?\n/).length : 0;
|
|
61
|
-
return lineCount <= codeBlockMaxLines ? normalized : codeBlockNotice;
|
|
62
|
-
});
|
|
66
|
+
export function filterSpeechOutput(text, options = {}) {
|
|
67
|
+
return markdownSpeech(text, options);
|
|
63
68
|
}
|