@chat21/chat21-web-widget 5.1.34 → 5.2.1

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