dsh-live-voice 0.3.2 → 0.4.1

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 (70) hide show
  1. package/README.md +48 -14
  2. package/lib/client.js +6474 -3396
  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 +242 -35
  8. package/src/app/client/composerSelection.ts +92 -0
  9. package/src/app/client/i18n/catalogs/base.ts +35 -0
  10. package/src/app/client/i18n/catalogs/en.ts +43 -3
  11. package/src/app/client/i18n/catalogs/es.ts +51 -11
  12. package/src/app/client/i18n/catalogs/fr.ts +52 -11
  13. package/src/app/client/i18n/catalogs/hi.ts +50 -11
  14. package/src/app/client/i18n/catalogs/pt-BR.ts +52 -11
  15. package/src/app/client/i18n/catalogs/zh.ts +50 -11
  16. package/src/modules/AGENTS.md +1 -1
  17. package/src/modules/conversation/ARCHITECTURE.md +6 -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 +91 -93
  21. package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
  22. package/src/modules/conversation/components/MeetingControls.tsx +40 -0
  23. package/src/modules/conversation/components/MicrophoneButton.tsx +15 -2
  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 +1 -3
  30. package/src/modules/conversation/components/createConversationComponents.tsx +1 -1
  31. package/src/modules/conversation/components/index.ts +1 -0
  32. package/src/modules/conversation/components/speechCaption.ts +27 -0
  33. package/src/modules/conversation/models/chat.ts +15 -2
  34. package/src/modules/conversation/models/meeting.ts +256 -0
  35. package/src/modules/conversation/models/meetingTranscript.ts +63 -0
  36. package/src/modules/core/ARCHITECTURE.md +1 -1
  37. package/src/modules/core/coordinator.ts +353 -81
  38. package/src/modules/core/developerExtension.ts +26 -0
  39. package/src/modules/core/diagnostics.ts +153 -0
  40. package/src/modules/core/filters.ts +20 -15
  41. package/src/modules/core/markdownSpeech.ts +154 -0
  42. package/src/modules/core/settings.ts +17 -13
  43. package/src/modules/core/sharedAudio.ts +88 -0
  44. package/src/modules/core/speechDefaults.ts +21 -0
  45. package/src/modules/recognition/engines/browser/BrowserRecognitionEngine.ts +8 -1
  46. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +10 -3
  47. package/src/modules/settings/components/LiveVoiceSettings.tsx +17 -3
  48. package/src/modules/settings/components/SettingsHeader.tsx +1 -1
  49. package/src/modules/settings/models/settingsStorage.ts +11 -3
  50. package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
  51. package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
  52. package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -1
  53. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +12 -1
  54. package/src/modules/speak/engines/audio/HostAudioEngine.ts +19 -2
  55. package/src/modules/speak/engines/browser/BrowserSpeakingEngine.ts +33 -2
  56. package/src/shared/design-system/buttons/ToggleButton.tsx +8 -1
  57. package/src/shared/design-system/icons/icons.ts +6 -0
  58. package/src/styles/index.ts +13 -3
  59. package/CHANGELOG.md +0 -240
  60. package/DEVELOPMENT.md +0 -83
  61. package/PLAN.md +0 -296
  62. package/docs/ARCHITECTURE.md +0 -33
  63. package/docs/CHOOSING-AN-ENGINE.md +0 -120
  64. package/docs/CONFIGURATION.md +0 -163
  65. package/docs/REVIEW.md +0 -35
  66. package/docs/VOICE-LIFECYCLE.md +0 -63
  67. package/scripts/build.ts +0 -62
  68. package/scripts/check-dist.mjs +0 -38
  69. package/scripts/preview-ui.ts +0 -31
  70. 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
  };
