@pipedog/core 0.0.0 → 0.2.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.
- package/LICENSE +25 -0
- package/NOTICE +9 -0
- package/dist/adapters/base_llm_adapter.d.ts +266 -0
- package/dist/adapters/base_llm_adapter.js +489 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/schemas/direct_function.d.ts +202 -0
- package/dist/adapters/schemas/direct_function.js +302 -0
- package/dist/adapters/schemas/function_schema.d.ts +67 -0
- package/dist/adapters/schemas/function_schema.js +72 -0
- package/dist/adapters/schemas/index.d.ts +2 -0
- package/dist/adapters/schemas/index.js +8 -0
- package/dist/adapters/schemas/tools_schema.d.ts +57 -0
- package/dist/adapters/schemas/tools_schema.js +79 -0
- package/dist/adapters/services/index.d.ts +2 -0
- package/dist/adapters/services/index.js +8 -0
- package/dist/adapters/services/open_ai_adapter.d.ts +147 -0
- package/dist/adapters/services/open_ai_adapter.js +250 -0
- package/dist/audio/dtmf/dtmf_data.d.ts +3 -0
- package/dist/audio/dtmf/dtmf_data.js +22 -0
- package/dist/audio/dtmf/index.d.ts +2 -0
- package/dist/audio/dtmf/index.js +8 -0
- package/dist/audio/dtmf/types.d.ts +31 -0
- package/dist/audio/dtmf/types.js +35 -0
- package/dist/audio/dtmf/utils.d.ts +13 -0
- package/dist/audio/dtmf/utils.js +62 -0
- package/dist/audio/filters/base_audio_filter.d.ts +51 -0
- package/dist/audio/filters/base_audio_filter.js +17 -0
- package/dist/audio/filters/index.d.ts +2 -0
- package/dist/audio/filters/index.js +8 -0
- package/dist/audio/index.d.ts +2 -0
- package/dist/audio/index.js +8 -0
- package/dist/audio/krisp_instance.d.ts +95 -0
- package/dist/audio/krisp_instance.js +229 -0
- package/dist/audio/mixers/base_audio_mixer.d.ts +56 -0
- package/dist/audio/mixers/base_audio_mixer.js +16 -0
- package/dist/audio/mixers/index.d.ts +2 -0
- package/dist/audio/mixers/index.js +8 -0
- package/dist/audio/resamplers/base_audio_resampler.d.ts +46 -0
- package/dist/audio/resamplers/base_audio_resampler.js +35 -0
- package/dist/audio/resamplers/engine.d.ts +60 -0
- package/dist/audio/resamplers/engine.js +89 -0
- package/dist/audio/resamplers/index.d.ts +2 -0
- package/dist/audio/resamplers/index.js +8 -0
- package/dist/audio/resamplers/resampy_resampler.d.ts +10 -0
- package/dist/audio/resamplers/resampy_resampler.js +15 -0
- package/dist/audio/resamplers/sinc_engine.d.ts +10 -0
- package/dist/audio/resamplers/sinc_engine.js +235 -0
- package/dist/audio/resamplers/soxr_resampler.d.ts +58 -0
- package/dist/audio/resamplers/soxr_resampler.js +79 -0
- package/dist/audio/resamplers/soxr_stream_resampler.d.ts +78 -0
- package/dist/audio/resamplers/soxr_stream_resampler.js +159 -0
- package/dist/audio/turn/base_turn_analyzer.d.ts +84 -0
- package/dist/audio/turn/base_turn_analyzer.js +61 -0
- package/dist/audio/turn/index.d.ts +2 -0
- package/dist/audio/turn/index.js +8 -0
- package/dist/audio/turn/smart_turn/_whisper_features.d.ts +30 -0
- package/dist/audio/turn/smart_turn/_whisper_features.js +225 -0
- package/dist/audio/turn/smart_turn/base_smart_turn.d.ts +94 -0
- package/dist/audio/turn/smart_turn/base_smart_turn.js +239 -0
- package/dist/audio/turn/smart_turn/local_smart_turn_v3.d.ts +76 -0
- package/dist/audio/turn/smart_turn/local_smart_turn_v3.js +151 -0
- package/dist/audio/utils.d.ts +224 -0
- package/dist/audio/utils.js +634 -0
- package/dist/audio/vad/data/index.d.ts +2 -0
- package/dist/audio/vad/data/index.js +8 -0
- package/dist/audio/vad/index.d.ts +2 -0
- package/dist/audio/vad/index.js +8 -0
- package/dist/audio/vad/vad_analyzer.d.ts +134 -0
- package/dist/audio/vad/vad_analyzer.js +249 -0
- package/dist/audio/vad/vad_controller.d.ts +143 -0
- package/dist/audio/vad/vad_controller.js +228 -0
- package/dist/audio/volume.d.ts +38 -0
- package/dist/audio/volume.js +75 -0
- package/dist/bus/adapters/base.d.ts +35 -0
- package/dist/bus/adapters/base.js +19 -0
- package/dist/bus/adapters/index.d.ts +10 -0
- package/dist/bus/adapters/index.js +15 -0
- package/dist/bus/adapters/llm_context_adapter.d.ts +36 -0
- package/dist/bus/adapters/llm_context_adapter.js +82 -0
- package/dist/bus/adapters/tools_schema_adapter.d.ts +25 -0
- package/dist/bus/adapters/tools_schema_adapter.js +70 -0
- package/dist/bus/bridge_processor.d.ts +111 -0
- package/dist/bus/bridge_processor.js +166 -0
- package/dist/bus/bus.d.ts +110 -0
- package/dist/bus/bus.js +200 -0
- package/dist/bus/index.d.ts +22 -0
- package/dist/bus/index.js +27 -0
- package/dist/bus/local/async_queue.d.ts +12 -0
- package/dist/bus/local/async_queue.js +21 -0
- package/dist/bus/local/index.d.ts +3 -0
- package/dist/bus/local/index.js +8 -0
- package/dist/bus/messages.d.ts +378 -0
- package/dist/bus/messages.js +357 -0
- package/dist/bus/network/index.d.ts +16 -0
- package/dist/bus/network/index.js +21 -0
- package/dist/bus/queue.d.ts +27 -0
- package/dist/bus/queue.js +51 -0
- package/dist/bus/serializers/base.d.ts +25 -0
- package/dist/bus/serializers/base.js +14 -0
- package/dist/bus/serializers/index.d.ts +9 -0
- package/dist/bus/serializers/index.js +14 -0
- package/dist/bus/serializers/json.d.ts +77 -0
- package/dist/bus/serializers/json.js +367 -0
- package/dist/bus/subscriber.d.ts +46 -0
- package/dist/bus/subscriber.js +27 -0
- package/dist/bus/ui/index.d.ts +3 -0
- package/dist/bus/ui/index.js +8 -0
- package/dist/bus/ui/messages.d.ts +186 -0
- package/dist/bus/ui/messages.js +171 -0
- package/dist/classifiers/base_classifier.d.ts +263 -0
- package/dist/classifiers/base_classifier.js +346 -0
- package/dist/classifiers/cloudflare/clef/classifier.d.ts +70 -0
- package/dist/classifiers/cloudflare/clef/classifier.js +204 -0
- package/dist/classifiers/cloudflare/clef/client.d.ts +104 -0
- package/dist/classifiers/cloudflare/clef/client.js +234 -0
- package/dist/classifiers/cloudflare/clef/index.d.ts +2 -0
- package/dist/classifiers/cloudflare/clef/index.js +8 -0
- package/dist/classifiers/cloudflare/index.d.ts +2 -0
- package/dist/classifiers/cloudflare/index.js +8 -0
- package/dist/classifiers/http_client.d.ts +70 -0
- package/dist/classifiers/http_client.js +161 -0
- package/dist/classifiers/index.d.ts +2 -0
- package/dist/classifiers/index.js +8 -0
- package/dist/classifiers/jev/classifier.d.ts +8 -0
- package/dist/classifiers/jev/classifier.js +13 -0
- package/dist/classifiers/jev/client.d.ts +8 -0
- package/dist/classifiers/jev/client.js +13 -0
- package/dist/classifiers/jev/index.d.ts +2 -0
- package/dist/classifiers/jev/index.js +8 -0
- package/dist/classifiers/llm/classifier.d.ts +72 -0
- package/dist/classifiers/llm/classifier.js +304 -0
- package/dist/classifiers/llm/index.d.ts +2 -0
- package/dist/classifiers/llm/index.js +8 -0
- package/dist/classifiers/openai/decisions/classifier.d.ts +77 -0
- package/dist/classifiers/openai/decisions/classifier.js +241 -0
- package/dist/classifiers/openai/decisions/client.d.ts +108 -0
- package/dist/classifiers/openai/decisions/client.js +234 -0
- package/dist/classifiers/openai/decisions/index.d.ts +2 -0
- package/dist/classifiers/openai/decisions/index.js +8 -0
- package/dist/classifiers/openai/index.d.ts +2 -0
- package/dist/classifiers/openai/index.js +8 -0
- package/dist/classifiers/typesafe/index.d.ts +2 -0
- package/dist/classifiers/typesafe/index.js +8 -0
- package/dist/classifiers/typesafe/jev/classifier.d.ts +70 -0
- package/dist/classifiers/typesafe/jev/classifier.js +220 -0
- package/dist/classifiers/typesafe/jev/client.d.ts +105 -0
- package/dist/classifiers/typesafe/jev/client.js +233 -0
- package/dist/classifiers/typesafe/jev/index.d.ts +2 -0
- package/dist/classifiers/typesafe/jev/index.js +8 -0
- package/dist/clocks/base_clock.d.ts +13 -0
- package/dist/clocks/base_clock.js +14 -0
- package/dist/clocks/index.d.ts +2 -0
- package/dist/clocks/index.js +8 -0
- package/dist/clocks/system_clock.d.ts +15 -0
- package/dist/clocks/system_clock.js +25 -0
- package/dist/frames/frames.d.ts +2533 -0
- package/dist/frames/frames.js +2485 -0
- package/dist/frames/index.d.ts +2 -0
- package/dist/frames/index.js +8 -0
- package/dist/frames/protobufs/frames_pb2.d.ts +229 -0
- package/dist/frames/protobufs/frames_pb2.js +466 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +56 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.js +8 -0
- package/dist/metrics/metrics.d.ts +276 -0
- package/dist/metrics/metrics.js +254 -0
- package/dist/observers/base_observer.d.ts +173 -0
- package/dist/observers/base_observer.js +151 -0
- package/dist/observers/error_observer.d.ts +75 -0
- package/dist/observers/error_observer.js +87 -0
- package/dist/observers/function_call_observer.d.ts +157 -0
- package/dist/observers/function_call_observer.js +222 -0
- package/dist/observers/index.d.ts +2 -0
- package/dist/observers/index.js +8 -0
- package/dist/observers/loggers/debug_log_observer.d.ts +91 -0
- package/dist/observers/loggers/debug_log_observer.js +228 -0
- package/dist/observers/loggers/index.d.ts +2 -0
- package/dist/observers/loggers/index.js +8 -0
- package/dist/observers/loggers/llm_log_observer.d.ts +24 -0
- package/dist/observers/loggers/llm_log_observer.js +110 -0
- package/dist/observers/loggers/metrics_log_observer.d.ts +85 -0
- package/dist/observers/loggers/metrics_log_observer.js +186 -0
- package/dist/observers/loggers/transcription_log_observer.d.ts +23 -0
- package/dist/observers/loggers/transcription_log_observer.js +74 -0
- package/dist/observers/service_metrics_observer.d.ts +168 -0
- package/dist/observers/service_metrics_observer.js +236 -0
- package/dist/observers/speaking_observer.d.ts +104 -0
- package/dist/observers/speaking_observer.js +150 -0
- package/dist/observers/startup_timing_observer.d.ts +221 -0
- package/dist/observers/startup_timing_observer.js +365 -0
- package/dist/observers/turn_tracking_observer.d.ts +78 -0
- package/dist/observers/turn_tracking_observer.js +189 -0
- package/dist/observers/user_bot_latency_observer.d.ts +411 -0
- package/dist/observers/user_bot_latency_observer.js +974 -0
- package/dist/pipeline/base_pipeline.d.ts +49 -0
- package/dist/pipeline/base_pipeline.js +101 -0
- package/dist/pipeline/capabilities.d.ts +76 -0
- package/dist/pipeline/capabilities.js +97 -0
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +8 -0
- package/dist/pipeline/job_context.d.ts +317 -0
- package/dist/pipeline/job_context.js +450 -0
- package/dist/pipeline/job_decorator.d.ts +54 -0
- package/dist/pipeline/job_decorator.js +92 -0
- package/dist/pipeline/llm_switcher.d.ts +84 -0
- package/dist/pipeline/llm_switcher.js +107 -0
- package/dist/pipeline/llm_with_backend.d.ts +435 -0
- package/dist/pipeline/llm_with_backend.js +806 -0
- package/dist/pipeline/parallel_pipeline.d.ts +83 -0
- package/dist/pipeline/parallel_pipeline.js +200 -0
- package/dist/pipeline/pipeline.d.ts +126 -0
- package/dist/pipeline/pipeline.js +196 -0
- package/dist/pipeline/service_switcher.d.ts +287 -0
- package/dist/pipeline/service_switcher.js +509 -0
- package/dist/pipeline/sync_parallel_pipeline.d.ts +192 -0
- package/dist/pipeline/sync_parallel_pipeline.js +387 -0
- package/dist/pipeline/worker.d.ts +821 -0
- package/dist/pipeline/worker.js +1656 -0
- package/dist/pipeline/worker_observer.d.ts +131 -0
- package/dist/pipeline/worker_observer.js +274 -0
- package/dist/pipeline/worker_ready_decorator.d.ts +45 -0
- package/dist/pipeline/worker_ready_decorator.js +76 -0
- package/dist/processors/aggregators/async_tool_messages.d.ts +146 -0
- package/dist/processors/aggregators/async_tool_messages.js +291 -0
- package/dist/processors/aggregators/dtmf_aggregator.d.ts +61 -0
- package/dist/processors/aggregators/dtmf_aggregator.js +144 -0
- package/dist/processors/aggregators/gated.d.ts +43 -0
- package/dist/processors/aggregators/gated.js +86 -0
- package/dist/processors/aggregators/gated_llm_context.d.ts +42 -0
- package/dist/processors/aggregators/gated_llm_context.js +86 -0
- package/dist/processors/aggregators/index.d.ts +2 -0
- package/dist/processors/aggregators/index.js +8 -0
- package/dist/processors/aggregators/llm_context.d.ts +370 -0
- package/dist/processors/aggregators/llm_context.js +580 -0
- package/dist/processors/aggregators/llm_context_summarizer.d.ts +219 -0
- package/dist/processors/aggregators/llm_context_summarizer.js +447 -0
- package/dist/processors/aggregators/llm_response.d.ts +37 -0
- package/dist/processors/aggregators/llm_response.js +71 -0
- package/dist/processors/aggregators/llm_response_universal.d.ts +966 -0
- package/dist/processors/aggregators/llm_response_universal.js +2394 -0
- package/dist/processors/aggregators/llm_text_processor.d.ts +53 -0
- package/dist/processors/aggregators/llm_text_processor.js +102 -0
- package/dist/processors/aggregators/sentence.d.ts +39 -0
- package/dist/processors/aggregators/sentence.js +69 -0
- package/dist/processors/async_generator.d.ts +42 -0
- package/dist/processors/async_generator.js +76 -0
- package/dist/processors/audio/audio_buffer_processor.d.ts +241 -0
- package/dist/processors/audio/audio_buffer_processor.js +565 -0
- package/dist/processors/audio/index.d.ts +2 -0
- package/dist/processors/audio/index.js +8 -0
- package/dist/processors/audio/vad_processor.d.ts +67 -0
- package/dist/processors/audio/vad_processor.js +103 -0
- package/dist/processors/consumer_processor.d.ts +49 -0
- package/dist/processors/consumer_processor.js +85 -0
- package/dist/processors/filters/frame_filter.d.ts +39 -0
- package/dist/processors/filters/frame_filter.js +52 -0
- package/dist/processors/filters/function_filter.d.ts +59 -0
- package/dist/processors/filters/function_filter.js +78 -0
- package/dist/processors/filters/identity_filter.d.ts +33 -0
- package/dist/processors/filters/identity_filter.js +37 -0
- package/dist/processors/filters/index.d.ts +2 -0
- package/dist/processors/filters/index.js +8 -0
- package/dist/processors/filters/null_filter.d.ts +34 -0
- package/dist/processors/filters/null_filter.js +47 -0
- package/dist/processors/filters/wake_notifier_filter.d.ts +48 -0
- package/dist/processors/filters/wake_notifier_filter.js +49 -0
- package/dist/processors/frame_processor.d.ts +360 -0
- package/dist/processors/frame_processor.js +932 -0
- package/dist/processors/frameworks/index.d.ts +2 -0
- package/dist/processors/frameworks/index.js +8 -0
- package/dist/processors/frameworks/rtvi/frames.d.ts +241 -0
- package/dist/processors/frameworks/rtvi/frames.js +259 -0
- package/dist/processors/frameworks/rtvi/index.d.ts +6 -0
- package/dist/processors/frameworks/rtvi/index.js +11 -0
- package/dist/processors/frameworks/rtvi/models.d.ts +1741 -0
- package/dist/processors/frameworks/rtvi/models.js +1807 -0
- package/dist/processors/frameworks/rtvi/observer.d.ts +340 -0
- package/dist/processors/frameworks/rtvi/observer.js +1113 -0
- package/dist/processors/frameworks/rtvi/processor.d.ts +182 -0
- package/dist/processors/frameworks/rtvi/processor.js +676 -0
- package/dist/processors/idle_frame_processor.d.ts +44 -0
- package/dist/processors/idle_frame_processor.js +86 -0
- package/dist/processors/index.d.ts +2 -0
- package/dist/processors/index.js +8 -0
- package/dist/processors/logger.d.ts +42 -0
- package/dist/processors/logger.js +87 -0
- package/dist/processors/metrics/frame_processor_metrics.d.ts +172 -0
- package/dist/processors/metrics/frame_processor_metrics.js +372 -0
- package/dist/processors/metrics/index.d.ts +2 -0
- package/dist/processors/metrics/index.js +8 -0
- package/dist/processors/producer_processor.d.ts +68 -0
- package/dist/processors/producer_processor.js +78 -0
- package/dist/processors/text_transformer.d.ts +30 -0
- package/dist/processors/text_transformer.js +48 -0
- package/dist/registry/index.d.ts +3 -0
- package/dist/registry/index.js +8 -0
- package/dist/registry/registry.d.ts +69 -0
- package/dist/registry/registry.js +149 -0
- package/dist/registry/types.d.ts +47 -0
- package/dist/registry/types.js +39 -0
- package/dist/serializers/base_serializer.d.ts +94 -0
- package/dist/serializers/base_serializer.js +82 -0
- package/dist/serializers/index.d.ts +2 -0
- package/dist/serializers/index.js +8 -0
- package/dist/services/ai_service.d.ts +145 -0
- package/dist/services/ai_service.js +262 -0
- package/dist/services/anthropic/index.d.ts +2 -0
- package/dist/services/anthropic/index.js +7 -0
- package/dist/services/assemblyai/index.d.ts +2 -0
- package/dist/services/assemblyai/index.js +7 -0
- package/dist/services/asyncai/index.d.ts +2 -0
- package/dist/services/asyncai/index.js +7 -0
- package/dist/services/aws/index.d.ts +2 -0
- package/dist/services/aws/index.js +7 -0
- package/dist/services/azure/index.d.ts +2 -0
- package/dist/services/azure/index.js +7 -0
- package/dist/services/baseten/index.d.ts +2 -0
- package/dist/services/baseten/index.js +7 -0
- package/dist/services/bland/index.d.ts +2 -0
- package/dist/services/bland/index.js +7 -0
- package/dist/services/camb/index.d.ts +2 -0
- package/dist/services/camb/index.js +7 -0
- package/dist/services/cartesia/index.d.ts +2 -0
- package/dist/services/cartesia/index.js +7 -0
- package/dist/services/cerebras/index.d.ts +2 -0
- package/dist/services/cerebras/index.js +7 -0
- package/dist/services/crusoe/index.d.ts +2 -0
- package/dist/services/crusoe/index.js +7 -0
- package/dist/services/deepgram/index.d.ts +2 -0
- package/dist/services/deepgram/index.js +7 -0
- package/dist/services/deepseek/index.d.ts +2 -0
- package/dist/services/deepseek/index.js +7 -0
- package/dist/services/elevenlabs/index.d.ts +2 -0
- package/dist/services/elevenlabs/index.js +7 -0
- package/dist/services/fal/index.d.ts +2 -0
- package/dist/services/fal/index.js +7 -0
- package/dist/services/fireworks/index.d.ts +2 -0
- package/dist/services/fireworks/index.js +7 -0
- package/dist/services/fish/index.d.ts +2 -0
- package/dist/services/fish/index.js +7 -0
- package/dist/services/funasr/index.d.ts +3 -0
- package/dist/services/funasr/index.js +8 -0
- package/dist/services/gladia/index.d.ts +2 -0
- package/dist/services/gladia/index.js +7 -0
- package/dist/services/google/index.d.ts +2 -0
- package/dist/services/google/index.js +7 -0
- package/dist/services/gradium/index.d.ts +2 -0
- package/dist/services/gradium/index.js +7 -0
- package/dist/services/grok/index.d.ts +2 -0
- package/dist/services/grok/index.js +7 -0
- package/dist/services/groq/index.d.ts +2 -0
- package/dist/services/groq/index.js +7 -0
- package/dist/services/heygen/index.d.ts +2 -0
- package/dist/services/heygen/index.js +7 -0
- package/dist/services/hume/index.d.ts +2 -0
- package/dist/services/hume/index.js +7 -0
- package/dist/services/image_service.d.ts +52 -0
- package/dist/services/image_service.js +60 -0
- package/dist/services/inception/index.d.ts +2 -0
- package/dist/services/inception/index.js +7 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +8 -0
- package/dist/services/inworld/index.d.ts +2 -0
- package/dist/services/inworld/index.js +7 -0
- package/dist/services/keenable/index.d.ts +2 -0
- package/dist/services/keenable/index.js +7 -0
- package/dist/services/kokoro/index.d.ts +2 -0
- package/dist/services/kokoro/index.js +7 -0
- package/dist/services/llm_service.d.ts +1106 -0
- package/dist/services/llm_service.js +2231 -0
- package/dist/services/mem0/index.d.ts +2 -0
- package/dist/services/mem0/index.js +7 -0
- package/dist/services/meta/index.d.ts +2 -0
- package/dist/services/meta/index.js +7 -0
- package/dist/services/minimax/index.d.ts +2 -0
- package/dist/services/minimax/index.js +7 -0
- package/dist/services/mistral/index.d.ts +2 -0
- package/dist/services/mistral/index.js +7 -0
- package/dist/services/moondream/index.d.ts +2 -0
- package/dist/services/moondream/index.js +7 -0
- package/dist/services/moonshine/index.d.ts +2 -0
- package/dist/services/moonshine/index.js +7 -0
- package/dist/services/nebius/index.d.ts +2 -0
- package/dist/services/nebius/index.js +7 -0
- package/dist/services/neuphonic/index.d.ts +2 -0
- package/dist/services/neuphonic/index.js +7 -0
- package/dist/services/novita/index.d.ts +2 -0
- package/dist/services/novita/index.js +7 -0
- package/dist/services/nvidia/index.d.ts +2 -0
- package/dist/services/nvidia/index.js +11 -0
- package/dist/services/ollama/index.d.ts +2 -0
- package/dist/services/ollama/index.js +7 -0
- package/dist/services/openai/index.d.ts +2 -0
- package/dist/services/openai/index.js +7 -0
- package/dist/services/openclaw/index.d.ts +14 -0
- package/dist/services/openclaw/index.js +19 -0
- package/dist/services/openrouter/index.d.ts +2 -0
- package/dist/services/openrouter/index.js +7 -0
- package/dist/services/perplexity/index.d.ts +2 -0
- package/dist/services/perplexity/index.js +7 -0
- package/dist/services/piper/index.d.ts +2 -0
- package/dist/services/piper/index.js +7 -0
- package/dist/services/pocket_tts/index.d.ts +2 -0
- package/dist/services/pocket_tts/index.js +7 -0
- package/dist/services/qwen/index.d.ts +2 -0
- package/dist/services/qwen/index.js +7 -0
- package/dist/services/resembleai/index.d.ts +2 -0
- package/dist/services/resembleai/index.js +7 -0
- package/dist/services/rime/index.d.ts +2 -0
- package/dist/services/rime/index.js +7 -0
- package/dist/services/sambanova/index.d.ts +2 -0
- package/dist/services/sambanova/index.js +7 -0
- package/dist/services/sarvam/index.d.ts +2 -0
- package/dist/services/sarvam/index.js +7 -0
- package/dist/services/settings.d.ts +356 -0
- package/dist/services/settings.js +437 -0
- package/dist/services/simli/index.d.ts +2 -0
- package/dist/services/simli/index.js +7 -0
- package/dist/services/smallest/index.d.ts +2 -0
- package/dist/services/smallest/index.js +7 -0
- package/dist/services/soniox/index.d.ts +2 -0
- package/dist/services/soniox/index.js +7 -0
- package/dist/services/speechify/index.d.ts +2 -0
- package/dist/services/speechify/index.js +7 -0
- package/dist/services/speechmatics/index.d.ts +2 -0
- package/dist/services/speechmatics/index.js +7 -0
- package/dist/services/stt_latency.d.ts +62 -0
- package/dist/services/stt_latency.js +71 -0
- package/dist/services/stt_service.d.ts +601 -0
- package/dist/services/stt_service.js +1044 -0
- package/dist/services/tavus/index.d.ts +2 -0
- package/dist/services/tavus/index.js +7 -0
- package/dist/services/together/index.d.ts +2 -0
- package/dist/services/together/index.js +7 -0
- package/dist/services/tts_service.d.ts +909 -0
- package/dist/services/tts_service.js +2018 -0
- package/dist/services/ultravox/index.d.ts +2 -0
- package/dist/services/ultravox/index.js +9 -0
- package/dist/services/vision_service.d.ts +56 -0
- package/dist/services/vision_service.js +62 -0
- package/dist/services/websocket_service.d.ts +213 -0
- package/dist/services/websocket_service.js +654 -0
- package/dist/services/whisper/index.d.ts +2 -0
- package/dist/services/whisper/index.js +9 -0
- package/dist/services/xai/index.d.ts +2 -0
- package/dist/services/xai/index.js +9 -0
- package/dist/services/xtts/index.d.ts +2 -0
- package/dist/services/xtts/index.js +9 -0
- package/dist/tests/index.d.ts +2 -0
- package/dist/tests/index.js +8 -0
- package/dist/tests/utils.d.ts +123 -0
- package/dist/tests/utils.js +212 -0
- package/dist/transcriptions/index.d.ts +2 -0
- package/dist/transcriptions/index.js +8 -0
- package/dist/transcriptions/language.d.ts +376 -0
- package/dist/transcriptions/language.js +543 -0
- package/dist/transports/base_input.d.ts +149 -0
- package/dist/transports/base_input.js +338 -0
- package/dist/transports/base_output.d.ts +432 -0
- package/dist/transports/base_output.js +1231 -0
- package/dist/transports/base_transport.d.ts +223 -0
- package/dist/transports/base_transport.js +167 -0
- package/dist/transports/daily/index.d.ts +2 -0
- package/dist/transports/daily/index.js +9 -0
- package/dist/transports/heygen/index.d.ts +2 -0
- package/dist/transports/heygen/index.js +9 -0
- package/dist/transports/index.d.ts +2 -0
- package/dist/transports/index.js +8 -0
- package/dist/transports/lemonslice/index.d.ts +2 -0
- package/dist/transports/lemonslice/index.js +9 -0
- package/dist/transports/livekit/index.d.ts +2 -0
- package/dist/transports/livekit/index.js +9 -0
- package/dist/transports/local/index.d.ts +2 -0
- package/dist/transports/local/index.js +9 -0
- package/dist/transports/moq/index.d.ts +2 -0
- package/dist/transports/moq/index.js +9 -0
- package/dist/transports/smallwebrtc/index.d.ts +2 -0
- package/dist/transports/smallwebrtc/index.js +9 -0
- package/dist/transports/tavus/index.d.ts +2 -0
- package/dist/transports/tavus/index.js +9 -0
- package/dist/transports/video_in_sampler.d.ts +157 -0
- package/dist/transports/video_in_sampler.js +258 -0
- package/dist/transports/vonage/index.d.ts +2 -0
- package/dist/transports/vonage/index.js +9 -0
- package/dist/transports/websocket/index.d.ts +2 -0
- package/dist/transports/websocket/index.js +9 -0
- package/dist/transports/whatsapp/index.d.ts +2 -0
- package/dist/transports/whatsapp/index.js +9 -0
- package/dist/turns/eager_end_of_turn_mixin.d.ts +70 -0
- package/dist/turns/eager_end_of_turn_mixin.js +122 -0
- package/dist/turns/empty_user_turn.d.ts +55 -0
- package/dist/turns/empty_user_turn.js +53 -0
- package/dist/turns/index.d.ts +2 -0
- package/dist/turns/index.js +8 -0
- package/dist/turns/speculation_gate.d.ts +169 -0
- package/dist/turns/speculation_gate.js +294 -0
- package/dist/turns/types.d.ts +33 -0
- package/dist/turns/types.js +35 -0
- package/dist/turns/user_idle_controller.d.ts +94 -0
- package/dist/turns/user_idle_controller.js +187 -0
- package/dist/turns/user_mute/always_user_mute_strategy.d.ts +15 -0
- package/dist/turns/user_mute/always_user_mute_strategy.js +29 -0
- package/dist/turns/user_mute/base_user_mute_strategy.d.ts +39 -0
- package/dist/turns/user_mute/base_user_mute_strategy.js +45 -0
- package/dist/turns/user_mute/first_speech_user_mute_strategy.d.ts +28 -0
- package/dist/turns/user_mute/first_speech_user_mute_strategy.js +52 -0
- package/dist/turns/user_mute/function_call_user_mute_strategy.d.ts +25 -0
- package/dist/turns/user_mute/function_call_user_mute_strategy.js +50 -0
- package/dist/turns/user_mute/index.d.ts +6 -0
- package/dist/turns/user_mute/index.js +11 -0
- package/dist/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.d.ts +43 -0
- package/dist/turns/user_mute/mute_until_first_bot_complete_user_mute_strategy.js +76 -0
- package/dist/turns/user_start/base_user_turn_start_strategy.d.ts +135 -0
- package/dist/turns/user_start/base_user_turn_start_strategy.js +134 -0
- package/dist/turns/user_start/external_user_turn_start_strategy.d.ts +45 -0
- package/dist/turns/user_start/external_user_turn_start_strategy.js +64 -0
- package/dist/turns/user_start/index.d.ts +8 -0
- package/dist/turns/user_start/index.js +16 -0
- package/dist/turns/user_start/krisp_viva_ip_user_turn_start_strategy.d.ts +133 -0
- package/dist/turns/user_start/krisp_viva_ip_user_turn_start_strategy.js +243 -0
- package/dist/turns/user_start/min_words_user_turn_start_strategy.d.ts +60 -0
- package/dist/turns/user_start/min_words_user_turn_start_strategy.js +102 -0
- package/dist/turns/user_start/transcription_user_turn_start_strategy.d.ts +29 -0
- package/dist/turns/user_start/transcription_user_turn_start_strategy.js +43 -0
- package/dist/turns/user_start/vad_user_turn_start_strategy.d.ts +20 -0
- package/dist/turns/user_start/vad_user_turn_start_strategy.js +31 -0
- package/dist/turns/user_start/wake_phrase_user_turn_start_strategy.d.ts +141 -0
- package/dist/turns/user_start/wake_phrase_user_turn_start_strategy.js +238 -0
- package/dist/turns/user_stop/base_user_turn_stop_strategy.d.ts +174 -0
- package/dist/turns/user_stop/base_user_turn_stop_strategy.js +170 -0
- package/dist/turns/user_stop/deferred_user_turn_stop_strategy.d.ts +81 -0
- package/dist/turns/user_stop/deferred_user_turn_stop_strategy.js +107 -0
- package/dist/turns/user_stop/eager_match_policy.d.ts +36 -0
- package/dist/turns/user_stop/eager_match_policy.js +50 -0
- package/dist/turns/user_stop/eager_user_turn_stop_strategy.d.ts +84 -0
- package/dist/turns/user_stop/eager_user_turn_stop_strategy.js +154 -0
- package/dist/turns/user_stop/external_user_turn_completion_stop_strategy.d.ts +45 -0
- package/dist/turns/user_stop/external_user_turn_completion_stop_strategy.js +55 -0
- package/dist/turns/user_stop/external_user_turn_stop_strategy.d.ts +115 -0
- package/dist/turns/user_stop/external_user_turn_stop_strategy.js +205 -0
- package/dist/turns/user_stop/index.d.ts +10 -0
- package/dist/turns/user_stop/index.js +15 -0
- package/dist/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.d.ts +44 -0
- package/dist/turns/user_stop/llm_turn_completion_user_turn_stop_strategy.js +59 -0
- package/dist/turns/user_stop/speech_timeout_user_turn_stop_strategy.d.ts +140 -0
- package/dist/turns/user_stop/speech_timeout_user_turn_stop_strategy.js +303 -0
- package/dist/turns/user_stop/turn_analyzer_user_turn_stop_strategy.d.ts +135 -0
- package/dist/turns/user_stop/turn_analyzer_user_turn_stop_strategy.js +332 -0
- package/dist/turns/user_turn_completion_mixin.d.ts +147 -0
- package/dist/turns/user_turn_completion_mixin.js +591 -0
- package/dist/turns/user_turn_controller.d.ts +134 -0
- package/dist/turns/user_turn_controller.js +335 -0
- package/dist/turns/user_turn_processor.d.ts +87 -0
- package/dist/turns/user_turn_processor.js +180 -0
- package/dist/turns/user_turn_strategies.d.ts +187 -0
- package/dist/turns/user_turn_strategies.js +222 -0
- package/dist/utils/async_tool_cancellation.d.ts +35 -0
- package/dist/utils/async_tool_cancellation.js +78 -0
- package/dist/utils/asyncio/index.d.ts +2 -0
- package/dist/utils/asyncio/index.js +7 -0
- package/dist/utils/asyncio/task_manager.d.ts +49 -0
- package/dist/utils/asyncio/task_manager.js +105 -0
- package/dist/utils/base_object.d.ts +76 -0
- package/dist/utils/base_object.js +154 -0
- package/dist/utils/context/aggregated_frame_sequencer.d.ts +491 -0
- package/dist/utils/context/aggregated_frame_sequencer.js +803 -0
- package/dist/utils/context/audio_context_manager.d.ts +131 -0
- package/dist/utils/context/audio_context_manager.js +233 -0
- package/dist/utils/context/index.d.ts +2 -0
- package/dist/utils/context/index.js +7 -0
- package/dist/utils/context/llm_context_summarization.d.ts +258 -0
- package/dist/utils/context/llm_context_summarization.js +511 -0
- package/dist/utils/context/text_segment_map.d.ts +611 -0
- package/dist/utils/context/text_segment_map.js +1132 -0
- package/dist/utils/context/word_completion_tracker.d.ts +273 -0
- package/dist/utils/context/word_completion_tracker.js +441 -0
- package/dist/utils/context/word_timestamp_tracker.d.ts +72 -0
- package/dist/utils/context/word_timestamp_tracker.js +98 -0
- package/dist/utils/deprecation.d.ts +28 -0
- package/dist/utils/deprecation.js +75 -0
- package/dist/utils/env.d.ts +38 -0
- package/dist/utils/env.js +62 -0
- package/dist/utils/errors.d.ts +75 -0
- package/dist/utils/errors.js +202 -0
- package/dist/utils/file_resolver.d.ts +126 -0
- package/dist/utils/file_resolver.js +314 -0
- package/dist/utils/file_storage.d.ts +115 -0
- package/dist/utils/file_storage.js +178 -0
- package/dist/utils/frame_queue.d.ts +36 -0
- package/dist/utils/frame_queue.js +66 -0
- package/dist/utils/http.d.ts +39 -0
- package/dist/utils/http.js +53 -0
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +63 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/jpeg_encoder.d.ts +14 -0
- package/dist/utils/jpeg_encoder.js +315 -0
- package/dist/utils/log_config.d.ts +34 -0
- package/dist/utils/log_config.js +76 -0
- package/dist/utils/logger.d.ts +40 -0
- package/dist/utils/logger.js +91 -0
- package/dist/utils/mixin.d.ts +14 -0
- package/dist/utils/mixin.js +63 -0
- package/dist/utils/network.d.ts +73 -0
- package/dist/utils/network.js +90 -0
- package/dist/utils/security/allowed_origins.d.ts +16 -0
- package/dist/utils/security/allowed_origins.js +34 -0
- package/dist/utils/security/index.d.ts +2 -0
- package/dist/utils/security/index.js +7 -0
- package/dist/utils/security/redaction.d.ts +27 -0
- package/dist/utils/security/redaction.js +123 -0
- package/dist/utils/security/ssrf.d.ts +79 -0
- package/dist/utils/security/ssrf.js +327 -0
- package/dist/utils/shared.d.ts +35 -0
- package/dist/utils/shared.js +136 -0
- package/dist/utils/startup.d.ts +17 -0
- package/dist/utils/startup.js +130 -0
- package/dist/utils/string.d.ts +116 -0
- package/dist/utils/string.js +460 -0
- package/dist/utils/sync/base_notifier.d.ts +25 -0
- package/dist/utils/sync/base_notifier.js +16 -0
- package/dist/utils/sync/event_notifier.d.ts +38 -0
- package/dist/utils/sync/event_notifier.js +51 -0
- package/dist/utils/sync/index.d.ts +2 -0
- package/dist/utils/sync/index.js +7 -0
- package/dist/utils/text/alnum_utils.d.ts +109 -0
- package/dist/utils/text/alnum_utils.js +262 -0
- package/dist/utils/text/base_text_aggregator.d.ts +146 -0
- package/dist/utils/text/base_text_aggregator.js +102 -0
- package/dist/utils/text/base_text_filter.d.ts +54 -0
- package/dist/utils/text/base_text_filter.js +50 -0
- package/dist/utils/text/index.d.ts +2 -0
- package/dist/utils/text/index.js +7 -0
- package/dist/utils/text/markdown_text_filter.d.ts +147 -0
- package/dist/utils/text/markdown_text_filter.js +522 -0
- package/dist/utils/text/markup_utils.d.ts +54 -0
- package/dist/utils/text/markup_utils.js +156 -0
- package/dist/utils/text/pattern_pair_aggregator.d.ts +251 -0
- package/dist/utils/text/pattern_pair_aggregator.js +478 -0
- package/dist/utils/text/phonemes.d.ts +60 -0
- package/dist/utils/text/phonemes.js +172 -0
- package/dist/utils/text/simple_text_aggregator.d.ts +135 -0
- package/dist/utils/text/simple_text_aggregator.js +254 -0
- package/dist/utils/text/skip_tags_aggregator.d.ts +85 -0
- package/dist/utils/text/skip_tags_aggregator.js +147 -0
- package/dist/utils/text/transforms/acronyms.d.ts +21 -0
- package/dist/utils/text/transforms/acronyms.js +31 -0
- package/dist/utils/text/transforms/currency.d.ts +16 -0
- package/dist/utils/text/transforms/currency.js +56 -0
- package/dist/utils/text/transforms/dates.d.ts +18 -0
- package/dist/utils/text/transforms/dates.js +102 -0
- package/dist/utils/text/transforms/email.d.ts +17 -0
- package/dist/utils/text/transforms/email.js +35 -0
- package/dist/utils/text/transforms/index.d.ts +37 -0
- package/dist/utils/text/transforms/index.js +42 -0
- package/dist/utils/text/transforms/numbers.d.ts +25 -0
- package/dist/utils/text/transforms/numbers.js +59 -0
- package/dist/utils/text/transforms/percentages.d.ts +16 -0
- package/dist/utils/text/transforms/percentages.js +32 -0
- package/dist/utils/text/transforms/phone.d.ts +21 -0
- package/dist/utils/text/transforms/phone.js +39 -0
- package/dist/utils/text/transforms/pronunciations.d.ts +65 -0
- package/dist/utils/text/transforms/pronunciations.js +112 -0
- package/dist/utils/text/transforms/replacements.d.ts +39 -0
- package/dist/utils/text/transforms/replacements.js +46 -0
- package/dist/utils/text/transforms/strip_markdown.d.ts +25 -0
- package/dist/utils/text/transforms/strip_markdown.js +60 -0
- package/dist/utils/text/transforms/units.d.ts +22 -0
- package/dist/utils/text/transforms/units.js +81 -0
- package/dist/utils/text/transforms/voice_formatter.d.ts +86 -0
- package/dist/utils/text/transforms/voice_formatter.js +98 -0
- package/dist/utils/text/unicode_digits.d.ts +3 -0
- package/dist/utils/text/unicode_digits.js +34 -0
- package/dist/utils/text/word_timestamp_utils.d.ts +26 -0
- package/dist/utils/text/word_timestamp_utils.js +52 -0
- package/dist/utils/time.d.ts +13 -0
- package/dist/utils/time.js +31 -0
- package/dist/utils/tracing/index.d.ts +3 -0
- package/dist/utils/tracing/index.js +8 -0
- package/dist/utils/tracing/service_attributes.d.ts +198 -0
- package/dist/utils/tracing/service_attributes.js +434 -0
- package/dist/utils/tracing/service_decorators.d.ts +214 -0
- package/dist/utils/tracing/service_decorators.js +1662 -0
- package/dist/utils/tracing/setup.d.ts +107 -0
- package/dist/utils/tracing/setup.js +160 -0
- package/dist/utils/tracing/tracing_context.d.ts +55 -0
- package/dist/utils/tracing/tracing_context.js +97 -0
- package/dist/utils/tracing/turn_trace_observer.d.ts +95 -0
- package/dist/utils/tracing/turn_trace_observer.js +233 -0
- package/dist/utils/types.d.ts +80 -0
- package/dist/utils/types.js +108 -0
- package/dist/utils/utils.d.ts +5 -0
- package/dist/utils/utils.js +22 -0
- package/dist/utils/yaml.d.ts +179 -0
- package/dist/utils/yaml.js +556 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +7 -0
- package/dist/workers/base_worker.d.ts +772 -0
- package/dist/workers/base_worker.js +1298 -0
- package/dist/workers/index.d.ts +2 -0
- package/dist/workers/index.js +8 -0
- package/dist/workers/llm/backend_llm_worker.d.ts +552 -0
- package/dist/workers/llm/backend_llm_worker.js +1055 -0
- package/dist/workers/llm/index.d.ts +6 -0
- package/dist/workers/llm/index.js +11 -0
- package/dist/workers/llm/llm_context_worker.d.ts +66 -0
- package/dist/workers/llm/llm_context_worker.js +72 -0
- package/dist/workers/llm/llm_worker.d.ts +180 -0
- package/dist/workers/llm/llm_worker.js +323 -0
- package/dist/workers/llm/tool_decorator.d.ts +68 -0
- package/dist/workers/llm/tool_decorator.js +60 -0
- package/dist/workers/proxy/index.d.ts +3 -0
- package/dist/workers/proxy/index.js +9 -0
- package/dist/workers/proxy/websocket/client.d.ts +110 -0
- package/dist/workers/proxy/websocket/client.js +196 -0
- package/dist/workers/proxy/websocket/index.d.ts +3 -0
- package/dist/workers/proxy/websocket/index.js +10 -0
- package/dist/workers/runner.d.ts +268 -0
- package/dist/workers/runner.js +489 -0
- package/dist/workers/ui/index.d.ts +22 -0
- package/dist/workers/ui/index.js +27 -0
- package/dist/workers/ui/ui_event_decorator.d.ts +43 -0
- package/dist/workers/ui/ui_event_decorator.js +75 -0
- package/dist/workers/ui/ui_job_context.d.ts +12 -0
- package/dist/workers/ui/ui_job_context.js +17 -0
- package/dist/workers/ui/ui_prompts.d.ts +21 -0
- package/dist/workers/ui/ui_prompts.js +41 -0
- package/dist/workers/ui/ui_tools.d.ts +32 -0
- package/dist/workers/ui/ui_tools.js +112 -0
- package/dist/workers/ui/ui_worker.d.ts +571 -0
- package/dist/workers/ui/ui_worker.js +1156 -0
- package/package.json +61 -3
- 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
|