@pipedog/core 0.0.0 → 0.1.0

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 (698) hide show
  1. package/LICENSE +25 -0
  2. package/NOTICE +9 -0
  3. package/dist/adapters/base_llm_adapter.d.ts +266 -0
  4. package/dist/adapters/base_llm_adapter.js +489 -0
  5. package/dist/adapters/index.d.ts +2 -0
  6. package/dist/adapters/index.js +8 -0
  7. package/dist/adapters/schemas/direct_function.d.ts +202 -0
  8. package/dist/adapters/schemas/direct_function.js +302 -0
  9. package/dist/adapters/schemas/function_schema.d.ts +67 -0
  10. package/dist/adapters/schemas/function_schema.js +72 -0
  11. package/dist/adapters/schemas/index.d.ts +2 -0
  12. package/dist/adapters/schemas/index.js +8 -0
  13. package/dist/adapters/schemas/tools_schema.d.ts +57 -0
  14. package/dist/adapters/schemas/tools_schema.js +79 -0
  15. package/dist/adapters/services/index.d.ts +2 -0
  16. package/dist/adapters/services/index.js +8 -0
  17. package/dist/adapters/services/open_ai_adapter.d.ts +147 -0
  18. package/dist/adapters/services/open_ai_adapter.js +250 -0
  19. package/dist/audio/dtmf/dtmf_data.d.ts +3 -0
  20. package/dist/audio/dtmf/dtmf_data.js +22 -0
  21. package/dist/audio/dtmf/index.d.ts +2 -0
  22. package/dist/audio/dtmf/index.js +8 -0
  23. package/dist/audio/dtmf/types.d.ts +31 -0
  24. package/dist/audio/dtmf/types.js +35 -0
  25. package/dist/audio/dtmf/utils.d.ts +13 -0
  26. package/dist/audio/dtmf/utils.js +62 -0
  27. package/dist/audio/filters/base_audio_filter.d.ts +51 -0
  28. package/dist/audio/filters/base_audio_filter.js +17 -0
  29. package/dist/audio/filters/index.d.ts +2 -0
  30. package/dist/audio/filters/index.js +8 -0
  31. package/dist/audio/index.d.ts +2 -0
  32. package/dist/audio/index.js +8 -0
  33. package/dist/audio/krisp_instance.d.ts +95 -0
  34. package/dist/audio/krisp_instance.js +229 -0
  35. package/dist/audio/mixers/base_audio_mixer.d.ts +56 -0
  36. package/dist/audio/mixers/base_audio_mixer.js +16 -0
  37. package/dist/audio/mixers/index.d.ts +2 -0
  38. package/dist/audio/mixers/index.js +8 -0
  39. package/dist/audio/resamplers/base_audio_resampler.d.ts +46 -0
  40. package/dist/audio/resamplers/base_audio_resampler.js +35 -0
  41. package/dist/audio/resamplers/index.d.ts +2 -0
  42. package/dist/audio/resamplers/index.js +8 -0
  43. package/dist/audio/resamplers/resampy_resampler.d.ts +10 -0
  44. package/dist/audio/resamplers/resampy_resampler.js +15 -0
  45. package/dist/audio/resamplers/soxr_resampler.d.ts +54 -0
  46. package/dist/audio/resamplers/soxr_resampler.js +69 -0
  47. package/dist/audio/resamplers/soxr_stream_resampler.d.ts +75 -0
  48. package/dist/audio/resamplers/soxr_stream_resampler.js +146 -0
  49. package/dist/audio/turn/base_turn_analyzer.d.ts +84 -0
  50. package/dist/audio/turn/base_turn_analyzer.js +61 -0
  51. package/dist/audio/turn/index.d.ts +2 -0
  52. package/dist/audio/turn/index.js +8 -0
  53. package/dist/audio/turn/smart_turn/_whisper_features.d.ts +30 -0
  54. package/dist/audio/turn/smart_turn/_whisper_features.js +225 -0
  55. package/dist/audio/turn/smart_turn/base_smart_turn.d.ts +94 -0
  56. package/dist/audio/turn/smart_turn/base_smart_turn.js +239 -0
  57. package/dist/audio/turn/smart_turn/local_smart_turn_v3.d.ts +76 -0
  58. package/dist/audio/turn/smart_turn/local_smart_turn_v3.js +143 -0
  59. package/dist/audio/utils.d.ts +224 -0
  60. package/dist/audio/utils.js +634 -0
  61. package/dist/audio/vad/data/index.d.ts +2 -0
  62. package/dist/audio/vad/data/index.js +8 -0
  63. package/dist/audio/vad/index.d.ts +2 -0
  64. package/dist/audio/vad/index.js +8 -0
  65. package/dist/audio/vad/vad_analyzer.d.ts +126 -0
  66. package/dist/audio/vad/vad_analyzer.js +211 -0
  67. package/dist/audio/vad/vad_controller.d.ts +143 -0
  68. package/dist/audio/vad/vad_controller.js +228 -0
  69. package/dist/audio/volume.d.ts +38 -0
  70. package/dist/audio/volume.js +75 -0
  71. package/dist/bus/adapters/base.d.ts +35 -0
  72. package/dist/bus/adapters/base.js +19 -0
  73. package/dist/bus/adapters/index.d.ts +10 -0
  74. package/dist/bus/adapters/index.js +15 -0
  75. package/dist/bus/adapters/llm_context_adapter.d.ts +36 -0
  76. package/dist/bus/adapters/llm_context_adapter.js +82 -0
  77. package/dist/bus/adapters/tools_schema_adapter.d.ts +25 -0
  78. package/dist/bus/adapters/tools_schema_adapter.js +70 -0
  79. package/dist/bus/bridge_processor.d.ts +111 -0
  80. package/dist/bus/bridge_processor.js +166 -0
  81. package/dist/bus/bus.d.ts +110 -0
  82. package/dist/bus/bus.js +200 -0
  83. package/dist/bus/index.d.ts +22 -0
  84. package/dist/bus/index.js +27 -0
  85. package/dist/bus/local/async_queue.d.ts +12 -0
  86. package/dist/bus/local/async_queue.js +21 -0
  87. package/dist/bus/local/index.d.ts +3 -0
  88. package/dist/bus/local/index.js +8 -0
  89. package/dist/bus/messages.d.ts +378 -0
  90. package/dist/bus/messages.js +357 -0
  91. package/dist/bus/network/index.d.ts +16 -0
  92. package/dist/bus/network/index.js +21 -0
  93. package/dist/bus/queue.d.ts +27 -0
  94. package/dist/bus/queue.js +51 -0
  95. package/dist/bus/serializers/base.d.ts +25 -0
  96. package/dist/bus/serializers/base.js +14 -0
  97. package/dist/bus/serializers/index.d.ts +9 -0
  98. package/dist/bus/serializers/index.js +14 -0
  99. package/dist/bus/serializers/json.d.ts +77 -0
  100. package/dist/bus/serializers/json.js +367 -0
  101. package/dist/bus/subscriber.d.ts +46 -0
  102. package/dist/bus/subscriber.js +27 -0
  103. package/dist/bus/ui/index.d.ts +3 -0
  104. package/dist/bus/ui/index.js +8 -0
  105. package/dist/bus/ui/messages.d.ts +186 -0
  106. package/dist/bus/ui/messages.js +171 -0
  107. package/dist/classifiers/base_classifier.d.ts +263 -0
  108. package/dist/classifiers/base_classifier.js +346 -0
  109. package/dist/classifiers/cloudflare/clef/classifier.d.ts +70 -0
  110. package/dist/classifiers/cloudflare/clef/classifier.js +204 -0
  111. package/dist/classifiers/cloudflare/clef/client.d.ts +104 -0
  112. package/dist/classifiers/cloudflare/clef/client.js +234 -0
  113. package/dist/classifiers/cloudflare/clef/index.d.ts +2 -0
  114. package/dist/classifiers/cloudflare/clef/index.js +8 -0
  115. package/dist/classifiers/cloudflare/index.d.ts +2 -0
  116. package/dist/classifiers/cloudflare/index.js +8 -0
  117. package/dist/classifiers/http_client.d.ts +70 -0
  118. package/dist/classifiers/http_client.js +161 -0
  119. package/dist/classifiers/index.d.ts +2 -0
  120. package/dist/classifiers/index.js +8 -0
  121. package/dist/classifiers/jev/classifier.d.ts +8 -0
  122. package/dist/classifiers/jev/classifier.js +13 -0
  123. package/dist/classifiers/jev/client.d.ts +8 -0
  124. package/dist/classifiers/jev/client.js +13 -0
  125. package/dist/classifiers/jev/index.d.ts +2 -0
  126. package/dist/classifiers/jev/index.js +8 -0
  127. package/dist/classifiers/llm/classifier.d.ts +72 -0
  128. package/dist/classifiers/llm/classifier.js +304 -0
  129. package/dist/classifiers/llm/index.d.ts +2 -0
  130. package/dist/classifiers/llm/index.js +8 -0
  131. package/dist/classifiers/openai/decisions/classifier.d.ts +77 -0
  132. package/dist/classifiers/openai/decisions/classifier.js +241 -0
  133. package/dist/classifiers/openai/decisions/client.d.ts +108 -0
  134. package/dist/classifiers/openai/decisions/client.js +234 -0
  135. package/dist/classifiers/openai/decisions/index.d.ts +2 -0
  136. package/dist/classifiers/openai/decisions/index.js +8 -0
  137. package/dist/classifiers/openai/index.d.ts +2 -0
  138. package/dist/classifiers/openai/index.js +8 -0
  139. package/dist/classifiers/typesafe/index.d.ts +2 -0
  140. package/dist/classifiers/typesafe/index.js +8 -0
  141. package/dist/classifiers/typesafe/jev/classifier.d.ts +70 -0
  142. package/dist/classifiers/typesafe/jev/classifier.js +220 -0
  143. package/dist/classifiers/typesafe/jev/client.d.ts +105 -0
  144. package/dist/classifiers/typesafe/jev/client.js +233 -0
  145. package/dist/classifiers/typesafe/jev/index.d.ts +2 -0
  146. package/dist/classifiers/typesafe/jev/index.js +8 -0
  147. package/dist/clocks/base_clock.d.ts +13 -0
  148. package/dist/clocks/base_clock.js +14 -0
  149. package/dist/clocks/index.d.ts +2 -0
  150. package/dist/clocks/index.js +8 -0
  151. package/dist/clocks/system_clock.d.ts +15 -0
  152. package/dist/clocks/system_clock.js +25 -0
  153. package/dist/frames/frames.d.ts +2533 -0
  154. package/dist/frames/frames.js +2485 -0
  155. package/dist/frames/index.d.ts +2 -0
  156. package/dist/frames/index.js +8 -0
  157. package/dist/frames/protobufs/frames_pb2.d.ts +229 -0
  158. package/dist/frames/protobufs/frames_pb2.js +466 -0
  159. package/dist/index.d.ts +11 -0
  160. package/dist/index.js +56 -0
  161. package/dist/metrics/index.d.ts +2 -0
  162. package/dist/metrics/index.js +8 -0
  163. package/dist/metrics/metrics.d.ts +276 -0
  164. package/dist/metrics/metrics.js +254 -0
  165. package/dist/observers/base_observer.d.ts +173 -0
  166. package/dist/observers/base_observer.js +151 -0
  167. package/dist/observers/error_observer.d.ts +75 -0
  168. package/dist/observers/error_observer.js +87 -0
  169. package/dist/observers/function_call_observer.d.ts +157 -0
  170. package/dist/observers/function_call_observer.js +222 -0
  171. package/dist/observers/index.d.ts +2 -0
  172. package/dist/observers/index.js +8 -0
  173. package/dist/observers/loggers/debug_log_observer.d.ts +91 -0
  174. package/dist/observers/loggers/debug_log_observer.js +228 -0
  175. package/dist/observers/loggers/index.d.ts +2 -0
  176. package/dist/observers/loggers/index.js +8 -0
  177. package/dist/observers/loggers/llm_log_observer.d.ts +24 -0
  178. package/dist/observers/loggers/llm_log_observer.js +110 -0
  179. package/dist/observers/loggers/metrics_log_observer.d.ts +85 -0
  180. package/dist/observers/loggers/metrics_log_observer.js +186 -0
  181. package/dist/observers/loggers/transcription_log_observer.d.ts +23 -0
  182. package/dist/observers/loggers/transcription_log_observer.js +74 -0
  183. package/dist/observers/service_metrics_observer.d.ts +168 -0
  184. package/dist/observers/service_metrics_observer.js +236 -0
  185. package/dist/observers/speaking_observer.d.ts +104 -0
  186. package/dist/observers/speaking_observer.js +150 -0
  187. package/dist/observers/startup_timing_observer.d.ts +221 -0
  188. package/dist/observers/startup_timing_observer.js +365 -0
  189. package/dist/observers/turn_tracking_observer.d.ts +78 -0
  190. package/dist/observers/turn_tracking_observer.js +189 -0
  191. package/dist/observers/user_bot_latency_observer.d.ts +411 -0
  192. package/dist/observers/user_bot_latency_observer.js +974 -0
  193. package/dist/pipeline/base_pipeline.d.ts +49 -0
  194. package/dist/pipeline/base_pipeline.js +101 -0
  195. package/dist/pipeline/capabilities.d.ts +76 -0
  196. package/dist/pipeline/capabilities.js +97 -0
  197. package/dist/pipeline/index.d.ts +2 -0
  198. package/dist/pipeline/index.js +8 -0
  199. package/dist/pipeline/job_context.d.ts +317 -0
  200. package/dist/pipeline/job_context.js +450 -0
  201. package/dist/pipeline/job_decorator.d.ts +54 -0
  202. package/dist/pipeline/job_decorator.js +92 -0
  203. package/dist/pipeline/llm_switcher.d.ts +84 -0
  204. package/dist/pipeline/llm_switcher.js +107 -0
  205. package/dist/pipeline/llm_with_backend.d.ts +435 -0
  206. package/dist/pipeline/llm_with_backend.js +806 -0
  207. package/dist/pipeline/parallel_pipeline.d.ts +83 -0
  208. package/dist/pipeline/parallel_pipeline.js +200 -0
  209. package/dist/pipeline/pipeline.d.ts +126 -0
  210. package/dist/pipeline/pipeline.js +196 -0
  211. package/dist/pipeline/service_switcher.d.ts +287 -0
  212. package/dist/pipeline/service_switcher.js +509 -0
  213. package/dist/pipeline/sync_parallel_pipeline.d.ts +192 -0
  214. package/dist/pipeline/sync_parallel_pipeline.js +387 -0
  215. package/dist/pipeline/worker.d.ts +814 -0
  216. package/dist/pipeline/worker.js +1639 -0
  217. package/dist/pipeline/worker_observer.d.ts +131 -0
  218. package/dist/pipeline/worker_observer.js +274 -0
  219. package/dist/pipeline/worker_ready_decorator.d.ts +45 -0
  220. package/dist/pipeline/worker_ready_decorator.js +76 -0
  221. package/dist/processors/aggregators/async_tool_messages.d.ts +146 -0
  222. package/dist/processors/aggregators/async_tool_messages.js +291 -0
  223. package/dist/processors/aggregators/dtmf_aggregator.d.ts +61 -0
  224. package/dist/processors/aggregators/dtmf_aggregator.js +144 -0
  225. package/dist/processors/aggregators/gated.d.ts +43 -0
  226. package/dist/processors/aggregators/gated.js +86 -0
  227. package/dist/processors/aggregators/gated_llm_context.d.ts +42 -0
  228. package/dist/processors/aggregators/gated_llm_context.js +86 -0
  229. package/dist/processors/aggregators/index.d.ts +2 -0
  230. package/dist/processors/aggregators/index.js +8 -0
  231. package/dist/processors/aggregators/llm_context.d.ts +370 -0
  232. package/dist/processors/aggregators/llm_context.js +580 -0
  233. package/dist/processors/aggregators/llm_context_summarizer.d.ts +219 -0
  234. package/dist/processors/aggregators/llm_context_summarizer.js +447 -0
  235. package/dist/processors/aggregators/llm_response.d.ts +37 -0
  236. package/dist/processors/aggregators/llm_response.js +71 -0
  237. package/dist/processors/aggregators/llm_response_universal.d.ts +966 -0
  238. package/dist/processors/aggregators/llm_response_universal.js +2394 -0
  239. package/dist/processors/aggregators/llm_text_processor.d.ts +53 -0
  240. package/dist/processors/aggregators/llm_text_processor.js +102 -0
  241. package/dist/processors/aggregators/sentence.d.ts +39 -0
  242. package/dist/processors/aggregators/sentence.js +69 -0
  243. package/dist/processors/async_generator.d.ts +42 -0
  244. package/dist/processors/async_generator.js +76 -0
  245. package/dist/processors/audio/audio_buffer_processor.d.ts +241 -0
  246. package/dist/processors/audio/audio_buffer_processor.js +565 -0
  247. package/dist/processors/audio/index.d.ts +2 -0
  248. package/dist/processors/audio/index.js +8 -0
  249. package/dist/processors/audio/vad_processor.d.ts +67 -0
  250. package/dist/processors/audio/vad_processor.js +103 -0
  251. package/dist/processors/consumer_processor.d.ts +49 -0
  252. package/dist/processors/consumer_processor.js +85 -0
  253. package/dist/processors/filters/frame_filter.d.ts +39 -0
  254. package/dist/processors/filters/frame_filter.js +52 -0
  255. package/dist/processors/filters/function_filter.d.ts +59 -0
  256. package/dist/processors/filters/function_filter.js +78 -0
  257. package/dist/processors/filters/identity_filter.d.ts +33 -0
  258. package/dist/processors/filters/identity_filter.js +37 -0
  259. package/dist/processors/filters/index.d.ts +2 -0
  260. package/dist/processors/filters/index.js +8 -0
  261. package/dist/processors/filters/null_filter.d.ts +34 -0
  262. package/dist/processors/filters/null_filter.js +47 -0
  263. package/dist/processors/filters/wake_notifier_filter.d.ts +48 -0
  264. package/dist/processors/filters/wake_notifier_filter.js +49 -0
  265. package/dist/processors/frame_processor.d.ts +360 -0
  266. package/dist/processors/frame_processor.js +932 -0
  267. package/dist/processors/frameworks/index.d.ts +2 -0
  268. package/dist/processors/frameworks/index.js +8 -0
  269. package/dist/processors/frameworks/rtvi/frames.d.ts +241 -0
  270. package/dist/processors/frameworks/rtvi/frames.js +259 -0
  271. package/dist/processors/frameworks/rtvi/index.d.ts +6 -0
  272. package/dist/processors/frameworks/rtvi/index.js +11 -0
  273. package/dist/processors/frameworks/rtvi/models.d.ts +1741 -0
  274. package/dist/processors/frameworks/rtvi/models.js +1807 -0
  275. package/dist/processors/frameworks/rtvi/observer.d.ts +340 -0
  276. package/dist/processors/frameworks/rtvi/observer.js +1113 -0
  277. package/dist/processors/frameworks/rtvi/processor.d.ts +182 -0
  278. package/dist/processors/frameworks/rtvi/processor.js +676 -0
  279. package/dist/processors/idle_frame_processor.d.ts +44 -0
  280. package/dist/processors/idle_frame_processor.js +86 -0
  281. package/dist/processors/index.d.ts +2 -0
  282. package/dist/processors/index.js +8 -0
  283. package/dist/processors/logger.d.ts +42 -0
  284. package/dist/processors/logger.js +87 -0
  285. package/dist/processors/metrics/frame_processor_metrics.d.ts +172 -0
  286. package/dist/processors/metrics/frame_processor_metrics.js +372 -0
  287. package/dist/processors/metrics/index.d.ts +2 -0
  288. package/dist/processors/metrics/index.js +8 -0
  289. package/dist/processors/producer_processor.d.ts +68 -0
  290. package/dist/processors/producer_processor.js +78 -0
  291. package/dist/processors/text_transformer.d.ts +30 -0
  292. package/dist/processors/text_transformer.js +48 -0
  293. package/dist/registry/index.d.ts +3 -0
  294. package/dist/registry/index.js +8 -0
  295. package/dist/registry/registry.d.ts +69 -0
  296. package/dist/registry/registry.js +149 -0
  297. package/dist/registry/types.d.ts +47 -0
  298. package/dist/registry/types.js +39 -0
  299. package/dist/serializers/base_serializer.d.ts +94 -0
  300. package/dist/serializers/base_serializer.js +82 -0
  301. package/dist/serializers/index.d.ts +2 -0
  302. package/dist/serializers/index.js +8 -0
  303. package/dist/services/ai_service.d.ts +145 -0
  304. package/dist/services/ai_service.js +262 -0
  305. package/dist/services/anthropic/index.d.ts +2 -0
  306. package/dist/services/anthropic/index.js +7 -0
  307. package/dist/services/assemblyai/index.d.ts +2 -0
  308. package/dist/services/assemblyai/index.js +7 -0
  309. package/dist/services/asyncai/index.d.ts +2 -0
  310. package/dist/services/asyncai/index.js +7 -0
  311. package/dist/services/aws/index.d.ts +2 -0
  312. package/dist/services/aws/index.js +7 -0
  313. package/dist/services/azure/index.d.ts +2 -0
  314. package/dist/services/azure/index.js +7 -0
  315. package/dist/services/baseten/index.d.ts +2 -0
  316. package/dist/services/baseten/index.js +7 -0
  317. package/dist/services/bland/index.d.ts +2 -0
  318. package/dist/services/bland/index.js +7 -0
  319. package/dist/services/camb/index.d.ts +2 -0
  320. package/dist/services/camb/index.js +7 -0
  321. package/dist/services/cartesia/index.d.ts +2 -0
  322. package/dist/services/cartesia/index.js +7 -0
  323. package/dist/services/cerebras/index.d.ts +2 -0
  324. package/dist/services/cerebras/index.js +7 -0
  325. package/dist/services/crusoe/index.d.ts +2 -0
  326. package/dist/services/crusoe/index.js +7 -0
  327. package/dist/services/deepgram/index.d.ts +2 -0
  328. package/dist/services/deepgram/index.js +7 -0
  329. package/dist/services/deepseek/index.d.ts +2 -0
  330. package/dist/services/deepseek/index.js +7 -0
  331. package/dist/services/elevenlabs/index.d.ts +2 -0
  332. package/dist/services/elevenlabs/index.js +7 -0
  333. package/dist/services/fal/index.d.ts +2 -0
  334. package/dist/services/fal/index.js +7 -0
  335. package/dist/services/fireworks/index.d.ts +2 -0
  336. package/dist/services/fireworks/index.js +7 -0
  337. package/dist/services/fish/index.d.ts +2 -0
  338. package/dist/services/fish/index.js +7 -0
  339. package/dist/services/funasr/index.d.ts +3 -0
  340. package/dist/services/funasr/index.js +8 -0
  341. package/dist/services/gladia/index.d.ts +2 -0
  342. package/dist/services/gladia/index.js +7 -0
  343. package/dist/services/google/index.d.ts +2 -0
  344. package/dist/services/google/index.js +7 -0
  345. package/dist/services/gradium/index.d.ts +2 -0
  346. package/dist/services/gradium/index.js +7 -0
  347. package/dist/services/grok/index.d.ts +2 -0
  348. package/dist/services/grok/index.js +7 -0
  349. package/dist/services/groq/index.d.ts +2 -0
  350. package/dist/services/groq/index.js +7 -0
  351. package/dist/services/heygen/index.d.ts +2 -0
  352. package/dist/services/heygen/index.js +7 -0
  353. package/dist/services/hume/index.d.ts +2 -0
  354. package/dist/services/hume/index.js +7 -0
  355. package/dist/services/image_service.d.ts +52 -0
  356. package/dist/services/image_service.js +60 -0
  357. package/dist/services/inception/index.d.ts +2 -0
  358. package/dist/services/inception/index.js +7 -0
  359. package/dist/services/index.d.ts +2 -0
  360. package/dist/services/index.js +8 -0
  361. package/dist/services/inworld/index.d.ts +2 -0
  362. package/dist/services/inworld/index.js +7 -0
  363. package/dist/services/keenable/index.d.ts +2 -0
  364. package/dist/services/keenable/index.js +7 -0
  365. package/dist/services/kokoro/index.d.ts +2 -0
  366. package/dist/services/kokoro/index.js +7 -0
  367. package/dist/services/llm_service.d.ts +1106 -0
  368. package/dist/services/llm_service.js +2231 -0
  369. package/dist/services/mem0/index.d.ts +2 -0
  370. package/dist/services/mem0/index.js +7 -0
  371. package/dist/services/meta/index.d.ts +2 -0
  372. package/dist/services/meta/index.js +7 -0
  373. package/dist/services/minimax/index.d.ts +2 -0
  374. package/dist/services/minimax/index.js +7 -0
  375. package/dist/services/mistral/index.d.ts +2 -0
  376. package/dist/services/mistral/index.js +7 -0
  377. package/dist/services/moondream/index.d.ts +2 -0
  378. package/dist/services/moondream/index.js +7 -0
  379. package/dist/services/moonshine/index.d.ts +2 -0
  380. package/dist/services/moonshine/index.js +7 -0
  381. package/dist/services/nebius/index.d.ts +2 -0
  382. package/dist/services/nebius/index.js +7 -0
  383. package/dist/services/neuphonic/index.d.ts +2 -0
  384. package/dist/services/neuphonic/index.js +7 -0
  385. package/dist/services/novita/index.d.ts +2 -0
  386. package/dist/services/novita/index.js +7 -0
  387. package/dist/services/nvidia/index.d.ts +2 -0
  388. package/dist/services/nvidia/index.js +11 -0
  389. package/dist/services/ollama/index.d.ts +2 -0
  390. package/dist/services/ollama/index.js +7 -0
  391. package/dist/services/openai/index.d.ts +2 -0
  392. package/dist/services/openai/index.js +7 -0
  393. package/dist/services/openclaw/index.d.ts +14 -0
  394. package/dist/services/openclaw/index.js +19 -0
  395. package/dist/services/openrouter/index.d.ts +2 -0
  396. package/dist/services/openrouter/index.js +7 -0
  397. package/dist/services/perplexity/index.d.ts +2 -0
  398. package/dist/services/perplexity/index.js +7 -0
  399. package/dist/services/piper/index.d.ts +2 -0
  400. package/dist/services/piper/index.js +7 -0
  401. package/dist/services/pocket_tts/index.d.ts +2 -0
  402. package/dist/services/pocket_tts/index.js +7 -0
  403. package/dist/services/qwen/index.d.ts +2 -0
  404. package/dist/services/qwen/index.js +7 -0
  405. package/dist/services/resembleai/index.d.ts +2 -0
  406. package/dist/services/resembleai/index.js +7 -0
  407. package/dist/services/rime/index.d.ts +2 -0
  408. package/dist/services/rime/index.js +7 -0
  409. package/dist/services/sambanova/index.d.ts +2 -0
  410. package/dist/services/sambanova/index.js +7 -0
  411. package/dist/services/sarvam/index.d.ts +2 -0
  412. package/dist/services/sarvam/index.js +7 -0
  413. package/dist/services/settings.d.ts +356 -0
  414. package/dist/services/settings.js +437 -0
  415. package/dist/services/simli/index.d.ts +2 -0
  416. package/dist/services/simli/index.js +7 -0
  417. package/dist/services/smallest/index.d.ts +2 -0
  418. package/dist/services/smallest/index.js +7 -0
  419. package/dist/services/soniox/index.d.ts +2 -0
  420. package/dist/services/soniox/index.js +7 -0
  421. package/dist/services/speechify/index.d.ts +2 -0
  422. package/dist/services/speechify/index.js +7 -0
  423. package/dist/services/speechmatics/index.d.ts +2 -0
  424. package/dist/services/speechmatics/index.js +7 -0
  425. package/dist/services/stt_latency.d.ts +62 -0
  426. package/dist/services/stt_latency.js +71 -0
  427. package/dist/services/stt_service.d.ts +601 -0
  428. package/dist/services/stt_service.js +1044 -0
  429. package/dist/services/tavus/index.d.ts +2 -0
  430. package/dist/services/tavus/index.js +7 -0
  431. package/dist/services/together/index.d.ts +2 -0
  432. package/dist/services/together/index.js +7 -0
  433. package/dist/services/tts_service.d.ts +909 -0
  434. package/dist/services/tts_service.js +2018 -0
  435. package/dist/services/ultravox/index.d.ts +2 -0
  436. package/dist/services/ultravox/index.js +9 -0
  437. package/dist/services/vision_service.d.ts +56 -0
  438. package/dist/services/vision_service.js +62 -0
  439. package/dist/services/websocket_service.d.ts +213 -0
  440. package/dist/services/websocket_service.js +654 -0
  441. package/dist/services/whisper/index.d.ts +2 -0
  442. package/dist/services/whisper/index.js +9 -0
  443. package/dist/services/xai/index.d.ts +2 -0
  444. package/dist/services/xai/index.js +9 -0
  445. package/dist/services/xtts/index.d.ts +2 -0
  446. package/dist/services/xtts/index.js +9 -0
  447. package/dist/tests/index.d.ts +2 -0
  448. package/dist/tests/index.js +8 -0
  449. package/dist/tests/utils.d.ts +123 -0
  450. package/dist/tests/utils.js +212 -0
  451. package/dist/transcriptions/index.d.ts +2 -0
  452. package/dist/transcriptions/index.js +8 -0
  453. package/dist/transcriptions/language.d.ts +376 -0
  454. package/dist/transcriptions/language.js +543 -0
  455. package/dist/transports/base_input.d.ts +149 -0
  456. package/dist/transports/base_input.js +338 -0
  457. package/dist/transports/base_output.d.ts +432 -0
  458. package/dist/transports/base_output.js +1231 -0
  459. package/dist/transports/base_transport.d.ts +223 -0
  460. package/dist/transports/base_transport.js +167 -0
  461. package/dist/transports/daily/index.d.ts +2 -0
  462. package/dist/transports/daily/index.js +9 -0
  463. package/dist/transports/heygen/index.d.ts +2 -0
  464. package/dist/transports/heygen/index.js +9 -0
  465. package/dist/transports/index.d.ts +2 -0
  466. package/dist/transports/index.js +8 -0
  467. package/dist/transports/lemonslice/index.d.ts +2 -0
  468. package/dist/transports/lemonslice/index.js +9 -0
  469. package/dist/transports/livekit/index.d.ts +2 -0
  470. package/dist/transports/livekit/index.js +9 -0
  471. package/dist/transports/local/index.d.ts +2 -0
  472. package/dist/transports/local/index.js +9 -0
  473. package/dist/transports/moq/index.d.ts +2 -0
  474. package/dist/transports/moq/index.js +9 -0
  475. package/dist/transports/smallwebrtc/index.d.ts +2 -0
  476. package/dist/transports/smallwebrtc/index.js +9 -0
  477. package/dist/transports/tavus/index.d.ts +2 -0
  478. package/dist/transports/tavus/index.js +9 -0
  479. package/dist/transports/video_in_sampler.d.ts +157 -0
  480. package/dist/transports/video_in_sampler.js +258 -0
  481. package/dist/transports/vonage/index.d.ts +2 -0
  482. package/dist/transports/vonage/index.js +9 -0
  483. package/dist/transports/websocket/index.d.ts +2 -0
  484. package/dist/transports/websocket/index.js +9 -0
  485. package/dist/transports/whatsapp/index.d.ts +2 -0
  486. package/dist/transports/whatsapp/index.js +9 -0
  487. package/dist/turns/eager_end_of_turn_mixin.d.ts +70 -0
  488. package/dist/turns/eager_end_of_turn_mixin.js +122 -0
  489. package/dist/turns/empty_user_turn.d.ts +55 -0
  490. package/dist/turns/empty_user_turn.js +53 -0
  491. package/dist/turns/index.d.ts +2 -0
  492. package/dist/turns/index.js +8 -0
  493. package/dist/turns/speculation_gate.d.ts +169 -0
  494. package/dist/turns/speculation_gate.js +294 -0
  495. package/dist/turns/types.d.ts +33 -0
  496. package/dist/turns/types.js +35 -0
  497. package/dist/turns/user_idle_controller.d.ts +94 -0
  498. package/dist/turns/user_idle_controller.js +187 -0
  499. package/dist/turns/user_mute/always_user_mute_strategy.d.ts +15 -0
  500. package/dist/turns/user_mute/always_user_mute_strategy.js +29 -0
  501. package/dist/turns/user_mute/base_user_mute_strategy.d.ts +39 -0
  502. package/dist/turns/user_mute/base_user_mute_strategy.js +45 -0
  503. package/dist/turns/user_mute/first_speech_user_mute_strategy.d.ts +28 -0
  504. package/dist/turns/user_mute/first_speech_user_mute_strategy.js +52 -0
  505. package/dist/turns/user_mute/function_call_user_mute_strategy.d.ts +25 -0
  506. package/dist/turns/user_mute/function_call_user_mute_strategy.js +50 -0
  507. package/dist/turns/user_mute/index.d.ts +6 -0
  508. package/dist/turns/user_mute/index.js +11 -0
  509. package/dist/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.d.ts +43 -0
  510. package/dist/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.js +76 -0
  511. package/dist/turns/user_start/base_user_turn_start_strategy.d.ts +135 -0
  512. package/dist/turns/user_start/base_user_turn_start_strategy.js +134 -0
  513. package/dist/turns/user_start/external_user_turn_start_strategy.d.ts +45 -0
  514. package/dist/turns/user_start/external_user_turn_start_strategy.js +64 -0
  515. package/dist/turns/user_start/index.d.ts +8 -0
  516. package/dist/turns/user_start/index.js +16 -0
  517. package/dist/turns/user_start/krisp_viva_ip_user_turn_start_strategy.d.ts +133 -0
  518. package/dist/turns/user_start/krisp_viva_ip_user_turn_start_strategy.js +243 -0
  519. package/dist/turns/user_start/min_words_user_turn_start_strategy.d.ts +60 -0
  520. package/dist/turns/user_start/min_words_user_turn_start_strategy.js +102 -0
  521. package/dist/turns/user_start/transcription_user_turn_start_strategy.d.ts +29 -0
  522. package/dist/turns/user_start/transcription_user_turn_start_strategy.js +43 -0
  523. package/dist/turns/user_start/vad_user_turn_start_strategy.d.ts +20 -0
  524. package/dist/turns/user_start/vad_user_turn_start_strategy.js +31 -0
  525. package/dist/turns/user_start/wake_phrase_user_turn_start_strategy.d.ts +141 -0
  526. package/dist/turns/user_start/wake_phrase_user_turn_start_strategy.js +238 -0
  527. package/dist/turns/user_stop/base_user_turn_stop_strategy.d.ts +174 -0
  528. package/dist/turns/user_stop/base_user_turn_stop_strategy.js +170 -0
  529. package/dist/turns/user_stop/deferred_user_turn_stop_strategy.d.ts +81 -0
  530. package/dist/turns/user_stop/deferred_user_turn_stop_strategy.js +107 -0
  531. package/dist/turns/user_stop/eager_match_policy.d.ts +36 -0
  532. package/dist/turns/user_stop/eager_match_policy.js +50 -0
  533. package/dist/turns/user_stop/eager_user_turn_stop_strategy.d.ts +84 -0
  534. package/dist/turns/user_stop/eager_user_turn_stop_strategy.js +154 -0
  535. package/dist/turns/user_stop/external_user_turn_completion_stop_strategy.d.ts +45 -0
  536. package/dist/turns/user_stop/external_user_turn_completion_stop_strategy.js +55 -0
  537. package/dist/turns/user_stop/external_user_turn_stop_strategy.d.ts +115 -0
  538. package/dist/turns/user_stop/external_user_turn_stop_strategy.js +205 -0
  539. package/dist/turns/user_stop/index.d.ts +10 -0
  540. package/dist/turns/user_stop/index.js +15 -0
  541. package/dist/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.d.ts +44 -0
  542. package/dist/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.js +59 -0
  543. package/dist/turns/user_stop/speech_timeout_user_turn_stop_strategy.d.ts +140 -0
  544. package/dist/turns/user_stop/speech_timeout_user_turn_stop_strategy.js +303 -0
  545. package/dist/turns/user_stop/turn_analyzer_user_turn_stop_strategy.d.ts +135 -0
  546. package/dist/turns/user_stop/turn_analyzer_user_turn_stop_strategy.js +332 -0
  547. package/dist/turns/user_turn_completion_mixin.d.ts +147 -0
  548. package/dist/turns/user_turn_completion_mixin.js +591 -0
  549. package/dist/turns/user_turn_controller.d.ts +134 -0
  550. package/dist/turns/user_turn_controller.js +335 -0
  551. package/dist/turns/user_turn_processor.d.ts +87 -0
  552. package/dist/turns/user_turn_processor.js +180 -0
  553. package/dist/turns/user_turn_strategies.d.ts +187 -0
  554. package/dist/turns/user_turn_strategies.js +222 -0
  555. package/dist/utils/async_tool_cancellation.d.ts +35 -0
  556. package/dist/utils/async_tool_cancellation.js +78 -0
  557. package/dist/utils/asyncio/index.d.ts +2 -0
  558. package/dist/utils/asyncio/index.js +7 -0
  559. package/dist/utils/asyncio/task_manager.d.ts +49 -0
  560. package/dist/utils/asyncio/task_manager.js +105 -0
  561. package/dist/utils/base_object.d.ts +76 -0
  562. package/dist/utils/base_object.js +154 -0
  563. package/dist/utils/context/aggregated_frame_sequencer.d.ts +491 -0
  564. package/dist/utils/context/aggregated_frame_sequencer.js +803 -0
  565. package/dist/utils/context/audio_context_manager.d.ts +131 -0
  566. package/dist/utils/context/audio_context_manager.js +233 -0
  567. package/dist/utils/context/index.d.ts +2 -0
  568. package/dist/utils/context/index.js +7 -0
  569. package/dist/utils/context/llm_context_summarization.d.ts +258 -0
  570. package/dist/utils/context/llm_context_summarization.js +511 -0
  571. package/dist/utils/context/text_segment_map.d.ts +611 -0
  572. package/dist/utils/context/text_segment_map.js +1132 -0
  573. package/dist/utils/context/word_completion_tracker.d.ts +273 -0
  574. package/dist/utils/context/word_completion_tracker.js +441 -0
  575. package/dist/utils/context/word_timestamp_tracker.d.ts +72 -0
  576. package/dist/utils/context/word_timestamp_tracker.js +98 -0
  577. package/dist/utils/deprecation.d.ts +28 -0
  578. package/dist/utils/deprecation.js +75 -0
  579. package/dist/utils/env.d.ts +38 -0
  580. package/dist/utils/env.js +62 -0
  581. package/dist/utils/errors.d.ts +75 -0
  582. package/dist/utils/errors.js +202 -0
  583. package/dist/utils/file_resolver.d.ts +126 -0
  584. package/dist/utils/file_resolver.js +314 -0
  585. package/dist/utils/file_storage.d.ts +99 -0
  586. package/dist/utils/file_storage.js +100 -0
  587. package/dist/utils/frame_queue.d.ts +36 -0
  588. package/dist/utils/frame_queue.js +66 -0
  589. package/dist/utils/http.d.ts +39 -0
  590. package/dist/utils/http.js +53 -0
  591. package/dist/utils/image.d.ts +15 -0
  592. package/dist/utils/image.js +30 -0
  593. package/dist/utils/index.d.ts +2 -0
  594. package/dist/utils/index.js +8 -0
  595. package/dist/utils/log_config.d.ts +34 -0
  596. package/dist/utils/log_config.js +76 -0
  597. package/dist/utils/logger.d.ts +40 -0
  598. package/dist/utils/logger.js +91 -0
  599. package/dist/utils/mixin.d.ts +14 -0
  600. package/dist/utils/mixin.js +63 -0
  601. package/dist/utils/network.d.ts +73 -0
  602. package/dist/utils/network.js +90 -0
  603. package/dist/utils/security/allowed_origins.d.ts +16 -0
  604. package/dist/utils/security/allowed_origins.js +34 -0
  605. package/dist/utils/security/index.d.ts +2 -0
  606. package/dist/utils/security/index.js +7 -0
  607. package/dist/utils/security/redaction.d.ts +27 -0
  608. package/dist/utils/security/redaction.js +123 -0
  609. package/dist/utils/security/ssrf.d.ts +79 -0
  610. package/dist/utils/security/ssrf.js +327 -0
  611. package/dist/utils/shared.d.ts +35 -0
  612. package/dist/utils/shared.js +136 -0
  613. package/dist/utils/startup.d.ts +17 -0
  614. package/dist/utils/startup.js +130 -0
  615. package/dist/utils/string.d.ts +116 -0
  616. package/dist/utils/string.js +460 -0
  617. package/dist/utils/sync/base_notifier.d.ts +25 -0
  618. package/dist/utils/sync/base_notifier.js +16 -0
  619. package/dist/utils/sync/event_notifier.d.ts +38 -0
  620. package/dist/utils/sync/event_notifier.js +51 -0
  621. package/dist/utils/sync/index.d.ts +2 -0
  622. package/dist/utils/sync/index.js +7 -0
  623. package/dist/utils/text/alnum_utils.d.ts +109 -0
  624. package/dist/utils/text/alnum_utils.js +262 -0
  625. package/dist/utils/text/base_text_aggregator.d.ts +146 -0
  626. package/dist/utils/text/base_text_aggregator.js +102 -0
  627. package/dist/utils/text/base_text_filter.d.ts +54 -0
  628. package/dist/utils/text/base_text_filter.js +50 -0
  629. package/dist/utils/text/index.d.ts +2 -0
  630. package/dist/utils/text/index.js +7 -0
  631. package/dist/utils/text/markdown_text_filter.d.ts +147 -0
  632. package/dist/utils/text/markdown_text_filter.js +522 -0
  633. package/dist/utils/text/markup_utils.d.ts +54 -0
  634. package/dist/utils/text/markup_utils.js +156 -0
  635. package/dist/utils/text/pattern_pair_aggregator.d.ts +251 -0
  636. package/dist/utils/text/pattern_pair_aggregator.js +478 -0
  637. package/dist/utils/text/phonemes.d.ts +60 -0
  638. package/dist/utils/text/phonemes.js +172 -0
  639. package/dist/utils/text/simple_text_aggregator.d.ts +135 -0
  640. package/dist/utils/text/simple_text_aggregator.js +254 -0
  641. package/dist/utils/text/skip_tags_aggregator.d.ts +85 -0
  642. package/dist/utils/text/skip_tags_aggregator.js +147 -0
  643. package/dist/utils/text/transforms/pronunciations.d.ts +65 -0
  644. package/dist/utils/text/transforms/pronunciations.js +111 -0
  645. package/dist/utils/text/word_timestamp_utils.d.ts +26 -0
  646. package/dist/utils/text/word_timestamp_utils.js +52 -0
  647. package/dist/utils/time.d.ts +13 -0
  648. package/dist/utils/time.js +31 -0
  649. package/dist/utils/tracing/index.d.ts +3 -0
  650. package/dist/utils/tracing/index.js +8 -0
  651. package/dist/utils/tracing/service_attributes.d.ts +198 -0
  652. package/dist/utils/tracing/service_attributes.js +434 -0
  653. package/dist/utils/tracing/service_decorators.d.ts +214 -0
  654. package/dist/utils/tracing/service_decorators.js +1662 -0
  655. package/dist/utils/tracing/setup.d.ts +95 -0
  656. package/dist/utils/tracing/setup.js +110 -0
  657. package/dist/utils/tracing/tracing_context.d.ts +55 -0
  658. package/dist/utils/tracing/tracing_context.js +97 -0
  659. package/dist/utils/tracing/turn_trace_observer.d.ts +90 -0
  660. package/dist/utils/tracing/turn_trace_observer.js +219 -0
  661. package/dist/utils/types.d.ts +80 -0
  662. package/dist/utils/types.js +108 -0
  663. package/dist/utils/utils.d.ts +5 -0
  664. package/dist/utils/utils.js +22 -0
  665. package/dist/utils/yaml.d.ts +64 -0
  666. package/dist/utils/yaml.js +41 -0
  667. package/dist/version.d.ts +3 -0
  668. package/dist/version.js +7 -0
  669. package/dist/workers/base_worker.d.ts +772 -0
  670. package/dist/workers/base_worker.js +1298 -0
  671. package/dist/workers/index.d.ts +2 -0
  672. package/dist/workers/index.js +8 -0
  673. package/dist/workers/llm/backend_llm_worker.d.ts +552 -0
  674. package/dist/workers/llm/backend_llm_worker.js +1055 -0
  675. package/dist/workers/llm/index.d.ts +6 -0
  676. package/dist/workers/llm/index.js +11 -0
  677. package/dist/workers/llm/llm_context_worker.d.ts +66 -0
  678. package/dist/workers/llm/llm_context_worker.js +72 -0
  679. package/dist/workers/llm/llm_worker.d.ts +180 -0
  680. package/dist/workers/llm/llm_worker.js +323 -0
  681. package/dist/workers/llm/tool_decorator.d.ts +68 -0
  682. package/dist/workers/llm/tool_decorator.js +60 -0
  683. package/dist/workers/runner.d.ts +268 -0
  684. package/dist/workers/runner.js +489 -0
  685. package/dist/workers/ui/index.d.ts +22 -0
  686. package/dist/workers/ui/index.js +27 -0
  687. package/dist/workers/ui/ui_event_decorator.d.ts +43 -0
  688. package/dist/workers/ui/ui_event_decorator.js +75 -0
  689. package/dist/workers/ui/ui_job_context.d.ts +12 -0
  690. package/dist/workers/ui/ui_job_context.js +17 -0
  691. package/dist/workers/ui/ui_prompts.d.ts +21 -0
  692. package/dist/workers/ui/ui_prompts.js +41 -0
  693. package/dist/workers/ui/ui_tools.d.ts +32 -0
  694. package/dist/workers/ui/ui_tools.js +112 -0
  695. package/dist/workers/ui/ui_worker.d.ts +571 -0
  696. package/dist/workers/ui/ui_worker.js +1156 -0
  697. package/package.json +46 -3
  698. package/README.md +0 -5