@@ -66,6 +77,7 @@ export class VoiceCoordinator {
66
77
  this.autoSendDraft = null;
67
78
  this.holdToTalkRelease = false;
68
79
  this.interruptionTimer = null;
80
+ this.interruptionResumeTimer = null;
69
81
  this.interruptionTranscriptConfirmed = false;
70
82
  this.interruptionPausedSpeech = false;
71
83
  this.assistantSpeechTimer = null;
@@ -131,7 +143,7 @@ export class VoiceCoordinator {
131
143
  this.patch({
132
144
  settings,
133
145
  muted: settings.microphoneEnabled === false,
134
- recognizing: settings.microphoneEnabled === false ? false : this.snapshot.recognizing,
146
+ recognizing: this.snapshot.recognizing,
135
147
  error: null,
136
148
  });
137
149
  if (settings.sendingMode === 'manual') this.cancelAutoSend();
@@ -223,12 +235,15 @@ export class VoiceCoordinator {
223
235
  startConversation() {
224
236
  return this.startListening(true);
225
237
  }
238
+ toggleTimestamps() {
239
+ this.patch({ timestamps: !this.snapshot.timestamps });
240
+ }
226
241
  muteListening() {
227
242
  this.cancelAutoSend();
228
243
  // Discard pending recognition without rewriting the composer.
229
244
  this.transcript.reset();
230
245
  this.updateSettings({ microphoneEnabled: false });
231
- if (!this.snapshot.settings.voiceCommandsEnabled) return this.stopListening();
246
+ // Ignoring discards text; capture and interruption remain active even without voice commands.
232
247
  }
233
248
  resumeListeningInput() {
234
249
  this.transcript.reset();
@@ -273,7 +288,32 @@ export class VoiceCoordinator {
273
288
  throw new Error(capability.reason || 'Local browser recognition is unavailable.');
274
289
  let started;
275
290
  try {
276
- started = await this.meter.start({ signal: controller.signal });
291
+ const deviceId = this.snapshot.settings.inputDeviceId;
292
+ try {
293
+ started = await this.meter.start({ signal: controller.signal });
294
+ } catch (error) {
295
+ if (
296
+ error?.name !== 'OverconstrainedError' ||
297
+ !deviceId ||
298
+ !valid() ||
299
+ controller.signal.aborted ||
300
+ this.snapshot.settings.inputDeviceId !== deviceId
301
+ )
302
+ throw error;
303
+ // Repair only the obsolete input preference, without ending this input operation.
304
+ VoiceCoordinator.prototype.updateSettings.call(this, { inputDeviceId: '' });
305
+ await cancellable(
306
+ Promise.resolve(this.persistSettings?.({ inputDeviceId: '' })),
307
+ controller.signal,
308
+ );
309
+ if (
310
+ !valid() ||
311
+ controller.signal.aborted ||
312
+ this.snapshot.settings.inputDeviceId !== ''
313
+ )
314
+ return;
315
+ started = await this.meter.start({ signal: controller.signal });
316
+ }
277
317
  } catch (error) {
278
318
  if (valid())
279
319
  this.patch({
@@ -307,22 +347,22 @@ export class VoiceCoordinator {
307
347
  if (!valid()) return;
308
348
  const count = Number.isSafeInteger(pending) && pending >= 0 ? pending : 0;
309
349
  this.patch({ pendingTranscriptions: count });
310
- if (count > 0) this.cancelAutoSend();
350
+ if (count > 0) this.cancelAutoSend({ preserveIntent: true });
311
351
  else if (this.holdToTalkRelease) this._finishHoldToTalk();
312
352
  else this.maybeScheduleAutoSend();
313
353
  },
314
354
  onActivity: (active) => {
315
355
  if (!valid()) return;
316
356
  if (active) {
317
- this.cancelAutoSend();
357
+ this.cancelAutoSend({ preserveIntent: true });
318
358
  this.assistantSpeechNotBefore = Infinity;
319
359
  this._cancelAssistantSpeechTimer();
320
360
  } else
321
361
  this.assistantSpeechNotBefore =
322
362
  Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
323
363
  this.patch({ recognizing: active });
324
- this._handleSpeechInterruption(active);
325
- if (!active) this._drain();
364
+ this._handleSpeechInterruption(this._speechActivity());
365
+ if (!active) { this.refreshDeliveryReadiness(); this._drain(); }
326
366
  },
327
367
  onError: (error) => {
328
368
  if (!valid()) return;
@@ -361,11 +401,51 @@ export class VoiceCoordinator {
361
401
  return hasMinimumWords(text, 1);
362
402
  }
363
403
 
404
+ _speechActivity() {
405
+ return this.snapshot.recognizing || this.externalSpeechActive;
406
+ }
407
+ setExternalSpeechActivity(active) {
408
+ if (this.disposed) return;
409
+ this.externalSpeechActive = Boolean(active);
410
+ if (!active) this.externalSpeechConfirmed = false;
411
+ if (active) {
412
+ this.assistantSpeechNotBefore = Infinity;
413
+ this._cancelAssistantSpeechTimer();
414
+ } else if (!this.snapshot.recognizing) {
415
+ this.assistantSpeechNotBefore =
416
+ Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
417
+ }
418
+ this._handleSpeechInterruption(this._speechActivity());
419
+ if (!active) this._drain();
420
+ }
421
+ confirmExternalSpeech(text) {
422
+ if (this.disposed) return false;
423
+ const normalized = (value) =>
424
+ String(value || '')
425
+ .toLowerCase()
426
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
427
+ .trim();
428
+ const heard = normalized(text);
429
+ const own = normalized(this.snapshot.speechText);
430
+ // System-audio mixes may still contain TTS despite browser exclusion hints.
431
+ if (this.snapshot.speaking && heard && own.includes(heard)) return false;
432
+ if (this.externalSpeechActive && this._interruptionTranscriptQualifies(text)) {
433
+ this.externalSpeechConfirmed = true;
434
+ this._handleSpeechInterruption(true);
435
+ }
436
+ return true;
437
+ }
364
438
  _handleSpeechInterruption(active) {
365
- if (this.snapshot.settings.mode !== 'headphones') return;
439
+ if (
440
+ this.snapshot.settings.mode !== 'headphones' &&
441
+ !this.externalSpeechConfirmed &&
442
+ !this.interruptionPausedSpeech
443
+ )
444
+ return;
366
445
  if (active) {
446
+ this._cancelInterruptionResume();
367
447
  if (
368
- !this.interruptionTranscriptConfirmed ||
448
+ !(this.interruptionTranscriptConfirmed || this.externalSpeechConfirmed) ||
369
449
  this.interruptionTimer !== null ||
370
450
  !this.snapshot.speaking ||
371
451
  this.snapshot.paused
@@ -374,21 +454,14 @@ export class VoiceCoordinator {
374
454
  this.interruptionTimer = setTimeout(() => {
375
455
  this.interruptionTimer = null;
376
456
  if (
377
- this.interruptionTranscriptConfirmed &&
378
- this.snapshot.recognizing &&
457
+ (this.interruptionTranscriptConfirmed || this.externalSpeechConfirmed) &&
458
+ this._speechActivity() &&
379
459
  this.snapshot.speaking &&
380
460
  !this.snapshot.paused
381
461
  ) {
382
462
  this.interruptionPausedSpeech = true;
383
- void this.pauseSpeech().then(() => {
384
- if (
385
- this.interruptionPausedSpeech &&
386
- !this.snapshot.recognizing &&
387
- this.snapshot.paused
388
- ) {
389
- this.interruptionPausedSpeech = false;
390
- void this.resumeSpeech();
391
- }
463
+ void this.pauseSpeech(true).then(() => {
464
+ if (!this._speechActivity()) this._scheduleInterruptionResume();
392
465
  });
393
466
  }
394
467
  }, this.snapshot.settings.assistantSpeechDelaySeconds * 1000);
@@ -397,13 +470,38 @@ export class VoiceCoordinator {
397
470
  if (this.interruptionTimer !== null) clearTimeout(this.interruptionTimer);
398
471
  this.interruptionTimer = null;
399
472
  this.interruptionTranscriptConfirmed = false;
400
- if (this.interruptionPausedSpeech && this.snapshot.paused) {
473
+ this._scheduleInterruptionResume();
474
+ }
475
+ _cancelInterruptionResume() {
476
+ if (this.interruptionResumeTimer !== null) clearTimeout(this.interruptionResumeTimer);
477
+ this.interruptionResumeTimer = null;
478
+ }
479
+ _scheduleInterruptionResume() {
480
+ if (
481
+ !this.interruptionPausedSpeech ||
482
+ !this.snapshot.paused ||
483
+ this._speechActivity() ||
484
+ this.interruptionResumeTimer !== null
485
+ )
486
+ return;
487
+ const epoch = this.speechEpoch;
488
+ const control = this.controlEpoch;
489
+ this.interruptionResumeTimer = setTimeout(() => {
490
+ this.interruptionResumeTimer = null;
491
+ if (
492
+ this.disposed ||
493
+ epoch !== this.speechEpoch ||
494
+ control !== this.controlEpoch ||
495
+ this._speechActivity() ||
496
+ !this.interruptionPausedSpeech
497
+ )
498
+ return;
401
499
  this.interruptionPausedSpeech = false;
402
500
  void this.resumeSpeech();
403
- }
501
+ }, this.snapshot.settings.assistantSpeechDelaySeconds * 1000);
404
502
  }
405
503
 
406
- onResult({ final = '', interim = '' }) {
504
+ onResult({ final = '', interim = '', startedAt }) {
407
505
  if (this.disposed || (!this.snapshot.listening && !this.snapshot.starting)) return;
408
506
  if (
409
507
  this.snapshot.speaking &&
@@ -422,6 +520,7 @@ export class VoiceCoordinator {
422
520
  // Pending structured questions own recognition while waiting for a hands-free
423
521
  // answer. Keep their interim and final text out of the normal chat composer.
424
522
  if (
523
+ !this.snapshot.muted &&
425
524
  typeof this.composer.handleQuestionResult === 'function' &&
426
525
  this.composer.handleQuestionResult({ final, interim })
427
526
  ) {
@@ -463,7 +562,7 @@ export class VoiceCoordinator {
463
562
  } else if (this.snapshot.muted) return;
464
563
  else {
465
564
  const sendingMode = command === 'send' ? 'steer' : 'queue';
466
- this.updateSettings({ sendingMode });
565
+ // Spoken delivery is a one-shot action, not an automatic-send preference change.
467
566
  if (this.composer.getDraft().trim() && typeof this.composer.submit === 'function')
468
567
  this.composer.submit(sendingMode);
469
568
  }
@@ -472,7 +571,13 @@ export class VoiceCoordinator {
472
571
  if (this.snapshot.muted) {
473
572
  // Discard pending recognition without rewriting the composer.
474
573
  this.transcript.reset();
475
- this.patch({ recognizing: false });
574
+ this.patch({ recognizing: !!interim });
575
+ this._handleSpeechInterruption(this._speechActivity());
576
+ if (!interim) {
577
+ this.assistantSpeechNotBefore =
578
+ Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
579
+ this._drain();
580
+ }
476
581
  return;
477
582
  }
478
583
  if (
@@ -482,45 +587,64 @@ export class VoiceCoordinator {
482
587
  // Discard pending recognition without rewriting the composer.
483
588
  this.transcript.reset();
484
589
  this.patch({ recognizing: false });
590
+ this.refreshDeliveryReadiness();
485
591
  return;
486
592
  }
487
- const next = this.transcript.update(this.composer.getDraft(), final, true);
488
- this.composer.setDraft(next);
593
+ this.cancelAutoSend();
594
+ const appended = this.composer.appendFinal?.(final, startedAt);
595
+ const next = appended ?? this.transcript.update(this.composer.getDraft(), final, true);
596
+ if (appended == null) this.composer.setDraft(next);
597
+ if (this.snapshot.settings.sendingMode !== 'manual') this.autoSendIntent = next;
489
598
  this.maybeScheduleAutoSend(next);
490
599
  }
491
600
  if (interim) {
492
- this.cancelAutoSend();
601
+ this.cancelAutoSend({ preserveIntent: true });
493
602
  // Interim hypotheses affect recognition status only, never composer content.
494
603
  }
495
604
  if (!interim && this.snapshot.recognizing)
496
605
  this.assistantSpeechNotBefore =
497
606
  Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1000;
498
607
  this.patch({ recognizing: !!interim });
608
+ this.refreshDeliveryReadiness();
609
+ this._handleSpeechInterruption(this._speechActivity());
499
610
  this._drain();
500
611
  }
501
- maybeScheduleAutoSend(draft = this.composer.getDraft()) {
612
+ maybeScheduleAutoSend(draft = this.autoSendIntent) {
613
+ if (draft == null || draft !== this.composer.getDraft()) return;
614
+ if (this.autoSendTimer !== null) return;
502
615
  if (
503
616
  this.snapshot.settings.sendingMode === 'manual' ||
617
+ this.snapshot.muted ||
618
+ this.composer.canAutoSend?.() === false ||
504
619
  this.snapshot.recognizing ||
505
620
  this.snapshot.pendingTranscriptions > 0
506
621
  )
507
622
  return;
508
623
  this.scheduleAutoSend(draft);
509
624
  }
625
+ refreshDeliveryReadiness() {
626
+ if (this.composer.canAutoSend?.() === false || this.snapshot.recognizing || this.snapshot.pendingTranscriptions > 0)
627
+ this.cancelAutoSend({ preserveIntent: true });
628
+ else this.maybeScheduleAutoSend();
629
+ }
510
630
  scheduleAutoSend(draft, { force = false, stopAfter = false } = {}) {
511
631
  this.cancelAutoSend();
512
632
  if (!draft.trim() || typeof this.composer.submit !== 'function') return;
513
633
  const delay = this.snapshot.settings.autoSendDelaySeconds * 1000;
514
634
  this.autoSendDraft = draft;
635
+ this.autoSendIntent = draft;
515
636
  this.patch({ autoSendAt: Date.now() + delay });
516
637
  this.autoSendTimer = setTimeout(() => {
517
638
  this.autoSendTimer = null;
518
639
  const expected = this.autoSendDraft;
640
+ this.autoSendIntent = null;
519
641
  this.autoSendDraft = null;
520
642
  this.patch({ autoSendAt: null });
521
643
  if (
522
644
  !this.disposed &&
523
645
  (force || this.snapshot.settings.sendingMode !== 'manual') &&
646
+ !this.snapshot.muted &&
647
+ this.composer.canAutoSend?.() !== false &&
524
648
  expected === this.composer.getDraft()
525
649
  ) {
526
650
  try {
@@ -542,18 +666,22 @@ export class VoiceCoordinator {
542
666
  this._drain();
543
667
  }
544
668
  } else {
669
+ if (!this.disposed && !this.snapshot.muted && expected === this.composer.getDraft() &&
670
+ this.snapshot.settings.sendingMode !== 'manual' && this.composer.canAutoSend?.() === false)
671
+ this.autoSendIntent = expected;
545
672
  this._drain();
546
673
  }
547
674
  }, delay);
548
675
  }
549
- cancelAutoSend() {
676
+ cancelAutoSend({ preserveIntent = false } = {}) {
677
+ if (!preserveIntent) this.autoSendIntent = null;
550
678
  if (this.autoSendTimer !== null) clearTimeout(this.autoSendTimer);
551
679
  this.autoSendTimer = null;
552
680
  this.autoSendDraft = null;
553
681
  if (this.snapshot?.autoSendAt !== null) this.patch({ autoSendAt: null });
554
682
  }
555
683
  composerChanged(draft) {
556
- if (this.autoSendDraft !== null && draft !== this.autoSendDraft) this.cancelAutoSend();
684
+ if (this.autoSendIntent != null && draft !== this.autoSendIntent) this.cancelAutoSend();
557
685
  }
558
686
  stopListening() {
559
687
  this.holdToTalkRelease = false;
@@ -586,13 +714,27 @@ export class VoiceCoordinator {
586
714
  this.prefetchController.abort();
587
715
  this.prefetchController = new AbortController();
588
716
  for (const item of this.prefetchItems) {
589
- item.prepared?.dispose?.();
590
- item.prepared = null;
717
+ if (!this.speechHistory.includes(item)) {
718
+ item.prepared?.dispose?.();
719
+ item.prepared = null;
720
+ }
591
721
  item.preparing = null;
592
722
  }
593
723
  this.prefetchItems.clear();
594
724
  }
595
725
 
726
+ _speechPreparationKey() {
727
+ const { engine, voice, lang, rate, outputDeviceId } = this.snapshot.settings;
728
+ return JSON.stringify([engine, voice, lang, rate, outputDeviceId]);
729
+ }
730
+ _resetStaleSpeechItem(item, key) {
731
+ if (item.preparationKey === key) return;
732
+ item.prepared?.dispose?.();
733
+ item.prepared = null;
734
+ item.preparing = null;
735
+ item.prepareError = null;
736
+ item.preparationKey = key;
737
+ }
596
738
  _prefetchSpeech() {
597
739
  const engine = this.engines[this.snapshot.settings.engine];
598
740
  if (typeof engine?.prepare !== 'function') return;
@@ -602,31 +744,75 @@ export class VoiceCoordinator {
602
744
  lang: this.snapshot.settings.lang,
603
745
  outputDeviceId: this.snapshot.settings.outputDeviceId,
604
746
  };
747
+ const key = this._speechPreparationKey();
605
748
  for (const item of this.queue.slice(0, 3)) {
749
+ this._resetStaleSpeechItem(item, key);
606
750
  if (item.prepared || item.preparing) continue;
607
751
  this.prefetchItems.add(item);
608
752
  const signal = this.prefetchController.signal;
609
753
  item.preparing = engine
610
754
  .prepare(item.text, { ...options, signal })
611
755
  .then((prepared) => {
612
- item.preparing = null;
613
- if (signal.aborted || !this.queue.includes(item)) {
756
+ if (item.preparationKey === key) item.preparing = null;
757
+ if (
758
+ signal.aborted ||
759
+ item.preparationKey !== key ||
760
+ (!this.queue.includes(item) && !this.speechHistory.includes(item))
761
+ ) {
614
762
  prepared.dispose?.();
615
763
  this.prefetchItems.delete(item);
616
764
  } else item.prepared = prepared;
617
765
  return prepared;
618
766
  })
619
767
  .catch((error) => {
620
- item.preparing = null;
621
- if (!signal.aborted) item.prepareError = error;
768
+ if (item.preparationKey === key) {
769
+ item.preparing = null;
770
+ if (!signal.aborted) item.prepareError = error;
771
+ }
622
772
  });
623
773
  }
624
774
  }
625
775
 
776
+ _releaseSpeechHistory() {
777
+ for (const item of this.speechHistory) item.prepared?.dispose?.();
778
+ this.speechHistory = [];
779
+ this.speechCursor = -1;
780
+ }
781
+ getSpeechHistory() {
782
+ const groups = [];
783
+ for (const item of [...this.speechHistory, ...this.queue]) {
784
+ let group = groups.at(-1);
785
+ if (!group || group.messageId !== item.id) {
786
+ group = { messageId: item.id, segments: [] };
787
+ groups.push(group);
788
+ }
789
+ if (!group.segments.includes(item)) group.segments.push(item);
790
+ }
791
+ return groups;
792
+ }
626
793
  _syncSpeechSegments() {
627
794
  const speechSegmentsRemaining = this.queue.length + (this.snapshot.speaking ? 1 : 0);
628
- if (speechSegmentsRemaining !== this.snapshot.speechSegmentsRemaining)
629
- this.patch({ speechSegmentsRemaining });
795
+ const speechHasPrevious = this.speechCursor > 0;
796
+ const items = [...new Set([...this.speechHistory, ...this.queue])];
797
+ const speechSegmentsTotal = items.length;
798
+ const speechSegmentIndex = this.speechCursor >= 0 ? this.speechCursor + 1 : 0;
799
+ const speechRunActive =
800
+ speechSegmentsRemaining > 0 ||
801
+ this.speechHistory.some((item) => !item.manual && this.unfinished.has(item.id));
802
+ if (
803
+ speechSegmentsRemaining !== this.snapshot.speechSegmentsRemaining ||
804
+ speechHasPrevious !== this.snapshot.speechHasPrevious ||
805
+ speechRunActive !== this.snapshot.speechRunActive ||
806
+ speechSegmentsTotal !== this.snapshot.speechSegmentsTotal ||
807
+ speechSegmentIndex !== this.snapshot.speechSegmentIndex
808
+ )
809
+ this.patch({
810
+ speechSegmentsRemaining,
811
+ speechHasPrevious,
812
+ speechRunActive,
813
+ speechSegmentsTotal,
814
+ speechSegmentIndex,
815
+ });
630
816
  }
631
817
 
632
818
  _suppressPending() {
@@ -636,15 +822,25 @@ export class VoiceCoordinator {
636
822
  this.queue = [];
637
823
  }
638
824
  async stopSpeech(resumeListening = true) {
825
+ this._cancelInterruptionResume();
639
826
  if (this.interruptionTimer !== null) clearTimeout(this.interruptionTimer);
640
827
  this.interruptionTimer = null;
828
+ this.interruptionResumeTimer = null;
641
829
  this.interruptionTranscriptConfirmed = false;
642
830
  this.interruptionPausedSpeech = false;
643
831
  const epoch = ++this.speechEpoch;
644
832
  ++this.controlEpoch;
645
833
  this._cancelSpeechPrefetch();
646
834
  this._suppressPending();
647
- this.patch({ speaking: false, paused: false, activeMessageId: null });
835
+ this._releaseSpeechHistory();
836
+ this.patch({
837
+ speaking: false,
838
+ paused: false,
839
+ activeMessageId: null,
840
+ speechLoading: false,
841
+ speechText: null,
842
+ speechEngine: null,
843
+ });
648
844
  this._syncSpeechSegments();
649
845
  const stopped = this.speechBarrier.then(async () => {
650
846
  const results = await Promise.allSettled(
@@ -671,50 +867,63 @@ export class VoiceCoordinator {
671
867
  )
672
868
  await this._startInput(true);
673
869
  }
870
+ async previousSpeechSegment() {
871
+ if (this.speechCursor <= 0 || this.disposed) return;
872
+ const replay = this.speechHistory.slice(this.speechCursor - 1);
873
+ this.queue = [...replay, ...this.queue.filter((item) => !replay.includes(item))];
874
+ await this._navigateSpeech();
875
+ }
674
876
  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.
877
+ if (this.disposed || !this.queue.length) return;
681
878
  if (!this.snapshot.speaking && !this.snapshot.paused) {
682
879
  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;
880
+ if (!this.speechHistory.includes(skipped)) this.speechHistory.push(skipped);
881
+ this.speechCursor = this.speechHistory.indexOf(skipped);
691
882
  }
883
+ await this._navigateSpeech();
884
+ }
885
+ async _navigateSpeech() {
886
+ this._cancelInterruptionResume();
887
+ this.interruptionPausedSpeech = false;
888
+ const engine = this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine];
692
889
  const epoch = ++this.speechEpoch;
693
890
  ++this.controlEpoch;
694
891
  this._cancelAssistantSpeechTimer();
695
892
  this.assistantSpeechNotBefore = 0;
696
- this._cancelSpeechPrefetch();
697
- this.patch({ speaking: false, paused: false, activeMessageId: null });
893
+ this.patch({ speaking: false, paused: false, speechLoading: false });
698
894
  this._syncSpeechSegments();
699
- const stopped = this.speechBarrier.then(() =>
700
- Promise.resolve().then(() => this.engines[this.snapshot.settings.engine]?.stop()),
701
- );
895
+ const stopped = this.speechBarrier.then(() => engine?.stop());
702
896
  this.speechBarrier = stopped.catch(() => {});
703
897
  try {
704
898
  await stopped;
705
899
  } catch (error) {
900
+ this.speechStopError = error;
706
901
  if (epoch === this.speechEpoch) this.patch({ error: message(error) });
707
902
  return;
708
903
  }
709
904
  if (epoch !== this.speechEpoch || this.disposed) return;
710
905
  this._drain();
711
906
  }
712
- async pauseSpeech() {
713
- if (this.disposed || !this.snapshot.speaking || this.snapshot.paused) return;
907
+ getSpeechProgress() {
908
+ return this.engines[this.snapshot.speechEngine]?.getPlaybackProgress?.() ?? null;
909
+ }
910
+ async pauseSpeech(automatic = false) {
911
+ if (!automatic) {
912
+ this._cancelInterruptionResume();
913
+ this.interruptionPausedSpeech = false;
914
+ }
915
+ if (this.disposed || this.snapshot.paused) return;
916
+ if (!this.snapshot.speaking && this.snapshot.speechRunActive) {
917
+ this._cancelAssistantSpeechTimer();
918
+ this.patch({ paused: true });
919
+ return;
920
+ }
921
+ if (!this.snapshot.speaking) return;
714
922
  const epoch = this.speechEpoch;
715
923
  const control = ++this.controlEpoch;
716
924
  try {
717
- const result = await this.engines[this.snapshot.settings.engine].pause();
925
+ const result =
926
+ await this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine].pause();
718
927
  if (result !== false) this._cancelSpeechPrefetch();
719
928
  if (
720
929
  epoch === this.speechEpoch &&
@@ -728,7 +937,15 @@ export class VoiceCoordinator {
728
937
  }
729
938
  }
730
939
  async resumeSpeech() {
940
+ this._cancelInterruptionResume();
941
+ this.interruptionPausedSpeech = false;
731
942
  if (this.disposed || !this.snapshot.paused) return;
943
+ if (!this.snapshot.speaking) {
944
+ this.patch({ paused: false });
945
+ this.assistantSpeechNotBefore = 0;
946
+ this._drain();
947
+ return;
948
+ }
732
949
  const epoch = this.speechEpoch;
733
950
  const control = ++this.controlEpoch;
734
951
  try {
@@ -737,7 +954,8 @@ export class VoiceCoordinator {
737
954
  if (this.inputReleaseError) throw this.inputReleaseError;
738
955
  }
739
956
  if (epoch !== this.speechEpoch || control !== this.controlEpoch) return;
740
- const result = await this.engines[this.snapshot.settings.engine].resume();
957
+ const result =
958
+ await this.engines[this.snapshot.speechEngine || this.snapshot.settings.engine].resume();
741
959
  if (epoch === this.speechEpoch && control === this.controlEpoch && result !== false) {
742
960
  this.patch({ paused: false });
743
961
  this._prefetchSpeech();
@@ -756,9 +974,13 @@ export class VoiceCoordinator {
756
974
  filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
757
975
  codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
758
976
  codeBlockNotice: this.snapshot.settings.outputCodeNotice,
977
+ lang: this.snapshot.settings.lang,
759
978
  });
760
- const first = segments.shift();
761
- if (!first) return;
979
+ const initial = segments.shift();
980
+ if (!initial) return;
981
+ const parts = splitInitialSpeechItem(initial);
982
+ const first = parts.shift();
983
+ segments.unshift(...parts);
762
984
  this.queue.push(...segments.map((segment) => ({ text: segment, id: messageId, manual: true })));
763
985
  this._prefetchSpeech();
764
986
  this._syncSpeechSegments();
@@ -772,15 +994,31 @@ export class VoiceCoordinator {
772
994
  this._syncSpeechSegments();
773
995
  return;
774
996
  }
997
+ const item =
998
+ this.activeSpeechItem?.text === text
999
+ ? this.activeSpeechItem
1000
+ : { text, id: messageId, manual };
1001
+ if (!this.speechHistory.includes(item)) this.speechHistory.push(item);
1002
+ this.speechCursor = this.speechHistory.indexOf(item);
775
1003
  const epoch = ++this.speechEpoch;
776
1004
  const engine = this.engines[this.snapshot.settings.engine];
1005
+ this._resetStaleSpeechItem(item, this._speechPreparationKey());
1006
+ item.engine = engine;
777
1007
  if (!engine) {
778
1008
  this.queue = [];
779
1009
  this.patch({ error: 'Speech engine unavailable.' });
780
1010
  return;
781
1011
  }
782
1012
  // Reserve playback synchronously so incoming stream chunks cannot overtake gating.
783
- this.patch({ speaking: true, paused: false, activeMessageId: messageId, error: null });
1013
+ this.patch({
1014
+ speaking: true,
1015
+ paused: false,
1016
+ activeMessageId: messageId,
1017
+ speechLoading: true,
1018
+ speechText: text,
1019
+ speechEngine: this.snapshot.settings.engine,
1020
+ error: null,
1021
+ });
784
1022
  this._syncSpeechSegments();
785
1023
  let failed = false;
786
1024
  try {
@@ -792,20 +1030,30 @@ export class VoiceCoordinator {
792
1030
  if (this.inputReleaseError) throw this.inputReleaseError;
793
1031
  }
794
1032
  if (epoch !== this.speechEpoch || this.disposed) return;
795
- const queued = this.activeSpeechItem;
1033
+ const queued = item;
796
1034
  if (queued?.preparing) await queued.preparing;
797
1035
  if (epoch !== this.speechEpoch || this.disposed) return;
798
1036
  if (queued?.prepareError) throw queued.prepareError;
799
1037
  const options = {
1038
+ onPlaybackStart: () => {
1039
+ if (epoch === this.speechEpoch && !this.disposed) this.patch({ speechLoading: false });
1040
+ },
800
1041
  voice: this.snapshot.settings.voice || undefined,
801
1042
  rate: this.snapshot.settings.rate,
802
1043
  outputDeviceId: this.snapshot.settings.outputDeviceId,
803
1044
  };
1045
+ if (!queued.prepared && typeof engine.prepare === 'function') {
1046
+ const prepared = await engine.prepare(text, options);
1047
+ if (epoch !== this.speechEpoch || this.disposed) {
1048
+ prepared.dispose?.();
1049
+ return;
1050
+ }
1051
+ queued.prepared = prepared;
1052
+ }
804
1053
  if (queued?.prepared && typeof engine.playPrepared === 'function') {
805
1054
  const prepared = queued.prepared;
806
- queued.prepared = null;
807
1055
  this.prefetchItems.delete(queued);
808
- await engine.playPrepared(prepared, options);
1056
+ await engine.playPrepared(prepared, { ...options, retainPrepared: true });
809
1057
  } else await engine.speak(text, options);
810
1058
  } catch (error) {
811
1059
  if (epoch === this.speechEpoch) {
@@ -815,7 +1063,21 @@ export class VoiceCoordinator {
815
1063
  }
816
1064
  } finally {
817
1065
  if (epoch === this.speechEpoch && !this.disposed) {
818
- this.patch({ speaking: false, paused: false, activeMessageId: null });
1066
+ this.patch({
1067
+ speaking: false,
1068
+ paused: false,
1069
+ activeMessageId: null,
1070
+ speechLoading: false,
1071
+ speechText: this.queue.length || this.unfinished.has(messageId) ? text : null,
1072
+ speechEngine:
1073
+ this.queue.length || this.unfinished.has(messageId) ? this.snapshot.speechEngine : null,
1074
+ });
1075
+ if (
1076
+ failed ||
1077
+ (!this.queue.length &&
1078
+ !this.speechHistory.some((item) => !item.manual && this.unfinished.has(item.id)))
1079
+ )
1080
+ this._releaseSpeechHistory();
819
1081
  this._syncSpeechSegments();
820
1082
  if (!failed && this.queue.length) {
821
1083
  this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.segmentGapMs;
@@ -839,7 +1101,8 @@ export class VoiceCoordinator {
839
1101
  (!this.snapshot.conversation && !this.queue[0]?.manual) ||
840
1102
  (!this.snapshot.settings.announceAssistantMessages && !this.queue[0]?.manual) ||
841
1103
  this.snapshot.speaking ||
842
- this.snapshot.recognizing ||
1104
+ this.snapshot.paused ||
1105
+ this._speechActivity() ||
843
1106
  this.snapshot.starting ||
844
1107
  (!this.queue[0]?.manual &&
845
1108
  (this.snapshot.pendingTranscriptions > 0 || this.snapshot.autoSendAt !== null)) ||
@@ -862,7 +1125,7 @@ export class VoiceCoordinator {
862
1125
  this._prefetchSpeech();
863
1126
  void this.play(next.text, next.id, next.manual === true).finally(() => {
864
1127
  if (this.activeSpeechItem === next) this.activeSpeechItem = null;
865
- next.prepared?.dispose?.();
1128
+ // Audio remains owned by the playback run until completion.
866
1129
  this.prefetchItems.delete(next);
867
1130
  });
868
1131
  }
@@ -871,6 +1134,16 @@ export class VoiceCoordinator {
871
1134
  if (this.disposed) return;
872
1135
  if (complete) this.unfinished.delete(id);
873
1136
  else this.unfinished.add(id);
1137
+ if (
1138
+ complete &&
1139
+ !this.snapshot.speaking &&
1140
+ !this.queue.length &&
1141
+ this.speechHistory.some((item) => item.id === id)
1142
+ ) {
1143
+ this._releaseSpeechHistory();
1144
+ this.patch({ speechText: null, speechEngine: null });
1145
+ this._syncSpeechSegments();
1146
+ }
874
1147
  if (
875
1148
  baseline ||
876
1149
  !this.snapshot.conversation ||
@@ -888,20 +1161,19 @@ export class VoiceCoordinator {
888
1161
  const remaining = text.slice(offset);
889
1162
  const boundary = complete ? remaining.length : remaining.lastIndexOf('\n') + 1;
890
1163
  if (boundary <= 0) return;
891
- if (
892
- this.snapshot.settings.outputCodeFilterEnabled &&
893
- hasUnclosedCodeFence(remaining.slice(0, boundary)) &&
894
- !complete
895
- )
896
- return;
1164
+ if (!complete && hasIncompleteSpeechMarkdown(remaining.slice(0, boundary))) return;
1165
+ if (hasUnclosedCodeFence(remaining.slice(0, boundary)) && !complete) return;
897
1166
  const chunk = filterSpeechOutput(remaining.slice(0, boundary).trim(), {
898
1167
  filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
899
1168
  codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
900
1169
  codeBlockNotice: this.snapshot.settings.outputCodeNotice,
1170
+ lang: this.snapshot.settings.lang,
901
1171
  }).trim();
902
1172
  this.consumed.set(id, offset + boundary);
903
1173
  if (chunk) {
904
- this.queue.push({ text: chunk, id });
1174
+ const initial = !this.snapshot.speaking && !this.queue.some((item) => item.prepared);
1175
+ const parts = initial ? splitInitialSpeechItem(chunk) : [chunk];
1176
+ this.queue.push(...parts.map((text) => ({ text, id })));
905
1177
  this._prefetchSpeech();
906
1178
  this._syncSpeechSegments();
907
1179
  this._drain();