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.
- package/README.md +48 -14
- package/lib/client.js +6474 -3396
- 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 +242 -35
- package/src/app/client/composerSelection.ts +92 -0
- package/src/app/client/i18n/catalogs/base.ts +35 -0
- package/src/app/client/i18n/catalogs/en.ts +43 -3
- package/src/app/client/i18n/catalogs/es.ts +51 -11
- package/src/app/client/i18n/catalogs/fr.ts +52 -11
- package/src/app/client/i18n/catalogs/hi.ts +50 -11
- package/src/app/client/i18n/catalogs/pt-BR.ts +52 -11
- package/src/app/client/i18n/catalogs/zh.ts +50 -11
- package/src/modules/AGENTS.md +1 -1
- package/src/modules/conversation/ARCHITECTURE.md +6 -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 +91 -93
- package/src/modules/conversation/components/DeliveryModeButton.tsx +3 -3
- package/src/modules/conversation/components/MeetingControls.tsx +40 -0
- package/src/modules/conversation/components/MicrophoneButton.tsx +15 -2
- 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 +1 -3
- package/src/modules/conversation/components/createConversationComponents.tsx +1 -1
- 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 +15 -2
- package/src/modules/conversation/models/meeting.ts +256 -0
- package/src/modules/conversation/models/meetingTranscript.ts +63 -0
- package/src/modules/core/ARCHITECTURE.md +1 -1
- package/src/modules/core/coordinator.ts +353 -81
- package/src/modules/core/developerExtension.ts +26 -0
- package/src/modules/core/diagnostics.ts +153 -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 +88 -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/components/SettingsHeader.tsx +1 -1
- package/src/modules/settings/models/settingsStorage.ts +11 -3
- package/src/modules/settings/sections/conversation/ConversationDelaySettings.tsx +8 -3
- package/src/modules/settings/sections/conversation/DeliverySettings.tsx +25 -2
- package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -1
- package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +12 -1
- 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 +6 -0
- package/src/styles/index.ts +13 -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
|
};
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
488
|
-
this.composer.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
590
|
-
|
|
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 (
|
|
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.
|
|
621
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
684
|
-
this.
|
|
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.
|
|
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
|
-
|
|
713
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
761
|
-
if (!
|
|
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({
|
|
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 =
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|