@chat21/chat21-web-widget 5.1.32-rc9 → 5.1.33-rc11
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/.angular-mcp-cache/package.json +1 -0
- package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
- package/.cursor/mcp.json +15 -0
- package/.github/workflows/playwright.yml +27 -0
- package/.playwright-mcp/console-2026-05-08T15-31-09-000Z.log +17 -0
- package/.playwright-mcp/console-2026-05-08T15-32-19-412Z.log +89 -0
- package/.playwright-mcp/console-2026-05-08T16-18-48-424Z.log +133 -0
- package/.playwright-mcp/console-2026-05-11T12-54-06-869Z.log +13 -0
- package/.playwright-mcp/console-2026-05-11T12-54-56-229Z.log +147 -0
- package/.playwright-mcp/console-2026-05-11T12-55-47-174Z.log +183 -0
- package/.playwright-mcp/console-2026-05-11T15-34-03-590Z.log +210 -0
- package/.playwright-mcp/console-2026-05-12T15-07-31-880Z.log +118 -0
- package/.playwright-mcp/page-2026-05-08T15-32-19-900Z.yml +851 -0
- package/.playwright-mcp/page-2026-05-08T15-32-47-264Z.yml +857 -0
- package/.playwright-mcp/page-2026-05-08T15-33-17-089Z.yml +1110 -0
- package/.playwright-mcp/page-2026-05-08T15-33-23-486Z.yml +1069 -0
- package/.playwright-mcp/page-2026-05-08T15-33-45-390Z.yml +1076 -0
- package/.playwright-mcp/page-2026-05-08T15-33-52-666Z.yml +1072 -0
- package/.playwright-mcp/page-2026-05-08T15-34-01-338Z.yml +1085 -0
- package/.playwright-mcp/page-2026-05-08T15-34-07-227Z.yml +1072 -0
- package/.playwright-mcp/page-2026-05-08T15-34-13-875Z.yml +1072 -0
- package/.playwright-mcp/page-2026-05-08T15-34-21-885Z.yml +1109 -0
- package/.playwright-mcp/page-2026-05-08T15-34-32-755Z.yml +1109 -0
- package/.playwright-mcp/page-2026-05-08T15-35-09-607Z.yml +1119 -0
- package/.playwright-mcp/page-2026-05-08T15-35-14-242Z.yml +1109 -0
- package/.playwright-mcp/page-2026-05-08T16-18-48-671Z.yml +44 -0
- package/.playwright-mcp/page-2026-05-08T16-18-52-753Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-19-13-919Z.yml +68 -0
- package/.playwright-mcp/page-2026-05-08T16-19-17-977Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-19-25-733Z.yml +120 -0
- package/.playwright-mcp/page-2026-05-08T16-19-29-252Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-19-39-269Z.yml +80 -0
- package/.playwright-mcp/page-2026-05-08T16-19-43-915Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-20-04-407Z.yml +81 -0
- package/.playwright-mcp/page-2026-05-08T16-20-08-984Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-20-32-397Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-20-58-658Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-21-12-320Z.yml +86 -0
- package/.playwright-mcp/page-2026-05-08T16-21-39-154Z.yml +91 -0
- package/.playwright-mcp/page-2026-05-08T16-21-45-420Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-22-21-062Z.yml +0 -0
- package/.playwright-mcp/page-2026-05-08T16-22-58-232Z.yml +91 -0
- package/.playwright-mcp/page-2026-05-08T16-23-36-520Z.yml +0 -0
- package/.playwright-mcp/page-2026-05-08T16-23-46-805Z.yml +100 -0
- package/.playwright-mcp/page-2026-05-08T16-23-55-169Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-24-26-574Z.yml +91 -0
- package/.playwright-mcp/page-2026-05-08T16-25-34-414Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-25-59-831Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-26-21-809Z.yml +91 -0
- package/.playwright-mcp/page-2026-05-08T16-26-47-443Z.yml +105 -0
- package/.playwright-mcp/page-2026-05-08T16-26-56-136Z.png +0 -0
- package/.playwright-mcp/page-2026-05-08T16-27-59-610Z.yml +48 -0
- package/.playwright-mcp/page-2026-05-11T12-54-07-180Z.yml +44 -0
- package/.playwright-mcp/page-2026-05-11T12-54-56-946Z.yml +4 -0
- package/.playwright-mcp/page-2026-05-11T12-55-47-503Z.yml +24 -0
- package/.playwright-mcp/page-2026-05-11T12-56-00-766Z.yml +28 -0
- package/.playwright-mcp/page-2026-05-11T12-56-06-438Z.yml +90 -0
- package/.playwright-mcp/page-2026-05-11T12-57-56-838Z.yml +106 -0
- package/.playwright-mcp/page-2026-05-11T12-58-00-124Z.yml +106 -0
- package/.playwright-mcp/page-2026-05-11T12-59-08-836Z.yml +61 -0
- package/.playwright-mcp/page-2026-05-11T12-59-12-088Z.yml +61 -0
- package/.playwright-mcp/page-2026-05-11T12-59-26-215Z.yml +69 -0
- package/.playwright-mcp/page-2026-05-11T12-59-29-519Z.yml +69 -0
- package/.playwright-mcp/page-2026-05-11T12-59-37-309Z.yml +0 -0
- package/.playwright-mcp/page-2026-05-11T12-59-39-968Z.yml +79 -0
- package/.playwright-mcp/page-2026-05-11T12-59-45-983Z.yml +78 -0
- package/.playwright-mcp/page-2026-05-11T12-59-49-951Z.yml +78 -0
- package/.playwright-mcp/page-2026-05-11T15-34-04-515Z.yml +0 -0
- package/.playwright-mcp/page-2026-05-12T15-07-32-171Z.yml +44 -0
- package/.playwright-mcp/page-2026-05-12T15-08-09-820Z.yml +119 -0
- package/CHANGELOG.md +61 -4
- package/angular.json +20 -3
- package/deploy_amazon_beta.sh +7 -17
- package/deploy_amazon_prod.sh +41 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +379 -0
- package/env.sample +3 -2
- package/mocks/voice-websocket-mock/server.cjs +245 -0
- package/package.json +7 -3
- package/playwright.config.ts +41 -0
- package/src/app/app.component.html +2 -2
- package/src/app/app.component.scss +25 -14
- package/src/app/app.component.spec.ts +21 -6
- package/src/app/app.module.ts +4 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.html +19 -11
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +28 -0
- package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +63 -17
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
- package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +17 -7
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +15 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +242 -149
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +7 -6
- package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component copy.html +172 -0
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +112 -61
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +133 -16
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +452 -78
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +198 -84
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
- package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
- package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
- package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
- package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -18
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +60 -2
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +135 -5
- package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
- package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
- package/src/app/component/form/form-builder/form-builder.component.html +1 -1
- package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
- package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
- package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
- package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
- package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
- package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
- package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
- package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
- package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
- package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
- package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
- package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
- package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
- package/src/app/component/form/prechat-form-test-mock.ts +35 -0
- package/src/app/component/home/home.component.html +38 -31
- package/src/app/component/home/home.component.scss +4 -2
- package/src/app/component/home/home.component.spec.ts +226 -11
- package/src/app/component/home-conversations/home-conversations.component.html +30 -26
- package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
- package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
- package/src/app/component/last-message/last-message.component.html +15 -9
- package/src/app/component/last-message/last-message.component.scss +16 -2
- package/src/app/component/last-message/last-message.component.spec.ts +204 -23
- package/src/app/component/launcher-button/launcher-button.component.html +8 -13
- package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
- package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
- package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
- package/src/app/component/list-conversations/list-conversations.component.html +22 -22
- package/src/app/component/menu-options/menu-options.component.html +30 -20
- package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
- package/src/app/component/message/audio/audio.component.html +13 -15
- package/src/app/component/message/audio/audio.component.spec.ts +140 -5
- package/src/app/component/message/audio/audio.component.ts +1 -0
- package/src/app/component/message/audio-sync/audio-sync.component.scss +1 -0
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +81 -1
- package/src/app/component/message/audio-sync/audio-sync.component.ts +133 -86
- package/src/app/component/message/avatar/avatar.component.html +2 -2
- package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
- package/src/app/component/message/bubble-message/bubble-message.component.html +39 -52
- package/src/app/component/message/bubble-message/bubble-message.component.scss +59 -1
- package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +154 -57
- package/src/app/component/message/bubble-message/bubble-message.component.ts +152 -110
- package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
- package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
- package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
- package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
- package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
- package/src/app/component/message/carousel/carousel.component.html +29 -16
- package/src/app/component/message/carousel/carousel.component.scss +20 -8
- package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
- package/src/app/component/message/carousel/carousel.component.ts +16 -0
- package/src/app/component/message/frame/frame.component.html +9 -4
- package/src/app/component/message/frame/frame.component.spec.ts +34 -15
- package/src/app/component/message/frame/frame.component.ts +7 -2
- package/src/app/component/message/html/html.component.html +1 -1
- package/src/app/component/message/html/html.component.scss +1 -1
- package/src/app/component/message/html/html.component.spec.ts +24 -7
- package/src/app/component/message/image/image.component.html +12 -10
- package/src/app/component/message/image/image.component.scss +16 -0
- package/src/app/component/message/image/image.component.spec.ts +101 -15
- package/src/app/component/message/image/image.component.ts +90 -51
- package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
- package/src/app/component/message/json-sources/json-sources.component.html +38 -0
- package/src/app/component/message/json-sources/json-sources.component.scss +201 -0
- package/src/app/component/message/json-sources/json-sources.component.ts +89 -0
- package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
- package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
- package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
- package/src/app/component/message/text/text.component.html +3 -3
- package/src/app/component/message/text/text.component.scss +80 -86
- package/src/app/component/message/text/text.component.spec.ts +106 -13
- package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
- package/src/app/component/selection-department/selection-department.component.html +21 -23
- package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
- package/src/app/component/selection-department/selection-department.component.ts +8 -1
- package/src/app/component/send-button/send-button.component.html +5 -13
- package/src/app/component/send-button/send-button.component.spec.ts +2 -2
- package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
- package/src/app/directives/tooltip.directive.spec.ts +8 -4
- package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
- package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
- package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
- package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
- package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
- package/src/app/pipe/marked.pipe.spec.ts +38 -2
- package/src/app/pipe/marked.pipe.ts +51 -41
- package/src/app/providers/app-config.service.ts +4 -2
- package/src/app/providers/brand.service.spec.ts +23 -2
- package/src/app/providers/brand.service.ts +1 -1
- package/src/app/providers/global-settings.service.spec.ts +1009 -14
- package/src/app/providers/global-settings.service.ts +59 -2
- package/src/app/providers/json-sources-parser.service.ts +175 -0
- package/src/app/providers/translator.service.ts +24 -6
- package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +117 -0
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +39 -16
- package/src/app/providers/url-preview.service.ts +82 -0
- package/src/app/providers/voice/audio.types.ts +6 -0
- package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
- package/src/app/providers/voice/voice-streaming.service.ts +702 -0
- package/src/app/providers/voice/voice-streaming.types.ts +112 -0
- package/src/app/providers/voice/voice.service.spec.ts +170 -3
- package/src/app/providers/voice/voice.service.ts +691 -17
- package/src/app/sass/_variables.scss +1 -1
- package/src/app/sass/animations.scss +19 -1
- package/src/app/utils/globals.ts +14 -0
- package/src/app/utils/json-sources-utils.ts +27 -0
- package/src/app/utils/url-utils.ts +98 -0
- package/src/app/utils/utils-resources.ts +1 -1
- package/src/assets/i18n/en.json +106 -100
- package/src/assets/i18n/es.json +107 -101
- package/src/assets/i18n/fr.json +107 -101
- package/src/assets/i18n/it.json +107 -99
- package/src/assets/sounds/keyboard.mp3 +0 -0
- package/src/assets/twp/index-dev.html +18 -0
- package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +14 -0
- package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
- package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
- package/src/chat21-core/utils/constants.ts +4 -0
- package/src/chat21-core/utils/utils-message.ts +23 -1
- package/src/widget-config-template.json +3 -1
- package/src/widget-config.json +28 -27
- package/tests/widget-form-rich.spec.ts +67 -0
- package/tests/widget-index-dev-settings.spec.ts +52 -0
- package/tests/widget-twp-iframe.spec.ts +39 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Inject, Injectable, Optional } from '@angular/core';
|
|
2
2
|
import type { MicVAD } from '@ricky0123/vad-web';
|
|
3
3
|
import { getDefaultRealTimeVADOptions } from '@ricky0123/vad-web';
|
|
4
|
-
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|
4
|
+
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
|
5
5
|
import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
|
|
6
6
|
import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service';
|
|
7
|
+
import { Globals } from 'src/app/utils/globals';
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS,
|
|
@@ -12,12 +13,22 @@ import {
|
|
|
12
13
|
} from './audio.types';
|
|
13
14
|
import { SpeechToTextProvider } from './STT&TTS/speech-provider.abstract';
|
|
14
15
|
import { VadService } from './vad.service';
|
|
16
|
+
import { VoiceStreamingService } from './voice-streaming.service';
|
|
17
|
+
import {
|
|
18
|
+
VoiceTtsKaraokeFrame,
|
|
19
|
+
VoiceTtsKaraokeWord,
|
|
20
|
+
VoiceStreamingSessionConfig,
|
|
21
|
+
VoiceWsControlMessage,
|
|
22
|
+
} from './voice-streaming.types';
|
|
23
|
+
import { TtsAudioPlaybackCoordinator } from '../tts-audio-playback-coordinator.service';
|
|
15
24
|
|
|
16
25
|
const VOICE_RECORDING_MIME = 'audio/webm';
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
28
|
+
* Due modalità:
|
|
29
|
+
* - **Ingresso WSS** (`voiceIngressStream`): microfono → proxy in streaming; niente VAD locale — silenzio/turni gestiti dal server.
|
|
30
|
+
* Eventi `transcript` / TTS binario arrivano sulla WSS.
|
|
31
|
+
* - **Legacy**: MicVAD + segmenti WebM (upload/STT client-side) se non passi `voiceIngressStream`.
|
|
21
32
|
*/
|
|
22
33
|
@Injectable({ providedIn: 'root' })
|
|
23
34
|
export class VoiceService {
|
|
@@ -28,52 +39,234 @@ export class VoiceService {
|
|
|
28
39
|
private sessionConstraints: MediaStreamConstraints = DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS;
|
|
29
40
|
private onRecordingComplete?: (result: VoiceSegmentPayload) => void;
|
|
30
41
|
private enableTranscription = true;
|
|
42
|
+
private voiceIngressConfig?: VoiceStreamingSessionConfig | null = null;
|
|
31
43
|
|
|
32
44
|
private readonly audioSegmentSubject = new Subject<VoiceSegmentPayload>();
|
|
33
|
-
|
|
34
|
-
readonly audioSegment$: Observable<VoiceSegmentPayload> = this.audioSegmentSubject.asObservable();
|
|
35
|
-
|
|
45
|
+
|
|
36
46
|
private readonly speechStartSubject = new Subject<void>();
|
|
37
47
|
/** Emesso quando il microfono intercetta parlato (VAD speech start). */
|
|
38
48
|
readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
|
|
39
49
|
|
|
40
|
-
|
|
50
|
+
private readonly speechEndSubject = new Subject<void>();
|
|
51
|
+
/** Emesso quando il parlato termina (VAD speech end). */
|
|
52
|
+
readonly speechEnd$: Observable<void> = this.speechEndSubject.asObservable();
|
|
53
|
+
|
|
54
|
+
/** Trascrizione dall’evento WSS `transcript` (proxy). */
|
|
55
|
+
private readonly voiceTranscriptSubject = new Subject<{ text: string; isFinal: boolean }>();
|
|
56
|
+
readonly voiceTranscript$: Observable<{ text: string; isFinal: boolean }> = this.voiceTranscriptSubject.asObservable();
|
|
57
|
+
|
|
58
|
+
/** Testo TTS in riproduzione, emesso dall'evento WSS `speaking` (proxy). */
|
|
59
|
+
private readonly voiceTtsTextSubject = new Subject<string>();
|
|
60
|
+
/** Emette il testo del bot che sta per essere riprodotto come audio TTS. */
|
|
61
|
+
readonly voiceTtsText$: Observable<string> = this.voiceTtsTextSubject.asObservable();
|
|
62
|
+
|
|
63
|
+
/** Errore applicativo dal proxy (evento `error`): testo descrittivo del problema. */
|
|
64
|
+
private readonly _wsError$ = new Subject<string>();
|
|
65
|
+
readonly wsError$: Observable<string> = this._wsError$.asObservable();
|
|
66
|
+
|
|
41
67
|
private readonly volumeSubject = new BehaviorSubject<number>(0);
|
|
42
68
|
readonly volume$: Observable<number> = this.volumeSubject.asObservable();
|
|
43
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Emits `true` while a WSS voice-proxy session is active.
|
|
72
|
+
* Used to suppress the tiledesk-server TTS playback path (audio-sync component)
|
|
73
|
+
* when the speech-proxy is already handling TTS over the WebSocket binary channel.
|
|
74
|
+
*/
|
|
75
|
+
private readonly _isWssVoiceActive$ = new BehaviorSubject<boolean>(false);
|
|
76
|
+
readonly isWssVoiceActive$: Observable<boolean> = this._isWssVoiceActive$.asObservable();
|
|
77
|
+
get isWssVoiceActive(): boolean { return this._isWssVoiceActive$.getValue(); }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* UIDs of TTS messages that were played by the speech-proxy during an active voice session.
|
|
81
|
+
* These messages must never be replayed by audio-sync after the session ends.
|
|
82
|
+
*/
|
|
83
|
+
private readonly _proxyHandledTtsIds = new Set<string>();
|
|
84
|
+
|
|
85
|
+
/** Register a TTS message UID as having been played by the proxy. */
|
|
86
|
+
markProxyHandled(uid: string): void {
|
|
87
|
+
if (uid) { this._proxyHandledTtsIds.add(uid); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Returns true if the message was already played by the proxy and should not be replayed. */
|
|
91
|
+
wasProxyHandled(uid: string | undefined): boolean {
|
|
92
|
+
return !!uid && this._proxyHandledTtsIds.has(uid);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 🎙️ TTS GATE — suppresses segment emission while TTS is playing
|
|
96
|
+
private isTTSActive = false;
|
|
97
|
+
private ttsGateSub?: Subscription;
|
|
98
|
+
private wsControlSub?: Subscription;
|
|
99
|
+
private ttsChunkSub?: Subscription;
|
|
100
|
+
|
|
101
|
+
// 🚫 ACQUISITION GATE — pauses VAD from speech-end until TTS response cycle completes
|
|
102
|
+
private isWaitingForResponse = false;
|
|
103
|
+
private responseTimeoutId?: ReturnType<typeof setTimeout>;
|
|
104
|
+
private readonly _isAcquisitionBlocked$ = new BehaviorSubject<boolean>(false);
|
|
105
|
+
/** Emits `true` from user speech-end until VAD resumes after TTS finishes; drives the grey orb. */
|
|
106
|
+
readonly isAcquisitionBlocked$: Observable<boolean> = this._isAcquisitionBlocked$.asObservable();
|
|
107
|
+
|
|
44
108
|
// 🎧 AUDIO ANALYSER
|
|
45
109
|
private audioContext?: AudioContext;
|
|
46
110
|
private analyser?: AnalyserNode;
|
|
47
111
|
/** Buffer dedicato (`ArrayBuffer`) per compatibilità con `getByteFrequencyData`. */
|
|
48
112
|
private dataArray?: Uint8Array;
|
|
113
|
+
/** RAF ID for volume loop - used to cancel on cleanup */
|
|
114
|
+
private volumeRafId?: number;
|
|
115
|
+
|
|
116
|
+
/** Riproduzione chunk TTS binari dal proxy (Web Audio). */
|
|
117
|
+
private ttsPlayContext?: AudioContext;
|
|
118
|
+
private ttsNextPlayTime = 0;
|
|
119
|
+
|
|
120
|
+
// Tracks how many TTS audio sources are still decoding or playing.
|
|
121
|
+
// Incremented synchronously when a binary chunk arrives (before decodeAudioData).
|
|
122
|
+
// Decremented in src.onended (or on decode error).
|
|
123
|
+
private _activeTtsSources = 0;
|
|
124
|
+
// References to active AudioBufferSourceNodes so they can be stopped on preemption.
|
|
125
|
+
private _activeTtsSourceNodes: AudioBufferSourceNode[] = [];
|
|
126
|
+
// Monotonic counter incremented every time all in-flight TTS audio is invalidated
|
|
127
|
+
// (barge_in or a new speaking event). playWsTtsChunk captures this at entry and
|
|
128
|
+
// checks it after the async decodeAudioData call to discard stale results.
|
|
129
|
+
private _ttsGeneration = 0;
|
|
130
|
+
|
|
131
|
+
// ── Ordered-scheduling state ──────────────────────────────────────────────────────────────────
|
|
132
|
+
// Chunks arrive over WebSocket and their decodeAudioData calls run concurrently.
|
|
133
|
+
// Because a smaller/later chunk can decode faster than a larger/earlier one, scheduling
|
|
134
|
+
// based solely on decode-completion order causes audio to play out of arrival order
|
|
135
|
+
// (e.g. "manuale" starts before "scrittura" even though it arrived after it).
|
|
136
|
+
// Fix: assign a monotonic sequence number on arrival, decode in parallel, but only
|
|
137
|
+
// schedule a buffer once every preceding buffer has already been scheduled.
|
|
138
|
+
private _ttsChunkSeq = 0; // Incremented on each chunk arrival (arrival order)
|
|
139
|
+
private _ttsScheduledSeq = 0; // Next sequence slot that is allowed to be scheduled
|
|
140
|
+
// Decoded buffers waiting for their turn to be scheduled (keyed by arrival sequence)
|
|
141
|
+
private _ttsDecodedPending = new Map<number, AudioBuffer>();
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// Set to true by the 'done' event; triggers acquisition unblock once all sources end.
|
|
144
|
+
private _unblockAfterTts = false;
|
|
145
|
+
private _unblockSafetyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
146
|
+
// Fallback timer started after sendPlaybackComplete. If the proxy does not reply
|
|
147
|
+
// with 'listening' within the timeout window, the UI is force-unblocked so the
|
|
148
|
+
// user is not left stuck waiting indefinitely.
|
|
149
|
+
private _listeningFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
150
|
+
// Track when the last TTS chunk is expected to finish playing.
|
|
151
|
+
// Used to calculate a proper safety timer duration for long messages.
|
|
152
|
+
private _ttsExpectedEndTime = 0;
|
|
153
|
+
|
|
154
|
+
// ── WSS TTS Karaoke ──────────────────────────────────────────────────────────────────────────
|
|
155
|
+
private _kText = '';
|
|
156
|
+
private _kWords: Array<VoiceTtsKaraokeWord & { start: number; end: number }> = [];
|
|
157
|
+
private _kStartContextTime = 0;
|
|
158
|
+
private _kDuration = 0;
|
|
159
|
+
private _kRafId?: number;
|
|
160
|
+
private _kLastActiveIndex = -2;
|
|
161
|
+
|
|
162
|
+
private readonly _voiceTtsKaraokeSubject = new Subject<VoiceTtsKaraokeFrame>();
|
|
163
|
+
/** Emits word-state frames while WebSocket TTS audio plays; drives the karaoke highlight in bubble-message. */
|
|
164
|
+
readonly voiceTtsKaraoke$: Observable<VoiceTtsKaraokeFrame> = this._voiceTtsKaraokeSubject.asObservable();
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
// ── Thinking / typing-indicator sound ─────────────────────────────────────────────────────────
|
|
168
|
+
// Played on loop while the bot is thinking or the first TTS chunk hasn't arrived yet.
|
|
169
|
+
// Only active during WSS voice sessions (voice-proxy mode).
|
|
170
|
+
private _keyboardSoundEl: HTMLAudioElement | null = null;
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
49
172
|
|
|
50
173
|
private readonly logger: LoggerService = LoggerInstance.getInstance();
|
|
51
174
|
|
|
175
|
+
private readonly bufferTime = 200000; // used as max safety timer duration for long TTS messages
|
|
176
|
+
|
|
52
177
|
constructor(
|
|
53
178
|
private readonly vadService: VadService,
|
|
179
|
+
private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
|
|
180
|
+
private readonly voiceStreaming: VoiceStreamingService,
|
|
54
181
|
@Optional() @Inject(SpeechToTextProvider) private readonly speechToText: SpeechToTextProvider | null,
|
|
182
|
+
private readonly globals: Globals,
|
|
55
183
|
) {}
|
|
56
184
|
|
|
57
185
|
get isSessionActive(): boolean {
|
|
58
186
|
return !!this.vad || !!this.stream;
|
|
59
187
|
}
|
|
60
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Returns the speech-proxy's streaming TTS endpoint URL, or `null` when no proxy is configured.
|
|
191
|
+
* The audio-sync component uses this to redirect TTS calls from the tiledesk-server to the proxy.
|
|
192
|
+
*/
|
|
193
|
+
get proxyTtsStreamUrl(): string | null {
|
|
194
|
+
const base = this.voiceStreaming.proxyHttpBaseUrl;
|
|
195
|
+
return base ? `${base}/api/tts/stream` : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get proxyTtsUrl(): string | null {
|
|
199
|
+
const base = this.voiceStreaming.proxyHttpBaseUrl;
|
|
200
|
+
return base ? `${base}/api/tts` : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
61
203
|
/**
|
|
62
204
|
* Richiede il microfono, avvia VAD in ascolto (inizio/fine parlato) e registra in WebM per segmento.
|
|
63
205
|
*/
|
|
64
206
|
async startSession(options: VoiceSessionStartOptions = {}): Promise<void> {
|
|
207
|
+
const mode = options.voiceIngressStream ? 'wss-proxy' : 'legacy-vad';
|
|
208
|
+
this.logger.info('[VoiceService] startSession', { mode });
|
|
65
209
|
await this.stopSession();
|
|
66
210
|
|
|
67
211
|
this.sessionConstraints = options.constraints ?? DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS;
|
|
68
212
|
this.onRecordingComplete = options.onRecordingComplete;
|
|
69
213
|
this.enableTranscription = options.enableTranscription !== false;
|
|
214
|
+
this.voiceIngressConfig = options.voiceIngressStream;
|
|
70
215
|
|
|
71
|
-
|
|
216
|
+
if (this.voiceIngressConfig) {
|
|
217
|
+
await this.startWssVoiceSession();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await this.startLegacyVadSession(options);
|
|
222
|
+
}
|
|
72
223
|
|
|
224
|
+
/** Sessione guidata dal proxy: solo mic + volume + WSS (mic in upload, eventi + TTS in download). */
|
|
225
|
+
private async startWssVoiceSession(): Promise<void> {
|
|
226
|
+
this.logger.info('[VoiceService] acquiring microphone for WSS session');
|
|
73
227
|
this.stream = await navigator.mediaDevices.getUserMedia(this.sessionConstraints);
|
|
228
|
+
const tracks = this.stream.getAudioTracks();
|
|
229
|
+
this.logger.info('[VoiceService] microphone acquired', {
|
|
230
|
+
tracks: tracks.length,
|
|
231
|
+
label: tracks[0]?.label ?? '(unknown)',
|
|
232
|
+
});
|
|
74
233
|
|
|
75
234
|
// 🎧 AUDIO ANALYSER INIT
|
|
76
235
|
this.initAudioAnalyser(this.stream);
|
|
236
|
+
this.startVolumeLoop();
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
// Subscribe before start() so early events (e.g. proxy 'error') are not lost.
|
|
240
|
+
this.wsControlSub = this.voiceStreaming.wsControl$.subscribe((msg) => this.onWsControl(msg));
|
|
241
|
+
this.ttsChunkSub = this.voiceStreaming.ttsBinaryChunk$.subscribe((buf) => void this.playWsTtsChunk(buf));
|
|
242
|
+
await this.voiceStreaming.start(this.voiceIngressConfig!, { sharedMediaStream: this.stream });
|
|
243
|
+
// Signal that the voice proxy is now live — suppresses tiledesk-server TTS.
|
|
244
|
+
this._isWssVoiceActive$.next(true);
|
|
245
|
+
this.logger.info('[VoiceService] WSS voice session started (no local VAD)');
|
|
246
|
+
} catch (e) {
|
|
247
|
+
this.wsControlSub?.unsubscribe();
|
|
248
|
+
this.wsControlSub = undefined;
|
|
249
|
+
this.ttsChunkSub?.unsubscribe();
|
|
250
|
+
this.ttsChunkSub = undefined;
|
|
251
|
+
this.voiceIngressConfig = null;
|
|
252
|
+
if (this.stream) {
|
|
253
|
+
this.stream.getTracks().forEach((t) => t.stop());
|
|
254
|
+
this.stream = undefined;
|
|
255
|
+
}
|
|
256
|
+
this.audioContext?.close();
|
|
257
|
+
this.audioContext = undefined;
|
|
258
|
+
this.analyser = undefined;
|
|
259
|
+
this.dataArray = undefined;
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** VAD + segmenti (nessun ingresso WSS). */
|
|
265
|
+
private async startLegacyVadSession(options: VoiceSessionStartOptions): Promise<void> {
|
|
266
|
+
await this.vadService.ensureOnnxRuntimeEnv();
|
|
267
|
+
|
|
268
|
+
this.stream = await navigator.mediaDevices.getUserMedia(this.sessionConstraints);
|
|
269
|
+
this.initAudioAnalyser(this.stream);
|
|
77
270
|
|
|
78
271
|
const vadDefaults = getDefaultRealTimeVADOptions('legacy');
|
|
79
272
|
|
|
@@ -92,10 +285,16 @@ export class VoiceService {
|
|
|
92
285
|
},
|
|
93
286
|
onSpeechEnd: () => {
|
|
94
287
|
this.logger.log('[VoiceService] speech end');
|
|
288
|
+
this.speechEndSubject.next();
|
|
95
289
|
this.stopMediaRecorderSegment();
|
|
290
|
+
// Pause VAD immediately — new recordings are blocked until the TTS response cycle completes.
|
|
291
|
+
this.isWaitingForResponse = true;
|
|
292
|
+
this._isAcquisitionBlocked$.next(true);
|
|
293
|
+
this.setResponseSafetyTimeout();
|
|
294
|
+
void this.vad?.pause();
|
|
96
295
|
},
|
|
97
296
|
minSpeechMs: 480,
|
|
98
|
-
redemptionMs: 1920,
|
|
297
|
+
redemptionMs: 800,//1920,
|
|
99
298
|
preSpeechPadMs: 960,
|
|
100
299
|
});
|
|
101
300
|
|
|
@@ -103,13 +302,427 @@ export class VoiceService {
|
|
|
103
302
|
|
|
104
303
|
// 🔁 start volume loop
|
|
105
304
|
this.startVolumeLoop();
|
|
305
|
+
|
|
306
|
+
// 🎙️ gate segments while TTS is playing; resume VAD when TTS cycle completes
|
|
307
|
+
this.ttsGateSub = this.ttsPlayback.isTTSPlaying$.subscribe((playing) => {
|
|
308
|
+
this.isTTSActive = playing;
|
|
309
|
+
this.logger.log('[VoiceService] TTS gate', playing ? 'closed (bot speaking)' : 'open (listening)');
|
|
310
|
+
if (!playing && this.isWaitingForResponse) {
|
|
311
|
+
this.resumeVadAfterResponse();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private onWsControl(msg: VoiceWsControlMessage): void {
|
|
317
|
+
this.logger.log('[VoiceService] ← ws-control', msg.event, msg);
|
|
318
|
+
switch (msg.event) {
|
|
319
|
+
case 'session_started':
|
|
320
|
+
this.logger.log('[VoiceService] session_started', { requestId: msg.requestId ?? '' });
|
|
321
|
+
break;
|
|
322
|
+
case 'listening':
|
|
323
|
+
// Proxy confirmed it is in LISTENING state — unblock the UI and resume
|
|
324
|
+
// the MediaRecorder. Recording was paused on 'thinking' and must only
|
|
325
|
+
// restart here, after TTS playback has fully completed and the proxy
|
|
326
|
+
// is confirmed ready to receive audio again.
|
|
327
|
+
if (this._listeningFallbackTimer !== null) {
|
|
328
|
+
clearTimeout(this._listeningFallbackTimer);
|
|
329
|
+
this._listeningFallbackTimer = null;
|
|
330
|
+
}
|
|
331
|
+
// If TTS never arrived (edge case) the keyboard sound would still be looping — stop it.
|
|
332
|
+
this._stopKeyboardSound();
|
|
333
|
+
this._isAcquisitionBlocked$.next(false);
|
|
334
|
+
this.voiceStreaming.resumeRecording();
|
|
335
|
+
this.logger.log('[VoiceService] listening – acquisition unblocked, recording resumed');
|
|
336
|
+
break;
|
|
337
|
+
case 'transcript': {
|
|
338
|
+
const text = typeof msg.text === 'string' ? msg.text : '';
|
|
339
|
+
const isFinal = !!msg.isFinal;
|
|
340
|
+
// Guard: if the proxy has already moved to PROCESSING (thinking) or SPEAKING,
|
|
341
|
+
// this transcript is a stale in-flight STT result. Discard it so it cannot
|
|
342
|
+
// override the blocked acquisition state or reach any downstream subscriber.
|
|
343
|
+
// 'thinking' is stronger than 'transcript' — state must not regress.
|
|
344
|
+
if (this._isAcquisitionBlocked$.value) {
|
|
345
|
+
this.logger.warn('[VoiceService] transcript discarded – arrived after thinking/speaking (stale STT result)', { text, isFinal });
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
this.logger.log('[VoiceService] transcript', { text, isFinal });
|
|
349
|
+
this.voiceTranscriptSubject.next({ text, isFinal });
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case 'thinking':
|
|
353
|
+
// Block acquisition UI while the bot processes the utterance.
|
|
354
|
+
// Pause the MediaRecorder so no audio chunks are sent to the proxy
|
|
355
|
+
// during PROCESSING state. Recording resumes only after the proxy
|
|
356
|
+
// confirms LISTENING (i.e. after TTS playback has fully finished).
|
|
357
|
+
this._isAcquisitionBlocked$.next(true);
|
|
358
|
+
this.voiceStreaming.pauseRecording();
|
|
359
|
+
// Play keyboard typing sound to mask the silence while the bot generates its response.
|
|
360
|
+
this._startKeyboardSound();
|
|
361
|
+
this.logger.log('[VoiceService] thinking – acquisition blocked, recording paused', { activeTtsSources: this._activeTtsSources });
|
|
362
|
+
break;
|
|
363
|
+
case 'speaking': {
|
|
364
|
+
this._isAcquisitionBlocked$.next(true);
|
|
365
|
+
// Do NOT mute the microphone. The MediaStream is captured with
|
|
366
|
+
// echoCancellation: true, so the browser's AEC filters out the bot's
|
|
367
|
+
// speaker output before it reaches the MediaRecorder. Audio keeps
|
|
368
|
+
// flowing to the proxy so Flux can fire StartOfTurn when the user
|
|
369
|
+
// speaks, enabling server-side barge-in detection.
|
|
370
|
+
this._cancelAllTtsAudio();
|
|
371
|
+
// Reset TTS scheduling so new chunks play from now, not a stale future time.
|
|
372
|
+
this.ttsNextPlayTime = this.ttsPlayContext?.currentTime ?? 0;
|
|
373
|
+
// Reset expected end time for new TTS stream
|
|
374
|
+
this._ttsExpectedEndTime = 0;
|
|
375
|
+
const preview = typeof msg.text === 'string' ? msg.text.slice(0, 80) : '';
|
|
376
|
+
this.logger.log('[VoiceService] speaking – acquisition blocked, TTS text preview', { preview });
|
|
377
|
+
// Keep keyboard sound going (or start it as a fallback if 'thinking' was missed)
|
|
378
|
+
// until the first TTS audio chunk actually starts playing.
|
|
379
|
+
this._startKeyboardSound();
|
|
380
|
+
// Emit the text being spoken so UI can display it alongside the audio.
|
|
381
|
+
if (typeof msg.text === 'string' && msg.text) {
|
|
382
|
+
this.voiceTtsTextSubject.next(msg.text);
|
|
383
|
+
this._startTtsKaraoke(msg.text);
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case 'done':
|
|
388
|
+
// Do not unblock immediately — the audio binary may still be decoding/playing.
|
|
389
|
+
// _activeTtsSources tracks pending sources; when the last one ends, acquisition unblocks.
|
|
390
|
+
if (this._activeTtsSources > 0) {
|
|
391
|
+
this._unblockAfterTts = true;
|
|
392
|
+
// Calculate safety timer based on expected audio end time.
|
|
393
|
+
// Add 5 seconds buffer for network/decode latency.
|
|
394
|
+
// Minimum 5 seconds, maximum 300 seconds for very long messages.
|
|
395
|
+
const remainingMs = Math.max(0, this._ttsExpectedEndTime - Date.now());
|
|
396
|
+
const safetyMs = Math.min(this.bufferTime, Math.max(5000, remainingMs + 5000));
|
|
397
|
+
if (this._unblockSafetyTimer !== null) clearTimeout(this._unblockSafetyTimer);
|
|
398
|
+
this._unblockSafetyTimer = setTimeout(() => this._flushTtsUnblock(true), safetyMs);
|
|
399
|
+
this.logger.log('[VoiceService] done – TTS still pending, waiting for all sources to end', {
|
|
400
|
+
activeTtsSources: this._activeTtsSources,
|
|
401
|
+
expectedEndInMs: remainingMs,
|
|
402
|
+
safetyTimerMs: safetyMs
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
// No audio sources tracked yet, but binary TTS chunks may still be in-flight
|
|
406
|
+
// (WebSocket binary frames can arrive after the JSON 'done' control message).
|
|
407
|
+
// Set _unblockAfterTts so that _onTtsSourceEnded() triggers _flushTtsUnblock
|
|
408
|
+
// naturally when those chunks finish playing, instead of relying solely on the
|
|
409
|
+
// safety timer (which would delay unblock by 10 s even when audio ends sooner).
|
|
410
|
+
this._unblockAfterTts = true;
|
|
411
|
+
this.logger.log('[VoiceService] done – no active sources yet, arming unblock for in-flight chunks');
|
|
412
|
+
// Safety timer as last resort in case no chunks arrive at all.
|
|
413
|
+
if (this._unblockSafetyTimer !== null) clearTimeout(this._unblockSafetyTimer);
|
|
414
|
+
this._unblockSafetyTimer = setTimeout(() => this._flushTtsUnblock(true), 10000);
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
case 'error': {
|
|
418
|
+
const errorMsg = typeof msg.message === 'string' ? msg.message : 'Voice session error';
|
|
419
|
+
this.logger.error('[VoiceService] WSS error', errorMsg);
|
|
420
|
+
this._wsError$.next(errorMsg);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
default:
|
|
424
|
+
this.logger.warn('[VoiceService] unhandled ws-control event', msg.event);
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
106
427
|
}
|
|
107
428
|
|
|
108
429
|
/**
|
|
109
|
-
*
|
|
430
|
+
* Chunk TTS: ogni buffer deve essere decodificabile da `decodeAudioData` (es. segmento WebM/Opus completo).
|
|
431
|
+
*
|
|
432
|
+
* Decode-race fix: multiple chunks decode concurrently; a smaller/later chunk can finish
|
|
433
|
+
* decoding before a larger/earlier one, which would cause the AudioBufferSourceNode to be
|
|
434
|
+
* scheduled out of arrival order (e.g. "manuale" before "scrittura"). To prevent this, each
|
|
435
|
+
* chunk is assigned a monotonic sequence number on arrival and stored in _ttsDecodedPending
|
|
436
|
+
* after decoding. _drainTtsDecodedBuffers() only advances the schedule when the next
|
|
437
|
+
* expected sequence slot is present, guaranteeing arrival-order playback regardless of decode speed.
|
|
110
438
|
*/
|
|
111
|
-
async
|
|
439
|
+
private async playWsTtsChunk(buf: ArrayBuffer): Promise<void> {
|
|
440
|
+
// Assign arrival-order sequence number SYNCHRONOUSLY before any await.
|
|
441
|
+
const seq = this._ttsChunkSeq++;
|
|
442
|
+
// Capture the current generation BEFORE the synchronous increment so that
|
|
443
|
+
// if _cancelAllTtsAudio() fires (incrementing _ttsGeneration) while this
|
|
444
|
+
// decode is in-flight, the mismatch is detected and the stale chunk is discarded.
|
|
445
|
+
const capturedGeneration = this._ttsGeneration;
|
|
446
|
+
// Increment SYNCHRONOUSLY before any await so the 'done' event handler (which arrives
|
|
447
|
+
// on the next WebSocket message — a different event-loop tick) sees a non-zero count.
|
|
448
|
+
this._activeTtsSources++;
|
|
449
|
+
this.logger.log('[VoiceService] TTS chunk received', { seq, bytes: buf.byteLength, activeTtsSources: this._activeTtsSources });
|
|
450
|
+
try {
|
|
451
|
+
if (!this.ttsPlayContext || this.ttsPlayContext.state === 'closed') {
|
|
452
|
+
this.ttsPlayContext = new AudioContext();
|
|
453
|
+
this.ttsNextPlayTime = this.ttsPlayContext.currentTime;
|
|
454
|
+
this.logger.info('[VoiceService] TTS AudioContext created');
|
|
455
|
+
}
|
|
456
|
+
const ctx = this.ttsPlayContext;
|
|
457
|
+
const audioBuf = await ctx.decodeAudioData(buf.slice(0));
|
|
458
|
+
// Stale-chunk guard: barge_in or a new speaking event called _cancelAllTtsAudio()
|
|
459
|
+
// which incremented _ttsGeneration. Discard this decoded buffer so no audio plays
|
|
460
|
+
// for a turn that was already cancelled, and undo the counter increment.
|
|
461
|
+
if (this._ttsGeneration !== capturedGeneration) {
|
|
462
|
+
this._activeTtsSources = Math.max(0, this._activeTtsSources - 1);
|
|
463
|
+
this.logger.log('[VoiceService] TTS chunk discarded – stale generation', { seq, capturedGeneration, currentGeneration: this._ttsGeneration });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Store the decoded buffer under its arrival sequence number and attempt to
|
|
467
|
+
// flush any contiguous run of decoded buffers in order.
|
|
468
|
+
this._ttsDecodedPending.set(seq, audioBuf);
|
|
469
|
+
this._drainTtsDecodedBuffers();
|
|
470
|
+
} catch (e) {
|
|
471
|
+
// Advance the scheduler past this failed slot so subsequent decoded chunks are
|
|
472
|
+
// not blocked waiting for a slot that will never be filled.
|
|
473
|
+
if (seq === this._ttsScheduledSeq) {
|
|
474
|
+
this._ttsScheduledSeq++;
|
|
475
|
+
this._drainTtsDecodedBuffers();
|
|
476
|
+
}
|
|
477
|
+
this._onTtsSourceEnded();
|
|
478
|
+
this.logger.warn('[VoiceService] TTS chunk decode failed', { seq }, e);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Schedules decoded TTS buffers in strict arrival order.
|
|
484
|
+
* Called after every successful decode. Drains the _ttsDecodedPending map
|
|
485
|
+
* starting at _ttsScheduledSeq, stopping as soon as the next slot is missing
|
|
486
|
+
* (i.e. that chunk is still decoding or failed).
|
|
487
|
+
*/
|
|
488
|
+
private _drainTtsDecodedBuffers(): void {
|
|
489
|
+
const ctx = this.ttsPlayContext;
|
|
490
|
+
if (!ctx) return;
|
|
491
|
+
while (this._ttsDecodedPending.has(this._ttsScheduledSeq)) {
|
|
492
|
+
const audioBuf = this._ttsDecodedPending.get(this._ttsScheduledSeq)!;
|
|
493
|
+
this._ttsDecodedPending.delete(this._ttsScheduledSeq);
|
|
494
|
+
this._ttsScheduledSeq++;
|
|
495
|
+
|
|
496
|
+
const src = ctx.createBufferSource();
|
|
497
|
+
src.buffer = audioBuf;
|
|
498
|
+
src.connect(ctx.destination);
|
|
499
|
+
const t0 = Math.max(ctx.currentTime, this.ttsNextPlayTime);
|
|
500
|
+
src.start(t0);
|
|
501
|
+
this.ttsNextPlayTime = t0 + audioBuf.duration;
|
|
502
|
+
// Track the expected end time in wall-clock time (ms) for safety timer calculation.
|
|
503
|
+
const audioEndDelayMs = (this.ttsNextPlayTime - ctx.currentTime) * 1000;
|
|
504
|
+
this._ttsExpectedEndTime = Date.now() + audioEndDelayMs;
|
|
505
|
+
const isFirstChunk = this._activeTtsSourceNodes.length === 0;
|
|
506
|
+
this._activeTtsSourceNodes.push(src);
|
|
507
|
+
if (isFirstChunk) {
|
|
508
|
+
// First real audio about to play — stop the keyboard typing sound immediately.
|
|
509
|
+
this._stopKeyboardSound();
|
|
510
|
+
this.logger.info('[VoiceService] TTS playback started', { durationS: audioBuf.duration.toFixed(3), startsAtS: t0.toFixed(3) });
|
|
511
|
+
}
|
|
512
|
+
this.logger.log('[VoiceService] TTS chunk scheduled', { seq: this._ttsScheduledSeq - 1, durationS: audioBuf.duration.toFixed(3), startsAtS: t0.toFixed(3), activeTtsSources: this._activeTtsSources, expectedEndInMs: audioEndDelayMs.toFixed(0) });
|
|
513
|
+
src.onended = () => this._onTtsSourceEnded(src);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private _onTtsSourceEnded(src?: AudioBufferSourceNode): void {
|
|
518
|
+
this._activeTtsSources = Math.max(0, this._activeTtsSources - 1);
|
|
519
|
+
if (src) {
|
|
520
|
+
const idx = this._activeTtsSourceNodes.indexOf(src);
|
|
521
|
+
if (idx !== -1) { this._activeTtsSourceNodes.splice(idx, 1); }
|
|
522
|
+
}
|
|
523
|
+
this.logger.log('[VoiceService] TTS source ended', { activeTtsSources: this._activeTtsSources, unblockPending: this._unblockAfterTts });
|
|
524
|
+
if (this._activeTtsSources === 0) {
|
|
525
|
+
this.logger.info('[VoiceService] TTS playback ended – all sources finished');
|
|
526
|
+
console.log('[VoiceService] TTS audio finished playing');
|
|
527
|
+
}
|
|
528
|
+
if (this._unblockAfterTts && this._activeTtsSources === 0) {
|
|
529
|
+
this._flushTtsUnblock(false);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Immediately stops all currently playing/scheduled TTS audio sources.
|
|
535
|
+
* Called when a new `speaking` event arrives (new bot turn) to prevent overlap with
|
|
536
|
+
* the previous turn's audio, and during `stopSession`.
|
|
537
|
+
* Clears `onended` callbacks BEFORE stopping so that `_onTtsSourceEnded` is NOT
|
|
538
|
+
* invoked for cancelled nodes (avoiding spurious `sendPlaybackComplete` calls).
|
|
539
|
+
* Also increments `_ttsGeneration` so any in-flight `decodeAudioData` promises
|
|
540
|
+
* can detect that their result is stale and discard the decoded buffer.
|
|
541
|
+
*/
|
|
542
|
+
private _cancelAllTtsAudio(): void {
|
|
543
|
+
this._ttsGeneration++;
|
|
544
|
+
if (this._unblockSafetyTimer !== null) {
|
|
545
|
+
clearTimeout(this._unblockSafetyTimer);
|
|
546
|
+
this._unblockSafetyTimer = null;
|
|
547
|
+
}
|
|
548
|
+
for (const src of this._activeTtsSourceNodes) {
|
|
549
|
+
src.onended = null;
|
|
550
|
+
try { src.stop(); } catch { /* already ended — ignore */ }
|
|
551
|
+
}
|
|
552
|
+
this._activeTtsSourceNodes = [];
|
|
553
|
+
this._activeTtsSources = 0;
|
|
554
|
+
this._unblockAfterTts = false;
|
|
555
|
+
this._ttsExpectedEndTime = 0;
|
|
556
|
+
// Reset ordered-scheduling state so the next speaking turn starts fresh.
|
|
557
|
+
this._ttsChunkSeq = 0;
|
|
558
|
+
this._ttsScheduledSeq = 0;
|
|
559
|
+
this._ttsDecodedPending.clear();
|
|
560
|
+
this._stopTtsKaraoke(true);
|
|
561
|
+
this.logger.log('[VoiceService] TTS cancelled – all audio sources stopped');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private _flushTtsUnblock(fromSafetyTimer = false): void {
|
|
565
|
+
this._unblockAfterTts = false;
|
|
566
|
+
this._activeTtsSources = 0;
|
|
567
|
+
if (this._unblockSafetyTimer !== null) {
|
|
568
|
+
clearTimeout(this._unblockSafetyTimer);
|
|
569
|
+
this._unblockSafetyTimer = null;
|
|
570
|
+
}
|
|
571
|
+
if (fromSafetyTimer) {
|
|
572
|
+
this.logger.warn('[VoiceService] TTS unblock: safety timer fired – forcing playback complete');
|
|
573
|
+
} else {
|
|
574
|
+
this.logger.log('[VoiceService] TTS unblock: all sources ended, sending playback complete');
|
|
575
|
+
}
|
|
576
|
+
this._stopTtsKaraoke(true);
|
|
577
|
+
// Signal the proxy that TTS playback is complete. The proxy will transition
|
|
578
|
+
// to LISTENING and send a 'listening' event back; the mic resumes and the UI
|
|
579
|
+
// unblocks only then — so the user sees 'listening' exactly when the stream
|
|
580
|
+
// is open, not before.
|
|
581
|
+
// Start a fallback timer: if the proxy does not respond with 'listening' within
|
|
582
|
+
// 3 seconds (network hiccup, server race, etc.) force-unblock so the user is
|
|
583
|
+
// never left stuck. The timer is cancelled immediately if 'listening' arrives.
|
|
584
|
+
this.voiceStreaming.sendPlaybackComplete();
|
|
585
|
+
if (this._listeningFallbackTimer !== null) clearTimeout(this._listeningFallbackTimer);
|
|
586
|
+
this._listeningFallbackTimer = setTimeout(() => {
|
|
587
|
+
this._listeningFallbackTimer = null;
|
|
588
|
+
this.logger.warn('[VoiceService] listening fallback timer fired – proxy did not respond, force-unblocking');
|
|
589
|
+
this._isAcquisitionBlocked$.next(false);
|
|
590
|
+
this.voiceStreaming.resumeRecording();
|
|
591
|
+
}, 3000);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── WSS TTS Karaoke helpers ───────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
private _startTtsKaraoke(text: string): void {
|
|
597
|
+
this._stopTtsKaraoke(false);
|
|
598
|
+
this._kText = text;
|
|
599
|
+
const rawWords = text.trim().split(/\s+/).filter((w) => w.length > 0);
|
|
600
|
+
if (rawWords.length === 0) return;
|
|
601
|
+
// ~140 WPM → ~0.43 s/word (same estimate as audio-sync)
|
|
602
|
+
const duration = Math.max(1, rawWords.length * 0.43);
|
|
603
|
+
this._kDuration = duration;
|
|
604
|
+
const step = duration / rawWords.length;
|
|
605
|
+
this._kWords = rawWords.map((w, i) => ({
|
|
606
|
+
text: w,
|
|
607
|
+
start: i * step,
|
|
608
|
+
end: (i + 1) * step,
|
|
609
|
+
state: 'future' as const,
|
|
610
|
+
}));
|
|
611
|
+
this._kStartContextTime = this.ttsPlayContext?.currentTime ?? 0;
|
|
612
|
+
this._kLastActiveIndex = -2;
|
|
613
|
+
this._rafKaraokeLoop();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private _stopTtsKaraoke(markAllPast: boolean): void {
|
|
617
|
+
if (this._kRafId !== undefined) {
|
|
618
|
+
cancelAnimationFrame(this._kRafId);
|
|
619
|
+
this._kRafId = undefined;
|
|
620
|
+
}
|
|
621
|
+
if (markAllPast && this._kWords.length > 0) {
|
|
622
|
+
this._kWords.forEach((w) => { w.state = 'past'; });
|
|
623
|
+
this._voiceTtsKaraokeSubject.next({
|
|
624
|
+
text: this._kText,
|
|
625
|
+
words: this._kWords.map(({ text, state }) => ({ text, state })),
|
|
626
|
+
activeIndex: -1,
|
|
627
|
+
});
|
|
628
|
+
this._kWords = [];
|
|
629
|
+
this._kText = '';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private _rafKaraokeLoop(): void {
|
|
634
|
+
const elapsed = (this.ttsPlayContext?.currentTime ?? 0) - this._kStartContextTime;
|
|
635
|
+
let activeIndex = -1;
|
|
636
|
+
|
|
637
|
+
this._kWords.forEach((w) => {
|
|
638
|
+
if (elapsed >= w.end) {
|
|
639
|
+
w.state = 'past';
|
|
640
|
+
} else if (elapsed >= w.start && elapsed < w.end) {
|
|
641
|
+
w.state = 'active';
|
|
642
|
+
activeIndex = this._kWords.indexOf(w);
|
|
643
|
+
} else {
|
|
644
|
+
w.state = 'future';
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (activeIndex !== this._kLastActiveIndex) {
|
|
649
|
+
this._kLastActiveIndex = activeIndex;
|
|
650
|
+
this._voiceTtsKaraokeSubject.next({
|
|
651
|
+
text: this._kText,
|
|
652
|
+
words: this._kWords.map(({ text, state }) => ({ text, state })),
|
|
653
|
+
activeIndex,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (elapsed < this._kDuration) {
|
|
658
|
+
this._kRafId = requestAnimationFrame(() => this._rafKaraokeLoop());
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
// ── Keyboard typing-indicator sound helpers ───────────────────────────────
|
|
665
|
+
/**
|
|
666
|
+
* Starts the keyboard sound on loop to mask silence while the bot is
|
|
667
|
+
* generating its response. No-op if already playing.
|
|
668
|
+
* Only called during WSS voice sessions (voice-proxy mode).
|
|
669
|
+
*/
|
|
670
|
+
private _startKeyboardSound(): void {
|
|
671
|
+
if (this._keyboardSoundEl) return; // already playing
|
|
672
|
+
const file = this.globals.keyboardSoundFile ?? 'keyboard.mp3';
|
|
673
|
+
const src = /^https?:\/\//i.test(file)
|
|
674
|
+
? file
|
|
675
|
+
: `${this.globals.baseLocation}/assets/sounds/${file}`;
|
|
676
|
+
const audio = new Audio(src);
|
|
677
|
+
audio.loop = true;
|
|
678
|
+
audio.volume = Math.min(1, Math.max(0, this.globals.keyboardSoundVolume));
|
|
679
|
+
audio.play().catch((e) => this.logger.warn('[VoiceService] keyboard sound play failed', e));
|
|
680
|
+
this._keyboardSoundEl = audio;
|
|
681
|
+
this.logger.log('[VoiceService] keyboard sound started', { src, volume: audio.volume });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Stops and discards the keyboard typing sound. No-op if not playing. */
|
|
685
|
+
private _stopKeyboardSound(): void {
|
|
686
|
+
if (!this._keyboardSoundEl) return;
|
|
687
|
+
this._keyboardSoundEl.pause();
|
|
688
|
+
this._keyboardSoundEl.currentTime = 0;
|
|
689
|
+
this._keyboardSoundEl = null;
|
|
690
|
+
this.logger.log('[VoiceService] keyboard sound stopped');
|
|
691
|
+
}
|
|
692
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
async stopSession(options?: { discardInProgressSegment?: boolean}): Promise<{ voiceIngressResultUrl: string | null }> {
|
|
112
695
|
const discard = options?.discardInProgressSegment === true;
|
|
696
|
+
this.logger.info('[VoiceService] stopSession', { discard, isWssVoiceActive: this._isWssVoiceActive$.getValue() });
|
|
697
|
+
|
|
698
|
+
this.wsControlSub?.unsubscribe();
|
|
699
|
+
this.wsControlSub = undefined;
|
|
700
|
+
this.ttsChunkSub?.unsubscribe();
|
|
701
|
+
this.ttsChunkSub = undefined;
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
if (this.ttsPlayContext && this.ttsPlayContext.state !== 'closed') {
|
|
705
|
+
await this.ttsPlayContext.close();
|
|
706
|
+
}
|
|
707
|
+
} catch {
|
|
708
|
+
/* ignore */
|
|
709
|
+
}
|
|
710
|
+
this._cancelAllTtsAudio();
|
|
711
|
+
this.ttsPlayContext = undefined;
|
|
712
|
+
this.ttsNextPlayTime = 0;
|
|
713
|
+
this._stopKeyboardSound();
|
|
714
|
+
|
|
715
|
+
let voiceIngressResultUrl: string | null = null;
|
|
716
|
+
if (this.voiceIngressConfig) {
|
|
717
|
+
try {
|
|
718
|
+
const { resultUrl } = await this.voiceStreaming.stop({discard: true, awaitServerResultUrl: true});
|
|
719
|
+
voiceIngressResultUrl = resultUrl ?? null;
|
|
720
|
+
} catch (e) {
|
|
721
|
+
this.logger.log('[VoiceService] stopSession voiceStreaming.stop', e);
|
|
722
|
+
}
|
|
723
|
+
this._isWssVoiceActive$.next(false);
|
|
724
|
+
this.voiceIngressConfig = null;
|
|
725
|
+
}
|
|
113
726
|
|
|
114
727
|
if (this.mediaRecorder) {
|
|
115
728
|
if (discard) {
|
|
@@ -140,6 +753,10 @@ export class VoiceService {
|
|
|
140
753
|
}
|
|
141
754
|
|
|
142
755
|
// 🎧 cleanup audio context
|
|
756
|
+
if (this.volumeRafId) {
|
|
757
|
+
cancelAnimationFrame(this.volumeRafId);
|
|
758
|
+
this.volumeRafId = undefined;
|
|
759
|
+
}
|
|
143
760
|
this.audioContext?.close();
|
|
144
761
|
this.audioContext = undefined;
|
|
145
762
|
this.analyser = undefined;
|
|
@@ -148,6 +765,23 @@ export class VoiceService {
|
|
|
148
765
|
this.volumeSubject.next(0);
|
|
149
766
|
|
|
150
767
|
this.onRecordingComplete = undefined;
|
|
768
|
+
|
|
769
|
+
// 🎙️ release TTS gate subscription
|
|
770
|
+
this.ttsGateSub?.unsubscribe();
|
|
771
|
+
this.ttsGateSub = undefined;
|
|
772
|
+
this.isTTSActive = false;
|
|
773
|
+
|
|
774
|
+
// 🚫 clear acquisition gate
|
|
775
|
+
clearTimeout(this.responseTimeoutId);
|
|
776
|
+
this.responseTimeoutId = undefined;
|
|
777
|
+
this.isWaitingForResponse = false;
|
|
778
|
+
if (this._listeningFallbackTimer !== null) {
|
|
779
|
+
clearTimeout(this._listeningFallbackTimer);
|
|
780
|
+
this._listeningFallbackTimer = null;
|
|
781
|
+
}
|
|
782
|
+
this._isAcquisitionBlocked$.next(false);
|
|
783
|
+
|
|
784
|
+
return { voiceIngressResultUrl };
|
|
151
785
|
}
|
|
152
786
|
|
|
153
787
|
/**
|
|
@@ -155,6 +789,9 @@ export class VoiceService {
|
|
|
155
789
|
* Lo stream resta in ascolto per il prossimo `onSpeechStart`.
|
|
156
790
|
*/
|
|
157
791
|
discardCurrentRecordingSegment(): void {
|
|
792
|
+
if (!this.vad) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
158
795
|
if (this.mediaRecorder) {
|
|
159
796
|
this.mediaRecorder.onstop = null;
|
|
160
797
|
this.mediaRecorder.ondataavailable = null;
|
|
@@ -164,13 +801,45 @@ export class VoiceService {
|
|
|
164
801
|
}
|
|
165
802
|
this.mediaRecorder = undefined;
|
|
166
803
|
this.audioChunks = [];
|
|
167
|
-
this.logger.log('[VoiceService] discarded in-progress segment
|
|
804
|
+
this.logger.log('[VoiceService] discarded in-progress segment (legacy VAD)');
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* 🔄 RESUME VAD AFTER RESPONSE
|
|
809
|
+
* Called when isTTSPlaying$ goes false while isWaitingForResponse is true,
|
|
810
|
+
* or by the safety timeout if no TTS response arrives within 30 s.
|
|
811
|
+
*/
|
|
812
|
+
private resumeVadAfterResponse(): void {
|
|
813
|
+
this.isWaitingForResponse = false;
|
|
814
|
+
clearTimeout(this.responseTimeoutId);
|
|
815
|
+
this.responseTimeoutId = undefined;
|
|
816
|
+
this._isAcquisitionBlocked$.next(false);
|
|
817
|
+
if (this.vad) {
|
|
818
|
+
this.vad.start().catch((e) => this.logger.log('[VoiceService] VAD resume error', e));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* ⏱️ SAFETY TIMEOUT
|
|
824
|
+
* Forces VAD re-enable after 30 s in case no TTS response ever arrives.
|
|
825
|
+
*/
|
|
826
|
+
private setResponseSafetyTimeout(): void {
|
|
827
|
+
clearTimeout(this.responseTimeoutId);
|
|
828
|
+
this.responseTimeoutId = setTimeout(() => {
|
|
829
|
+
this.logger.log('[VoiceService] safety timeout: re-enabling VAD acquisition');
|
|
830
|
+
this.resumeVadAfterResponse();
|
|
831
|
+
}, 30_000);
|
|
168
832
|
}
|
|
169
833
|
|
|
170
834
|
/**
|
|
171
835
|
* 🎧 AUDIO ANALYSER INIT
|
|
172
836
|
*/
|
|
173
837
|
private initAudioAnalyser(stream: MediaStream): void {
|
|
838
|
+
if (!stream?.getAudioTracks?.()?.length) {
|
|
839
|
+
this.logger.log('[VoiceService] initAudioAnalyser: no audio track on stream, skipping analyser');
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
174
843
|
this.audioContext = new AudioContext();
|
|
175
844
|
|
|
176
845
|
const source = this.audioContext.createMediaStreamSource(stream);
|
|
@@ -190,8 +859,7 @@ export class VoiceService {
|
|
|
190
859
|
private startVolumeLoop(): void {
|
|
191
860
|
const tick = () => {
|
|
192
861
|
if (!this.analyser || !this.dataArray) {
|
|
193
|
-
|
|
194
|
-
return;
|
|
862
|
+
return; // Stop the loop if analyser is cleaned up
|
|
195
863
|
}
|
|
196
864
|
|
|
197
865
|
this.analyser.getByteFrequencyData(
|
|
@@ -207,10 +875,10 @@ export class VoiceService {
|
|
|
207
875
|
|
|
208
876
|
this.volumeSubject.next(volume);
|
|
209
877
|
|
|
210
|
-
requestAnimationFrame(tick);
|
|
878
|
+
this.volumeRafId = requestAnimationFrame(tick);
|
|
211
879
|
};
|
|
212
880
|
|
|
213
|
-
tick
|
|
881
|
+
this.volumeRafId = requestAnimationFrame(tick);
|
|
214
882
|
}
|
|
215
883
|
|
|
216
884
|
/**
|
|
@@ -290,10 +958,16 @@ export class VoiceService {
|
|
|
290
958
|
* 📡 EMIT RESULT
|
|
291
959
|
*/
|
|
292
960
|
private emitSegmentPayload(payload: VoiceSegmentPayload): void {
|
|
293
|
-
this.
|
|
961
|
+
if (this.isTTSActive) {
|
|
962
|
+
this.logger.log('[VoiceService] segment suppressed — TTS is playing');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
this.logger.log('[VoiceService] segment ready', payload.transcript ?? payload.transcriptionError ?? payload.blob.size);
|
|
294
967
|
|
|
295
968
|
this.audioSegmentSubject.next(payload);
|
|
296
969
|
|
|
297
970
|
this.onRecordingComplete?.(payload);
|
|
298
971
|
}
|
|
972
|
+
|
|
299
973
|
}
|