@@ -0,0 +1,2018 @@
1
+ // Copyright (c) 2024-2026, Daily
2
+ // Copyright (c) 2026, pipedog contributors
3
+ // SPDX-License-Identifier: BSD-2-Clause
4
+ //
5
+ // Ported from pipecat: src/pipecat/services/tts_service.py
6
+ /**
7
+ * Base classes for Text-to-speech services.
8
+ *
9
+ * Porting notes: the deprecated `aggregateSentences`, `pauseWatchdogTimeoutS`
10
+ * and `skipAggregatorTypes` constructor options, the deprecated
11
+ * `aggregationType` argument of `addTextTransformer()` /
12
+ * `removeTextTransformer()`, `setModel()` / `setVoice()`, the legacy
13
+ * `TTSUpdateSettingsFrame(settings={...})` path and the deprecated
14
+ * `WordTTSService`, `WebsocketWordTTSService`, `InterruptibleWordTTSService`,
15
+ * `AudioContextTTSService` and `AudioContextWordTTSService` classes are not
16
+ * ported.
17
+ */
18
+ import { rethrowIfCancelled, sleep, TimeoutError } from "@pipedog/asyncio";
19
+ import { createStreamResampler } from "../audio/utils.js";
20
+ import { AggregatedTextFrame, BotStartedSpeakingFrame, BotStoppedSpeakingFrame, EagerTranscriptionFrame, EndFrame, ErrorFrame, Frame, InterimTranscriptionFrame, InterruptionFrame, LLMAssistantPushAggregationFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, SystemFrame, TextFrame, TextType, TranscriptionFrame, TTSAudioRawFrame, TTSSpeakFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame, TTSUpdateSettingsFrame, } from "../frames/frames.js";
21
+ import { FrameDirection } from "../processors/frame_processor.js";
22
+ import { isLanguage } from "../transcriptions/language.js";
23
+ import { AggregatedFrameSequencer } from "../utils/context/aggregated_frame_sequencer.js";
24
+ import { AudioContextFrame, AudioContextManager } from "../utils/context/audio_context_manager.js";
25
+ import { WordTimestamp, WordTimestampTracker } from "../utils/context/word_timestamp_tracker.js";
26
+ import { ErrorCategory } from "../utils/errors.js";
27
+ import { logger } from "../utils/logger.js";
28
+ import { resolveSentenceTokenizerLanguage } from "../utils/string.js";
29
+ import { PatternMatch } from "../utils/text/pattern_pair_aggregator.js";
30
+ import { SimpleTextAggregator } from "../utils/text/simple_text_aggregator.js";
31
+ import { PronunciationTransform, pronunciationTransform, } from "../utils/text/transforms/pronunciations.js";
32
+ import { mergePunctTokens } from "../utils/text/word_timestamp_utils.js";
33
+ import { isGiven } from "../utils/types.js";
34
+ import { AIService } from "./ai_service.js";
35
+ import { TTSSettings } from "./settings.js";
36
+ import { State, WebsocketService } from "./websocket_service.js";
37
+ /**
38
+ * Controls how incoming text is aggregated before TTS synthesis.
39
+ *
40
+ * - SENTENCE: Buffer text until sentence boundaries are detected before
41
+ * synthesis. Produces more natural speech but adds latency (~200-300ms per
42
+ * sentence).
43
+ * - TOKEN: Stream text tokens directly to TTS as they arrive. Reduces latency
44
+ * but may affect speech quality depending on the TTS provider.
45
+ */
46
+ export const TextAggregationMode = {
47
+ SENTENCE: "sentence",
48
+ TOKEN: "token",
49
+ };
50
+ /**
51
+ * Context information for a TTS request.
52
+ *
53
+ * - `appendToContext`: Whether this TTS output should be appended to the
54
+ * conversation context after it is spoken.
55
+ * - `pushAssistantAggregation`: Whether to push an
56
+ * `LLMAssistantPushAggregationFrame` after the TTS has finished speaking,
57
+ * forcing the assistant aggregator to commit its current text buffer to the
58
+ * conversation context.
59
+ * - `interruptible`: Whether an interruption may discard this synthesis.
60
+ */
61
+ export class TTSContext {
62
+ appendToContext;
63
+ pushAssistantAggregation;
64
+ interruptible;
65
+ constructor(options = {}) {
66
+ this.appendToContext = options.appendToContext ?? true;
67
+ this.pushAssistantAggregation = options.pushAssistantAggregation ?? false;
68
+ this.interruptible = options.interruptible ?? true;
69
+ }
70
+ }
71
+ /** Internal: what has happened so far while an audio context plays. */
72
+ export class _AudioContextPlayback {
73
+ receivedAudio = false;
74
+ stopFrameDue = false;
75
+ }
76
+ /**
77
+ * Base class for text-to-speech services.
78
+ *
79
+ * Provides common functionality for TTS services including text aggregation,
80
+ * filtering, audio generation, and frame management. Supports configurable
81
+ * sentence aggregation, silence insertion, and frame processing control.
82
+ *
83
+ * Event handlers:
84
+ *
85
+ * - on_connected: Called when connected to the TTS service.
86
+ * - on_disconnected: Called when disconnected from the TTS service.
87
+ * - on_connection_error: Called when a connection to the TTS service error occurs.
88
+ * - on_tts_request: Called before a TTS request is made, with the context ID and text.
89
+ *
90
+ * @example
91
+ * tts.eventHandler("on_connected", async (tts: TTSService) => {
92
+ * logger.debug("TTS connected");
93
+ * });
94
+ * tts.eventHandler("on_disconnected", async (tts: TTSService) => {
95
+ * logger.debug("TTS disconnected");
96
+ * });
97
+ * tts.eventHandler("on_connection_error", async (tts: TTSService, error: string) => {
98
+ * logger.error(`TTS connection error: ${error}`);
99
+ * });
100
+ * tts.eventHandler("on_tts_request", async (tts: TTSService, contextId: string, text: string) => {
101
+ * logger.debug(`TTS request: ${contextId} - ${text}`);
102
+ * });
103
+ */
104
+ export class TTSService extends AIService {
105
+ _textAggregationMode;
106
+ _pushTextFrames;
107
+ _pushStopFrames;
108
+ _pushStartFrame;
109
+ _stopFrameTimeoutS;
110
+ _pushSilenceAfterStop;
111
+ _silenceTimeS;
112
+ _pauseFrameProcessing;
113
+ _maxConsecutiveZeroAudioContexts;
114
+ _consecutiveZeroAudioContexts = 0;
115
+ _appendTrailingSpace;
116
+ _initSampleRate;
117
+ _sampleRate = 0;
118
+ _textAggregator;
119
+ _skippedTextAggregator;
120
+ _skipTextTypes;
121
+ _textTransforms;
122
+ _skippingPronunciations = false;
123
+ _textFilters;
124
+ _transportDestination;
125
+ _aggregatedFrameSequencer;
126
+ _resampler;
127
+ _ttsContexts = new Map();
128
+ _streamedText = "";
129
+ _sentNonWhitespaceInContext = false;
130
+ _textAggregationMetricsStarted = false;
131
+ _wordTimestampTracker;
132
+ _reuseContextIdWithinTurn;
133
+ _turnContextId = null;
134
+ _audioContextManager;
135
+ _audioContextTask = null;
136
+ _isYieldingFramesSynchronously = false;
137
+ /**
138
+ * Initialize the TTS service.
139
+ *
140
+ * @param options See `TTSServiceOptions`. Other options are passed to the
141
+ * parent AIService.
142
+ */
143
+ constructor(options = {}) {
144
+ super({
145
+ ...options,
146
+ settings: options.settings ??
147
+ // Here in case subclass doesn't implement more specific settings
148
+ // (which hopefully should be rare)
149
+ new TTSSettings(),
150
+ });
151
+ const language = this._settings.language;
152
+ const aggregationLanguage = resolveSentenceTokenizerLanguage(isGiven(language) ? language : null);
153
+ // Runtime updates are converted by _updateSettings(), but init-time
154
+ // settings bypass that path.
155
+ if (typeof this._settings.language === "string") {
156
+ this._settings.language = this._serviceLanguage(this._settings.language);
157
+ }
158
+ this._textAggregationMode = options.textAggregationMode ?? TextAggregationMode.SENTENCE;
159
+ this._pushTextFrames = options.pushTextFrames ?? true;
160
+ this._pushStopFrames = options.pushStopFrames ?? false;
161
+ this._pushStartFrame = options.pushStartFrame ?? false;
162
+ this._stopFrameTimeoutS = options.stopFrameTimeoutS ?? 3.0;
163
+ this._pushSilenceAfterStop = options.pushSilenceAfterStop ?? false;
164
+ this._silenceTimeS = options.silenceTimeS ?? 2.0;
165
+ this._pauseFrameProcessing = options.pauseFrameProcessing ?? false;
166
+ this._maxConsecutiveZeroAudioContexts = options.maxConsecutiveZeroAudioContexts ?? 3;
167
+ this._appendTrailingSpace = options.appendTrailingSpace ?? false;
168
+ this._initSampleRate = options.sampleRate ?? null;
169
+ this._textAggregator = new SimpleTextAggregator({
170
+ aggregationType: this._textAggregationMode,
171
+ language: aggregationLanguage,
172
+ });
173
+ // Cuts LLM text marked skipTts into sentences, so a reply that isn't
174
+ // spoken still reaches clients as bot output. It's separate from the
175
+ // aggregator above because that one yields single tokens in token mode,
176
+ // and only spoken tokens are regrouped into sentences afterwards (by the
177
+ // sequencer).
178
+ this._skippedTextAggregator = new SimpleTextAggregator({
179
+ aggregationType: TextAggregationMode.SENTENCE,
180
+ language: aggregationLanguage,
181
+ });
182
+ this._skipTextTypes = options.skipTextTypes ?? [];
183
+ this._textTransforms = options.textTransforms ?? [];
184
+ // TODO: Deprecate _textFilters when added to LLMTextProcessor
185
+ this._textFilters = options.textFilters ?? [];
186
+ this._transportDestination = options.transportDestination ?? null;
187
+ // Ordered sequence of every AggregatedTextFrame slot that passes through
188
+ // _handleAggregatedText (both spoken and skipped). Skipped frames are held
189
+ // here until all preceding spoken slots are complete, then flushed
190
+ // downstream so their appendToContext=true arrives at the assistant
191
+ // aggregator in the correct order relative to the TTSTextFrames from
192
+ // spoken sentences.
193
+ //
194
+ // In streaming (TOKEN) mode the sequencer regroups tokens back into
195
+ // sentences, which relies on the whole turn sharing one context ID; this
196
+ // is only sound with reuseContextIdWithinTurn=true (see the sequencer's
197
+ // `streaming` docstring).
198
+ this._aggregatedFrameSequencer = new AggregatedFrameSequencer({
199
+ name: String(this),
200
+ streaming: this._isStreamingTokens,
201
+ });
202
+ this._resampler = createStreamResampler();
203
+ // Turns provider word times into the PTS of word frames, and gives
204
+ // TTSStoppedFrame and LLMFullResponseEndFrame the PTS of the last word.
205
+ this._wordTimestampTracker = new WordTimestampTracker();
206
+ this._reuseContextIdWithinTurn = options.reuseContextIdWithinTurn ?? true;
207
+ // _turnContextId is shared by the sentences of the current turn, so they
208
+ // form one audio context. A TTSSpeakFrame swaps in its own while it is
209
+ // spoken. Unlike the playing context (see getActiveAudioContextId), it is
210
+ // cleared once the turn's synthesis completes, before its audio has
211
+ // played.
212
+ // Audio contexts and the downstream frames between them, emitted in
213
+ // order. Created once so it survives interruptions, which drop
214
+ // interruptible contexts and frames while keeping uninterruptible ones
215
+ // (e.g. FunctionCallResultFrame) that must not be lost mid-flight.
216
+ this._audioContextManager = new AudioContextManager();
217
+ this._registerEventHandler("on_connected");
218
+ this._registerEventHandler("on_disconnected");
219
+ this._registerEventHandler("on_connection_error");
220
+ this._registerEventHandler("on_tts_request");
221
+ // _isYieldingFramesSynchronously: whether the TTS process is currently
222
+ // yielding audio frames synchronously.
223
+ }
224
+ /** The current sample rate for audio output, in Hz. */
225
+ get sampleRate() {
226
+ return this._sampleRate;
227
+ }
228
+ /**
229
+ * The recommended chunk size for audio streaming, in bytes.
230
+ *
231
+ * This property indicates how much audio we download (from TTS services
232
+ * that require chunking) before we start pushing the first audio frame.
233
+ * This will make sure we download the rest of the audio while audio is
234
+ * being played without causing audio glitches (specially at the
235
+ * beginning). Of course, this will also depend on how fast the TTS service
236
+ * generates bytes.
237
+ */
238
+ get chunkSize() {
239
+ const CHUNK_SECONDS = 0.5;
240
+ return Math.trunc(this.sampleRate * CHUNK_SECONDS * 2); // 2 bytes/sample
241
+ }
242
+ /** Current sentence tokenizer language, derived from TTS settings. */
243
+ get textAggregationLanguage() {
244
+ return this._textAggregator.language;
245
+ }
246
+ /**
247
+ * Whether this service has a meaningful processing-time metric.
248
+ *
249
+ * Processing time is measured around `runTts()`, so it only means something
250
+ * when synthesis finishes before `runTts` returns. Services that hand the
251
+ * text off and receive audio elsewhere — anything holding a persistent
252
+ * connection with its own receive task — return false, since the
253
+ * measurement would cover the send and nothing else. TTFB and TTFA carry
254
+ * the latency for those.
255
+ */
256
+ get supportsProcessingMetrics() {
257
+ return true;
258
+ }
259
+ /**
260
+ * Record TTS usage metrics.
261
+ *
262
+ * When streaming tokens, usage metrics are aggregated and reported at flush
263
+ * time instead of per token, so individual calls are skipped.
264
+ *
265
+ * @param text The text being processed by TTS.
266
+ */
267
+ async startTtsUsageMetrics(text) {
268
+ if (this._isStreamingTokens)
269
+ return;
270
+ await super.startTtsUsageMetrics(text);
271
+ }
272
+ /**
273
+ * Start text aggregation metrics if not already started.
274
+ *
275
+ * Only starts the metric once per LLM response. Skipped when streaming
276
+ * tokens since per-token aggregation time is not meaningful.
277
+ */
278
+ async startTextAggregationMetrics() {
279
+ if (this._isStreamingTokens || this._textAggregationMetricsStarted)
280
+ return;
281
+ this._textAggregationMetricsStarted = true;
282
+ await super.startTextAggregationMetrics();
283
+ }
284
+ /** Stop text aggregation metrics and reset the started flag. */
285
+ async stopTextAggregationMetrics() {
286
+ this._textAggregationMetricsStarted = false;
287
+ await super.stopTextAggregationMetrics();
288
+ }
289
+ /**
290
+ * Set up the service.
291
+ *
292
+ * @param setup Configuration object containing setup parameters.
293
+ */
294
+ async setup(setup) {
295
+ await super.setup(setup);
296
+ this._sampleRate = this._initSampleRate || setup.audioOutSampleRate;
297
+ }
298
+ /**
299
+ * Start the TTS service.
300
+ *
301
+ * @param frame The start frame containing initialization parameters.
302
+ */
303
+ async start(frame) {
304
+ await super.start(frame);
305
+ this._createAudioContextTask();
306
+ }
307
+ /**
308
+ * Stop the TTS service.
309
+ *
310
+ * @param frame The end frame.
311
+ */
312
+ async stop(frame) {
313
+ await super.stop(frame);
314
+ if (this._audioContextTask) {
315
+ // Playback ends once all pending contexts and frames have played.
316
+ await this._audioContextManager.endPlayback();
317
+ await this._audioContextTask;
318
+ this._audioContextTask = null;
319
+ }
320
+ }
321
+ /**
322
+ * Cancel the TTS service.
323
+ *
324
+ * @param frame The cancel frame.
325
+ */
326
+ async cancel(frame) {
327
+ await super.cancel(frame);
328
+ // Prompt stop of audio production. cleanup() repeats this idempotently.
329
+ await this._stopAudioContextTask();
330
+ }
331
+ /** Release TTS resources at teardown. */
332
+ async cleanup() {
333
+ await super.cleanup();
334
+ await this._stopAudioContextTask();
335
+ }
336
+ /**
337
+ * Set whether this service can be given work.
338
+ *
339
+ * @param isUsable Whether the service can be given work.
340
+ */
341
+ async setUsable(isUsable) {
342
+ // Whatever silence wrote the service off has been dealt with, so the
343
+ // count of silent contexts starts over.
344
+ if (isUsable) {
345
+ this._consecutiveZeroAudioContexts = 0;
346
+ }
347
+ await super.setUsable(isUsable);
348
+ }
349
+ //
350
+ // Text transforms and pronunciations
351
+ //
352
+ /**
353
+ * Transform text of a specific type.
354
+ *
355
+ * @param transformFunction The function to apply for transformation. This
356
+ * function should take the text and its type as input and return the
357
+ * transformed text, e.g.
358
+ * `async function myTransform(text: string, textType: string): Promise<string>`.
359
+ * @param textType The type of text to transform. This value defaults to
360
+ * "*" indicating the function should handle all text before sending to
361
+ * TTS.
362
+ */
363
+ addTextTransformer(transformFunction, textType = "*") {
364
+ this._textTransforms.push([textType, transformFunction]);
365
+ }
366
+ /**
367
+ * Remove a text transformer for a specific text type.
368
+ *
369
+ * @param transformFunction The function to remove.
370
+ * @param textType The type of text to remove the transformer for.
371
+ */
372
+ removeTextTransformer(transformFunction, textType = "*") {
373
+ this._textTransforms = this._textTransforms.filter(([transformType, func]) => !(transformType === textType && func === transformFunction));
374
+ }
375
+ /**
376
+ * Render a word's pronunciation in this service's markup.
377
+ *
378
+ * Services that support pronunciation hints override this. The base
379
+ * implementation supports none.
380
+ *
381
+ * @param word The word as it appears in the text.
382
+ * @param ipa How to pronounce it, in IPA.
383
+ * @returns The text to send in place of `word`, or null when this service
384
+ * cannot use the pronunciation.
385
+ */
386
+ static formatPronunciation(_word, _ipa) {
387
+ return null;
388
+ }
389
+ /**
390
+ * Whether this service reads pronunciation markup with its current settings.
391
+ *
392
+ * Pronunciation transforms are skipped while this is false, so their words
393
+ * are spoken as written. Services whose markup depends on the model or on a
394
+ * setting override this; it is checked for every text sent, so it follows
395
+ * settings updates.
396
+ *
397
+ * @returns true in the base implementation.
398
+ */
399
+ get supportsPronunciations() {
400
+ return true;
401
+ }
402
+ /**
403
+ * Create a text transform that makes this service say words as IPA describes.
404
+ *
405
+ * Each word is replaced with `formatPronunciation()` output, so the same
406
+ * IPA works with any service that supports it. Words the service cannot use
407
+ * are reported once and spoken as written. While the service cannot read
408
+ * pronunciation markup at all (see `supportsPronunciations`), the transform
409
+ * is skipped.
410
+ *
411
+ * Register this transform last in `textTransforms`. Transforms run in
412
+ * order, and one that runs after it (stripping markdown or symbols,
413
+ * replacing text) can rewrite the markup it inserts, such as Cartesia's
414
+ * `<<…>>` blocks or an SSML `<phoneme>` tag, and break the hint.
415
+ *
416
+ * @param pronunciations Word to IPA, e.g. `{ Metformin: "mɛtˈfɔɹmɪn" }`.
417
+ * @returns A transform to register with `textTransforms`.
418
+ *
419
+ * @example
420
+ * const pronounce = CartesiaTTSService.pronunciationTransformIpa({ Metformin: "mɛtˈfɔɹmɪn" });
421
+ * const tts = new CartesiaTTSService({
422
+ * textTransforms: [
423
+ * ["*", stripMarkdown],
424
+ * ["*", expandCurrency],
425
+ * ["*", pronounce], // last, so nothing rewrites its markup
426
+ * ],
427
+ * });
428
+ */
429
+ static pronunciationTransformIpa(pronunciations) {
430
+ // `this` is the subclass the method is called on, like Python's `cls`.
431
+ return pronunciationTransform(pronunciations,
432
+ // biome-ignore lint/complexity/noThisInStatic: polymorphic classmethod.
433
+ (word, ipa) => this.formatPronunciation(word, ipa),
434
+ // biome-ignore lint/complexity/noThisInStatic: polymorphic classmethod.
435
+ { serviceName: this.name });
436
+ }
437
+ /**
438
+ * Convert a language to the service-specific language format.
439
+ *
440
+ * @param language The language to convert.
441
+ * @returns The service-specific language identifier. Return null to
442
+ * indicate an unsupported language. This optional return is an extension
443
+ * hook for future or third-party subclasses; as of 2026-04-28,
444
+ * first-party services return a string.
445
+ */
446
+ languageToServiceLanguage(language) {
447
+ return language;
448
+ }
449
+ /**
450
+ * Generate or reuse a context ID based on concurrent TTS support.
451
+ *
452
+ * @returns A context ID string for the TTS request.
453
+ */
454
+ createContextId() {
455
+ let contextId;
456
+ if (this._reuseContextIdWithinTurn && this._turnContextId) {
457
+ this._refreshAudioContext(this._turnContextId);
458
+ contextId = this._turnContextId;
459
+ }
460
+ else {
461
+ contextId = crypto.randomUUID();
462
+ }
463
+ return contextId;
464
+ }
465
+ /**
466
+ * Flush any buffered audio data.
467
+ *
468
+ * @param contextId The specific context to flush. If null, falls back to
469
+ * the currently active context (for non-concurrent services).
470
+ */
471
+ async flushAudio(_contextId = null) { }
472
+ /**
473
+ * Process frames from an async generator, routing them through the audio context.
474
+ *
475
+ * All non-null frames yielded by the generator are appended to the audio
476
+ * context identified by contextId. The audio context must be created by
477
+ * runTts (via createAudioContext) before the first frame is yielded.
478
+ *
479
+ * WebSocket services yield null to signal that audio will arrive via a
480
+ * separate receive loop; those services manage context lifetime themselves
481
+ * (via removeAudioContext in the receive loop on "done"). HTTP services
482
+ * never yield null and do NOT call removeAudioContext in runTts —
483
+ * onTurnContextCompleted closes the context once the turn's remaining
484
+ * frames (e.g. TTSTextFrame) are queued.
485
+ *
486
+ * @param contextId The audio context to route frames to.
487
+ * @param generator An async generator yielding Frame objects or null.
488
+ */
489
+ async ttsProcessGenerator(contextId, generator) {
490
+ let isYieldingFrames = false;
491
+ for await (const frame of generator) {
492
+ if (frame) {
493
+ await this.appendToAudioContext(contextId, frame);
494
+ if (frame instanceof TTSAudioRawFrame) {
495
+ isYieldingFrames = true;
496
+ }
497
+ }
498
+ }
499
+ this._isYieldingFramesSynchronously = isYieldingFrames;
500
+ }
501
+ /**
502
+ * Called when a new turn context ID has been created.
503
+ *
504
+ * Override to perform provider-specific setup (e.g., eagerly opening a
505
+ * server-side context) before text starts flowing. This is called from
506
+ * `processFrame` when an `LLMFullResponseStartFrame` or `TTSSpeakFrame`
507
+ * arrives.
508
+ *
509
+ * @param contextId The newly created turn context ID.
510
+ */
511
+ async onTurnContextCreated(_contextId) { }
512
+ /** Handle the completion of a turn. */
513
+ async onTurnContextCompleted() {
514
+ // HTTP services yield their audio inline, so their audio context is
515
+ // closed here, once all the turn's frames (including TTSTextFrame) are
516
+ // queued.
517
+ if (this._isYieldingFramesSynchronously &&
518
+ this._turnContextId !== null &&
519
+ this.audioContextAvailable(this._turnContextId)) {
520
+ if (this._pushStopFrames) {
521
+ await this.appendToAudioContext(this._turnContextId, new TTSStoppedFrame({ contextId: this._turnContextId }));
522
+ }
523
+ await this.removeAudioContext(this._turnContextId);
524
+ }
525
+ // Flush any pending audio so the TTS service closes the current context.
526
+ // Only flush if the context was actually opened (text reached runTts).
527
+ // When an interruption arrives before any text flows, the turn context ID
528
+ // exists but was never registered via createAudioContext, so flushing
529
+ // would send a message for a context the provider never opened.
530
+ if (this._turnContextId && this.audioContextAvailable(this._turnContextId)) {
531
+ await this.flushAudio(this._turnContextId);
532
+ }
533
+ // Reset the turn context ID
534
+ this._turnContextId = null;
535
+ }
536
+ /**
537
+ * Called when an audio context is cancelled due to an interruption.
538
+ *
539
+ * Override this in a subclass to perform provider-specific cleanup (e.g.
540
+ * sending a cancel/close message over the WebSocket) when the bot is
541
+ * interrupted mid-speech. The context is still registered when this hook
542
+ * runs. Uninterruptible contexts continue playing and do not call this
543
+ * hook.
544
+ *
545
+ * @param contextId The ID of the audio context that was interrupted.
546
+ */
547
+ async onAudioContextInterrupted(_contextId) { }
548
+ /**
549
+ * Called after an audio context has finished playing all of its audio.
550
+ *
551
+ * Override this in a subclass to perform provider-specific cleanup (e.g.
552
+ * sending a close-context message to free server-side resources) once an
553
+ * audio context has been fully processed. The context entry has already
554
+ * been removed from the internal context map, and the active context has
555
+ * **not** yet been reset when this is called.
556
+ *
557
+ * @param contextId The ID of the audio context that finished processing.
558
+ */
559
+ async onAudioContextCompleted(_contextId) { }
560
+ //
561
+ // Word timestamps
562
+ //
563
+ /** Start tracking word timestamps from the current time. */
564
+ async startWordTimestamps() {
565
+ for (const cached of this._wordTimestampTracker.start(this.getClock().getTime())) {
566
+ await this._addWordTimestamps([[cached.word, cached.timestamp]], cached.contextId, {
567
+ includesInterFrameSpaces: cached.includesInterFrameSpaces,
568
+ });
569
+ }
570
+ }
571
+ /** Reset word timestamp tracking. */
572
+ async resetWordTimestamps() {
573
+ this._wordTimestampTracker.reset();
574
+ }
575
+ /**
576
+ * Add word timestamps for processing.
577
+ *
578
+ * When an audio context exists for this contextId, timestamps are routed
579
+ * into the per-context audio queue alongside audio frames so they are
580
+ * processed in strict playback order by _handleAudioContext. Otherwise they
581
+ * are processed immediately via _addWordTimestamps.
582
+ *
583
+ * @param wordTimes List of [word, timestamp] tuples where timestamp is in
584
+ * seconds.
585
+ * @param contextId Unique identifier for the TTS context.
586
+ * @param options.includesInterFrameSpaces When true, each emitted
587
+ * TTSTextFrame carries `includesInterFrameSpaces=true` so downstream
588
+ * consumers do not inject extra spaces between consecutive frames (e.g.
589
+ * ElevenLabs for CJK languages where word tokens already self-contain
590
+ * their spacing). null leaves the frame's own default unchanged.
591
+ * @param options.preMergeTokens When true, punctuation and space-only
592
+ * tokens are merged into the preceding word via `mergePunctTokens` before
593
+ * queuing (e.g. Inworld TTS, which emits spaces and punctuation as
594
+ * separate tokens). After merging the resulting tokens are clean word
595
+ * strings, so `includesInterFrameSpaces` should be left false (its
596
+ * default).
597
+ */
598
+ async addWordTimestamps(wordTimes, contextId = null, options = {}) {
599
+ let times = wordTimes;
600
+ if (options.preMergeTokens) {
601
+ times = mergePunctTokens(times);
602
+ }
603
+ const ifs = Boolean(options.includesInterFrameSpaces);
604
+ if (contextId && this.audioContextAvailable(contextId)) {
605
+ for (const [word, timestamp] of times) {
606
+ await this.appendToAudioContext(contextId, new WordTimestamp({
607
+ word,
608
+ timestamp,
609
+ contextId,
610
+ includesInterFrameSpaces: ifs,
611
+ }));
612
+ }
613
+ }
614
+ else if (contextId !== null) {
615
+ logger.trace(`Dropping stale words for context ${contextId}; word times: ${JSON.stringify(times)}`);
616
+ }
617
+ else {
618
+ await this._addWordTimestamps(times, contextId, { includesInterFrameSpaces: ifs });
619
+ }
620
+ }
621
+ //
622
+ // Audio contexts
623
+ //
624
+ /**
625
+ * Create a new audio context for grouping related audio.
626
+ *
627
+ * @param contextId Unique identifier for the audio context.
628
+ */
629
+ async createAudioContext(contextId) {
630
+ const context = this._ttsContexts.get(contextId);
631
+ const interruptible = context ? context.interruptible : true;
632
+ await this._audioContextManager.create(contextId, { interruptible });
633
+ logger.trace(`${this} created audio context ${contextId}`);
634
+ }
635
+ /**
636
+ * Append a frame or word-timestamp entry to an existing audio context queue.
637
+ *
638
+ * Passing a `frame` of null signals end-of-context (used by
639
+ * removeAudioContext to mark the queue for deletion). If the context no
640
+ * longer exists but the contextId matches the active turn, the context is
641
+ * transparently recreated before appending.
642
+ *
643
+ * @param contextId The context to append to. null is accepted as a no-op
644
+ * (with a debug log) so callers can pass through values from
645
+ * `getActiveAudioContextId()` without an explicit guard.
646
+ * @param frame The frame, word-timestamp entry, or null (end-of-context
647
+ * sentinel) to append.
648
+ */
649
+ async appendToAudioContext(contextId, frame) {
650
+ if (!contextId) {
651
+ logger.debug(`${this} unable to append audio to context: no context ID provided`);
652
+ return;
653
+ }
654
+ // Sometimes the HTTP service can take more than 3 seconds without sending
655
+ // any audio, so the context is recreated while we are in the same turn.
656
+ // An end marker (null) never recreates it.
657
+ if (!this.audioContextAvailable(contextId) && contextId === this._turnContextId && frame) {
658
+ logger.debug(`${this} recreating audio context ${contextId}`);
659
+ await this.createAudioContext(contextId);
660
+ }
661
+ if (this.audioContextAvailable(contextId)) {
662
+ logger.trace(`${this} appending audio ${frame} to audio context ${contextId}`);
663
+ await this._audioContextManager.append(contextId, frame);
664
+ }
665
+ else {
666
+ logger.debug(`${this} unable to append audio to context ${contextId}`);
667
+ }
668
+ }
669
+ /**
670
+ * Remove an existing audio context.
671
+ *
672
+ * @param contextId The context to remove. null is accepted as a no-op
673
+ * (logged) so callers can pass through values from
674
+ * `getActiveAudioContextId()` without an explicit guard.
675
+ */
676
+ async removeAudioContext(contextId) {
677
+ if (!contextId) {
678
+ logger.debug(`${this} unable to remove audio context: no context ID provided`);
679
+ return;
680
+ }
681
+ if (this.audioContextAvailable(contextId)) {
682
+ // We just mark the audio context for deletion by appending null. Once
683
+ // we reach null while handling audio we know we can safely remove the
684
+ // context.
685
+ logger.trace(`${this} marking audio context ${contextId} for deletion`);
686
+ await this.appendToAudioContext(contextId, null);
687
+ }
688
+ else {
689
+ logger.warning(`${this} unable to remove context ${contextId}`);
690
+ }
691
+ }
692
+ /**
693
+ * Check whether the given audio context is registered.
694
+ *
695
+ * @param contextId The context ID to check.
696
+ * @returns true if the context exists and is available.
697
+ */
698
+ audioContextAvailable(contextId) {
699
+ return this._audioContextManager.available(contextId);
700
+ }
701
+ /** Get a list of all available audio contexts. */
702
+ getAudioContexts() {
703
+ return this._audioContextManager.contextIds;
704
+ }
705
+ /**
706
+ * Check if there is an active audio context.
707
+ *
708
+ * @returns true if an active audio context exists, false otherwise.
709
+ */
710
+ hasActiveAudioContext() {
711
+ const playingContextId = this._audioContextManager.playingContextId;
712
+ return playingContextId !== null && this.audioContextAvailable(playingContextId);
713
+ }
714
+ /**
715
+ * Get the active audio context ID.
716
+ *
717
+ * Returns the playback cursor when set (during active playback), falling
718
+ * back to the current turn's synthesis contextId. The fallback covers the
719
+ * gap between contexts and the start of a turn before the playback task has
720
+ * dequeued the just-created context — important for services whose wire
721
+ * protocol does not echo contextId back on incoming audio.
722
+ *
723
+ * @returns The active context ID, or null if neither cursor is set.
724
+ */
725
+ getActiveAudioContextId() {
726
+ return this._audioContextManager.playingContextId || this._turnContextId;
727
+ }
728
+ /** Remove the active audio context. */
729
+ async removeActiveAudioContext() {
730
+ const playingContextId = this._audioContextManager.playingContextId;
731
+ if (playingContextId) {
732
+ await this.removeAudioContext(playingContextId);
733
+ this.resetActiveAudioContext();
734
+ }
735
+ }
736
+ /** Reset the active audio context. */
737
+ resetActiveAudioContext() {
738
+ this._audioContextManager.playingContextId = null;
739
+ }
740
+ //
741
+ // Frame processor
742
+ //
743
+ /**
744
+ * Process frames for text-to-speech conversion.
745
+ *
746
+ * Handles TextFrames for synthesis, interruption frames, settings updates,
747
+ * and various control frames.
748
+ *
749
+ * @param frame The frame to process.
750
+ * @param direction The direction of frame processing.
751
+ */
752
+ async processFrame(frame, direction) {
753
+ await super.processFrame(frame, direction);
754
+ // A reply that isn't spoken is queued behind the audio still playing, so
755
+ // it comes out after that audio, its start, sentences and end in order.
756
+ if (frame instanceof LLMFullResponseStartFrame && frame.skipTts) {
757
+ await this._audioContextManager.putFrame(frame);
758
+ }
759
+ else if (frame instanceof LLMTextFrame && frame.skipTts) {
760
+ await this._handleSkippedLlmText(frame);
761
+ }
762
+ else if (frame instanceof LLMFullResponseEndFrame && frame.skipTts) {
763
+ await this._handleSkippedLlmResponseEnd(frame);
764
+ }
765
+ else if (frame instanceof TextFrame && frame.skipTts) {
766
+ await this.pushFrame(frame, direction);
767
+ }
768
+ else if (frame instanceof AggregatedTextFrame) {
769
+ await this._handleAggregatedText(frame);
770
+ }
771
+ else if (frame instanceof TextFrame &&
772
+ !(frame instanceof InterimTranscriptionFrame) &&
773
+ !(frame instanceof TranscriptionFrame) &&
774
+ !(frame instanceof EagerTranscriptionFrame)) {
775
+ await this.startTextAggregationMetrics();
776
+ await this._handleText(frame);
777
+ }
778
+ else if (frame instanceof InterruptionFrame) {
779
+ await this._handleInterruption(frame, direction);
780
+ await this.pushFrame(frame, direction);
781
+ }
782
+ else if (frame instanceof LLMFullResponseStartFrame) {
783
+ await this._handleLlmResponseStart(frame);
784
+ }
785
+ else if (frame instanceof LLMFullResponseEndFrame || frame instanceof EndFrame) {
786
+ await this._handleLlmResponseEnd(frame, direction);
787
+ }
788
+ else if (frame instanceof TTSSpeakFrame) {
789
+ await this._handleTtsSpeak(frame);
790
+ }
791
+ else if (frame instanceof TTSUpdateSettingsFrame) {
792
+ await this._handleUpdateSettings(frame, direction);
793
+ }
794
+ else {
795
+ if (direction === FrameDirection.DOWNSTREAM && !(frame instanceof SystemFrame)) {
796
+ // Queue non-system downstream frames behind the audio contexts so
797
+ // they are emitted in the same order they arrive relative to any
798
+ // audio contexts that are already queued (e.g. a FooFrame sent right
799
+ // after a TTSSpeakFrame must not overtake the TTSStartedFrame /
800
+ // TTSAudioRawFrame / TTSStoppedFrame sequence from that speak frame).
801
+ await this._audioContextManager.putFrame(frame);
802
+ }
803
+ else {
804
+ await this.pushFrame(frame, direction);
805
+ }
806
+ }
807
+ }
808
+ /**
809
+ * Push a frame downstream with TTS-specific handling.
810
+ *
811
+ * @param frame The frame to push.
812
+ * @param direction The direction to push the frame.
813
+ */
814
+ async pushFrame(frame, direction = FrameDirection.DOWNSTREAM) {
815
+ this._applyContextInterruptible(frame);
816
+ if (frame instanceof TTSStoppedFrame) {
817
+ await this._finishTtsContext(frame);
818
+ await this._pushStopSilence(frame);
819
+ }
820
+ if (frame instanceof TTSStartedFrame ||
821
+ frame instanceof TTSStoppedFrame ||
822
+ frame instanceof TTSAudioRawFrame ||
823
+ frame instanceof TTSTextFrame) {
824
+ frame.transportDestination = this._transportDestination;
825
+ }
826
+ if (frame instanceof AggregatedTextFrame) {
827
+ this._stampSegmentAnnouncement(frame);
828
+ }
829
+ await super.pushFrame(frame, direction);
830
+ }
831
+ //
832
+ // Settings
833
+ //
834
+ /**
835
+ * Apply a TTS settings delta.
836
+ *
837
+ * Translates language to service-specific value before applying.
838
+ *
839
+ * @param delta A TTS settings delta.
840
+ * @returns Record mapping changed field names to their previous values.
841
+ */
842
+ async _updateSettings(delta) {
843
+ const language = delta.language;
844
+ // Translate language *before* applying so the stored value is canonical.
845
+ if (isGiven(delta.language) && typeof delta.language === "string") {
846
+ delta.language = this._serviceLanguage(delta.language);
847
+ }
848
+ const changed = await super._updateSettings(delta);
849
+ if (isGiven(language)) {
850
+ this._textAggregator.setLanguage(language);
851
+ this._skippedTextAggregator.setLanguage(language);
852
+ }
853
+ return changed;
854
+ }
855
+ /** Convert a language to the service's format, passing unknown codes as they are. */
856
+ _serviceLanguage(language) {
857
+ // Raw strings (e.g. "de-DE") are first checked against the Language
858
+ // codes so they go through the same resolution logic.
859
+ if (!isLanguage(language)) {
860
+ logger.debug(`Language string '${language}' is not a recognized ` +
861
+ "Language code. It will be passed to the service as-is.");
862
+ return language;
863
+ }
864
+ const converted = this.languageToServiceLanguage(language);
865
+ return converted ?? language;
866
+ }
867
+ //
868
+ // Frame handling
869
+ //
870
+ /** Start a turn whose sentences share one context ID. */
871
+ async _handleLlmResponseStart(frame) {
872
+ this._turnContextId = this.createContextId();
873
+ await this.onTurnContextCreated(this._turnContextId);
874
+ // Queue behind the audio contexts so this frame is emitted only after any
875
+ // earlier audio context already queued has fully drained, rather than
876
+ // racing ahead of it (e.g. a new turn starting via LLMMessagesAppendFrame
877
+ // while the previous turn's audio is still playing out downstream).
878
+ await this._audioContextManager.putFrame(frame);
879
+ }
880
+ /** Speak the turn's remaining text and complete the turn. */
881
+ async _handleLlmResponseEnd(frame, direction) {
882
+ // Flush any remaining text (including text waiting for lookahead)
883
+ const remaining = await this._textAggregator.flush();
884
+ // Stop the aggregation metric (no-op if already stopped on first sentence).
885
+ await this.stopTextAggregationMetrics();
886
+ if (remaining) {
887
+ await this._handleAggregatedText(this._aggregatedTextFrame(remaining));
888
+ }
889
+ // Force-promote any sentence still pending in the sequencer (streaming
890
+ // mode only; a no-op otherwise) — handles a response that ends with no
891
+ // terminal punctuation.
892
+ await this._pushSequencerFrames(await this._aggregatedFrameSequencer.finalize(this._turnContextId), this._turnContextId);
893
+ await this._holdFramesWhileAudioContextsOpen();
894
+ // Log accumulated streamed text and emit aggregated usage metric.
895
+ if (this._streamedText) {
896
+ logger.debug(`${this}: Generating TTS [${this._streamedText}]`);
897
+ await super.startTtsUsageMetrics(this._streamedText);
898
+ this._streamedText = "";
899
+ }
900
+ // Reset aggregator state
901
+ this._sentNonWhitespaceInContext = false;
902
+ if (frame instanceof LLMFullResponseEndFrame) {
903
+ // Queue behind the audio contexts so the frame follows the turn's audio
904
+ // and text.
905
+ await this._audioContextManager.putFrame(frame);
906
+ }
907
+ else {
908
+ await this.pushFrame(frame, direction);
909
+ }
910
+ await this.onTurnContextCompleted();
911
+ }
912
+ /** Queue each sentence of LLM text that isn't spoken. */
913
+ async _handleSkippedLlmText(frame) {
914
+ for await (const aggregate of this._skippedTextAggregator.aggregate(frame.text)) {
915
+ await this._queueSkippedLlmText(aggregate);
916
+ }
917
+ }
918
+ /** Queue the rest of a reply that isn't spoken, then the end of the reply. */
919
+ async _handleSkippedLlmResponseEnd(frame) {
920
+ const remaining = await this._skippedTextAggregator.flush();
921
+ if (remaining) {
922
+ await this._queueSkippedLlmText(remaining);
923
+ }
924
+ await this._audioContextManager.putFrame(frame);
925
+ }
926
+ /** Queue a sentence of LLM text that isn't spoken, as the bot's output. */
927
+ async _queueSkippedLlmText(aggregation) {
928
+ if (!aggregation.text.trim())
929
+ return;
930
+ const frame = this._aggregatedTextFrame(aggregation);
931
+ frame.skipTts = true;
932
+ frame.transportDestination = this._transportDestination;
933
+ await this._audioContextManager.putFrame(frame);
934
+ }
935
+ /** Speak a TTSSpeakFrame as a turn of its own, even in the middle of an LLM turn. */
936
+ async _handleTtsSpeak(frame) {
937
+ const savedSentNonWhitespace = this._sentNonWhitespaceInContext;
938
+ this._sentNonWhitespaceInContext = false;
939
+ // TTSSpeakFrame is independent — temporarily clear the turn context so
940
+ // createContextId() generates a fresh UUID for this utterance.
941
+ const savedTurnContextId = this._turnContextId;
942
+ this._turnContextId = null;
943
+ // Creating a new contextId for the TTS request.
944
+ const speakContextId = this.createContextId();
945
+ this._turnContextId = speakContextId;
946
+ // Inside an LLM turn, the end of the turn commits the spoken text to the
947
+ // context. Outside one, the speak frame commits it once spoken.
948
+ this._ttsContexts.set(speakContextId, new TTSContext({
949
+ appendToContext: frame.appendToContext,
950
+ pushAssistantAggregation: frame.appendToContext && !savedTurnContextId,
951
+ interruptible: frame.interruptible,
952
+ }));
953
+ await this.onTurnContextCreated(speakContextId);
954
+ const textFrame = new AggregatedTextFrame({
955
+ text: frame.text,
956
+ textType: frame.textType,
957
+ rawText: frame.text,
958
+ });
959
+ textFrame.interruptible = frame.interruptible;
960
+ await this._handleAggregatedText(textFrame, speakContextId);
961
+ // Force-promote the utterance's pending sentence into a real slot
962
+ // (streaming mode only; a no-op otherwise). A TTSSpeakFrame is a
963
+ // self-contained sentence with no following token to confirm its
964
+ // boundary, so without this it would stay buffered and never be spoken.
965
+ await this._pushSequencerFrames(await this._aggregatedFrameSequencer.finalize(this._turnContextId), this._turnContextId);
966
+ await this.onTurnContextCompleted();
967
+ if (!this.audioContextAvailable(speakContextId)) {
968
+ this._ttsContexts.delete(speakContextId);
969
+ }
970
+ this._turnContextId = savedTurnContextId;
971
+ this._sentNonWhitespaceInContext = savedSentNonWhitespace;
972
+ // Inside an LLM turn, the end of the turn holds the next frames.
973
+ if (!savedTurnContextId) {
974
+ await this._holdFramesWhileAudioContextsOpen();
975
+ }
976
+ }
977
+ /** Apply a settings update meant for this service, or pass it on. */
978
+ async _handleUpdateSettings(frame, direction) {
979
+ if (frame.service !== null && frame.service !== this) {
980
+ await this.pushFrame(frame, direction);
981
+ }
982
+ else if (frame.delta !== null) {
983
+ await this._updateSettings(frame.delta);
984
+ }
985
+ }
986
+ /** Reset the turn and drop interruptible audio contexts. */
987
+ async _handleInterruption(_frame, _direction) {
988
+ this._sentNonWhitespaceInContext = false;
989
+ this._streamedText = "";
990
+ this._textAggregationMetricsStarted = false;
991
+ await this._textAggregator.handleInterruption();
992
+ await this._skippedTextAggregator.handleInterruption();
993
+ for (const filter of this._textFilters) {
994
+ await filter.handleInterruption();
995
+ }
996
+ await this._interruptAudioContexts();
997
+ this._createAudioContextTask();
998
+ // Uninterruptible speech still open holds frames, even when the reply it
999
+ // was part of never ended to hold them.
1000
+ await this._holdFramesWhileAudioContextsOpen();
1001
+ // Dropped contexts never reach _endAudioContext, so frames held for them
1002
+ // are released here.
1003
+ await this._releaseFramesIfAudioContextsEnded();
1004
+ }
1005
+ /** Discard interruptible contexts while preserving ongoing synthesis and playback. */
1006
+ async _interruptAudioContexts() {
1007
+ const preserved = new Set();
1008
+ for (const [key, context] of this._ttsContexts) {
1009
+ if (!context.interruptible)
1010
+ preserved.add(key);
1011
+ }
1012
+ const playingContextId = this._audioContextManager.playingContextId;
1013
+ const interruptPlaying = !(playingContextId !== null && preserved.has(playingContextId));
1014
+ if (interruptPlaying) {
1015
+ await this._stopAudioContextTask();
1016
+ await this.resetWordTimestamps();
1017
+ // Next words follow the uninterruptible words still to play.
1018
+ this._wordTimestampTracker.lastPts = this._wordTimestampTracker.uninterruptibleLastPts;
1019
+ }
1020
+ this._audioContextManager.reset();
1021
+ this._aggregatedFrameSequencer.clear({ keepContexts: preserved });
1022
+ for (const contextId of this.getAudioContexts()) {
1023
+ if (preserved.has(contextId))
1024
+ continue;
1025
+ // A failing provider hook must not prevent cleanup or task restart.
1026
+ try {
1027
+ await this.onAudioContextInterrupted(contextId);
1028
+ }
1029
+ catch (e) {
1030
+ rethrowIfCancelled(e);
1031
+ logger.warning(`${this} on_audio_context_interrupted failed for ${contextId}: ${errorText(e)}`);
1032
+ }
1033
+ this._audioContextManager.remove(contextId);
1034
+ this._ttsContexts.delete(contextId);
1035
+ }
1036
+ if (interruptPlaying) {
1037
+ this.resetActiveAudioContext();
1038
+ }
1039
+ if (!(this._turnContextId !== null && preserved.has(this._turnContextId))) {
1040
+ this._turnContextId = null;
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Hold incoming frames while an audio context is open.
1045
+ *
1046
+ * Services whose audio doesn't carry its request's context ID label
1047
+ * incoming audio with the context still open, so the next request waits for
1048
+ * every open context to end. Contexts play in order, so audio from
1049
+ * different requests never overlaps.
1050
+ */
1051
+ async _holdFramesWhileAudioContextsOpen() {
1052
+ if (this._pauseFrameProcessing && this.getAudioContexts().length > 0) {
1053
+ await this.pauseProcessingFrames();
1054
+ }
1055
+ }
1056
+ /** Let held frames through once no audio context is open. */
1057
+ async _releaseFramesIfAudioContextsEnded() {
1058
+ if (this._pauseFrameProcessing && this.getAudioContexts().length === 0) {
1059
+ await this.resumeProcessingFrames();
1060
+ }
1061
+ }
1062
+ /** Make a frame of an uninterruptible context uninterruptible too. */
1063
+ _applyContextInterruptible(frame) {
1064
+ const contextId = frame.contextId ?? null;
1065
+ const context = contextId !== null ? this._ttsContexts.get(contextId) : undefined;
1066
+ if (context) {
1067
+ frame.interruptible = frame.interruptible && context.interruptible;
1068
+ }
1069
+ if (frame instanceof TTSTextFrame && frame.pts && !frame.interruptible) {
1070
+ this._wordTimestampTracker.uninterruptibleLastPts = frame.pts;
1071
+ }
1072
+ }
1073
+ /** Forget a stopped context and push the assistant aggregation it asked for. */
1074
+ async _finishTtsContext(frame) {
1075
+ let context;
1076
+ if (frame.contextId) {
1077
+ context = this._ttsContexts.get(frame.contextId);
1078
+ this._ttsContexts.delete(frame.contextId);
1079
+ }
1080
+ if (!context)
1081
+ return;
1082
+ logger.debug(`${this} cleaning up TTS context ${frame.contextId}`);
1083
+ if (context.pushAssistantAggregation) {
1084
+ const aggregationFrame = new LLMAssistantPushAggregationFrame();
1085
+ aggregationFrame.interruptible = frame.interruptible;
1086
+ // When word-level TTSTextFrames are routed through the transport's
1087
+ // clock queue (PTS-based), the aggregation frame would otherwise take
1088
+ // the audio (sync) queue path and could overtake the final word frames.
1089
+ // Stamping it with a PTS just past the last word forces it through the
1090
+ // clock queue too, so the assistant aggregator sees every word before
1091
+ // flushing.
1092
+ if (this._wordTimestampTracker.lastPts) {
1093
+ aggregationFrame.pts = this._wordTimestampTracker.lastPts + 1;
1094
+ }
1095
+ await this.pushFrame(aggregationFrame);
1096
+ }
1097
+ }
1098
+ /** Push the silence configured to follow a TTSStoppedFrame. */
1099
+ async _pushStopSilence(frame) {
1100
+ if (!this._pushSilenceAfterStop)
1101
+ return;
1102
+ const silenceNumBytes = Math.trunc(this._silenceTimeS * this.sampleRate * 2); // 16-bit
1103
+ const silenceFrame = new TTSAudioRawFrame({
1104
+ audio: new Uint8Array(silenceNumBytes),
1105
+ sampleRate: this.sampleRate,
1106
+ numChannels: 1,
1107
+ });
1108
+ silenceFrame.interruptible = frame.interruptible;
1109
+ await this.pushFrame(silenceFrame);
1110
+ }
1111
+ /** Send a segment's willBeSpoken frame through the transport's clock queue. */
1112
+ _stampSegmentAnnouncement(frame) {
1113
+ // The willBeSpoken AggregatedTextFrame is the "new segment" announcement
1114
+ // whose segmentId the per-word progress frames reference. Those progress
1115
+ // frames carry a PTS and travel the transport's clock queue; the
1116
+ // announcement itself has no PTS and would take the audio (sync) queue,
1117
+ // so for a context whose audio is delayed behind another context's, its
1118
+ // clock-queued progress can be delivered before it. Stamp it with the
1119
+ // same baseline the first word will use so it rides the clock queue too,
1120
+ // sorted immediately before its first progress frame (ties broken by push
1121
+ // order). Only on the word-timestamp path (pushTextFrames services have no
1122
+ // progress frames).
1123
+ if (frame.willBeSpoken && frame.pts === null && !this._pushTextFrames) {
1124
+ frame.pts = Math.max(this._wordTimestampTracker.lastPts, this.getClock().getTime());
1125
+ }
1126
+ }
1127
+ //
1128
+ // Text to speech
1129
+ //
1130
+ /** Whether the service is streaming tokens directly without sentence aggregation. */
1131
+ get _isStreamingTokens() {
1132
+ return this._textAggregationMode === TextAggregationMode.TOKEN;
1133
+ }
1134
+ /** Aggregate LLM text and speak each aggregation. */
1135
+ async _handleText(frame) {
1136
+ for await (const aggregate of this._textAggregator.aggregate(frame.text)) {
1137
+ if (aggregate.type !== TextType.TOKEN) {
1138
+ // Stop the aggregation metric on the first sentence only.
1139
+ await this.stopTextAggregationMetrics();
1140
+ }
1141
+ await this._handleAggregatedText(this._aggregatedTextFrame(aggregate));
1142
+ }
1143
+ }
1144
+ /** Build the frame for an aggregation, keeping a pattern's delimiters in its raw text. */
1145
+ _aggregatedTextFrame(aggregation) {
1146
+ const rawText = aggregation instanceof PatternMatch ? aggregation.fullMatch : aggregation.text;
1147
+ return new AggregatedTextFrame({
1148
+ text: aggregation.text,
1149
+ textType: aggregation.type,
1150
+ rawText,
1151
+ });
1152
+ }
1153
+ /** Speak aggregated text in `contextId` or a new context, or push it on if skipped. */
1154
+ async _handleAggregatedText(srcFrame, contextId = null) {
1155
+ const id = contextId || this.createContextId();
1156
+ const text = this._trimText(srcFrame.text);
1157
+ if (this._skipTextTypes.includes(srcFrame.textType)) {
1158
+ await this._pushSkippedText(srcFrame, id);
1159
+ }
1160
+ else if (text !== null) {
1161
+ await this._speakText(srcFrame, text, id);
1162
+ }
1163
+ }
1164
+ /** Push text that isn't spoken, in order with the spoken text around it. */
1165
+ async _pushSkippedText(frame, contextId) {
1166
+ // Marked so whatever is downstream knows the TTS left it out.
1167
+ frame.skipTts = true;
1168
+ // The sequencer returns the frame at once if no spoken slot precedes it,
1169
+ // or holds it until it can flush it in order.
1170
+ await this._pushSequencerFrames(await this._aggregatedFrameSequencer.registerSkipped(frame, contextId, this._transportDestination), contextId);
1171
+ }
1172
+ /** Filter, transform and synthesize text, timing it as processing. */
1173
+ async _speakText(srcFrame, text, contextId) {
1174
+ // Accumulate text for a single debug log at flush time when streaming
1175
+ // tokens.
1176
+ if (this._isStreamingTokens) {
1177
+ this._streamedText += text;
1178
+ }
1179
+ // Two things disqualify the measurement. A service that hands text off
1180
+ // for synthesis elsewhere would time only the handoff, and a token is the
1181
+ // wrong unit to report a processing time against — one metric per token,
1182
+ // for work that spans a sentence. TTFB and TTFA carry the timing that
1183
+ // matters in both cases.
1184
+ if (this.supportsProcessingMetrics && !this._isStreamingTokens) {
1185
+ await this.startProcessingMetrics();
1186
+ }
1187
+ const filteredText = await this._filterText(text);
1188
+ if (filteredText !== null) {
1189
+ const preparedText = await this._transformText(filteredText, srcFrame.textType);
1190
+ if (preparedText !== null) {
1191
+ // A TTSSpeakFrame sets up its context before its text gets here.
1192
+ let context = this._ttsContexts.get(contextId);
1193
+ if (!context) {
1194
+ context = new TTSContext({ interruptible: srcFrame.interruptible });
1195
+ this._ttsContexts.set(contextId, context);
1196
+ }
1197
+ await this._announceText(srcFrame, contextId);
1198
+ await this._callEventHandler("on_tts_request", contextId, preparedText);
1199
+ await this._startAudioContext(contextId);
1200
+ await this._registerSpokenText(srcFrame, preparedText, contextId);
1201
+ await this._synthesizeText(preparedText, contextId);
1202
+ await this._pushSpokenText(srcFrame, filteredText, contextId, context.appendToContext);
1203
+ }
1204
+ }
1205
+ await this.stopProcessingMetrics();
1206
+ }
1207
+ /** Trim leading whitespace from text to synthesize, or null if nothing is left to say. */
1208
+ _trimText(text) {
1209
+ // Whitespace gating depends on aggregation mode:
1210
+ // - Token streaming: drop all leading whitespace at the start of a
1211
+ // context, as nothing substantive has been sent yet for it to attach
1212
+ // to. Once a non-whitespace token has been sent, send whitespace as-is
1213
+ // since it can influence prosody between non-whitespace tokens.
1214
+ //
1215
+ // - Sentence aggregation: strip leading newlines only and drop
1216
+ // pure-whitespace frames.
1217
+ let result = text;
1218
+ let hasText;
1219
+ if (this._isStreamingTokens) {
1220
+ if (!this._sentNonWhitespaceInContext) {
1221
+ result = result.trimStart();
1222
+ }
1223
+ hasText = result.length > 0;
1224
+ }
1225
+ else {
1226
+ result = result.replace(/^\n+/, "");
1227
+ hasText = result.trim().length > 0;
1228
+ }
1229
+ return hasText ? result : null;
1230
+ }
1231
+ /**
1232
+ * Run the text filters on text about to be synthesized.
1233
+ *
1234
+ * @returns The filtered text, or null if nothing is left to say.
1235
+ */
1236
+ async _filterText(text) {
1237
+ let result = text;
1238
+ for (const filter of this._textFilters) {
1239
+ await filter.resetInterruption();
1240
+ result = await filter.filter(result);
1241
+ }
1242
+ // Mirrors the whitespace gate before the filters, so filter output that
1243
+ // collapses to whitespace-only is handled consistently. When streaming
1244
+ // tokens, whitespace is only sent once something else has been.
1245
+ let hasText;
1246
+ if (this._isStreamingTokens) {
1247
+ hasText = result.trim().length > 0 || (result.length > 0 && this._sentNonWhitespaceInContext);
1248
+ if (hasText) {
1249
+ this._sentNonWhitespaceInContext = true;
1250
+ }
1251
+ }
1252
+ else {
1253
+ hasText = result.trim().length > 0;
1254
+ }
1255
+ return hasText ? result : null;
1256
+ }
1257
+ /**
1258
+ * Turn filtered text into the text sent to the TTS.
1259
+ *
1260
+ * Transforms only affect the text sent to the TTS for TTS-specific purposes
1261
+ * (e.g. inserting tags for spelling or emotion, or replacing an @ with
1262
+ * "at"). For services with word timestamps they can affect the context,
1263
+ * since TTSTextFrames are built from the TTS output.
1264
+ *
1265
+ * @returns The text to synthesize, or null if a transform failed.
1266
+ */
1267
+ async _transformText(text, textType) {
1268
+ let transformedText = text;
1269
+ let preparedText = null;
1270
+ try {
1271
+ for (const [transformType, transform] of this._textTransforms) {
1272
+ if ((transformType === textType || transformType === "*") &&
1273
+ (!(transform instanceof PronunciationTransform) ||
1274
+ this._canApplyPronunciations())) {
1275
+ transformedText = await transform(transformedText, textType);
1276
+ }
1277
+ }
1278
+ // Apply any final text preparation (e.g., trailing space)
1279
+ preparedText = this._prepareTextForTts(transformedText);
1280
+ }
1281
+ catch (e) {
1282
+ rethrowIfCancelled(e);
1283
+ // Pushing the error with category "APPLICATION" since the failure came
1284
+ // from the application's text transformer. Speaking the untransformed
1285
+ // text isn't safe — a transformer may exist to remove something — so
1286
+ // this turn produces no audio.
1287
+ await this.pushError(`Error transforming text for TTS [${transformedText}]: ${errorText(e)}`, {
1288
+ exception: e,
1289
+ category: ErrorCategory.APPLICATION,
1290
+ });
1291
+ }
1292
+ return preparedText;
1293
+ }
1294
+ /** Whether pronunciation transforms apply, warning once when they stop applying. */
1295
+ _canApplyPronunciations() {
1296
+ const supported = this.supportsPronunciations;
1297
+ if (!supported && !this._skippingPronunciations) {
1298
+ logger.warning(`${this} does not read pronunciation markup with its current settings ` +
1299
+ `(model ${this._settings.model}), so pronunciation hints are spoken as written`);
1300
+ }
1301
+ this._skippingPronunciations = !supported;
1302
+ return supported;
1303
+ }
1304
+ /**
1305
+ * Prepare text for TTS by applying any transformations required by the TTS service.
1306
+ *
1307
+ * @param text The text to prepare.
1308
+ * @returns The prepared text with transformations applied.
1309
+ */
1310
+ _prepareTextForTts(text) {
1311
+ // A trailing space only makes sense on sentence-sized inputs. When
1312
+ // streaming tokens, the incoming text's own whitespace is authoritative:
1313
+ // appending a space to every token would split words across tokens.
1314
+ if (this._appendTrailingSpace && !this._isStreamingTokens && !text.endsWith(" ")) {
1315
+ return `${text} `;
1316
+ }
1317
+ return text;
1318
+ }
1319
+ /** Mark text as about to be spoken and push it ahead of its audio. */
1320
+ async _announceText(srcFrame, contextId) {
1321
+ // To support use cases that may want to know the text before it's spoken,
1322
+ // we push the AggregatedTextFrame version before sending it to TTS.
1323
+ // However, we do not want to add this text to the assistant context until
1324
+ // it is spoken, so we set appendToContext to false.
1325
+ srcFrame.appendToContext = false;
1326
+ srcFrame.contextId = contextId;
1327
+ srcFrame.willBeSpoken = true;
1328
+ // In streaming (TOKEN) mode this srcFrame is a single token, not the unit
1329
+ // the listener cares about: the sequencer regroups tokens into a sentence
1330
+ // and emits that sentence as the willBeSpoken anchor (see _promote).
1331
+ // Pushing srcFrame too would emit a second, redundant anchor (for a
1332
+ // TTSSpeakFrame, an exact duplicate). So only the non-streaming path
1333
+ // pushes srcFrame, queued behind the audio contexts so it is emitted
1334
+ // immediately before the TTSStartedFrame of the audio context it
1335
+ // describes, or into that context if it exists.
1336
+ if (!this._isStreamingTokens) {
1337
+ if (this.audioContextAvailable(contextId)) {
1338
+ await this.appendToAudioContext(contextId, srcFrame);
1339
+ }
1340
+ else {
1341
+ await this._audioContextManager.putFrame(srcFrame);
1342
+ }
1343
+ }
1344
+ }
1345
+ /** Open an audio context with a TTSStartedFrame, for services that leave it to the base. */
1346
+ async _startAudioContext(contextId) {
1347
+ if (!this._pushStartFrame || this.audioContextAvailable(contextId))
1348
+ return;
1349
+ await this.createAudioContext(contextId);
1350
+ await this.startTtfbMetrics();
1351
+ // Note: TTSStartedFrame's appendToContext is stamped in
1352
+ // _pushAudioContextItem, where every TTSStartedFrame (base-class- and
1353
+ // subclass-emitted) funnels through.
1354
+ await this.appendToAudioContext(contextId, new TTSStartedFrame({ contextId }));
1355
+ }
1356
+ /** Give the text a slot in the sequencer, which orders it with skipped text. */
1357
+ async _registerSpokenText(srcFrame, text, contextId) {
1358
+ // The slot unblocks any skipped frames queued behind it once complete.
1359
+ // Word-timestamp services complete it via processWord; pushTextFrames
1360
+ // services complete it after the TTSTextFrame is appended to the audio
1361
+ // context. When streaming tokens, the sequencer accumulates this call's
1362
+ // text into a pending sentence internally and only registers a real slot
1363
+ // once a sentence boundary is detected.
1364
+ const context = this._ttsContexts.get(contextId);
1365
+ await this._pushSequencerFrames(await this._aggregatedFrameSequencer.registerSpoken(srcFrame, contextId, text, context.appendToContext, {
1366
+ buildTracker: !this._pushTextFrames,
1367
+ language: this.textAggregationLanguage,
1368
+ }), contextId);
1369
+ }
1370
+ /**
1371
+ * Emit frames returned by the aggregated-frame sequencer in audio order.
1372
+ *
1373
+ * In streaming (TOKEN) mode these frames — a promoted sentence anchor and
1374
+ * any replayed word/progress frames — describe an audio context whose audio
1375
+ * and word frames are emitted by the dedicated audio-context task. Routing
1376
+ * them through that context's queue keeps a single consumer emitting
1377
+ * everything in order; pushing directly here would let them race the
1378
+ * audio-context task. When the context is gone (or the sequencer is
1379
+ * non-streaming), push directly.
1380
+ *
1381
+ * @param frames Frames returned by registerSpoken/registerSkipped/finalize.
1382
+ * @param contextId The context these frames belong to.
1383
+ */
1384
+ async _pushSequencerFrames(frames, contextId) {
1385
+ for (const f of frames) {
1386
+ if (this._isStreamingTokens && contextId && this.audioContextAvailable(contextId)) {
1387
+ await this.appendToAudioContext(contextId, f);
1388
+ }
1389
+ else {
1390
+ await this.pushFrame(f);
1391
+ }
1392
+ }
1393
+ }
1394
+ /** Run the service's runTts for text, unless the service is no longer usable. */
1395
+ async _synthesizeText(text, contextId) {
1396
+ // When streaming tokens, per-call logs are kept at trace level; the
1397
+ // accumulated turn text is logged at debug level at flush time.
1398
+ if (this._isStreamingTokens) {
1399
+ logger.trace(`${this}: Generating TTS [${text}]`);
1400
+ }
1401
+ else {
1402
+ logger.debug(`${this}: Generating TTS [${text}]`);
1403
+ }
1404
+ // A service that can no longer work can't synthesize anything, and
1405
+ // services that connect on demand would attempt a handshake per request.
1406
+ // The surrounding bookkeeping still runs, so the turn completes with no
1407
+ // audio rather than stalling.
1408
+ if (this.isUsable) {
1409
+ await this.ttsProcessGenerator(contextId, this.runTts(text, contextId));
1410
+ }
1411
+ else {
1412
+ // Name the text that goes unspoken: silence from the bot is otherwise
1413
+ // hard to trace back to the service that caused it.
1414
+ logger.warning(`${this}: service is no longer usable, not speaking [${text}]`);
1415
+ }
1416
+ }
1417
+ /** Queue the spoken text behind its audio, for services without word timestamps. */
1418
+ async _pushSpokenText(srcFrame, text, contextId, appendToContext) {
1419
+ // In TTS services that support word timestamps, the TTSTextFrames are
1420
+ // pushed as words are spoken. Services without word timestamps
1421
+ // (_pushTextFrames) get the original (non-transformed) text after the TTS
1422
+ // generation has completed. This way, if we are interrupted, the text is
1423
+ // not added to the assistant context and the context that IS added does
1424
+ // not include TTS-specific tags or transformations.
1425
+ //
1426
+ // In streaming (TOKEN) mode this is handled instead by the sequencer's
1427
+ // per-sentence promotion (see AggregatedFrameSequencer._promote): a call
1428
+ // here represents a single token, not the sentence-level unit this frame
1429
+ // should carry.
1430
+ if (!this._pushTextFrames || this._isStreamingTokens)
1431
+ return;
1432
+ const frame = new TTSTextFrame({
1433
+ text,
1434
+ textType: srcFrame.textType,
1435
+ segmentId: srcFrame.id,
1436
+ });
1437
+ frame.willBeSpoken = true;
1438
+ frame.contextId = contextId;
1439
+ frame.appendToContext = appendToContext;
1440
+ // Appending to the context, so it preserves the ordering.
1441
+ await this.appendToAudioContext(contextId, frame);
1442
+ // TTSTextFrame is queued; mark the spoken slot complete so any skipped
1443
+ // frames (e.g. code blocks) waiting behind it can be flushed in order.
1444
+ for (const f of this._aggregatedFrameSequencer.completeSpokenSlot()) {
1445
+ await this.pushFrame(f);
1446
+ }
1447
+ }
1448
+ /**
1449
+ * Stream audio frames from an async byte iterator with optional resampling.
1450
+ *
1451
+ * For WAV data, use `stripWavHeader: true` to strip the header and
1452
+ * auto-detect the source sample rate. For raw PCM data, pass `inSampleRate`
1453
+ * directly. Audio is resampled to `this.sampleRate` when the source rate
1454
+ * differs.
1455
+ *
1456
+ * @param iterator Async iterator yielding audio bytes.
1457
+ * @param options.stripWavHeader Strip WAV header and parse source sample
1458
+ * rate from it.
1459
+ * @param options.inSampleRate Source sample rate for raw PCM data.
1460
+ * Overrides WAV-detected rate if both are provided.
1461
+ * @param options.contextId Unique identifier for this TTS context.
1462
+ */
1463
+ async *_streamAudioFramesFromIterator(iterator, options = {}) {
1464
+ let buffer = new Uint8Array(0);
1465
+ let sourceSampleRate = options.inSampleRate ?? null;
1466
+ let needToStripWavHeader = options.stripWavHeader ?? false;
1467
+ const contextId = options.contextId ?? null;
1468
+ const maybeResample = async (audio) => {
1469
+ if (sourceSampleRate && sourceSampleRate !== this.sampleRate) {
1470
+ return await this._resampler.resample(audio, sourceSampleRate, this.sampleRate);
1471
+ }
1472
+ return audio;
1473
+ };
1474
+ for await (let chunk of iterator) {
1475
+ if (needToStripWavHeader && startsWithRiff(chunk)) {
1476
+ // Parse sample rate from WAV header (bytes 24-28, little-endian uint32).
1477
+ if (chunk.length >= 44 && sourceSampleRate === null) {
1478
+ sourceSampleRate = new DataView(chunk.buffer, chunk.byteOffset, chunk.length).getUint32(24, true);
1479
+ }
1480
+ chunk = chunk.subarray(44);
1481
+ needToStripWavHeader = false;
1482
+ }
1483
+ // Append to current buffer.
1484
+ buffer = concatBytes(buffer, chunk);
1485
+ // Round to nearest even number.
1486
+ const alignedLength = buffer.length & ~1; // 111111111...11110
1487
+ if (alignedLength > 0) {
1488
+ const alignedChunk = await maybeResample(buffer.slice(0, alignedLength));
1489
+ buffer = buffer.slice(alignedLength); // keep any leftover byte
1490
+ if (alignedChunk.length > 0) {
1491
+ yield new TTSAudioRawFrame({
1492
+ audio: alignedChunk,
1493
+ sampleRate: this.sampleRate,
1494
+ numChannels: 1,
1495
+ contextId,
1496
+ });
1497
+ }
1498
+ }
1499
+ }
1500
+ if (buffer.length > 0) {
1501
+ // Make sure we don't need an extra padding byte.
1502
+ if (buffer.length % 2 === 1) {
1503
+ buffer = concatBytes(buffer, new Uint8Array(1));
1504
+ }
1505
+ const audio = await maybeResample(buffer);
1506
+ yield new TTSAudioRawFrame({
1507
+ audio,
1508
+ sampleRate: this.sampleRate,
1509
+ numChannels: 1,
1510
+ contextId,
1511
+ });
1512
+ }
1513
+ }
1514
+ //
1515
+ // Word frames
1516
+ //
1517
+ /**
1518
+ * Process word timestamps directly, building and pushing TTSTextFrames inline.
1519
+ *
1520
+ * Used both from _handleAudioContext (via WordTimestamp items) and from
1521
+ * services that do not use audio contexts. Each entry emits a TTSTextFrame
1522
+ * with a PTS relative to when startWordTimestamps() was called. Entries that
1523
+ * come before that are cached and timed once it is called (i.e. when the
1524
+ * first audio chunk is received).
1525
+ */
1526
+ async _addWordTimestamps(wordTimes, contextId = null, options = {}) {
1527
+ const includesInterFrameSpaces = options.includesInterFrameSpaces ?? false;
1528
+ for (const [word, timestamp] of wordTimes) {
1529
+ if (!this._wordTimestampTracker.started) {
1530
+ this._wordTimestampTracker.cache(new WordTimestamp({ word, timestamp, contextId, includesInterFrameSpaces }));
1531
+ continue;
1532
+ }
1533
+ // Build TTSTextFrame(s) for this word token, advancing the active
1534
+ // slot's tracker and flushing any skipped frames now unblocked.
1535
+ await this._pushWordFrames(this._aggregatedFrameSequencer.processWord(word, this._wordTimestampTracker.pts(timestamp), contextId, includesInterFrameSpaces));
1536
+ }
1537
+ }
1538
+ /** Push frames built by the sequencer, noting the PTS of the last word. */
1539
+ async _pushWordFrames(frames) {
1540
+ for (const f of frames) {
1541
+ if (f instanceof TTSTextFrame) {
1542
+ // The sequencer stamps every word frame it builds.
1543
+ if (f.pts === null)
1544
+ throw new Error("word frame built without a PTS");
1545
+ this._wordTimestampTracker.lastPts = f.pts;
1546
+ }
1547
+ await this.pushFrame(f);
1548
+ }
1549
+ }
1550
+ /**
1551
+ * Force-complete a context's spoken slots and push any unblocked skipped frames.
1552
+ *
1553
+ * Called at end-of-context to handle TTS providers that silently drop
1554
+ * word-timestamp events. Emits a TTSTextFrame for any remaining unspoken
1555
+ * text in this context, then flushes skipped frames that were blocked by
1556
+ * those incomplete slots. Slots for other contexts still in flight are left
1557
+ * untouched.
1558
+ *
1559
+ * @param contextId The audio context that has ended.
1560
+ */
1561
+ async _applyForceComplete(contextId) {
1562
+ await this._pushWordFrames(this._aggregatedFrameSequencer.forceComplete(contextId, this._wordTimestampTracker.lastPts));
1563
+ }
1564
+ //
1565
+ // Audio context playback
1566
+ //
1567
+ /** Signal that the audio context is still in use, resetting the timeout. */
1568
+ _refreshAudioContext(contextId) {
1569
+ this._audioContextManager.keepalive(contextId);
1570
+ }
1571
+ /** Create the task that plays audio contexts. */
1572
+ _createAudioContextTask() {
1573
+ if (!this._audioContextTask) {
1574
+ this._audioContextTask = this.createTask(() => this._audioContextTaskHandler(), "_audioContextTaskHandler");
1575
+ }
1576
+ }
1577
+ /** Cancel the task that plays audio contexts. */
1578
+ async _stopAudioContextTask() {
1579
+ if (this._audioContextTask) {
1580
+ await this.cancelTask(this._audioContextTask);
1581
+ this._audioContextTask = null;
1582
+ }
1583
+ }
1584
+ /**
1585
+ * Play audio contexts and the frames between them in order.
1586
+ *
1587
+ * Each audio context plays until all its audio has been pushed downstream,
1588
+ * then onAudioContextCompleted() is called. A frame between contexts (e.g.
1589
+ * an AggregatedTextFrame preceding its audio, or a frame that arrived
1590
+ * between two speak frames) is pushed at its position.
1591
+ */
1592
+ async _audioContextTaskHandler() {
1593
+ let running = true;
1594
+ while (running) {
1595
+ const contextValue = await this._audioContextManager.nextForPlayback();
1596
+ if (contextValue instanceof AudioContextFrame) {
1597
+ await this._handleAudioContext(contextValue.contextId);
1598
+ }
1599
+ else if (contextValue instanceof Frame) {
1600
+ this._stampLlmResponseFrame(contextValue);
1601
+ await this.pushFrame(contextValue);
1602
+ }
1603
+ else {
1604
+ running = false;
1605
+ }
1606
+ }
1607
+ }
1608
+ /** Send a reply's end frame after its words, or behind its start frame if it timed none. */
1609
+ _stampLlmResponseFrame(frame) {
1610
+ if (frame instanceof LLMFullResponseStartFrame) {
1611
+ this._wordTimestampTracker.startReply();
1612
+ }
1613
+ else if (frame instanceof LLMFullResponseEndFrame && !this._pushTextFrames) {
1614
+ frame.pts = this._wordTimestampTracker.replyLastPts;
1615
+ }
1616
+ }
1617
+ /** Push an audio context's items in order until it ends or goes quiet. */
1618
+ async _handleAudioContext(contextId) {
1619
+ const playback = new _AudioContextPlayback();
1620
+ let item = await this._nextAudioContextItem(contextId);
1621
+ while (item !== null) {
1622
+ await this._pushAudioContextItem(contextId, item, playback);
1623
+ item = await this._nextAudioContextItem(contextId);
1624
+ }
1625
+ await this._endAudioContext(contextId, playback);
1626
+ }
1627
+ /** Wait for the context's next item, or null once it ends or goes quiet. */
1628
+ async _nextAudioContextItem(contextId) {
1629
+ let item = null;
1630
+ try {
1631
+ item =
1632
+ (await this._audioContextManager.get(contextId, this._stopFrameTimeoutS)) ?? null;
1633
+ }
1634
+ catch (e) {
1635
+ if (!(e instanceof TimeoutError))
1636
+ throw e;
1637
+ // We didn't get audio, so let's consider this context finished.
1638
+ logger.trace(`${this} time out on audio context ${contextId}`);
1639
+ }
1640
+ return item;
1641
+ }
1642
+ /** Push one item of the playing audio context. */
1643
+ async _pushAudioContextItem(contextId, item, playback) {
1644
+ if (item instanceof WordTimestamp) {
1645
+ // Route word timestamps through _addWordTimestamps so they are
1646
+ // processed in playback order alongside audio frames.
1647
+ await this._addWordTimestamps([[item.word, item.timestamp]], item.contextId, {
1648
+ includesInterFrameSpaces: item.includesInterFrameSpaces,
1649
+ });
1650
+ }
1651
+ else if (item instanceof ErrorFrame) {
1652
+ await this.pushErrorFrame(item);
1653
+ }
1654
+ else {
1655
+ if (item instanceof TTSAudioRawFrame) {
1656
+ // Set the word-timestamp baseline once, on the first audio chunk.
1657
+ if (!playback.receivedAudio) {
1658
+ await this.stopTtfbMetrics();
1659
+ await this.startWordTimestamps();
1660
+ }
1661
+ playback.receivedAudio = true;
1662
+ await this.processTtfaMetrics(item);
1663
+ }
1664
+ else if (item instanceof TTSStartedFrame) {
1665
+ playback.stopFrameDue = this._pushStopFrames;
1666
+ // Stamp appropriate appendToContext value onto every TTSStartedFrame
1667
+ // here — the single point both base-class- and subclass-emitted
1668
+ // started frames pass through.
1669
+ const ttsContext = this._ttsContexts.get(contextId);
1670
+ if (ttsContext !== undefined) {
1671
+ item.appendToContext = ttsContext.appendToContext;
1672
+ }
1673
+ }
1674
+ else if (item instanceof TTSStoppedFrame) {
1675
+ // Checking if we have any remaining spoken slots before pushing the
1676
+ // TTSStoppedFrame
1677
+ await this._applyForceComplete(contextId);
1678
+ playback.stopFrameDue = false;
1679
+ // Setting the last word timestamp as the TTSStoppedFrame PTS
1680
+ if (!item.pts) {
1681
+ item.pts = this._wordTimestampTracker.lastPts;
1682
+ }
1683
+ }
1684
+ await this.pushFrame(item);
1685
+ }
1686
+ }
1687
+ /** Finish the playing audio context once it ends or goes quiet. */
1688
+ async _endAudioContext(contextId, playback) {
1689
+ // Words the provider never timed come before the stop frame.
1690
+ await this._applyForceComplete(contextId);
1691
+ if (playback.stopFrameDue) {
1692
+ await this.pushFrame(new TTSStoppedFrame({ contextId }));
1693
+ }
1694
+ await this.resetWordTimestamps();
1695
+ await this._recordContextAudioOutcome(contextId, playback.receivedAudio);
1696
+ // If audio resumes after a timeout, the context is recreated without the
1697
+ // partial sample held here.
1698
+ this._audioContextManager.remove(contextId);
1699
+ // A failing hook must not end the playback task, or no more audio is
1700
+ // played.
1701
+ try {
1702
+ await this.onAudioContextCompleted(contextId);
1703
+ }
1704
+ catch (e) {
1705
+ rethrowIfCancelled(e);
1706
+ logger.warning(`${this} on_audio_context_completed failed for ${contextId}: ${errorText(e)}`);
1707
+ }
1708
+ this.resetActiveAudioContext();
1709
+ await this._releaseFramesIfAudioContextsEnded();
1710
+ }
1711
+ /**
1712
+ * Track whether contexts are producing audio, and act when they stop.
1713
+ *
1714
+ * A provider can accept every request and return no audio at all — an
1715
+ * unknown voice ID, say — without ever reporting an error. Every context
1716
+ * that completes in silence is reported as an error, so application code
1717
+ * hears about a turn that produced no speech as it happens. Enough of them
1718
+ * in a row means the service isn't going to speak again, so it is reported
1719
+ * unable to do its job: it stops being given work, a `ServiceSwitcher` can
1720
+ * fail over to another provider, and the pipeline worker applies its
1721
+ * `ProcessorUnusablePolicy`.
1722
+ *
1723
+ * Only contexts that complete are counted; an interrupted context is
1724
+ * cancelled before it gets here.
1725
+ *
1726
+ * @param contextId The audio context that just completed.
1727
+ * @param receivedAudio Whether any audio arrived for that context.
1728
+ */
1729
+ async _recordContextAudioOutcome(contextId, receivedAudio) {
1730
+ if (receivedAudio) {
1731
+ this._consecutiveZeroAudioContexts = 0;
1732
+ return;
1733
+ }
1734
+ // An unusable service is deliberately not given work (see
1735
+ // _synthesizeText), so its silent contexts say nothing new.
1736
+ if (!this.isUsable)
1737
+ return;
1738
+ this._consecutiveZeroAudioContexts += 1;
1739
+ logger.warning(`${this} audio context ${contextId} completed with no audio ` +
1740
+ `(${this._consecutiveZeroAudioContexts} in a row)`);
1741
+ if (this._maxConsecutiveZeroAudioContexts &&
1742
+ this._consecutiveZeroAudioContexts >= this._maxConsecutiveZeroAudioContexts) {
1743
+ await this.pushError(`${this._consecutiveZeroAudioContexts} consecutive TTS contexts ` +
1744
+ "completed with no audio", { forceTreatAsPermanent: true });
1745
+ }
1746
+ else {
1747
+ // A single silent context says nothing about whether the service will
1748
+ // speak again, so it is reported without costing the service its
1749
+ // usability.
1750
+ await this.pushError(`TTS context ${contextId} completed with no audio`);
1751
+ }
1752
+ }
1753
+ }
1754
+ /**
1755
+ * Base class for websocket-based TTS services.
1756
+ *
1757
+ * Combines TTS functionality with websocket connectivity, providing automatic
1758
+ * error handling and reconnection capabilities.
1759
+ *
1760
+ * Includes an optional keepalive for providers that close an idle
1761
+ * connection. A subclass enables it by overriding `_sendKeepalive()` to send
1762
+ * its provider's keepalive message, and calling
1763
+ * `_createKeepaliveTask(interval)` / `_cancelKeepaliveTask()` from its
1764
+ * `_connect()` / `_disconnect()`.
1765
+ *
1766
+ * Event handlers:
1767
+ *
1768
+ * - on_connection_error: Called when a websocket connection error occurs.
1769
+ *
1770
+ * @example
1771
+ * tts.eventHandler("on_connection_error", async (tts: TTSService, error: string) => {
1772
+ * logger.error(`TTS connection error: ${error}`);
1773
+ * });
1774
+ */
1775
+ export class WebsocketTTSService extends WebsocketService(TTSService) {
1776
+ _keepaliveTask = null;
1777
+ /**
1778
+ * Initialize the Websocket TTS service.
1779
+ *
1780
+ * @param options.reconnectOnError Whether to automatically reconnect on
1781
+ * websocket errors. Other options are passed to parent classes.
1782
+ */
1783
+ constructor(options = {}) {
1784
+ // reconnectOnError defaults to true in the WebsocketService mixin.
1785
+ super(options);
1786
+ }
1787
+ /**
1788
+ * Whether this service has a meaningful processing-time metric.
1789
+ *
1790
+ * false: `runTts` sends the text and returns, and audio arrives later on
1791
+ * the receive task, so there is no synthesis inside the measured window. A
1792
+ * subclass that instead waits for the server to signal the end of synthesis
1793
+ * before returning can override this back to true.
1794
+ */
1795
+ get supportsProcessingMetrics() {
1796
+ return false;
1797
+ }
1798
+ /**
1799
+ * Stop the websocket TTS service on a graceful end.
1800
+ *
1801
+ * @param frame The end frame.
1802
+ */
1803
+ async stop(frame) {
1804
+ await super.stop(frame);
1805
+ await this._disconnect();
1806
+ }
1807
+ /**
1808
+ * Cancel the websocket TTS service immediately.
1809
+ *
1810
+ * Disconnecting here is the prompt teardown: the websocket receive loop
1811
+ * runs independently of the audio-context task, so it keeps reading from
1812
+ * the provider until the socket is closed. Stopping only the audio-context
1813
+ * task (in `TTSService.cancel()`) halts output but not reception.
1814
+ *
1815
+ * @param frame The cancel frame.
1816
+ */
1817
+ async cancel(frame) {
1818
+ await super.cancel(frame);
1819
+ await this._disconnect();
1820
+ }
1821
+ /** Release websocket TTS resources at teardown. */
1822
+ async cleanup() {
1823
+ await super.cleanup();
1824
+ await this._disconnect();
1825
+ }
1826
+ /**
1827
+ * End the audio contexts of a connection that is being replaced.
1828
+ *
1829
+ * Providers keep context state per connection and don't resume it on a new
1830
+ * one, so audio still due on the old connection never arrives. Every open
1831
+ * context is closed (audio already received still plays), and a turn in
1832
+ * progress moves to a new context ID so its remaining sentences open a
1833
+ * fresh provider context. Reopening a connection that was already closed,
1834
+ * to send the text at hand, doesn't come through here, so the context it
1835
+ * sends to stays open.
1836
+ */
1837
+ async _closeConnectionContexts() {
1838
+ for (const contextId of this.getAudioContexts()) {
1839
+ await this.removeAudioContext(contextId);
1840
+ }
1841
+ if (this._turnContextId) {
1842
+ this._turnContextId = null;
1843
+ this._turnContextId = this.createContextId();
1844
+ }
1845
+ }
1846
+ async _disconnect() {
1847
+ // Subclasses call super._disconnect() first, so this runs before they
1848
+ // close their socket.
1849
+ await this._closeConnectionContexts();
1850
+ await super._disconnect();
1851
+ }
1852
+ async _reconnectWebsocket(attemptNumber) {
1853
+ await this._closeConnectionContexts();
1854
+ return await super._reconnectWebsocket(attemptNumber);
1855
+ }
1856
+ async _reportError(error, options = {}) {
1857
+ await this._callEventHandler("on_connection_error", error.error);
1858
+ await this.pushErrorFrame(error, {
1859
+ forceTreatAsPermanent: options.forceTreatAsPermanent ?? false,
1860
+ });
1861
+ }
1862
+ /**
1863
+ * Start the keepalive task if it isn't running.
1864
+ *
1865
+ * @param interval Seconds between keepalive messages.
1866
+ */
1867
+ _createKeepaliveTask(interval) {
1868
+ if (this._keepaliveTask && !this._keepaliveTask.done())
1869
+ return;
1870
+ this._keepaliveTask = this.createTask(() => this._keepaliveTaskHandler(interval), "keepalive");
1871
+ }
1872
+ /** Stop the keepalive task if running. */
1873
+ async _cancelKeepaliveTask() {
1874
+ if (this._keepaliveTask) {
1875
+ await this.cancelTask(this._keepaliveTask);
1876
+ this._keepaliveTask = null;
1877
+ }
1878
+ }
1879
+ /**
1880
+ * Send a keepalive every `interval` seconds while connected.
1881
+ *
1882
+ * A failed send is logged and the loop keeps going: the receive loop
1883
+ * handles a lost connection, and the next keepalive goes out on the
1884
+ * reconnected socket.
1885
+ */
1886
+ async _keepaliveTaskHandler(interval) {
1887
+ while (true) {
1888
+ await sleep(interval);
1889
+ try {
1890
+ if (this._isKeepaliveReady()) {
1891
+ await this._sendKeepalive();
1892
+ }
1893
+ }
1894
+ catch (e) {
1895
+ rethrowIfCancelled(e);
1896
+ logger.warning(`${this} keepalive error: ${errorText(e)}`);
1897
+ }
1898
+ }
1899
+ }
1900
+ /**
1901
+ * Check if the service is ready to send a keepalive.
1902
+ *
1903
+ * @returns true if the websocket is open.
1904
+ */
1905
+ _isKeepaliveReady() {
1906
+ return this._websocket !== null && this._websocket.state === State.OPEN;
1907
+ }
1908
+ /**
1909
+ * Send a keepalive message over the websocket.
1910
+ *
1911
+ * Subclasses that enable keepalive must override this to send their
1912
+ * provider's keepalive message.
1913
+ */
1914
+ async _sendKeepalive() {
1915
+ throw new Error("Subclasses must override _sendKeepalive");
1916
+ }
1917
+ }
1918
+ /**
1919
+ * Websocket-based TTS service that handles interruptions without word timestamps.
1920
+ *
1921
+ * Designed for TTS services that don't support word timestamps. Handles
1922
+ * interruptions by reconnecting the websocket when the bot is speaking and
1923
+ * gets interrupted.
1924
+ */
1925
+ export class InterruptibleTTSService extends WebsocketTTSService {
1926
+ // Whether the bot is speaking, as BotStartedSpeakingFrame and
1927
+ // BotStoppedSpeakingFrame report it.
1928
+ _botSpeaking = false;
1929
+ // True once runTts has been invoked (TTSStartedFrame pushed) for the
1930
+ // current turn but before BotStartedSpeakingFrame confirms playback — the
1931
+ // narrow window where _botSpeaking (which only reflects confirmed playback)
1932
+ // can't yet tell a reconnect is needed. Kept separate from _botSpeaking so
1933
+ // this early, unconfirmed marker never makes a turn that produces no audio
1934
+ // look like one that played.
1935
+ _ttsStarted = false;
1936
+ async _handleInterruption(frame, direction) {
1937
+ // If the bot is not speaking we don't need to reconnect when the user
1938
+ // speaks. If the bot is speaking and the user interrupts we need to
1939
+ // reconnect.
1940
+ const shouldReconnect = this._botSpeaking || this._ttsStarted;
1941
+ // Without context IDs, audio still coming for an interrupted context
1942
+ // would be labeled with an uninterruptible one, so the connection is kept
1943
+ // only when uninterruptible speech is all that's open.
1944
+ const contexts = [];
1945
+ for (const c of this.getAudioContexts()) {
1946
+ const context = this._ttsContexts.get(c);
1947
+ if (context)
1948
+ contexts.push(context);
1949
+ }
1950
+ const keepConnection = contexts.length > 0 && contexts.every((c) => !c.interruptible);
1951
+ this._botSpeaking = false;
1952
+ this._ttsStarted = false;
1953
+ await super._handleInterruption(frame, direction);
1954
+ if (shouldReconnect && !keepConnection) {
1955
+ await this._disconnect();
1956
+ await this._connect();
1957
+ }
1958
+ }
1959
+ /**
1960
+ * Push a frame downstream with TTS-specific handling.
1961
+ *
1962
+ * @param frame The frame to push.
1963
+ * @param direction The direction to push the frame.
1964
+ */
1965
+ async pushFrame(frame, direction = FrameDirection.DOWNSTREAM) {
1966
+ // This prevents a race condition in cases where runTts has been invoked
1967
+ // but the BotStartedSpeakingFrame has not yet been received, which could
1968
+ // allow stale audio to leak through.
1969
+ if (frame instanceof TTSStartedFrame) {
1970
+ this._ttsStarted = true;
1971
+ }
1972
+ await super.pushFrame(frame, direction);
1973
+ }
1974
+ /**
1975
+ * Process frames, tracking whether an interruption needs a reconnect.
1976
+ *
1977
+ * @param frame The frame to process.
1978
+ * @param direction The direction of frame processing.
1979
+ */
1980
+ async processFrame(frame, direction) {
1981
+ await super.processFrame(frame, direction);
1982
+ if (frame instanceof BotStartedSpeakingFrame) {
1983
+ this._botSpeaking = true;
1984
+ }
1985
+ else if (frame instanceof BotStoppedSpeakingFrame) {
1986
+ // The turn ended normally; nothing left to reconnect for.
1987
+ this._botSpeaking = false;
1988
+ this._ttsStarted = false;
1989
+ }
1990
+ else if (frame instanceof LLMFullResponseStartFrame) {
1991
+ // Safety net for a previous turn that never produced audio, so
1992
+ // BotStoppedSpeakingFrame never arrived to clear it above.
1993
+ this._ttsStarted = false;
1994
+ }
1995
+ }
1996
+ }
1997
+ /** Whether a chunk starts with a WAV "RIFF" header. */
1998
+ function startsWithRiff(chunk) {
1999
+ return (chunk.length >= 4 &&
2000
+ chunk[0] === 0x52 && // R
2001
+ chunk[1] === 0x49 && // I
2002
+ chunk[2] === 0x46 && // F
2003
+ chunk[3] === 0x46 // F
2004
+ );
2005
+ }
2006
+ function concatBytes(a, b) {
2007
+ if (a.length === 0)
2008
+ return b.slice();
2009
+ const out = new Uint8Array(a.length + b.length);
2010
+ out.set(a, 0);
2011
+ out.set(b, a.length);
2012
+ return out;
2013
+ }
2014
+ /** Python's `f"{e}"` for an exception. */
2015
+ function errorText(e) {
2016
+ return e instanceof Error ? e.message : String(e);
2017
+ }
2018
+ //# sourceMappingURL=tts_service.js.map