@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
@@ -0,0 +1,710 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { Observable, Subject } from 'rxjs';
3
+ import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
4
+ import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service';
5
+ import { AppConfigService } from 'src/app/providers/app-config.service';
6
+
7
+ import {
8
+ VoiceStreamingConnectionState,
9
+ VoiceStreamingServerMessage,
10
+ VoiceStreamingSessionConfig,
11
+ VoiceStreamingStopOptions,
12
+ VoiceStreamingStopResult,
13
+ VoiceWsControlMessage,
14
+ } from './voice-streaming.types';
15
+
16
+ // Flux docs recommend 80ms chunks for optimal latency; 250ms is a practical
17
+ // balance for WebM containerization overhead in the browser.
18
+ // Source: https://developers.deepgram.com/docs/flux/quickstart
19
+ const DEFAULT_TIMESLICE_MS = 250;
20
+ const READY_TIMEOUT_MS = 10_000;
21
+ const SESSION_STARTED_TIMEOUT_MS = 10_000;
22
+
23
+ /** Ordered by preference; covers Chrome (webm), Firefox (ogg), Safari ≥14.1 (mp4). */
24
+ const PREFERRED_MIME_CANDIDATES = [
25
+ 'audio/webm;codecs=opus',
26
+ 'audio/ogg;codecs=opus',
27
+ 'audio/mp4',
28
+ 'audio/webm',
29
+ ] as const;
30
+
31
+ /**
32
+ * Connette al proxy voce (WSS), invia in streaming i chunk `MediaRecorder` (binario) come da contratto.
33
+ * Mantiene anche i chunk locali per costruire un `Blob` completo a fine registrazione (anteprima / invio legacy).
34
+ */
35
+ @Injectable({ providedIn: 'root' })
36
+ export class VoiceStreamingService {
37
+ private readonly logger: LoggerService = LoggerInstance.getInstance();
38
+ private readonly _state$ = new Subject<VoiceStreamingConnectionState>();
39
+ private readonly _serverMessage$ = new Subject<VoiceStreamingServerMessage>();
40
+ private readonly _wsControl$ = new Subject<VoiceWsControlMessage>();
41
+ private readonly _ttsBinaryChunk$ = new Subject<ArrayBuffer>();
42
+ private readonly _lastError$ = new Subject<unknown>();
43
+ private readonly _closeCode$ = new Subject<number>();
44
+
45
+ private ws: WebSocket | null = null;
46
+ private mediaStream: MediaStream | null = null;
47
+ private mediaRecorder: MediaRecorder | null = null;
48
+ private localChunks: Blob[] = [];
49
+ private currentMimeType = '';
50
+ private stopResolver: (() => void) | null = null;
51
+ /** Reject callback for a pending start() promise; called by cleanup() if cancelled mid-connect. */
52
+ private pendingStartFail: ((err: Error) => void) | null = null;
53
+ /** Stream esterno: non fermare le tracce in `cleanup` (le gestisce il chiamante, es. VoiceService). */
54
+ private streamSharedWithIngress = false;
55
+ /** When true, mic audio chunks are not forwarded to the proxy WebSocket. */
56
+ private _audioMuted = false;
57
+ private pendingSharedStream?: MediaStream;
58
+ private pendingConfig?: VoiceStreamingSessionConfig;
59
+ /** Session ID assigned by the proxy (from session_started payload). Used for log correlation. */
60
+ private currentSessionId: string | undefined;
61
+ /** Audio chunk counter — reset on each new session. */
62
+ private audioChunkCount = 0;
63
+ /** Total bytes sent — reset on each new session. */
64
+ private totalAudioBytesSent = 0;
65
+ /** Emits one debug log when the first chunk is dropped due to muting; reset on unmute. */
66
+ private _mutedDropLogged = false;
67
+
68
+ constructor(private readonly appConfig: AppConfigService) { }
69
+
70
+ readonly state$: Observable<VoiceStreamingConnectionState> = this._state$.asObservable();
71
+ readonly serverMessage$: Observable<VoiceStreamingServerMessage> = this._serverMessage$.asObservable();
72
+ /** Eventi JSON `msg.event` dal proxy (transcript, listening, speaking, …). */
73
+ readonly wsControl$: Observable<VoiceWsControlMessage> = this._wsControl$.asObservable();
74
+ /** Chunk audio TTS in arrivo (ArrayBuffer) — da decodificare / riprodurre. */
75
+ readonly ttsBinaryChunk$: Observable<ArrayBuffer> = this._ttsBinaryChunk$.asObservable();
76
+ readonly lastError$: Observable<unknown> = this._lastError$.asObservable();
77
+ /** Emette il close code WebSocket a ogni disconnessione (4401 = auth, 4400 = config, 1001 = server down, 1006 = network). */
78
+ readonly closeCode$: Observable<number> = this._closeCode$.asObservable();
79
+
80
+ /**
81
+ * Returns the HTTP(S) origin of the speech-proxy, derived from `voiceProxyWsBaseUrl`.
82
+ * `ws://host:port/...` → `http://host:port`
83
+ * `wss://host:port/...` → `https://host:port`
84
+ * Returns `null` when no proxy URL is configured.
85
+ */
86
+ get proxyHttpBaseUrl(): string | null {
87
+ return this.appConfig.getConfig()?.voiceProxyApiBaseUrl ?? '';
88
+ }
89
+
90
+ get connectionState(): VoiceStreamingConnectionState {
91
+ return this._currentState;
92
+ }
93
+
94
+ private _currentState: VoiceStreamingConnectionState = 'idle';
95
+ private setState(s: VoiceStreamingConnectionState): void {
96
+ this._currentState = s;
97
+ this._state$.next(s);
98
+ }
99
+
100
+ /**
101
+ * Apre la WSS, poi `MediaRecorder.start(timeslice)` e invia ogni chunk al socket.
102
+ * Con `sharedMediaStream` riusa lo stesso `MediaStream` del chiamante (es. VAD) senza seconda richiesta al mic.
103
+ */
104
+ async start(
105
+ config: VoiceStreamingSessionConfig,
106
+ opts?: { sharedMediaStream?: MediaStream },
107
+ ): Promise<void> {
108
+ await this.stop({ discard: true });
109
+ this.setState('connecting');
110
+ this.localChunks = [];
111
+ this.currentMimeType = '';
112
+ this.pendingSharedStream = opts?.sharedMediaStream;
113
+ this.pendingConfig = config;
114
+ this.streamSharedWithIngress = !!opts?.sharedMediaStream;
115
+
116
+ const baseUrl = this.resolveBaseUrl(config.wsBaseUrl);
117
+ const mime = this.resolveMimeType(config.mimeType);
118
+ const timeslice = config.timesliceMs ?? DEFAULT_TIMESLICE_MS;
119
+
120
+ const url = this.buildWebSocketUrl(baseUrl, {
121
+ ...config,
122
+ mimeType: mime,
123
+ });
124
+ this.logger.info('[VoiceStreaming] connecting', { url: this.redactQuery(url), mime: mime || '(auto)', timeslice });
125
+
126
+ return new Promise<void>((resolve, reject) => {
127
+ const socket = new WebSocket(url);
128
+ this.ws = socket;
129
+ socket.binaryType = 'arraybuffer';
130
+ let startSettled = false;
131
+
132
+ const fail = (err: unknown) => {
133
+ if (startSettled) return;
134
+ startSettled = true;
135
+ this.pendingStartFail = null;
136
+ this._lastError$.next(err);
137
+ this.setState('error');
138
+ this.logger.error('[VoiceStreaming] start failed', err);
139
+ this.cleanup();
140
+ reject(err instanceof Error ? err : new Error(String(err)));
141
+ };
142
+
143
+ const succeed = () => {
144
+ if (startSettled) return;
145
+ startSettled = true;
146
+ this.pendingStartFail = null;
147
+ resolve();
148
+ };
149
+
150
+ this.pendingStartFail = (err: Error) => fail(err);
151
+
152
+ socket.onerror = (ev) => {
153
+ if (this.ws === socket) {
154
+ this.logger.warn('[VoiceStreaming] socket error', ev);
155
+ fail(ev);
156
+ }
157
+ };
158
+
159
+ socket.onclose = (ev: CloseEvent) => {
160
+ if (this.ws === socket) {
161
+ this.logger.info('[VoiceStreaming] socket closed', { code: ev.code, reason: ev.reason || '(none)' });
162
+ this.ws = null;
163
+ this._closeCode$.next(ev.code);
164
+ if (this._currentState === 'streaming') {
165
+ // Socket closed while already streaming — stop the recorder and clean up.
166
+ this.cleanup();
167
+ this.setState('closed');
168
+ } else {
169
+ // Socket closed before streaming started (connecting/open state) — reject start().
170
+ const msg = ev.code === 4401 ? 'auth_failed'
171
+ : ev.code === 4400 ? 'config_error'
172
+ : `socket closed before streaming (code ${ev.code})`;
173
+ fail(new Error(msg));
174
+ }
175
+ }
176
+ };
177
+
178
+ socket.onmessage = (ev: MessageEvent) => {
179
+ if (ev.data instanceof ArrayBuffer) {
180
+ this.logger.debug('[VoiceStreaming] TTS binary chunk received', ev.data.byteLength, 'bytes');
181
+ this._ttsBinaryChunk$.next(ev.data);
182
+ return;
183
+ }
184
+ if (typeof ev.data === 'string') {
185
+ try {
186
+ const msg = JSON.parse(ev.data) as Record<string, unknown>;
187
+ if (typeof msg.event === 'string') {
188
+ this.logger.info('[VoiceStreaming] ←', msg.event);
189
+ if (msg.event === 'session_started' && typeof msg.sessionId === 'string') {
190
+ this.currentSessionId = msg.sessionId;
191
+ this.logger.info('[VoiceStreaming] proxy session ID:', this.currentSessionId);
192
+ }
193
+ this._wsControl$.next(msg as VoiceWsControlMessage);
194
+ }
195
+ } catch {
196
+ /* non JSON */
197
+ }
198
+ this._serverMessage$.next({ data: ev.data, isBinary: false });
199
+ }
200
+ };
201
+
202
+ socket.onopen = () => {
203
+ if (this.ws !== socket) {
204
+ return;
205
+ }
206
+ this.logger.info('[VoiceStreaming] socket open');
207
+ this.setState('open');
208
+ const cfg = this.pendingConfig!;
209
+ this.pendingConfig = undefined;
210
+ void this.beginRecordingAfterOpen(socket, cfg, mime, timeslice, succeed, fail);
211
+ };
212
+ });
213
+ }
214
+
215
+ private async beginRecordingAfterOpen(
216
+ socket: WebSocket,
217
+ config: VoiceStreamingSessionConfig,
218
+ mime: string,
219
+ timeslice: number,
220
+ resolve: () => void,
221
+ fail: (e: unknown) => void,
222
+ ): Promise<void> {
223
+ try {
224
+ // 1. Wait for the proxy's "ready" signal before sending anything.
225
+ // The proxy emits this once all server-side handlers are registered.
226
+ this.logger.info('[VoiceStreaming] step 1/5: waiting for proxy ready signal');
227
+ await this.waitForReady(socket);
228
+
229
+ // 2. Register the session_started waiter BEFORE sending (defensive ordering).
230
+ const sessionReady = this.waitForSessionStarted(socket);
231
+
232
+ // 3. Send config frame (spec §3) — must be sent after "ready", before audio.
233
+ this.logger.info('[VoiceStreaming] step 2/5: sending config frame', {
234
+ sender: config.sender,
235
+ recipient: config.recipient,
236
+ lang: config.lang ?? 'it',
237
+ });
238
+ socket.send(JSON.stringify({
239
+ sender: config.sender,
240
+ recipient: config.recipient,
241
+ lang: config.lang ?? 'it',
242
+ text: config.text ?? '',
243
+ type: config.type ?? 'text',
244
+ recipient_fullname: config.recipient_fullname ?? '',
245
+ sender_fullname: config.sender_fullname ?? '',
246
+ attributes: config.attributes ?? {},
247
+ metadata: config.metadata ?? '',
248
+ channel_type: config.channel_type ?? '',
249
+ }));
250
+
251
+ // 4. Wait for session_started before opening the mic/recorder.
252
+ this.logger.info('[VoiceStreaming] step 3/5: waiting for session_started');
253
+ await sessionReady;
254
+ this.logger.info('[VoiceStreaming] step 4/5: session ready – opening microphone');
255
+
256
+ const shared = this.pendingSharedStream;
257
+ this.pendingSharedStream = undefined;
258
+ this.mediaStream = shared
259
+ ? shared
260
+ : await navigator.mediaDevices.getUserMedia({ audio: true });
261
+ const recorderOpts: MediaRecorderOptions = {};
262
+ if (mime) {
263
+ recorderOpts.mimeType = mime;
264
+ }
265
+ this.mediaRecorder = new MediaRecorder(this.mediaStream, recorderOpts);
266
+ this.currentMimeType = this.mediaRecorder.mimeType || mime || 'audio/webm';
267
+
268
+ this.mediaRecorder.ondataavailable = (e: BlobEvent) => {
269
+ if (e.data && e.data.size > 0) {
270
+ this.localChunks.push(e.data);
271
+ if (!this._audioMuted) {
272
+ this._mutedDropLogged = false;
273
+ this.sendChunkIfOpen(socket, e.data);
274
+ } else {
275
+ if (!this._mutedDropLogged) {
276
+ this.logger.debug('[VoiceStreaming] audio chunk dropped (muted) – suppressing further drops until unmuted');
277
+ this._mutedDropLogged = true;
278
+ }
279
+ }
280
+ }
281
+ };
282
+
283
+ this.mediaRecorder.onerror = (ev) => {
284
+ this.logger.error('[VoiceStreaming] MediaRecorder error', ev);
285
+ };
286
+
287
+ this.mediaRecorder.start(timeslice);
288
+ this.logger.info('[VoiceStreaming] step 5/5: recorder started', {
289
+ mimeType: this.currentMimeType,
290
+ timeslice,
291
+ });
292
+ this.setState('streaming');
293
+ resolve();
294
+ } catch (e) {
295
+ this.logger.error('[VoiceStreaming] beginRecordingAfterOpen failed', e);
296
+ fail(e);
297
+ }
298
+ }
299
+
300
+ /** Resolves when the proxy sends `ready`; rejects on socket close or timeout. */
301
+ private waitForReady(socket: WebSocket): Promise<void> {
302
+ this.logger.info('[VoiceStreaming] waiting for ready...');
303
+ return new Promise<void>((resolve, reject) => {
304
+ let settled = false;
305
+
306
+ const timer = setTimeout(() => {
307
+ if (!settled) {
308
+ settled = true;
309
+ cleanup();
310
+ this.logger.warn('[VoiceStreaming] ready timeout after', READY_TIMEOUT_MS, 'ms');
311
+ reject(new Error('[VoiceStreaming] ready timeout'));
312
+ }
313
+ }, READY_TIMEOUT_MS);
314
+
315
+ const onMessage = (ev: MessageEvent) => {
316
+ if (settled || typeof ev.data !== 'string') return;
317
+ try {
318
+ const msg = JSON.parse(ev.data) as Record<string, unknown>;
319
+ if (msg.event === 'ready') {
320
+ settled = true;
321
+ cleanup();
322
+ this.logger.info('[VoiceStreaming] ready received');
323
+ resolve();
324
+ } else if (msg.event === 'error') {
325
+ settled = true;
326
+ cleanup();
327
+ this.logger.warn('[VoiceStreaming] proxy error before ready:', msg.message ?? msg.code ?? 'unknown');
328
+ reject(new Error(`[VoiceStreaming] proxy error before ready: ${msg.message ?? msg.code ?? 'unknown'}`));
329
+ }
330
+ } catch { /* non-JSON */ }
331
+ };
332
+
333
+ const onClose = (ev: CloseEvent) => {
334
+ if (settled) return;
335
+ settled = true;
336
+ cleanup();
337
+ reject(new Error(`[VoiceStreaming] socket closed before ready (code ${ev.code})`));
338
+ };
339
+
340
+ const cleanup = () => {
341
+ clearTimeout(timer);
342
+ socket.removeEventListener('message', onMessage);
343
+ socket.removeEventListener('close', onClose);
344
+ };
345
+
346
+ socket.addEventListener('message', onMessage);
347
+ socket.addEventListener('close', onClose);
348
+ });
349
+ }
350
+
351
+ /** Resolves when the proxy sends `session_started`; rejects on socket close or timeout. */
352
+ private waitForSessionStarted(socket: WebSocket): Promise<void> {
353
+ this.logger.info('[VoiceStreaming] waiting for session_started...');
354
+ return new Promise<void>((resolve, reject) => {
355
+ let settled = false;
356
+
357
+ const timer = setTimeout(() => {
358
+ if (!settled) {
359
+ settled = true;
360
+ cleanup();
361
+ this.logger.warn('[VoiceStreaming] session_started timeout after', SESSION_STARTED_TIMEOUT_MS, 'ms');
362
+ reject(new Error('[VoiceStreaming] session_started timeout'));
363
+ }
364
+ }, SESSION_STARTED_TIMEOUT_MS);
365
+
366
+ const onMessage = (ev: MessageEvent) => {
367
+ if (settled || typeof ev.data !== 'string') return;
368
+ try {
369
+ const msg = JSON.parse(ev.data) as Record<string, unknown>;
370
+ if (msg.event === 'session_started') {
371
+ settled = true;
372
+ cleanup();
373
+ this.logger.info('[VoiceStreaming] session_started received');
374
+ resolve();
375
+ } else if (msg.event === 'error') {
376
+ settled = true;
377
+ cleanup();
378
+ this.logger.warn('[VoiceStreaming] proxy error before session_started:', msg.message ?? msg.code ?? 'unknown');
379
+ reject(new Error(`[VoiceStreaming] proxy error before session_started: ${msg.message ?? msg.code ?? 'unknown'}`));
380
+ }
381
+ } catch { /* non-JSON */ }
382
+ };
383
+
384
+ const onClose = (ev: CloseEvent) => {
385
+ if (settled) return;
386
+ settled = true;
387
+ cleanup();
388
+ reject(new Error(`[VoiceStreaming] socket closed before session_started (code ${ev.code})`));
389
+ };
390
+
391
+ const cleanup = () => {
392
+ clearTimeout(timer);
393
+ socket.removeEventListener('message', onMessage);
394
+ socket.removeEventListener('close', onClose);
395
+ };
396
+
397
+ socket.addEventListener('message', onMessage);
398
+ socket.addEventListener('close', onClose);
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Ferma recorder e, se richiesto, attende l’URL dal server prima di chiudere la WSS.
404
+ */
405
+ stop(options?: VoiceStreamingStopOptions): Promise<VoiceStreamingStopResult> {
406
+ const discard = options?.discard === true;
407
+ const awaitUrl = options?.awaitServerResultUrl === true;
408
+ const urlTimeout = options?.serverResultTimeoutMs ?? 0;// 30_000;
409
+ if (!this.ws && !this.mediaRecorder && !this.mediaStream) {
410
+ this.setState('idle');
411
+ return Promise.resolve({ blob: null, mimeType: '', resultUrl: null });
412
+ }
413
+ this.logger.info('[VoiceStreaming] stop', {
414
+ chunks: this.audioChunkCount,
415
+ totalBytes: this.totalAudioBytesSent,
416
+ discard,
417
+ sessionId: this.currentSessionId,
418
+ });
419
+ this.setState('stopping');
420
+ return new Promise((resolve) => {
421
+ const finalize = (resultUrl: string | null) => {
422
+ this.stopResolver = null;
423
+ if (discard) {
424
+ this.localChunks = [];
425
+ }
426
+ const mime = this.currentMimeType || 'audio/webm';
427
+ const blob =
428
+ !discard && this.localChunks.length > 0
429
+ ? new Blob(this.localChunks, { type: mime })
430
+ : null;
431
+ this.localChunks = [];
432
+ this.cleanup();
433
+ this.setState('closed');
434
+ resolve({ blob, mimeType: blob?.type || mime, resultUrl });
435
+ };
436
+
437
+ this.stopResolver = () => {
438
+ const ws = this.ws;
439
+ if (awaitUrl && ws && ws.readyState === WebSocket.OPEN) {
440
+ void this.waitForResultUrl(ws, urlTimeout).then((url) => finalize(url));
441
+ } else {
442
+ finalize(null);
443
+ }
444
+ };
445
+
446
+ if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
447
+ this.mediaRecorder.onstop = () => {
448
+ if (this.stopResolver) {
449
+ this.stopResolver();
450
+ }
451
+ };
452
+ this.mediaRecorder.stop();
453
+ } else {
454
+ if (this.stopResolver) {
455
+ this.stopResolver();
456
+ }
457
+ }
458
+ });
459
+ }
460
+
461
+ /**
462
+ * Dopo la chiusura recorder: URL in JSON, oppure messaggio `{ event: 'done' }` (restituisce url se presente, altrimenti null).
463
+ */
464
+ private waitForResultUrl(ws: WebSocket, timeoutMs: number): Promise<string | null> {
465
+ return new Promise((resolve) => {
466
+ let settled = false;
467
+ const finish = (url: string | null) => {
468
+ if (settled) {
469
+ return;
470
+ }
471
+ settled = true;
472
+ clearTimeout(timer);
473
+ ws.removeEventListener('message', onMessage);
474
+ resolve(url);
475
+ };
476
+ const onMessage = (ev: MessageEvent) => {
477
+ if (typeof ev.data !== 'string') {
478
+ return;
479
+ }
480
+ try {
481
+ const o = JSON.parse(ev.data) as Record<string, unknown>;
482
+ if (o.event === 'done') {
483
+ finish(this.parseResultUrlFromPayload(ev.data));
484
+ return;
485
+ }
486
+ } catch {
487
+ /* non JSON */
488
+ }
489
+ const url = this.parseResultUrlFromPayload(ev.data);
490
+ if (url) {
491
+ finish(url);
492
+ }
493
+ };
494
+ const timer = setTimeout(() => finish(null), timeoutMs);
495
+ ws.addEventListener('message', onMessage);
496
+ });
497
+ }
498
+
499
+ private parseResultUrlFromPayload(data: string): string | null {
500
+ const t = data.trim();
501
+ if (!t) {
502
+ return null;
503
+ }
504
+ try {
505
+ const o = JSON.parse(t) as Record<string, unknown>;
506
+ if (o.event === 'done') {
507
+ const u = o.url ?? o.audioUrl ?? o.fileUrl ?? o.resultUrl ?? o.href;
508
+ if (typeof u === 'string' && u.length > 0) {
509
+ return u;
510
+ }
511
+ }
512
+ const u = o.url ?? o.audioUrl ?? o.fileUrl ?? o.resultUrl ?? o.href;
513
+ if (typeof u === 'string' && u.length > 0) {
514
+ return u;
515
+ }
516
+ } catch {
517
+ /* not JSON */
518
+ }
519
+ if (/^https?:\/\//i.test(t)) {
520
+ return t;
521
+ }
522
+ return null;
523
+ }
524
+
525
+ isActive(): boolean {
526
+ return (
527
+ this.mediaRecorder?.state === 'recording' ||
528
+ this._currentState === 'connecting' ||
529
+ this._currentState === 'open' ||
530
+ this._currentState === 'streaming'
531
+ );
532
+ }
533
+
534
+ /**
535
+ * Mute or unmute microphone audio forwarding to the proxy.
536
+ * Call `setAudioMuted(true)` when the proxy starts speaking (to prevent echo),
537
+ * and `setAudioMuted(false)` after TTS playback ends.
538
+ */
539
+ setAudioMuted(muted: boolean): void {
540
+ this._audioMuted = muted;
541
+ if (!muted) {
542
+ this._mutedDropLogged = false;
543
+ }
544
+ this.logger.info(`[VoiceStreaming] audio ${muted ? 'muted' : 'unmuted'}`);
545
+ }
546
+
547
+ /**
548
+ * Pause the MediaRecorder to stop encoding and sending audio chunks to the proxy.
549
+ * Safe to call when the recorder is already paused or not yet started.
550
+ * Use when the proxy enters a non-listening state (thinking, speaking) to save
551
+ * bandwidth and reduce load on the STT service.
552
+ */
553
+ pauseRecording(): void {
554
+ if (this.mediaRecorder?.state === 'recording') {
555
+ this.mediaRecorder.pause();
556
+ this.logger.info('[VoiceStreaming] recording paused');
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Resume the MediaRecorder after a `pauseRecording()` call.
562
+ * Safe to call when the recorder is not paused (no-op).
563
+ * Use when the proxy transitions back to LISTENING.
564
+ */
565
+ resumeRecording(): void {
566
+ if (this.mediaRecorder?.state === 'paused') {
567
+ this.mediaRecorder.resume();
568
+ this.logger.info('[VoiceStreaming] recording resumed');
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Send `{ event: "tts_playback_complete" }` to the proxy, signalling that TTS
574
+ * playback has finished and the microphone is ready to receive user speech.
575
+ */
576
+ sendPlaybackComplete(): void {
577
+ if (this.ws?.readyState === WebSocket.OPEN) {
578
+ this.ws.send(JSON.stringify({ event: 'tts_playback_complete' }));
579
+ this.logger.info('[VoiceStreaming] tts_playback_complete sent');
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Send `{ event: "barge_in" }` to the proxy, requesting an immediate interruption
585
+ * of the ongoing TTS playback. Use when the user explicitly wants to speak while
586
+ * the bot is talking (e.g. via a UI button or a client-side VAD onset).
587
+ *
588
+ * The proxy will stop the TTS stream and transition to LISTENING; the widget should
589
+ * handle the server-sent `barge_in` and `listening` events to update local state.
590
+ */
591
+ sendBargeIn(): void {
592
+ if (this.ws?.readyState === WebSocket.OPEN) {
593
+ this.ws.send(JSON.stringify({ event: 'barge_in' }));
594
+ this.logger.info('[VoiceStreaming] barge_in sent');
595
+ }
596
+ }
597
+
598
+ private cleanup(): void {
599
+ this.logger.info('[VoiceStreaming] cleanup', { state: this._currentState, sessionId: this.currentSessionId });
600
+ this.audioChunkCount = 0;
601
+ this.totalAudioBytesSent = 0;
602
+ this.currentSessionId = undefined;
603
+ this._audioMuted = false;
604
+ this._mutedDropLogged = false;
605
+ // If cleanup() is called externally while start() is still pending (e.g. stop() during
606
+ // a mid-connect state), reject the stranded start() promise so the caller isn't hung.
607
+ if (this.pendingStartFail) {
608
+ const f = this.pendingStartFail;
609
+ this.pendingStartFail = null;
610
+ f(new Error('start cancelled'));
611
+ }
612
+ if (this.mediaStream) {
613
+ if (!this.streamSharedWithIngress) {
614
+ this.mediaStream.getTracks().forEach((t) => t.stop());
615
+ }
616
+ this.mediaStream = null;
617
+ }
618
+ this.streamSharedWithIngress = false;
619
+ this.mediaRecorder = null;
620
+ if (this.ws) {
621
+ const s = this.ws;
622
+ this.ws = null;
623
+ s.onopen = null;
624
+ s.onmessage = null;
625
+ s.onerror = null;
626
+ s.onclose = null;
627
+ if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
628
+ s.close();
629
+ }
630
+ }
631
+ }
632
+
633
+ private sendChunkIfOpen(socket: WebSocket, data: Blob): void {
634
+ if (socket.readyState !== WebSocket.OPEN) {
635
+ this.logger.warn('[VoiceStreaming] sendChunk skipped – socket not open', { readyState: socket.readyState });
636
+ return;
637
+ }
638
+ void data.arrayBuffer().then((buf) => {
639
+ if (socket.readyState === WebSocket.OPEN) {
640
+ try {
641
+ socket.send(buf);
642
+ this.audioChunkCount++;
643
+ this.totalAudioBytesSent += buf.byteLength;
644
+ if (this.audioChunkCount === 1) {
645
+ this.logger.info('[VoiceStreaming] first audio chunk sent', { bytes: buf.byteLength, sessionId: this.currentSessionId });
646
+ } else if (this.audioChunkCount % 40 === 0) {
647
+ this.logger.debug('[VoiceStreaming] audio streaming', { chunks: this.audioChunkCount, totalBytes: this.totalAudioBytesSent });
648
+ }
649
+ } catch (e) {
650
+ this.logger.error('[VoiceStreaming] send chunk error', e);
651
+ }
652
+ }
653
+ });
654
+ }
655
+
656
+ private resolveBaseUrl(override?: string): string {
657
+ const fromApp = String(this.appConfig.getConfig()?.voiceProxyWsUrl ?? '').trim();
658
+ const raw = (String(override ?? '').trim() || fromApp).trim();
659
+ if (!raw) {
660
+ throw new Error(
661
+ 'Voice stream: nessun ws base URL. Imposta `wsBaseUrl` nel config, `voiceProxyWsBaseUrl` in AppConfig, o in widget config.',
662
+ );
663
+ }
664
+ return this.normalizeWsBase(raw);
665
+ }
666
+
667
+ private normalizeWsBase(u: string): string {
668
+ let s = u.trim();
669
+ if (s.startsWith('http://')) {
670
+ s = 'ws://' + s.slice('http://'.length);
671
+ } else if (s.startsWith('https://')) {
672
+ s = 'wss://' + s.slice('https://'.length);
673
+ }
674
+ return s.replace(/\/$/, '');
675
+ }
676
+
677
+ private buildWebSocketUrl(base: string, config: VoiceStreamingSessionConfig & { mimeType: string }): string {
678
+ const params = new URLSearchParams();
679
+ params.set('token', config.token.replace(/^JWT\s+/i, ''));
680
+ if (config.sttProvider) {
681
+ params.set('sttProvider', config.sttProvider);
682
+ }
683
+ if (config.ttsProvider) {
684
+ params.set('ttsProvider', config.ttsProvider);
685
+ }
686
+ if (config.mimeType) {
687
+ params.set('mimeType', config.mimeType);
688
+ }
689
+ const q = params.toString();
690
+ return q ? `${base}?${q}` : base;
691
+ }
692
+
693
+ private redactQuery(url: string): string {
694
+ return url.replace(/([?&]token=)[^&]*/g, '$1<redacted>');
695
+ }
696
+
697
+ private resolveMimeType(override?: string): string {
698
+ if (override && override.trim() !== '') {
699
+ return override.trim();
700
+ }
701
+ if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported) {
702
+ for (const c of PREFERRED_MIME_CANDIDATES) {
703
+ if (MediaRecorder.isTypeSupported(c)) {
704
+ return c;
705
+ }
706
+ }
707
+ }
708
+ return '';
709
+ }
710
+ }