@chat21/chat21-web-widget 5.1.32-rc8 → 5.1.33-rc8
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 -3
- 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-report/index.html +90 -0
- 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 +61 -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 +22 -9
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +23 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +249 -149
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +0 -1
- 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 -62
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +133 -7
- 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 +193 -79
- 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 +43 -19
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +63 -10
- package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +142 -12
- 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 +26 -0
- 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 -1
- 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 +134 -24
- 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 +54 -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 +138 -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 +197 -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/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 +30 -2
- package/src/app/providers/json-sources-parser.service.ts +182 -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 +45 -7
- 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 +710 -0
- package/src/app/providers/voice/voice-streaming.types.ts +113 -0
- package/src/app/providers/voice/voice.service.spec.ts +203 -3
- package/src/app/providers/voice/voice.service.ts +521 -12
- package/src/app/sass/_variables.scss +1 -1
- package/src/app/sass/animations.scss +19 -1
- package/src/app/utils/globals.ts +4 -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 +26 -1
- package/src/assets/i18n/es.json +106 -101
- package/src/assets/i18n/fr.json +106 -101
- package/src/assets/i18n/it.json +106 -99
- 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/test-results/.last-run.json +4 -0
- 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,7 +1,7 @@
|
|
|
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
7
|
|
|
@@ -12,12 +12,22 @@ import {
|
|
|
12
12
|
} from './audio.types';
|
|
13
13
|
import { SpeechToTextProvider } from './STT&TTS/speech-provider.abstract';
|
|
14
14
|
import { VadService } from './vad.service';
|
|
15
|
+
import { VoiceStreamingService } from './voice-streaming.service';
|
|
16
|
+
import {
|
|
17
|
+
VoiceTtsKaraokeFrame,
|
|
18
|
+
VoiceTtsKaraokeWord,
|
|
19
|
+
VoiceStreamingSessionConfig,
|
|
20
|
+
VoiceWsControlMessage,
|
|
21
|
+
} from './voice-streaming.types';
|
|
22
|
+
import { TtsAudioPlaybackCoordinator } from '../tts-audio-playback-coordinator.service';
|
|
15
23
|
|
|
16
24
|
const VOICE_RECORDING_MIME = 'audio/webm';
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
27
|
+
* Due modalità:
|
|
28
|
+
* - **Ingresso WSS** (`voiceIngressStream`): microfono → proxy in streaming; niente VAD locale — silenzio/turni gestiti dal server.
|
|
29
|
+
* Eventi `transcript` / TTS binario arrivano sulla WSS.
|
|
30
|
+
* - **Legacy**: MicVAD + segmenti WebM (upload/STT client-side) se non passi `voiceIngressStream`.
|
|
21
31
|
*/
|
|
22
32
|
@Injectable({ providedIn: 'root' })
|
|
23
33
|
export class VoiceService {
|
|
@@ -28,25 +38,115 @@ export class VoiceService {
|
|
|
28
38
|
private sessionConstraints: MediaStreamConstraints = DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS;
|
|
29
39
|
private onRecordingComplete?: (result: VoiceSegmentPayload) => void;
|
|
30
40
|
private enableTranscription = true;
|
|
41
|
+
private voiceIngressConfig?: VoiceStreamingSessionConfig | null = null;
|
|
31
42
|
|
|
32
43
|
private readonly audioSegmentSubject = new Subject<VoiceSegmentPayload>();
|
|
33
|
-
|
|
34
|
-
readonly
|
|
44
|
+
|
|
45
|
+
private readonly speechStartSubject = new Subject<void>();
|
|
46
|
+
/** Emesso quando il microfono intercetta parlato (VAD speech start). */
|
|
47
|
+
readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
|
|
48
|
+
|
|
49
|
+
private readonly speechEndSubject = new Subject<void>();
|
|
50
|
+
/** Emesso quando il parlato termina (VAD speech end). */
|
|
51
|
+
readonly speechEnd$: Observable<void> = this.speechEndSubject.asObservable();
|
|
52
|
+
|
|
53
|
+
/** Trascrizione dall’evento WSS `transcript` (proxy). */
|
|
54
|
+
private readonly voiceTranscriptSubject = new Subject<{ text: string; isFinal: boolean }>();
|
|
55
|
+
readonly voiceTranscript$: Observable<{ text: string; isFinal: boolean }> = this.voiceTranscriptSubject.asObservable();
|
|
56
|
+
|
|
57
|
+
/** Testo TTS in riproduzione, emesso dall'evento WSS `speaking` (proxy). */
|
|
58
|
+
private readonly voiceTtsTextSubject = new Subject<string>();
|
|
59
|
+
/** Emette il testo del bot che sta per essere riprodotto come audio TTS. */
|
|
60
|
+
readonly voiceTtsText$: Observable<string> = this.voiceTtsTextSubject.asObservable();
|
|
61
|
+
|
|
62
|
+
/** Errore applicativo dal proxy (evento `error`): testo descrittivo del problema. */
|
|
63
|
+
private readonly _wsError$ = new Subject<string>();
|
|
64
|
+
readonly wsError$: Observable<string> = this._wsError$.asObservable();
|
|
35
65
|
|
|
36
|
-
// 🔊 REALTIME VOLUME STREAM
|
|
37
66
|
private readonly volumeSubject = new BehaviorSubject<number>(0);
|
|
38
67
|
readonly volume$: Observable<number> = this.volumeSubject.asObservable();
|
|
39
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Emits `true` while a WSS voice-proxy session is active.
|
|
71
|
+
* Used to suppress the tiledesk-server TTS playback path (audio-sync component)
|
|
72
|
+
* when the speech-proxy is already handling TTS over the WebSocket binary channel.
|
|
73
|
+
*/
|
|
74
|
+
private readonly _isWssVoiceActive$ = new BehaviorSubject<boolean>(false);
|
|
75
|
+
readonly isWssVoiceActive$: Observable<boolean> = this._isWssVoiceActive$.asObservable();
|
|
76
|
+
get isWssVoiceActive(): boolean { return this._isWssVoiceActive$.getValue(); }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* UIDs of TTS messages that were played by the speech-proxy during an active voice session.
|
|
80
|
+
* These messages must never be replayed by audio-sync after the session ends.
|
|
81
|
+
*/
|
|
82
|
+
private readonly _proxyHandledTtsIds = new Set<string>();
|
|
83
|
+
|
|
84
|
+
/** Register a TTS message UID as having been played by the proxy. */
|
|
85
|
+
markProxyHandled(uid: string): void {
|
|
86
|
+
if (uid) { this._proxyHandledTtsIds.add(uid); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns true if the message was already played by the proxy and should not be replayed. */
|
|
90
|
+
wasProxyHandled(uid: string | undefined): boolean {
|
|
91
|
+
return !!uid && this._proxyHandledTtsIds.has(uid);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 🎙️ TTS GATE — suppresses segment emission while TTS is playing
|
|
95
|
+
private isTTSActive = false;
|
|
96
|
+
private ttsGateSub?: Subscription;
|
|
97
|
+
private wsControlSub?: Subscription;
|
|
98
|
+
private ttsChunkSub?: Subscription;
|
|
99
|
+
|
|
100
|
+
// 🚫 ACQUISITION GATE — pauses VAD from speech-end until TTS response cycle completes
|
|
101
|
+
private isWaitingForResponse = false;
|
|
102
|
+
private responseTimeoutId?: ReturnType<typeof setTimeout>;
|
|
103
|
+
private readonly _isAcquisitionBlocked$ = new BehaviorSubject<boolean>(false);
|
|
104
|
+
/** Emits `true` from user speech-end until VAD resumes after TTS finishes; drives the grey orb. */
|
|
105
|
+
readonly isAcquisitionBlocked$: Observable<boolean> = this._isAcquisitionBlocked$.asObservable();
|
|
106
|
+
|
|
40
107
|
// 🎧 AUDIO ANALYSER
|
|
41
108
|
private audioContext?: AudioContext;
|
|
42
109
|
private analyser?: AnalyserNode;
|
|
43
110
|
/** Buffer dedicato (`ArrayBuffer`) per compatibilità con `getByteFrequencyData`. */
|
|
44
111
|
private dataArray?: Uint8Array;
|
|
45
112
|
|
|
113
|
+
/** Riproduzione chunk TTS binari dal proxy (Web Audio). */
|
|
114
|
+
private ttsPlayContext?: AudioContext;
|
|
115
|
+
private ttsNextPlayTime = 0;
|
|
116
|
+
|
|
117
|
+
// Tracks how many TTS audio sources are still decoding or playing.
|
|
118
|
+
// Incremented synchronously when a binary chunk arrives (before decodeAudioData).
|
|
119
|
+
// Decremented in src.onended (or on decode error).
|
|
120
|
+
private _activeTtsSources = 0;
|
|
121
|
+
// References to active AudioBufferSourceNodes so they can be stopped on preemption.
|
|
122
|
+
private _activeTtsSourceNodes: AudioBufferSourceNode[] = [];
|
|
123
|
+
// Monotonic counter incremented every time all in-flight TTS audio is invalidated
|
|
124
|
+
// (barge_in or a new speaking event). playWsTtsChunk captures this at entry and
|
|
125
|
+
// checks it after the async decodeAudioData call to discard stale results.
|
|
126
|
+
private _ttsGeneration = 0;
|
|
127
|
+
// Set to true by the 'done' event; triggers acquisition unblock once all sources end.
|
|
128
|
+
private _unblockAfterTts = false;
|
|
129
|
+
private _unblockSafetyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
|
|
131
|
+
// ── WSS TTS Karaoke ──────────────────────────────────────────────────────────────────────────
|
|
132
|
+
private _kText = '';
|
|
133
|
+
private _kWords: Array<VoiceTtsKaraokeWord & { start: number; end: number }> = [];
|
|
134
|
+
private _kStartContextTime = 0;
|
|
135
|
+
private _kDuration = 0;
|
|
136
|
+
private _kRafId?: number;
|
|
137
|
+
private _kLastActiveIndex = -2;
|
|
138
|
+
|
|
139
|
+
private readonly _voiceTtsKaraokeSubject = new Subject<VoiceTtsKaraokeFrame>();
|
|
140
|
+
/** Emits word-state frames while WebSocket TTS audio plays; drives the karaoke highlight in bubble-message. */
|
|
141
|
+
readonly voiceTtsKaraoke$: Observable<VoiceTtsKaraokeFrame> = this._voiceTtsKaraokeSubject.asObservable();
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
46
144
|
private readonly logger: LoggerService = LoggerInstance.getInstance();
|
|
47
145
|
|
|
48
146
|
constructor(
|
|
49
147
|
private readonly vadService: VadService,
|
|
148
|
+
private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
|
|
149
|
+
private readonly voiceStreaming: VoiceStreamingService,
|
|
50
150
|
@Optional() @Inject(SpeechToTextProvider) private readonly speechToText: SpeechToTextProvider | null,
|
|
51
151
|
) {}
|
|
52
152
|
|
|
@@ -54,6 +154,20 @@ export class VoiceService {
|
|
|
54
154
|
return !!this.vad || !!this.stream;
|
|
55
155
|
}
|
|
56
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Returns the speech-proxy's streaming TTS endpoint URL, or `null` when no proxy is configured.
|
|
159
|
+
* The audio-sync component uses this to redirect TTS calls from the tiledesk-server to the proxy.
|
|
160
|
+
*/
|
|
161
|
+
get proxyTtsStreamUrl(): string | null {
|
|
162
|
+
const base = this.voiceStreaming.proxyHttpBaseUrl;
|
|
163
|
+
return base ? `${base}/api/tts/stream` : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get proxyTtsUrl(): string | null {
|
|
167
|
+
const base = this.voiceStreaming.proxyHttpBaseUrl;
|
|
168
|
+
return base ? `${base}/api/tts` : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
57
171
|
/**
|
|
58
172
|
* Richiede il microfono, avvia VAD in ascolto (inizio/fine parlato) e registra in WebM per segmento.
|
|
59
173
|
*/
|
|
@@ -63,13 +177,56 @@ export class VoiceService {
|
|
|
63
177
|
this.sessionConstraints = options.constraints ?? DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS;
|
|
64
178
|
this.onRecordingComplete = options.onRecordingComplete;
|
|
65
179
|
this.enableTranscription = options.enableTranscription !== false;
|
|
180
|
+
this.voiceIngressConfig = options.voiceIngressStream;
|
|
66
181
|
|
|
67
|
-
|
|
182
|
+
if (this.voiceIngressConfig) {
|
|
183
|
+
await this.startWssVoiceSession();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
68
186
|
|
|
187
|
+
await this.startLegacyVadSession(options);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Sessione guidata dal proxy: solo mic + volume + WSS (mic in upload, eventi + TTS in download). */
|
|
191
|
+
private async startWssVoiceSession(): Promise<void> {
|
|
69
192
|
this.stream = await navigator.mediaDevices.getUserMedia(this.sessionConstraints);
|
|
70
193
|
|
|
71
194
|
// 🎧 AUDIO ANALYSER INIT
|
|
72
195
|
this.initAudioAnalyser(this.stream);
|
|
196
|
+
this.startVolumeLoop();
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// Subscribe before start() so early events (e.g. proxy 'error') are not lost.
|
|
200
|
+
this.wsControlSub = this.voiceStreaming.wsControl$.subscribe((msg) => this.onWsControl(msg));
|
|
201
|
+
this.ttsChunkSub = this.voiceStreaming.ttsBinaryChunk$.subscribe((buf) => void this.playWsTtsChunk(buf));
|
|
202
|
+
await this.voiceStreaming.start(this.voiceIngressConfig!, { sharedMediaStream: this.stream });
|
|
203
|
+
// Signal that the voice proxy is now live — suppresses tiledesk-server TTS.
|
|
204
|
+
this._isWssVoiceActive$.next(true);
|
|
205
|
+
this.logger.log('[VoiceService] sessione WSS (nessun VAD locale)');
|
|
206
|
+
} catch (e) {
|
|
207
|
+
this.wsControlSub?.unsubscribe();
|
|
208
|
+
this.wsControlSub = undefined;
|
|
209
|
+
this.ttsChunkSub?.unsubscribe();
|
|
210
|
+
this.ttsChunkSub = undefined;
|
|
211
|
+
this.voiceIngressConfig = null;
|
|
212
|
+
if (this.stream) {
|
|
213
|
+
this.stream.getTracks().forEach((t) => t.stop());
|
|
214
|
+
this.stream = undefined;
|
|
215
|
+
}
|
|
216
|
+
this.audioContext?.close();
|
|
217
|
+
this.audioContext = undefined;
|
|
218
|
+
this.analyser = undefined;
|
|
219
|
+
this.dataArray = undefined;
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** VAD + segmenti (nessun ingresso WSS). */
|
|
225
|
+
private async startLegacyVadSession(options: VoiceSessionStartOptions): Promise<void> {
|
|
226
|
+
await this.vadService.ensureOnnxRuntimeEnv();
|
|
227
|
+
|
|
228
|
+
this.stream = await navigator.mediaDevices.getUserMedia(this.sessionConstraints);
|
|
229
|
+
this.initAudioAnalyser(this.stream);
|
|
73
230
|
|
|
74
231
|
const vadDefaults = getDefaultRealTimeVADOptions('legacy');
|
|
75
232
|
|
|
@@ -83,14 +240,21 @@ export class VoiceService {
|
|
|
83
240
|
},
|
|
84
241
|
onSpeechStart: () => {
|
|
85
242
|
this.logger.log('[VoiceService] speech start');
|
|
243
|
+
this.speechStartSubject.next();
|
|
86
244
|
this.startMediaRecorderSegment();
|
|
87
245
|
},
|
|
88
246
|
onSpeechEnd: () => {
|
|
89
247
|
this.logger.log('[VoiceService] speech end');
|
|
248
|
+
this.speechEndSubject.next();
|
|
90
249
|
this.stopMediaRecorderSegment();
|
|
250
|
+
// Pause VAD immediately — new recordings are blocked until the TTS response cycle completes.
|
|
251
|
+
this.isWaitingForResponse = true;
|
|
252
|
+
this._isAcquisitionBlocked$.next(true);
|
|
253
|
+
this.setResponseSafetyTimeout();
|
|
254
|
+
void this.vad?.pause();
|
|
91
255
|
},
|
|
92
256
|
minSpeechMs: 480,
|
|
93
|
-
redemptionMs: 1920,
|
|
257
|
+
redemptionMs: 800,//1920,
|
|
94
258
|
preSpeechPadMs: 960,
|
|
95
259
|
});
|
|
96
260
|
|
|
@@ -98,14 +262,305 @@ export class VoiceService {
|
|
|
98
262
|
|
|
99
263
|
// 🔁 start volume loop
|
|
100
264
|
this.startVolumeLoop();
|
|
265
|
+
|
|
266
|
+
// 🎙️ gate segments while TTS is playing; resume VAD when TTS cycle completes
|
|
267
|
+
this.ttsGateSub = this.ttsPlayback.isTTSPlaying$.subscribe((playing) => {
|
|
268
|
+
this.isTTSActive = playing;
|
|
269
|
+
this.logger.log('[VoiceService] TTS gate', playing ? 'closed (bot speaking)' : 'open (listening)');
|
|
270
|
+
if (!playing && this.isWaitingForResponse) {
|
|
271
|
+
this.resumeVadAfterResponse();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private onWsControl(msg: VoiceWsControlMessage): void {
|
|
277
|
+
this.logger.log('[VoiceService] ← ws-control', msg.event, msg);
|
|
278
|
+
switch (msg.event) {
|
|
279
|
+
case 'session_started':
|
|
280
|
+
this.logger.log('[VoiceService] session_started', { requestId: msg.requestId ?? '' });
|
|
281
|
+
break;
|
|
282
|
+
case 'listening':
|
|
283
|
+
// Proxy confirmed it is in LISTENING state — unblock the UI.
|
|
284
|
+
// Audio has been flowing continuously (AEC handles echo suppression),
|
|
285
|
+
// so there is nothing to unmute here.
|
|
286
|
+
this._isAcquisitionBlocked$.next(false);
|
|
287
|
+
this.logger.log('[VoiceService] listening – acquisition unblocked');
|
|
288
|
+
break;
|
|
289
|
+
case 'transcript': {
|
|
290
|
+
const text = typeof msg.text === 'string' ? msg.text : '';
|
|
291
|
+
const isFinal = !!msg.isFinal;
|
|
292
|
+
this.logger.log('[VoiceService] transcript', { text, isFinal });
|
|
293
|
+
this.voiceTranscriptSubject.next({ text, isFinal });
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'thinking':
|
|
297
|
+
// Block acquisition UI while the bot processes the utterance.
|
|
298
|
+
// Audio continues flowing to the proxy so the server can detect
|
|
299
|
+
// barge-in via Flux STT even during PROCESSING state.
|
|
300
|
+
this._isAcquisitionBlocked$.next(true);
|
|
301
|
+
this.logger.log('[VoiceService] thinking – acquisition blocked', { activeTtsSources: this._activeTtsSources });
|
|
302
|
+
break;
|
|
303
|
+
case 'speaking': {
|
|
304
|
+
this._isAcquisitionBlocked$.next(true);
|
|
305
|
+
// Do NOT mute the microphone. The MediaStream is captured with
|
|
306
|
+
// echoCancellation: true, so the browser's AEC filters out the bot's
|
|
307
|
+
// speaker output before it reaches the MediaRecorder. Audio keeps
|
|
308
|
+
// flowing to the proxy so Flux can fire StartOfTurn when the user
|
|
309
|
+
// speaks, enabling server-side barge-in detection.
|
|
310
|
+
this._cancelAllTtsAudio();
|
|
311
|
+
// Reset TTS scheduling so new chunks play from now, not a stale future time.
|
|
312
|
+
this.ttsNextPlayTime = this.ttsPlayContext?.currentTime ?? 0;
|
|
313
|
+
const preview = typeof msg.text === 'string' ? msg.text.slice(0, 80) : '';
|
|
314
|
+
this.logger.log('[VoiceService] speaking – acquisition blocked, TTS text preview', { preview });
|
|
315
|
+
// Emit the text being spoken so UI can display it alongside the audio.
|
|
316
|
+
if (typeof msg.text === 'string' && msg.text) {
|
|
317
|
+
this.voiceTtsTextSubject.next(msg.text);
|
|
318
|
+
this._startTtsKaraoke(msg.text);
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'done':
|
|
323
|
+
// Do not unblock immediately — the audio binary may still be decoding/playing.
|
|
324
|
+
// _activeTtsSources tracks pending sources; when the last one ends, acquisition unblocks.
|
|
325
|
+
if (this._activeTtsSources > 0) {
|
|
326
|
+
this._unblockAfterTts = true;
|
|
327
|
+
// Safety: force-unblock after 15 s in case onended never fires.
|
|
328
|
+
if (this._unblockSafetyTimer !== null) clearTimeout(this._unblockSafetyTimer);
|
|
329
|
+
this._unblockSafetyTimer = setTimeout(() => this._flushTtsUnblock(true), 15000);
|
|
330
|
+
this.logger.log('[VoiceService] done – TTS still pending, waiting for all sources to end', { activeTtsSources: this._activeTtsSources });
|
|
331
|
+
} else {
|
|
332
|
+
// No audio sources pending — playback was already complete (or audio was empty).
|
|
333
|
+
// Signal the proxy synchronously; mic stays muted until the proxy confirms
|
|
334
|
+
// LISTENING via the 'listening' event.
|
|
335
|
+
this.logger.log('[VoiceService] done – no pending TTS, sending playback complete immediately');
|
|
336
|
+
this.voiceStreaming.sendPlaybackComplete();
|
|
337
|
+
// Do NOT unblock acquisition here — proxy will send 'listening' which is
|
|
338
|
+
// the single source of truth for unblocking both UI and mic.
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
case 'barge_in':
|
|
342
|
+
// Proxy's VAD detected user speech while the bot was talking — stop TTS immediately.
|
|
343
|
+
// Do NOT send tts_playback_complete; this is an interruption, not a normal completion.
|
|
344
|
+
// The proxy will follow with { event: "listening" } which authoritatively unblocks the UI.
|
|
345
|
+
// Audio was never muted, so there is nothing to unmute.
|
|
346
|
+
this._cancelAllTtsAudio();
|
|
347
|
+
this.ttsNextPlayTime = 0;
|
|
348
|
+
this._unblockAfterTts = false;
|
|
349
|
+
this._isAcquisitionBlocked$.next(false);
|
|
350
|
+
this.logger.log('[VoiceService] barge_in – TTS cancelled, acquisition unblocked');
|
|
351
|
+
break;
|
|
352
|
+
case 'error': {
|
|
353
|
+
const errorMsg = typeof msg.message === 'string' ? msg.message : 'Voice session error';
|
|
354
|
+
this.logger.error('[VoiceService] WSS error', errorMsg);
|
|
355
|
+
this._wsError$.next(errorMsg);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
default:
|
|
359
|
+
this.logger.warn('[VoiceService] unhandled ws-control event', msg.event);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Chunk TTS: ogni buffer deve essere decodificabile da `decodeAudioData` (es. segmento WebM/Opus completo). */
|
|
365
|
+
private async playWsTtsChunk(buf: ArrayBuffer): Promise<void> {
|
|
366
|
+
// Capture the current generation BEFORE the synchronous increment so that
|
|
367
|
+
// if _cancelAllTtsAudio() fires (incrementing _ttsGeneration) while this
|
|
368
|
+
// decode is in-flight, the mismatch is detected and the stale chunk is discarded.
|
|
369
|
+
const capturedGeneration = this._ttsGeneration;
|
|
370
|
+
// Increment SYNCHRONOUSLY before any await so the 'done' event handler (which arrives
|
|
371
|
+
// on the next WebSocket message — a different event-loop tick) sees a non-zero count.
|
|
372
|
+
this._activeTtsSources++;
|
|
373
|
+
this.logger.log('[VoiceService] TTS chunk received', { bytes: buf.byteLength, activeTtsSources: this._activeTtsSources });
|
|
374
|
+
try {
|
|
375
|
+
if (!this.ttsPlayContext || this.ttsPlayContext.state === 'closed') {
|
|
376
|
+
this.ttsPlayContext = new AudioContext();
|
|
377
|
+
this.ttsNextPlayTime = this.ttsPlayContext.currentTime;
|
|
378
|
+
}
|
|
379
|
+
const ctx = this.ttsPlayContext;
|
|
380
|
+
const audioBuf = await ctx.decodeAudioData(buf.slice(0));
|
|
381
|
+
// Stale-chunk guard: barge_in or a new speaking event called _cancelAllTtsAudio()
|
|
382
|
+
// which incremented _ttsGeneration. Discard this decoded buffer so no audio plays
|
|
383
|
+
// for a turn that was already cancelled, and undo the counter increment.
|
|
384
|
+
if (this._ttsGeneration !== capturedGeneration) {
|
|
385
|
+
this._activeTtsSources = Math.max(0, this._activeTtsSources - 1);
|
|
386
|
+
this.logger.log('[VoiceService] TTS chunk discarded – stale generation', { capturedGeneration, currentGeneration: this._ttsGeneration });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const src = ctx.createBufferSource();
|
|
390
|
+
src.buffer = audioBuf;
|
|
391
|
+
src.connect(ctx.destination);
|
|
392
|
+
const t0 = Math.max(ctx.currentTime, this.ttsNextPlayTime);
|
|
393
|
+
src.start(t0);
|
|
394
|
+
this.ttsNextPlayTime = t0 + audioBuf.duration;
|
|
395
|
+
this._activeTtsSourceNodes.push(src);
|
|
396
|
+
this.logger.log('[VoiceService] TTS chunk scheduled', { durationS: audioBuf.duration.toFixed(3), startsAtS: t0.toFixed(3), activeTtsSources: this._activeTtsSources });
|
|
397
|
+
src.onended = () => this._onTtsSourceEnded(src);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
this._onTtsSourceEnded();
|
|
400
|
+
this.logger.warn('[VoiceService] TTS chunk decode failed', e);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private _onTtsSourceEnded(src?: AudioBufferSourceNode): void {
|
|
405
|
+
this._activeTtsSources = Math.max(0, this._activeTtsSources - 1);
|
|
406
|
+
if (src) {
|
|
407
|
+
const idx = this._activeTtsSourceNodes.indexOf(src);
|
|
408
|
+
if (idx !== -1) { this._activeTtsSourceNodes.splice(idx, 1); }
|
|
409
|
+
}
|
|
410
|
+
this.logger.log('[VoiceService] TTS source ended', { activeTtsSources: this._activeTtsSources, unblockPending: this._unblockAfterTts });
|
|
411
|
+
if (this._unblockAfterTts && this._activeTtsSources === 0) {
|
|
412
|
+
this._flushTtsUnblock(false);
|
|
413
|
+
}
|
|
101
414
|
}
|
|
102
415
|
|
|
103
416
|
/**
|
|
104
|
-
*
|
|
417
|
+
* Immediately stops all currently playing/scheduled TTS audio sources.
|
|
418
|
+
* Called when a new `speaking` event arrives (new bot turn) to prevent overlap with
|
|
419
|
+
* the previous turn's audio, and during `stopSession`.
|
|
420
|
+
* Clears `onended` callbacks BEFORE stopping so that `_onTtsSourceEnded` is NOT
|
|
421
|
+
* invoked for cancelled nodes (avoiding spurious `sendPlaybackComplete` calls).
|
|
422
|
+
* Also increments `_ttsGeneration` so any in-flight `decodeAudioData` promises
|
|
423
|
+
* can detect that their result is stale and discard the decoded buffer.
|
|
105
424
|
*/
|
|
106
|
-
|
|
425
|
+
private _cancelAllTtsAudio(): void {
|
|
426
|
+
this._ttsGeneration++;
|
|
427
|
+
if (this._unblockSafetyTimer !== null) {
|
|
428
|
+
clearTimeout(this._unblockSafetyTimer);
|
|
429
|
+
this._unblockSafetyTimer = null;
|
|
430
|
+
}
|
|
431
|
+
for (const src of this._activeTtsSourceNodes) {
|
|
432
|
+
src.onended = null;
|
|
433
|
+
try { src.stop(); } catch { /* already ended — ignore */ }
|
|
434
|
+
}
|
|
435
|
+
this._activeTtsSourceNodes = [];
|
|
436
|
+
this._activeTtsSources = 0;
|
|
437
|
+
this._unblockAfterTts = false;
|
|
438
|
+
this._stopTtsKaraoke(true);
|
|
439
|
+
this.logger.log('[VoiceService] TTS cancelled – all audio sources stopped');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private _flushTtsUnblock(fromSafetyTimer = false): void {
|
|
443
|
+
this._unblockAfterTts = false;
|
|
444
|
+
this._activeTtsSources = 0;
|
|
445
|
+
if (this._unblockSafetyTimer !== null) {
|
|
446
|
+
clearTimeout(this._unblockSafetyTimer);
|
|
447
|
+
this._unblockSafetyTimer = null;
|
|
448
|
+
}
|
|
449
|
+
if (fromSafetyTimer) {
|
|
450
|
+
this.logger.warn('[VoiceService] TTS unblock: safety timer fired – forcing playback complete');
|
|
451
|
+
} else {
|
|
452
|
+
this.logger.log('[VoiceService] TTS unblock: all sources ended, sending playback complete');
|
|
453
|
+
}
|
|
454
|
+
this._stopTtsKaraoke(true);
|
|
455
|
+
// Signal the proxy that TTS playback is complete. The proxy will transition
|
|
456
|
+
// to LISTENING and send a 'listening' event back; the mic is unmuted there
|
|
457
|
+
// (not here) so it is live only when the proxy is confirmed ready.
|
|
458
|
+
// Do NOT call _isAcquisitionBlocked$.next(false) here — 'listening' is the
|
|
459
|
+
// single source of truth so that UI and mic unblock atomically.
|
|
460
|
+
this.voiceStreaming.sendPlaybackComplete();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── WSS TTS Karaoke helpers ───────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
private _startTtsKaraoke(text: string): void {
|
|
466
|
+
this._stopTtsKaraoke(false);
|
|
467
|
+
this._kText = text;
|
|
468
|
+
const rawWords = text.trim().split(/\s+/).filter((w) => w.length > 0);
|
|
469
|
+
if (rawWords.length === 0) return;
|
|
470
|
+
// ~140 WPM → ~0.43 s/word (same estimate as audio-sync)
|
|
471
|
+
const duration = Math.max(1, rawWords.length * 0.43);
|
|
472
|
+
this._kDuration = duration;
|
|
473
|
+
const step = duration / rawWords.length;
|
|
474
|
+
this._kWords = rawWords.map((w, i) => ({
|
|
475
|
+
text: w,
|
|
476
|
+
start: i * step,
|
|
477
|
+
end: (i + 1) * step,
|
|
478
|
+
state: 'future' as const,
|
|
479
|
+
}));
|
|
480
|
+
this._kStartContextTime = this.ttsPlayContext?.currentTime ?? 0;
|
|
481
|
+
this._kLastActiveIndex = -2;
|
|
482
|
+
this._rafKaraokeLoop();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private _stopTtsKaraoke(markAllPast: boolean): void {
|
|
486
|
+
if (this._kRafId !== undefined) {
|
|
487
|
+
cancelAnimationFrame(this._kRafId);
|
|
488
|
+
this._kRafId = undefined;
|
|
489
|
+
}
|
|
490
|
+
if (markAllPast && this._kWords.length > 0) {
|
|
491
|
+
this._kWords.forEach((w) => { w.state = 'past'; });
|
|
492
|
+
this._voiceTtsKaraokeSubject.next({
|
|
493
|
+
text: this._kText,
|
|
494
|
+
words: this._kWords.map(({ text, state }) => ({ text, state })),
|
|
495
|
+
activeIndex: -1,
|
|
496
|
+
});
|
|
497
|
+
this._kWords = [];
|
|
498
|
+
this._kText = '';
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private _rafKaraokeLoop(): void {
|
|
503
|
+
const elapsed = (this.ttsPlayContext?.currentTime ?? 0) - this._kStartContextTime;
|
|
504
|
+
let activeIndex = -1;
|
|
505
|
+
|
|
506
|
+
this._kWords.forEach((w) => {
|
|
507
|
+
if (elapsed >= w.end) {
|
|
508
|
+
w.state = 'past';
|
|
509
|
+
} else if (elapsed >= w.start && elapsed < w.end) {
|
|
510
|
+
w.state = 'active';
|
|
511
|
+
activeIndex = this._kWords.indexOf(w);
|
|
512
|
+
} else {
|
|
513
|
+
w.state = 'future';
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (activeIndex !== this._kLastActiveIndex) {
|
|
518
|
+
this._kLastActiveIndex = activeIndex;
|
|
519
|
+
this._voiceTtsKaraokeSubject.next({
|
|
520
|
+
text: this._kText,
|
|
521
|
+
words: this._kWords.map(({ text, state }) => ({ text, state })),
|
|
522
|
+
activeIndex,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (elapsed < this._kDuration) {
|
|
527
|
+
this._kRafId = requestAnimationFrame(() => this._rafKaraokeLoop());
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
async stopSession(options?: { discardInProgressSegment?: boolean}): Promise<{ voiceIngressResultUrl: string | null }> {
|
|
107
534
|
const discard = options?.discardInProgressSegment === true;
|
|
108
535
|
|
|
536
|
+
this.wsControlSub?.unsubscribe();
|
|
537
|
+
this.wsControlSub = undefined;
|
|
538
|
+
this.ttsChunkSub?.unsubscribe();
|
|
539
|
+
this.ttsChunkSub = undefined;
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
if (this.ttsPlayContext && this.ttsPlayContext.state !== 'closed') {
|
|
543
|
+
await this.ttsPlayContext.close();
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
/* ignore */
|
|
547
|
+
}
|
|
548
|
+
this._cancelAllTtsAudio();
|
|
549
|
+
this.ttsPlayContext = undefined;
|
|
550
|
+
this.ttsNextPlayTime = 0;
|
|
551
|
+
|
|
552
|
+
let voiceIngressResultUrl: string | null = null;
|
|
553
|
+
if (this.voiceIngressConfig) {
|
|
554
|
+
try {
|
|
555
|
+
const { resultUrl } = await this.voiceStreaming.stop({discard: true, awaitServerResultUrl: true});
|
|
556
|
+
voiceIngressResultUrl = resultUrl ?? null;
|
|
557
|
+
} catch (e) {
|
|
558
|
+
this.logger.log('[VoiceService] stopSession voiceStreaming.stop', e);
|
|
559
|
+
}
|
|
560
|
+
this._isWssVoiceActive$.next(false);
|
|
561
|
+
this.voiceIngressConfig = null;
|
|
562
|
+
}
|
|
563
|
+
|
|
109
564
|
if (this.mediaRecorder) {
|
|
110
565
|
if (discard) {
|
|
111
566
|
this.mediaRecorder.onstop = null;
|
|
@@ -143,6 +598,19 @@ export class VoiceService {
|
|
|
143
598
|
this.volumeSubject.next(0);
|
|
144
599
|
|
|
145
600
|
this.onRecordingComplete = undefined;
|
|
601
|
+
|
|
602
|
+
// 🎙️ release TTS gate subscription
|
|
603
|
+
this.ttsGateSub?.unsubscribe();
|
|
604
|
+
this.ttsGateSub = undefined;
|
|
605
|
+
this.isTTSActive = false;
|
|
606
|
+
|
|
607
|
+
// 🚫 clear acquisition gate
|
|
608
|
+
clearTimeout(this.responseTimeoutId);
|
|
609
|
+
this.responseTimeoutId = undefined;
|
|
610
|
+
this.isWaitingForResponse = false;
|
|
611
|
+
this._isAcquisitionBlocked$.next(false);
|
|
612
|
+
|
|
613
|
+
return { voiceIngressResultUrl };
|
|
146
614
|
}
|
|
147
615
|
|
|
148
616
|
/**
|
|
@@ -150,6 +618,9 @@ export class VoiceService {
|
|
|
150
618
|
* Lo stream resta in ascolto per il prossimo `onSpeechStart`.
|
|
151
619
|
*/
|
|
152
620
|
discardCurrentRecordingSegment(): void {
|
|
621
|
+
if (!this.vad) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
153
624
|
if (this.mediaRecorder) {
|
|
154
625
|
this.mediaRecorder.onstop = null;
|
|
155
626
|
this.mediaRecorder.ondataavailable = null;
|
|
@@ -159,13 +630,45 @@ export class VoiceService {
|
|
|
159
630
|
}
|
|
160
631
|
this.mediaRecorder = undefined;
|
|
161
632
|
this.audioChunks = [];
|
|
162
|
-
this.logger.log('[VoiceService] discarded in-progress segment
|
|
633
|
+
this.logger.log('[VoiceService] discarded in-progress segment (legacy VAD)');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* 🔄 RESUME VAD AFTER RESPONSE
|
|
638
|
+
* Called when isTTSPlaying$ goes false while isWaitingForResponse is true,
|
|
639
|
+
* or by the safety timeout if no TTS response arrives within 30 s.
|
|
640
|
+
*/
|
|
641
|
+
private resumeVadAfterResponse(): void {
|
|
642
|
+
this.isWaitingForResponse = false;
|
|
643
|
+
clearTimeout(this.responseTimeoutId);
|
|
644
|
+
this.responseTimeoutId = undefined;
|
|
645
|
+
this._isAcquisitionBlocked$.next(false);
|
|
646
|
+
if (this.vad) {
|
|
647
|
+
this.vad.start().catch((e) => this.logger.log('[VoiceService] VAD resume error', e));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* ⏱️ SAFETY TIMEOUT
|
|
653
|
+
* Forces VAD re-enable after 30 s in case no TTS response ever arrives.
|
|
654
|
+
*/
|
|
655
|
+
private setResponseSafetyTimeout(): void {
|
|
656
|
+
clearTimeout(this.responseTimeoutId);
|
|
657
|
+
this.responseTimeoutId = setTimeout(() => {
|
|
658
|
+
this.logger.log('[VoiceService] safety timeout: re-enabling VAD acquisition');
|
|
659
|
+
this.resumeVadAfterResponse();
|
|
660
|
+
}, 30_000);
|
|
163
661
|
}
|
|
164
662
|
|
|
165
663
|
/**
|
|
166
664
|
* 🎧 AUDIO ANALYSER INIT
|
|
167
665
|
*/
|
|
168
666
|
private initAudioAnalyser(stream: MediaStream): void {
|
|
667
|
+
if (!stream?.getAudioTracks?.()?.length) {
|
|
668
|
+
this.logger.log('[VoiceService] initAudioAnalyser: no audio track on stream, skipping analyser');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
169
672
|
this.audioContext = new AudioContext();
|
|
170
673
|
|
|
171
674
|
const source = this.audioContext.createMediaStreamSource(stream);
|
|
@@ -285,10 +788,16 @@ export class VoiceService {
|
|
|
285
788
|
* 📡 EMIT RESULT
|
|
286
789
|
*/
|
|
287
790
|
private emitSegmentPayload(payload: VoiceSegmentPayload): void {
|
|
288
|
-
this.
|
|
791
|
+
if (this.isTTSActive) {
|
|
792
|
+
this.logger.log('[VoiceService] segment suppressed — TTS is playing');
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
this.logger.log('[VoiceService] segment ready', payload.transcript ?? payload.transcriptionError ?? payload.blob.size);
|
|
289
797
|
|
|
290
798
|
this.audioSegmentSubject.next(payload);
|
|
291
799
|
|
|
292
800
|
this.onRecordingComplete?.(payload);
|
|
293
801
|
}
|
|
802
|
+
|
|
294
803
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
--chat-footer-logo-height: 30px;
|
|
39
39
|
--chat-footer-close-button-height: 30px;
|
|
40
40
|
--chat-footer-border-radius: 16px;
|
|
41
|
-
--chat-footer-stream-button-height:
|
|
41
|
+
--chat-footer-stream-button-height: 72px;
|
|
42
42
|
--chat-footer-stream-button-padding: 10px 0;
|
|
43
43
|
--chat-footer-background-color: #f6f7fb;
|
|
44
44
|
--chat-footer-color: #1a1a1a;
|
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
$trasp-black: rgba(0, 0, 0, 0.8);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Respect the user's reduced-motion preference (WCAG 2.3.3 Animation from Interactions).
|
|
5
|
+
* When `prefers-reduced-motion: reduce` is active, neutralize all CSS animations and transitions
|
|
6
|
+
* inside the widget so users do not experience non-essential motion.
|
|
7
|
+
*/
|
|
8
|
+
@media (prefers-reduced-motion: reduce) {
|
|
9
|
+
chat-root *,
|
|
10
|
+
chat-root *::before,
|
|
11
|
+
chat-root *::after {
|
|
12
|
+
animation-duration: 0.001ms !important;
|
|
13
|
+
animation-iteration-count: 1 !important;
|
|
14
|
+
animation-delay: 0ms !important;
|
|
15
|
+
transition-duration: 0.001ms !important;
|
|
16
|
+
transition-delay: 0ms !important;
|
|
17
|
+
scroll-behavior: auto !important;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
/**
|
|
4
22
|
* ----------------------------------------
|