@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.
Files changed (246) 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/.playwright-mcp/console-2026-05-08T15-31-09-000Z.log +17 -0
  6. package/.playwright-mcp/console-2026-05-08T15-32-19-412Z.log +89 -0
  7. package/.playwright-mcp/console-2026-05-08T16-18-48-424Z.log +133 -0
  8. package/.playwright-mcp/console-2026-05-11T12-54-06-869Z.log +13 -0
  9. package/.playwright-mcp/console-2026-05-11T12-54-56-229Z.log +147 -0
  10. package/.playwright-mcp/console-2026-05-11T12-55-47-174Z.log +183 -0
  11. package/.playwright-mcp/console-2026-05-11T15-34-03-590Z.log +210 -0
  12. package/.playwright-mcp/console-2026-05-12T15-07-31-880Z.log +118 -0
  13. package/.playwright-mcp/page-2026-05-08T15-32-19-900Z.yml +851 -0
  14. package/.playwright-mcp/page-2026-05-08T15-32-47-264Z.yml +857 -0
  15. package/.playwright-mcp/page-2026-05-08T15-33-17-089Z.yml +1110 -0
  16. package/.playwright-mcp/page-2026-05-08T15-33-23-486Z.yml +1069 -0
  17. package/.playwright-mcp/page-2026-05-08T15-33-45-390Z.yml +1076 -0
  18. package/.playwright-mcp/page-2026-05-08T15-33-52-666Z.yml +1072 -0
  19. package/.playwright-mcp/page-2026-05-08T15-34-01-338Z.yml +1085 -0
  20. package/.playwright-mcp/page-2026-05-08T15-34-07-227Z.yml +1072 -0
  21. package/.playwright-mcp/page-2026-05-08T15-34-13-875Z.yml +1072 -0
  22. package/.playwright-mcp/page-2026-05-08T15-34-21-885Z.yml +1109 -0
  23. package/.playwright-mcp/page-2026-05-08T15-34-32-755Z.yml +1109 -0
  24. package/.playwright-mcp/page-2026-05-08T15-35-09-607Z.yml +1119 -0
  25. package/.playwright-mcp/page-2026-05-08T15-35-14-242Z.yml +1109 -0
  26. package/.playwright-mcp/page-2026-05-08T16-18-48-671Z.yml +44 -0
  27. package/.playwright-mcp/page-2026-05-08T16-18-52-753Z.png +0 -0
  28. package/.playwright-mcp/page-2026-05-08T16-19-13-919Z.yml +68 -0
  29. package/.playwright-mcp/page-2026-05-08T16-19-17-977Z.png +0 -0
  30. package/.playwright-mcp/page-2026-05-08T16-19-25-733Z.yml +120 -0
  31. package/.playwright-mcp/page-2026-05-08T16-19-29-252Z.png +0 -0
  32. package/.playwright-mcp/page-2026-05-08T16-19-39-269Z.yml +80 -0
  33. package/.playwright-mcp/page-2026-05-08T16-19-43-915Z.png +0 -0
  34. package/.playwright-mcp/page-2026-05-08T16-20-04-407Z.yml +81 -0
  35. package/.playwright-mcp/page-2026-05-08T16-20-08-984Z.png +0 -0
  36. package/.playwright-mcp/page-2026-05-08T16-20-32-397Z.png +0 -0
  37. package/.playwright-mcp/page-2026-05-08T16-20-58-658Z.png +0 -0
  38. package/.playwright-mcp/page-2026-05-08T16-21-12-320Z.yml +86 -0
  39. package/.playwright-mcp/page-2026-05-08T16-21-39-154Z.yml +91 -0
  40. package/.playwright-mcp/page-2026-05-08T16-21-45-420Z.png +0 -0
  41. package/.playwright-mcp/page-2026-05-08T16-22-21-062Z.yml +0 -0
  42. package/.playwright-mcp/page-2026-05-08T16-22-58-232Z.yml +91 -0
  43. package/.playwright-mcp/page-2026-05-08T16-23-36-520Z.yml +0 -0
  44. package/.playwright-mcp/page-2026-05-08T16-23-46-805Z.yml +100 -0
  45. package/.playwright-mcp/page-2026-05-08T16-23-55-169Z.png +0 -0
  46. package/.playwright-mcp/page-2026-05-08T16-24-26-574Z.yml +91 -0
  47. package/.playwright-mcp/page-2026-05-08T16-25-34-414Z.png +0 -0
  48. package/.playwright-mcp/page-2026-05-08T16-25-59-831Z.png +0 -0
  49. package/.playwright-mcp/page-2026-05-08T16-26-21-809Z.yml +91 -0
  50. package/.playwright-mcp/page-2026-05-08T16-26-47-443Z.yml +105 -0
  51. package/.playwright-mcp/page-2026-05-08T16-26-56-136Z.png +0 -0
  52. package/.playwright-mcp/page-2026-05-08T16-27-59-610Z.yml +48 -0
  53. package/.playwright-mcp/page-2026-05-11T12-54-07-180Z.yml +44 -0
  54. package/.playwright-mcp/page-2026-05-11T12-54-56-946Z.yml +4 -0
  55. package/.playwright-mcp/page-2026-05-11T12-55-47-503Z.yml +24 -0
  56. package/.playwright-mcp/page-2026-05-11T12-56-00-766Z.yml +28 -0
  57. package/.playwright-mcp/page-2026-05-11T12-56-06-438Z.yml +90 -0
  58. package/.playwright-mcp/page-2026-05-11T12-57-56-838Z.yml +106 -0
  59. package/.playwright-mcp/page-2026-05-11T12-58-00-124Z.yml +106 -0
  60. package/.playwright-mcp/page-2026-05-11T12-59-08-836Z.yml +61 -0
  61. package/.playwright-mcp/page-2026-05-11T12-59-12-088Z.yml +61 -0
  62. package/.playwright-mcp/page-2026-05-11T12-59-26-215Z.yml +69 -0
  63. package/.playwright-mcp/page-2026-05-11T12-59-29-519Z.yml +69 -0
  64. package/.playwright-mcp/page-2026-05-11T12-59-37-309Z.yml +0 -0
  65. package/.playwright-mcp/page-2026-05-11T12-59-39-968Z.yml +79 -0
  66. package/.playwright-mcp/page-2026-05-11T12-59-45-983Z.yml +78 -0
  67. package/.playwright-mcp/page-2026-05-11T12-59-49-951Z.yml +78 -0
  68. package/.playwright-mcp/page-2026-05-11T15-34-04-515Z.yml +0 -0
  69. package/.playwright-mcp/page-2026-05-12T15-07-32-171Z.yml +44 -0
  70. package/.playwright-mcp/page-2026-05-12T15-08-09-820Z.yml +119 -0
  71. package/CHANGELOG.md +61 -3
  72. package/angular.json +20 -3
  73. package/deploy_amazon_beta.sh +7 -17
  74. package/deploy_amazon_prod.sh +41 -0
  75. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +379 -0
  76. package/env.sample +3 -2
  77. package/mocks/voice-websocket-mock/server.cjs +245 -0
  78. package/package.json +7 -3
  79. package/playwright-report/index.html +90 -0
  80. package/playwright.config.ts +41 -0
  81. package/src/app/app.component.html +2 -2
  82. package/src/app/app.component.scss +25 -14
  83. package/src/app/app.component.spec.ts +21 -6
  84. package/src/app/app.module.ts +4 -0
  85. package/src/app/component/conversation-detail/conversation/conversation.component.html +19 -11
  86. package/src/app/component/conversation-detail/conversation/conversation.component.scss +28 -0
  87. package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
  88. package/src/app/component/conversation-detail/conversation/conversation.component.ts +61 -17
  89. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
  90. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
  91. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
  92. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +22 -9
  93. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +23 -1
  94. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +249 -149
  95. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +0 -1
  96. package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
  97. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component copy.html +172 -0
  98. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +112 -62
  99. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +133 -7
  100. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +452 -78
  101. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +193 -79
  102. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
  103. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
  104. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
  105. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
  106. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
  107. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
  108. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
  109. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
  110. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +43 -19
  111. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +63 -10
  112. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +142 -12
  113. package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
  114. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
  115. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
  116. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
  117. package/src/app/component/form/form-builder/form-builder.component.html +1 -1
  118. package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
  119. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
  120. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
  121. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
  122. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
  123. package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
  124. package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
  125. package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
  126. package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
  127. package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
  128. package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
  129. package/src/app/component/form/inputs/form-text/form-text.component.ts +26 -0
  130. package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
  131. package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
  132. package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
  133. package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
  134. package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
  135. package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
  136. package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
  137. package/src/app/component/form/prechat-form-test-mock.ts +35 -0
  138. package/src/app/component/home/home.component.html +38 -31
  139. package/src/app/component/home/home.component.scss +4 -2
  140. package/src/app/component/home/home.component.spec.ts +226 -11
  141. package/src/app/component/home-conversations/home-conversations.component.html +30 -26
  142. package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
  143. package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
  144. package/src/app/component/last-message/last-message.component.html +15 -9
  145. package/src/app/component/last-message/last-message.component.scss +16 -2
  146. package/src/app/component/last-message/last-message.component.spec.ts +204 -23
  147. package/src/app/component/launcher-button/launcher-button.component.html +8 -13
  148. package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
  149. package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
  150. package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
  151. package/src/app/component/list-conversations/list-conversations.component.html +22 -22
  152. package/src/app/component/menu-options/menu-options.component.html +30 -20
  153. package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
  154. package/src/app/component/message/audio/audio.component.html +13 -15
  155. package/src/app/component/message/audio/audio.component.spec.ts +140 -5
  156. package/src/app/component/message/audio/audio.component.ts +1 -0
  157. package/src/app/component/message/audio-sync/audio-sync.component.scss +1 -1
  158. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +81 -1
  159. package/src/app/component/message/audio-sync/audio-sync.component.ts +134 -24
  160. package/src/app/component/message/avatar/avatar.component.html +2 -2
  161. package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
  162. package/src/app/component/message/bubble-message/bubble-message.component.html +39 -52
  163. package/src/app/component/message/bubble-message/bubble-message.component.scss +54 -1
  164. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +154 -57
  165. package/src/app/component/message/bubble-message/bubble-message.component.ts +138 -110
  166. package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
  167. package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
  168. package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
  169. package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
  170. package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
  171. package/src/app/component/message/carousel/carousel.component.html +29 -16
  172. package/src/app/component/message/carousel/carousel.component.scss +20 -8
  173. package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
  174. package/src/app/component/message/carousel/carousel.component.ts +16 -0
  175. package/src/app/component/message/frame/frame.component.html +9 -4
  176. package/src/app/component/message/frame/frame.component.spec.ts +34 -15
  177. package/src/app/component/message/frame/frame.component.ts +7 -2
  178. package/src/app/component/message/html/html.component.html +1 -1
  179. package/src/app/component/message/html/html.component.scss +1 -1
  180. package/src/app/component/message/html/html.component.spec.ts +24 -7
  181. package/src/app/component/message/image/image.component.html +12 -10
  182. package/src/app/component/message/image/image.component.scss +16 -0
  183. package/src/app/component/message/image/image.component.spec.ts +101 -15
  184. package/src/app/component/message/image/image.component.ts +90 -51
  185. package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
  186. package/src/app/component/message/json-sources/json-sources.component.html +38 -0
  187. package/src/app/component/message/json-sources/json-sources.component.scss +197 -0
  188. package/src/app/component/message/json-sources/json-sources.component.ts +89 -0
  189. package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
  190. package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
  191. package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
  192. package/src/app/component/message/text/text.component.html +3 -3
  193. package/src/app/component/message/text/text.component.scss +80 -86
  194. package/src/app/component/message/text/text.component.spec.ts +106 -13
  195. package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
  196. package/src/app/component/selection-department/selection-department.component.html +21 -23
  197. package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
  198. package/src/app/component/selection-department/selection-department.component.ts +8 -1
  199. package/src/app/component/send-button/send-button.component.html +5 -13
  200. package/src/app/component/send-button/send-button.component.spec.ts +2 -2
  201. package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
  202. package/src/app/directives/tooltip.directive.spec.ts +8 -4
  203. package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
  204. package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
  205. package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
  206. package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
  207. package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
  208. package/src/app/pipe/marked.pipe.spec.ts +38 -2
  209. package/src/app/providers/app-config.service.ts +4 -2
  210. package/src/app/providers/brand.service.spec.ts +23 -2
  211. package/src/app/providers/brand.service.ts +1 -1
  212. package/src/app/providers/global-settings.service.spec.ts +1009 -14
  213. package/src/app/providers/global-settings.service.ts +30 -2
  214. package/src/app/providers/json-sources-parser.service.ts +182 -0
  215. package/src/app/providers/translator.service.ts +24 -6
  216. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +117 -0
  217. package/src/app/providers/tts-audio-playback-coordinator.service.ts +45 -7
  218. package/src/app/providers/url-preview.service.ts +82 -0
  219. package/src/app/providers/voice/audio.types.ts +6 -0
  220. package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
  221. package/src/app/providers/voice/voice-streaming.service.ts +710 -0
  222. package/src/app/providers/voice/voice-streaming.types.ts +113 -0
  223. package/src/app/providers/voice/voice.service.spec.ts +203 -3
  224. package/src/app/providers/voice/voice.service.ts +521 -12
  225. package/src/app/sass/_variables.scss +1 -1
  226. package/src/app/sass/animations.scss +19 -1
  227. package/src/app/utils/globals.ts +4 -0
  228. package/src/app/utils/json-sources-utils.ts +27 -0
  229. package/src/app/utils/url-utils.ts +98 -0
  230. package/src/app/utils/utils-resources.ts +1 -1
  231. package/src/assets/i18n/en.json +26 -1
  232. package/src/assets/i18n/es.json +106 -101
  233. package/src/assets/i18n/fr.json +106 -101
  234. package/src/assets/i18n/it.json +106 -99
  235. package/src/assets/twp/index-dev.html +18 -0
  236. package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +14 -0
  237. package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
  238. package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
  239. package/src/chat21-core/utils/constants.ts +4 -0
  240. package/src/chat21-core/utils/utils-message.ts +23 -1
  241. package/src/widget-config-template.json +3 -1
  242. package/src/widget-config.json +28 -27
  243. package/test-results/.last-run.json +4 -0
  244. package/tests/widget-form-rich.spec.ts +67 -0
  245. package/tests/widget-index-dev-settings.spec.ts +52 -0
  246. 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
- * Voce: VadService (ONNX WASM) → MicVAD → MediaRecorder su ogni segmento parlato.
20
- * Opzionalmente STT (`SpeechToTextProvider`) arricchisce il payload con `transcript`.
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
- /** Emesso a ogni fine segmento parlato: audio WebM + opzionalmente `transcript` / `transcriptionError`. */
34
- readonly audioSegment$: Observable<VoiceSegmentPayload> = this.audioSegmentSubject.asObservable();
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
- await this.vadService.ensureOnnxRuntimeEnv();
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
- * @param options.discardInProgressSegment non inviare STT/upload per il segmento WebM corrente (es. interruzione da messaggio in arrivo).
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
- async stopSession(options?: { discardInProgressSegment?: boolean }): Promise<void> {
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; VAD session unchanged');
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.logger.log( '[VoiceService] segment ready', payload.transcript ?? payload.transcriptionError ?? payload.blob.size);
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: 96px;
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
- @import 'variables';
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
  * ----------------------------------------