@chat21/chat21-web-widget 5.1.32-rc9 → 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 +54 -4
  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 +17 -7
  93. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +15 -3
  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 +192 -84
  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 -18
  111. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +56 -2
  112. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +135 -5
  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 -0
  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 +133 -86
  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 +39 -16
  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 +517 -13
  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,29 +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();
35
-
44
+
36
45
  private readonly speechStartSubject = new Subject<void>();
37
46
  /** Emesso quando il microfono intercetta parlato (VAD speech start). */
38
47
  readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
39
48
 
40
- // 🔊 REALTIME VOLUME STREAM
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();
65
+
41
66
  private readonly volumeSubject = new BehaviorSubject<number>(0);
42
67
  readonly volume$: Observable<number> = this.volumeSubject.asObservable();
43
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
+
44
107
  // 🎧 AUDIO ANALYSER
45
108
  private audioContext?: AudioContext;
46
109
  private analyser?: AnalyserNode;
47
110
  /** Buffer dedicato (`ArrayBuffer`) per compatibilità con `getByteFrequencyData`. */
48
111
  private dataArray?: Uint8Array;
49
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
+
50
144
  private readonly logger: LoggerService = LoggerInstance.getInstance();
51
145
 
52
146
  constructor(
53
147
  private readonly vadService: VadService,
148
+ private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
149
+ private readonly voiceStreaming: VoiceStreamingService,
54
150
  @Optional() @Inject(SpeechToTextProvider) private readonly speechToText: SpeechToTextProvider | null,
55
151
  ) {}
56
152
 
@@ -58,6 +154,20 @@ export class VoiceService {
58
154
  return !!this.vad || !!this.stream;
59
155
  }
60
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
+
61
171
  /**
62
172
  * Richiede il microfono, avvia VAD in ascolto (inizio/fine parlato) e registra in WebM per segmento.
63
173
  */
@@ -67,13 +177,56 @@ export class VoiceService {
67
177
  this.sessionConstraints = options.constraints ?? DEFAULT_VOICE_MEDIA_STREAM_CONSTRAINTS;
68
178
  this.onRecordingComplete = options.onRecordingComplete;
69
179
  this.enableTranscription = options.enableTranscription !== false;
180
+ this.voiceIngressConfig = options.voiceIngressStream;
70
181
 
71
- await this.vadService.ensureOnnxRuntimeEnv();
182
+ if (this.voiceIngressConfig) {
183
+ await this.startWssVoiceSession();
184
+ return;
185
+ }
186
+
187
+ await this.startLegacyVadSession(options);
188
+ }
72
189
 
190
+ /** Sessione guidata dal proxy: solo mic + volume + WSS (mic in upload, eventi + TTS in download). */
191
+ private async startWssVoiceSession(): Promise<void> {
73
192
  this.stream = await navigator.mediaDevices.getUserMedia(this.sessionConstraints);
74
193
 
75
194
  // 🎧 AUDIO ANALYSER INIT
76
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);
77
230
 
78
231
  const vadDefaults = getDefaultRealTimeVADOptions('legacy');
79
232
 
@@ -92,10 +245,16 @@ export class VoiceService {
92
245
  },
93
246
  onSpeechEnd: () => {
94
247
  this.logger.log('[VoiceService] speech end');
248
+ this.speechEndSubject.next();
95
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();
96
255
  },
97
256
  minSpeechMs: 480,
98
- redemptionMs: 1920,
257
+ redemptionMs: 800,//1920,
99
258
  preSpeechPadMs: 960,
100
259
  });
101
260
 
@@ -103,14 +262,305 @@ export class VoiceService {
103
262
 
104
263
  // 🔁 start volume loop
105
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
+ }
106
414
  }
107
415
 
108
416
  /**
109
- * @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.
110
424
  */
111
- 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 }> {
112
534
  const discard = options?.discardInProgressSegment === true;
113
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
+
114
564
  if (this.mediaRecorder) {
115
565
  if (discard) {
116
566
  this.mediaRecorder.onstop = null;
@@ -148,6 +598,19 @@ export class VoiceService {
148
598
  this.volumeSubject.next(0);
149
599
 
150
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 };
151
614
  }
152
615
 
153
616
  /**
@@ -155,6 +618,9 @@ export class VoiceService {
155
618
  * Lo stream resta in ascolto per il prossimo `onSpeechStart`.
156
619
  */
157
620
  discardCurrentRecordingSegment(): void {
621
+ if (!this.vad) {
622
+ return;
623
+ }
158
624
  if (this.mediaRecorder) {
159
625
  this.mediaRecorder.onstop = null;
160
626
  this.mediaRecorder.ondataavailable = null;
@@ -164,13 +630,45 @@ export class VoiceService {
164
630
  }
165
631
  this.mediaRecorder = undefined;
166
632
  this.audioChunks = [];
167
- 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);
168
661
  }
169
662
 
170
663
  /**
171
664
  * 🎧 AUDIO ANALYSER INIT
172
665
  */
173
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
+
174
672
  this.audioContext = new AudioContext();
175
673
 
176
674
  const source = this.audioContext.createMediaStreamSource(stream);
@@ -290,10 +788,16 @@ export class VoiceService {
290
788
  * 📡 EMIT RESULT
291
789
  */
292
790
  private emitSegmentPayload(payload: VoiceSegmentPayload): void {
293
- 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);
294
797
 
295
798
  this.audioSegmentSubject.next(payload);
296
799
 
297
800
  this.onRecordingComplete?.(payload);
298
801
  }
802
+
299
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: 50px;
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
  * ----------------------------------------