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
@@ -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
- started = await this.meter.start({ signal: controller.signal });
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
- this.updateSettings({ sendingMode });
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 next = this.transcript.update(this.composer.getDraft(), final, true);
488
- this.composer.setDraft(next);
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
- item.prepared?.dispose?.();
590
- item.prepared = null;
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 (signal.aborted || !this.queue.includes(item)) {
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
- if (speechSegmentsRemaining !== this.snapshot.speechSegmentsRemaining)
629
- this.patch({ speechSegmentsRemaining });
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.patch({ speaking: false, paused: false, activeMessageId: null });
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
- // Unlike Stop, Next discards only the active segment. Queued and future
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?.prepared?.dispose?.();
684
- this.prefetchItems.delete(skipped);
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._cancelSpeechPrefetch();
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 || !this.snapshot.speaking || this.snapshot.paused) return;
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 = await this.engines[this.snapshot.settings.engine].pause();
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 = await this.engines[this.snapshot.settings.engine].resume();
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 first = segments.shift();
761
- if (!first) return;
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({ speaking: true, paused: false, activeMessageId: messageId, error: null });
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 = this.activeSpeechItem;
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({ speaking: false, paused: false, activeMessageId: null });
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
- next.prepared?.dispose?.();
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
- this.snapshot.settings.outputCodeFilterEnabled &&
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.push({ text: chunk, id });
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
  }