.
├─CODE_OF_CONDUCT.md
├─CONTRIBUTING.md
├─LICENSE
├─NOTICE
├─README.md
├─examples
│ ├─.env.example
│ ├─Dockerfile-example
│ ├─avatar_agents
│ │ ├─README.md
│ │ ├─audio_wave
│ │ │ ├─README.md
│ │ │ ├─agent_worker.py
│ │ │ ├─avatar_runner.py
│ │ │ ├─dispatcher.py
│ │ │ ├─requirements.txt
│ │ │ └─wave_viz.py
│ │ ├─bey
│ │ │ ├─README.md
│ │ │ └─agent_worker.py
│ │ ├─bithuman
│ │ │ ├─README.md
│ │ │ ├─agent_worker.py
│ │ │ └─requirements.txt
│ │ └─tavus
│ │ ├─README.md
│ │ └─agent_worker.py
│ ├─full_examples
│ │ └─restaurant_agent
│ │ └─restaurant_agent.py
│ ├─other
│ │ ├─browser
│ │ │ ├─browser_track.py
│ │ │ └─standalone_app.py
│ │ ├─datastream-audio
│ │ │ ├─README.md
│ │ │ ├─agent_worker.py
│ │ │ └─audio_receiver.py
│ │ ├─datastream-chat-listener.py
│ │ ├─echo-agent
│ │ │ └─echo-agent.py
│ │ ├─hive-moderation-agent
│ │ │ ├─README.md
│ │ │ ├─agent.py
│ │ │ ├─hive_data_classes.py
│ │ │ └─requirements.txt
│ │ ├─kokoro_tts.py
│ │ ├─participant-entrypoint
│ │ │ ├─README.md
│ │ │ ├─participant_entrypoint.py
│ │ │ └─requirements.txt
│ │ ├─simple-color
│ │ │ ├─README.md
│ │ │ ├─agent.py
│ │ │ └─requirements.txt
│ │ ├─speech-to-text
│ │ │ ├─README.md
│ │ │ ├─requirements.txt
│ │ │ └─transcriber.py
│ │ ├─text-to-speech
│ │ │ ├─README.md
│ │ │ ├─cartesia_tts.py
│ │ │ ├─elevenlabs_tts.py
│ │ │ ├─neuphonic_tts.py
│ │ │ ├─openai_tts.py
│ │ │ ├─requirements.txt
│ │ │ └─sync_tts_transcription.py
│ │ └─text_only.py
│ └─voice_agents
│ ├─annotated_tool_args.py
│ ├─background_audio.py
│ ├─basic_agent.py
│ ├─dynamic_tool_creation.py
│ ├─error_callback.py
│ ├─fast-preresponse.py
│ ├─gemini_video_agent.py
│ ├─llamaindex-rag
│ │ ├─README.md
│ │ ├─chat_engine.py
│ │ ├─data
│ │ │ └─raw_data.txt
│ │ ├─query_engine.py
│ │ └─retrieval.py
│ ├─multi_agent.py
│ ├─push_to_talk.py
│ ├─raw_function_description.py
│ ├─realtime_load_chat_history.py
│ ├─realtime_turn_detector.py
│ ├─requirements.txt
│ ├─silent_function_call.py
│ ├─speedup_output_audio.py
│ ├─structured_output.py
│ ├─toggle_io.py
│ ├─weather_agent.py
│ └─web_search.py
├─livekit-agents
│ ├─README.md
│ ├─livekit
│ │ └─agents
│ │ ├─__init__.py
│ │ ├─_exceptions.py
│ │ ├─cli
│ │ │ ├─__init__.py
│ │ │ ├─_run.py
│ │ │ ├─cli.py
│ │ │ ├─log.py
│ │ │ ├─proto.py
│ │ │ └─watcher.py
│ │ ├─debug
│ │ │ ├─__init__.py
│ │ │ ├─index.html
│ │ │ └─tracing.py
│ │ ├─http_server.py
│ │ ├─inference_runner.py
│ │ ├─ipc
│ │ │ ├─__init__.py
│ │ │ ├─channel.py
│ │ │ ├─inference_executor.py
│ │ │ ├─inference_proc_executor.py
│ │ │ ├─inference_proc_lazy_main.py
│ │ │ ├─job_executor.py
│ │ │ ├─job_proc_executor.py
│ │ │ ├─job_proc_lazy_main.py
│ │ │ ├─job_thread_executor.py
│ │ │ ├─log_queue.py
│ │ │ ├─mock_room.py
│ │ │ ├─proc_client.py
│ │ │ ├─proc_pool.py
│ │ │ ├─proto.py
│ │ │ └─supervised_proc.py
│ │ ├─job.py
│ │ ├─jupyter.py
│ │ ├─llm
│ │ │ ├─__init__.py
│ │ │ ├─_strict.py
│ │ │ ├─chat_context.py
│ │ │ ├─fallback_adapter.py
│ │ │ ├─llm.py
│ │ │ ├─realtime.py
│ │ │ ├─remote_chat_context.py
│ │ │ ├─tool_context.py
│ │ │ └─utils.py
│ │ ├─log.py
│ │ ├─metrics
│ │ │ ├─__init__.py
│ │ │ ├─base.py
│ │ │ ├─usage_collector.py
│ │ │ └─utils.py
│ │ ├─plugin.py
│ │ ├─py.typed
│ │ ├─resources
│ │ │ ├─NOTICE
│ │ │ └─__init__.py
│ │ ├─stt
│ │ │ ├─__init__.py
│ │ │ ├─fallback_adapter.py
│ │ │ ├─stream_adapter.py
│ │ │ └─stt.py
│ │ ├─tokenize
│ │ │ ├─__init__.py
│ │ │ ├─_basic_hyphenator.py
│ │ │ ├─_basic_paragraph.py
│ │ │ ├─_basic_sent.py
│ │ │ ├─_basic_word.py
│ │ │ ├─basic.py
│ │ │ ├─token_stream.py
│ │ │ ├─tokenizer.py
│ │ │ └─utils.py
│ │ ├─tts
│ │ │ ├─__init__.py
│ │ │ ├─fallback_adapter.py
│ │ │ ├─stream_adapter.py
│ │ │ └─tts.py
│ │ ├─types.py
│ │ ├─utils
│ │ │ ├─__init__.py
│ │ │ ├─aio
│ │ │ │ ├─__init__.py
│ │ │ │ ├─channel.py
│ │ │ │ ├─debug.py
│ │ │ │ ├─duplex_unix.py
│ │ │ │ ├─interval.py
│ │ │ │ ├─itertools.py
│ │ │ │ ├─sleep.py
│ │ │ │ ├─task_set.py
│ │ │ │ ├─utils.py
│ │ │ │ └─wait_group.py
│ │ │ ├─audio.py
│ │ │ ├─codecs
│ │ │ │ ├─__init__.py
│ │ │ │ └─decoder.py
│ │ │ ├─connection_pool.py
│ │ │ ├─exp_filter.py
│ │ │ ├─http_context.py
│ │ │ ├─hw
│ │ │ │ ├─__init__.py
│ │ │ │ └─cpu.py
│ │ │ ├─images
│ │ │ │ ├─__init__.py
│ │ │ │ └─image.py
│ │ │ ├─log.py
│ │ │ ├─misc.py
│ │ │ ├─moving_average.py
│ │ │ └─participant.py
│ │ ├─vad.py
│ │ ├─version.py
│ │ ├─voice
│ │ │ ├─__init__.py
│ │ │ ├─agent.py
│ │ │ ├─agent_activity.py
│ │ │ ├─agent_session.py
│ │ │ ├─audio_recognition.py
│ │ │ ├─avatar
│ │ │ │ ├─__init__.py
│ │ │ │ ├─_datastream_io.py
│ │ │ │ ├─_queue_io.py
│ │ │ │ ├─_runner.py
│ │ │ │ └─_types.py
│ │ │ ├─background_audio.py
│ │ │ ├─chat_cli.py
│ │ │ ├─events.py
│ │ │ ├─generation.py
│ │ │ ├─io.py
│ │ │ ├─room_io
│ │ │ │ ├─__init__.py
│ │ │ │ ├─_input.py
│ │ │ │ ├─_output.py
│ │ │ │ └─room_io.py
│ │ │ ├─speech_handle.py
│ │ │ └─transcription
│ │ │ ├─__init__.py
│ │ │ ├─_speaking_rate.py
│ │ │ ├─_utils.py
│ │ │ └─synchronizer.py
│ │ └─worker.py
│ └─pyproject.toml
├─livekit-plugins
│ ├─livekit-plugins-anthropic
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─anthropic
│ │ │ ├─__init__.py
│ │ │ ├─llm.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─utils.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-assemblyai
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─assemblyai
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-aws
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─aws
│ │ │ ├─__init__.py
│ │ │ ├─llm.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ ├─tts.py
│ │ │ ├─utils.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-azure
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─azure
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-bey
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─bey
│ │ │ ├─__init__.py
│ │ │ ├─avatar.py
│ │ │ ├─log.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-bithuman
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─bithuman
│ │ │ ├─__init__.py
│ │ │ ├─avatar.py
│ │ │ ├─log.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-browser
│ │ ├─.clang-format
│ │ ├─CMakeLists.txt
│ │ ├─LICENSE.txt
│ │ ├─README.md
│ │ ├─cmake
│ │ │ └─DownloadCEF.cmake
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─browser
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─proc.py
│ │ │ ├─proc_main.py
│ │ │ ├─proto.py
│ │ │ ├─py.typed
│ │ │ ├─resources
│ │ │ │ └─__init__.py
│ │ │ └─version.py
│ │ ├─pyproject.toml
│ │ ├─setup.py
│ │ └─src
│ │ ├─CMakeLists.txt
│ │ ├─agents_python.cpp
│ │ ├─agents_python.hpp
│ │ ├─app.cpp
│ │ ├─app.hpp
│ │ ├─app_mac.mm
│ │ ├─browser_handle.cpp
│ │ ├─browser_handle.hpp
│ │ ├─dev_renderer.cpp
│ │ ├─dev_renderer.hpp
│ │ ├─dummy.cpp
│ │ ├─gleq.h
│ │ ├─handler.cpp
│ │ ├─handler.hpp
│ │ ├─helper_main_linux.cpp
│ │ ├─helper_main_mac.mm
│ │ ├─helper_main_win.cpp
│ │ ├─keyboard_codes.h
│ │ ├─resources
│ │ │ ├─lkcefapp-Info.plist
│ │ │ └─lkcefhelper-Info.plist
│ │ └─run_browser.py
│ ├─livekit-plugins-cartesia
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─cartesia
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-clova
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─clova
│ │ │ ├─__init__.py
│ │ │ ├─common.py
│ │ │ ├─constants.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─stt.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-deepgram
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─deepgram
│ │ │ ├─__init__.py
│ │ │ ├─_utils.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-elevenlabs
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─elevenlabs
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-fal
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─fal
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-gladia
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─gladia
│ │ │ ├─__init__.py
│ │ │ ├─_utils.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-google
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─google
│ │ │ ├─__init__.py
│ │ │ ├─beta
│ │ │ │ ├─__init__.py
│ │ │ │ └─realtime
│ │ │ │ ├─__init__.py
│ │ │ │ ├─api_proto.py
│ │ │ │ └─realtime_api.py
│ │ │ ├─llm.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ ├─tts.py
│ │ │ ├─utils.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-groq
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─groq
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─services.py
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-hume
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─hume
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-minimal
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─minimal
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-neuphonic
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─neuphonic
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-nltk
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─nltk
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─py.typed
│ │ │ ├─sentence_tokenizer.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-openai
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─openai
│ │ │ ├─__init__.py
│ │ │ ├─embeddings.py
│ │ │ ├─llm.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─realtime
│ │ │ │ ├─__init__.py
│ │ │ │ └─realtime_model.py
│ │ │ ├─stt.py
│ │ │ ├─tts.py
│ │ │ ├─utils.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-playai
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─playai
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-resemble
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─resemble
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-rime
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─rime
│ │ │ ├─__init__.py
│ │ │ ├─langs.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-silero
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─silero
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─onnx_model.py
│ │ │ ├─py.typed
│ │ │ ├─resources
│ │ │ │ ├─__init__.py
│ │ │ │ └─silero_vad.onnx
│ │ │ ├─vad.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-speechify
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─speechify
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─models.py
│ │ │ ├─py.typed
│ │ │ ├─tts.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ ├─livekit-plugins-speechmatics
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─speechmatics
│ │ │ ├─__init__.py
│ │ │ ├─log.py
│ │ │ ├─py.typed
│ │ │ ├─stt.py
│ │ │ ├─types.py
│ │ │ ├─utils.py
│ │ │ └─version.py
│ │ ├─project.toml
│ │ ├─pyproject.toml
│ │ └─setup.py
│ ├─livekit-plugins-tavus
│ │ ├─README.md
│ │ ├─livekit
│ │ │ └─plugins
│ │ │ └─tavus
│ │ │ ├─__init__.py
│ │ │ ├─api.py
│ │ │ ├─avatar.py
│ │ │ ├─log.py
│ │ │ └─version.py
│ │ └─pyproject.toml
│ └─livekit-plugins-turn-detector
│ ├─README.md
│ ├─livekit
│ │ └─plugins
│ │ └─turn_detector
│ │ ├─__init__.py
│ │ ├─base.py
│ │ ├─english.py
│ │ ├─log.py
│ │ ├─models.py
│ │ ├─multilingual.py
│ │ └─version.py
│ └─pyproject.toml
├─pyproject.toml
├─tests
│ ├─Dockerfile.tests
│ ├─Dockerfile.toxiproxy
│ ├─Makefile
│ ├─__init__.py
│ ├─conftest.py
│ ├─docker-compose.yml
│ ├─fake_stt.py
│ ├─fake_tts.py
│ ├─long.mp3
│ ├─long_synthesize.txt
│ ├─long_transcript.txt
│ ├─test_aio.py
│ ├─test_audio_decoder.py
│ ├─test_chat_ctx.py
│ ├─test_config.py
│ ├─test_connection_pool.py
│ ├─test_ipc.py
│ ├─test_llm.py
│ ├─test_schema_gemini.py
│ ├─test_stt.py
│ ├─test_stt_fallback.py
│ ├─test_tokenizer.py
│ ├─test_tts.py
│ ├─test_tts_fallback.py
│ ├─test_vad.py
│ ├─toxic_proxy.py
│ └─utils.py
└─uv.lock
# Code of Conduct
## Our Pledge
We are committed to providing a welcoming, respectful, and harassment-free
environment for everyone, regardless of background, experience, or identity. We
strive to foster a positive and inclusive community where all participants feel
valued and empowered to contribute.
## Our Standards
### Expected behavior
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
### Unacceptable behavior
* Harassment, discrimination, or offensive comments regarding identity,
appearance, or background
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Personal attacks, insults, or disruptive behavior that undermines the
community
* Posting content or engaging in activities that are inappropriate, unlawful, or
harmful
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<conduct@livekit.io>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Violations of this Code of Conduct may result in removal from the community,
project, or repository. Severe violations may result in a permanent ban.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
It has been subtly adapted for formatting and brevity, as well as changing the
actions taken after a violation.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
# Contributing to livekit/agents
The LiveKit Agents framework is an open-source project, and we welcome any contribution from anyone
willing to work in good faith with the community. No contribution is too small!
## Code of Conduct
The LiveKit Agents project has a [Code of Conduct](/CODE_OF_CONDUCT.md) to which all contributors
must adhere.
## Contribute code
There are many ways you can contribute code to the project:
- **Write a plugin**: if there is a TTS/STT/LLM provider you use that isn't on our plugins list,
feel free to write a plugin for it! Refer to the source code of similar plugins to see how they're
built.
- **Fix bugs**: we strive to make this framework as reliable as possible, and we'd appreciate your
help with squashing bugs and improving stability. Follow the guidelines below for information
about authoring pull requests.
- **Add new features**: we're open to adding new features to the framework, though we ask that you
open an issue first to discuss the viability and scope of the new functionality before starting
work.
Our continuous integration requires a few additional code quality steps for your pull request to
be approved:
- Run `ruff check --fix` and `ruff format` before committing your changes to ensure consistent file
formatting and best practices.
- If writing new methods/enums/classes, document them. This project uses
[pdoc3](https://pdoc3.github.io/pdoc/) for automatic API documentation generation, and every new
addition has to be properly documented.
- On your first pull request, the CLA Assistant bot will give you a link to sign this project's
Contributor License Agreement, required to add your code to the repository.
- There's no need to mess around with `CHANGELOG.md` or package manifests — we have a bot handle
that for us. A maintainer will add the necessary notes before merging.
## Assist others in the community
If you can't contribute code, you can still help us greatly by helping out community members who
may have questions about the framework and how to use it. Join the `#agents` channel on
[our Slack](https://livekit.io/join-slack).
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Copyright 2023 LiveKit, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
<!--BEGIN_BANNER_IMAGE-->
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/banner_dark.png">
<source media="(prefers-color-scheme: light)" srcset="/.github/banner_light.png">
<img style="width:100%;" alt="The LiveKit icon, the name of the repository and some sample code in the background." src="https://raw.githubusercontent.com/livekit/agents/main/.github/banner_light.png">
</picture>
<!--END_BANNER_IMAGE-->
<br /><br />
Looking for the JS/TS library? Check out [AgentsJS](https://github.com/livekit/agents-js)
## ✨ 1.0 release ✨
This README reflects the 1.0 release. For documentation on the previous 0.x release, see the [0.x branch](https://github.com/livekit/agents/tree/0.x)
## What is Agents?
<!--BEGIN_DESCRIPTION-->
The **Agents framework** enables you to build voice AI agents that can see, hear, and speak in realtime. It provides a fully open-source platform for creating server-side agentic applications.
<!--END_DESCRIPTION-->
## Features
- **Flexible integrations**: A comprehensive ecosystem to mix and match the right STT, LLM, TTS, and Realtime API to suit your use case.
- **Integrated job scheduling**: Built-in task scheduling and distribution with [dispatch APIs](https://docs.livekit.io/agents/build/dispatch/) to connect end users to agents.
- **Extensive WebRTC clients**: Build client applications using LiveKit's open-source SDK ecosystem, supporting nearly all major platforms.
- **Telephony integration**: Works seamlessly with LiveKit's [telephony stack](https://docs.livekit.io/sip/), allowing your agent to make calls to or receive calls from phones.
- **Exchange data with clients**: Use [RPCs](https://docs.livekit.io/home/client/data/rpc/) and other [Data APIs](https://docs.livekit.io/home/client/data/) to seamlessly exchange data with clients.
- **Semantic turn detection**: Uses a transformer model to detect when a user is done with their turn, helps to reduce interruptions.
- **Open-source**: Fully open-source, allowing you to run the entire stack on your own servers, including [LiveKit server](https://github.com/livekit/livekit), one of the most widely used WebRTC media servers.
## Installation
To install the core Agents library, along with plugins for popular model providers:
```bash
pip install "livekit-agents[openai,silero,deepgram,cartesia,turn-detector]~=1.0"
Documentation on the framework and how to use it can be found here
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RunContext,
WorkerOptions,
cli,
function_tool,
)
from livekit.plugins import deepgram, openai, silero
@function_tool
async def lookup_weather(
context: RunContext,
location: str,
):
"""Used to look up weather information."""
return {"weather": "sunny", "temperature": 70}
async def entrypoint(ctx: JobContext):
await ctx.connect()
agent = Agent(
instructions="You are a friendly voice assistant built by LiveKit.",
tools=[lookup_weather],
)
session = AgentSession(
vad=silero.VAD.load(),
# any combination of STT, LLM, TTS, or realtime API can be used
stt=deepgram.STT(model="nova-3"),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(voice="ash"),
)
await session.start(agent=agent, room=ctx.room)
await session.generate_reply(instructions="greet the user and ask about their day")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
You’ll need the following environment variables for this example:
This code snippet is abbreviated. For the full example, see multi_agent.py
...
class IntroAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions=f"You are a story teller. Your goal is to gather a few pieces of information from the user to make the story personalized and engaging."
"Ask the user for their name and where they are from"
)
async def on_enter(self):
self.session.generate_reply(instructions="greet the user and gather information")
@function_tool
async def information_gathered(
self,
context: RunContext,
name: str,
location: str,
):
"""Called when the user has provided the information needed to make the story personalized and engaging.
Args:
name: The name of the user
location: The location of the user
"""
context.userdata.name = name
context.userdata.location = location
story_agent = StoryAgent(name, location)
return story_agent, "Let's start the story!"
class StoryAgent(Agent):
def __init__(self, name: str, location: str) -> None:
super().__init__(
instructions=f"You are a storyteller. Use the user's information in order to make the story personalized."
f"The user's name is {name}, from {location}"
# override the default model, switching to Realtime API from standard LLMs
llm=openai.realtime.RealtimeModel(voice="echo"),
chat_ctx=chat_ctx,
)
async def on_enter(self):
self.session.generate_reply()
async def entrypoint(ctx: JobContext):
await ctx.connect()
userdata = StoryData()
session = AgentSession[StoryData](
vad=silero.VAD.load(),
stt=deepgram.STT(model="nova-3"),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(voice="echo"),
userdata=userdata,
)
await session.start(
agent=IntroAgent(),
room=ctx.room,
)
...
🎙️ Starter AgentA starter agent optimized for voice conversations. |
🔄 Multi-user push to talkResponds to multiple users in the room via push-to-talk. |
🎵 Background audioBackground ambient and thinking audio to improve realism. |
🛠️ Dynamic tool creationCreating function tools dynamically. |
☎️ Phone CallerAgent that makes outbound phone calls |
📋 Structured outputUsing structured output from LLM to guide TTS tone. |
🍽️ Restaurant ordering and reservationsFull example of an agent that handles calls for a restaurant. |
👁️ Gemini Live visionFull example (including iOS app) of Gemini Live agent that can see. |
python myagent.py console
Runs your agent in terminal mode, enabling local audio input and output for testing. This mode doesn’t require external servers or dependencies and is useful for quickly validating behavior.
python myagent.py dev
Starts the agent server and enables hot reloading when files change. This mode allows each process to host multiple concurrent agents efficiently.
The agent connects to LiveKit Cloud or your self-hosted server. Set the following environment variables:
You can connect using any LiveKit client SDK or telephony integration. To get started quickly, try the Agents Playground.
python myagent.py start
Runs the agent with production-ready optimizations.
The Agents framework is under active development in a rapidly evolving field. We welcome and appreciate contributions of any kind, be it feedback, bugfixes, features, new plugins and tools, or better documentation. You can file issues under this repo, open a PR, or chat with us in LiveKit’s Slack community.
<table>
</table>
## examples/.env.example
```example
LIVEKIT_API_SECRET="<your livekit api secret>"
LIVEKIT_API_KEY="<your livekit api key>"
LIVEKIT_URL="<your livekit ws url>"
# This is an example Dockerfile that builds a minimal container for running LK Agents
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.11.6
FROM python:${PYTHON_VERSION}-slim
# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1
# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#user
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/home/appuser" \
--shell "/sbin/nologin" \
--uid "${UID}" \
appuser
# Install gcc, g++ and other build dependencies.
RUN apt-get update && \
apt-get install -y \
gcc \
g++ \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
USER appuser
RUN mkdir -p /home/appuser/.cache
RUN chown -R appuser /home/appuser/.cache
WORKDIR /home/appuser
COPY requirements.txt .
RUN python -m pip install --user --no-cache-dir -r requirements.txt
COPY . .
# ensure that any dependent models are downloaded at build-time
RUN python myagent.py download-files
# Run the application.
ENTRYPOINT ["python", "myagent.py"]
CMD ["start"]
# Avatar Examples
Avatars provide a visual representation for agents.
+ **audio_wave:** A simple local mock demo that visualizes audio input as waveforms. It demonstrates how to build a avatar service.
+ **bey:** The bey example shows how to use the [Beyond Presence API](https://docs.bey.dev/introduction) for avatar generation.
## Contributing
Feel free to contribute additional avatar examples or technical improvements to existing ones by submitting a pull request.
# LiveKit Mock Avatar Example
This example demonstrates how to create an animated avatar that responds to audio input using LiveKit's agent system. The avatar worker generates synchronized video based on received audio input.
## How it Works
1. The agent sends connection info (including token, room name, and URL) to the avatar dispatcher server
2. The dispatcher launches an avatar worker process for that room
3. The agent streams audio to the avatar worker using LiveKit's DataStream
4. The avatar worker:
- Receives the audio stream
- Generates synchronized video frames based on the audio
- Publishes both the audio and video back to the room
## Usage
1. Start the avatar dispatcher server:
```bash
python examples/avatar/dispatcher.py [--port 8089]
python examples/avatar/agent_worker.py dev [--avatar-url http://localhost:8089/launch]
## examples/avatar_agents/audio_wave/agent_worker.py
```py
import argparse
import logging
import sys
from dataclasses import asdict, dataclass
from functools import partial
import httpx
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import JobContext, WorkerOptions, WorkerType, cli
from livekit.agents.voice import Agent, AgentSession
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.io import PlaybackFinishedEvent
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF, RoomOutputOptions
from livekit.plugins import openai
logger = logging.getLogger("avatar-example")
logger.setLevel(logging.INFO)
load_dotenv()
AVATAR_IDENTITY = "avatar_worker"
@dataclass
class AvatarConnectionInfo:
room_name: str
url: str
"""LiveKit server URL"""
token: str
"""Token for avatar worker to join"""
async def launch_avatar_worker(
ctx: JobContext, avatar_dispatcher_url: str, avatar_identity: str
) -> None:
"""Wait for worker participant to join and start streaming"""
# create a token for the avatar worker
agent_identity = ctx.room.local_participant.identity
token = (
api.AccessToken()
.with_identity(avatar_identity)
.with_name("Avatar Runner")
.with_grants(api.VideoGrants(room_join=True, room=ctx.room.name))
.with_kind("agent")
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: agent_identity})
.to_jwt()
)
logger.info(f"Sending connection info to avatar dispatcher {avatar_dispatcher_url}")
connection_info = AvatarConnectionInfo(room_name=ctx.room.name, url=ctx._info.url, token=token)
async with httpx.AsyncClient() as client:
response = await client.post(avatar_dispatcher_url, json=asdict(connection_info))
response.raise_for_status()
logger.info("Avatar handshake completed")
# wait for the remote participant to join
await ctx.wait_for_participant(
identity=avatar_identity, kind=rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
)
logger.info("Avatar runner joined")
async def entrypoint(ctx: JobContext, avatar_dispatcher_url: str):
await ctx.connect()
agent = Agent(instructions="Talk to me!")
session = AgentSession(
llm=openai.realtime.RealtimeModel(),
# stt=deepgram.STT(),
# llm=openai.LLM(model="gpt-4o-mini"),
# tts=cartesia.TTS(),
)
# wait for the participant to join the room and the avatar worker to connect
await launch_avatar_worker(ctx, avatar_dispatcher_url, AVATAR_IDENTITY)
# connect the output audio to the avatar runner
session.output.audio = DataStreamAudioOutput(ctx.room, destination_identity=AVATAR_IDENTITY)
# start agent with room input and room text output
await session.start(
agent=agent,
room=ctx.room,
room_output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=True,
),
)
@session.output.audio.on("playback_finished")
def on_playback_finished(ev: PlaybackFinishedEvent) -> None:
logger.info(
"playback_finished",
extra={
"playback_position": ev.playback_position,
"interrupted": ev.interrupted,
},
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--avatar-url", type=str, default="http://localhost:8089/launch")
args, remaining_args = parser.parse_known_args()
print(sys.argv, remaining_args)
sys.argv = sys.argv[:1] + remaining_args
# WorkerType.ROOM is the default worker type which will create an agent for every room.
# You can also use WorkerType.PUBLISHER to create a single agent for all participants that publish a track. # noqa: E501
cli.run_app(
WorkerOptions(
entrypoint_fnc=partial(entrypoint, avatar_dispatcher_url=args.avatar_url),
worker_type=WorkerType.ROOM,
)
)
import asyncio
import logging
import sys
from collections.abc import AsyncGenerator, AsyncIterator, Generator
from pathlib import Path
from typing import Optional, Union
import numpy as np
from livekit import rtc
from livekit.agents.voice.avatar import (
AudioSegmentEnd,
AvatarOptions,
AvatarRunner,
DataStreamAudioReceiver,
VideoGenerator,
)
sys.path.insert(0, str(Path(__file__).parent))
from wave_viz import WaveformVisualizer
logger = logging.getLogger("avatar-example")
class AudioWaveGenerator(VideoGenerator):
def __init__(self, options: AvatarOptions):
self._options = options
self._audio_queue: asyncio.Queue[Union[rtc.AudioFrame, AudioSegmentEnd]] = asyncio.Queue()
self._audio_resampler: Optional[rtc.AudioResampler] = None
# NOTE: Audio frames and video frames have different frequencies,
# so we accumulate audio samples in a buffer before
# generating corresponding video frames
self._audio_buffer = np.zeros((0, self._options.audio_channels), dtype=np.int16)
self._audio_samples_per_frame = int(
self._options.audio_sample_rate / self._options.video_fps
)
self._av_sync: Optional[rtc.AVSynchronizer] = None
async def push_audio(self, frame: rtc.AudioFrame | AudioSegmentEnd) -> None:
"""Process and queue audio frames, handling resampling if needed.
Args:
frame: Either an AudioFrame to process or AudioFlushSentinel to flush
"""
if isinstance(frame, AudioSegmentEnd):
if self._audio_resampler is not None:
# flush the resampler and queue any remaining frames
for resampled_frame in self._audio_resampler.flush():
await self._audio_queue.put(resampled_frame)
await self._audio_queue.put(frame)
return
# initialize resampler if needed
needs_resampling = (
frame.sample_rate != self._options.audio_sample_rate
or frame.num_channels != self._options.audio_channels
)
if needs_resampling and self._audio_resampler is None:
self._audio_resampler = rtc.AudioResampler(
input_rate=frame.sample_rate,
output_rate=self._options.audio_sample_rate,
num_channels=self._options.audio_channels,
)
if self._audio_resampler is not None:
for resampled_frame in self._audio_resampler.push(frame):
await self._audio_queue.put(resampled_frame)
else:
# no resampling needed, queue directly
await self._audio_queue.put(frame)
def clear_buffer(self) -> None:
while not self._audio_queue.empty():
try:
self._audio_queue.get_nowait()
except asyncio.QueueEmpty:
break
self._reset_audio_buffer()
def __aiter__(
self,
) -> AsyncIterator[rtc.VideoFrame | rtc.AudioFrame | AudioSegmentEnd]:
return self._stream_impl()
async def _stream_impl(
self,
) -> AsyncGenerator[rtc.VideoFrame | rtc.AudioFrame | AudioSegmentEnd, None]:
# initialize background canvas
background = np.zeros(
(self._options.video_height, self._options.video_width, 4),
dtype=np.uint8,
)
background.fill(255)
wave_visualizer = WaveformVisualizer(sample_rate=self._options.audio_sample_rate)
def _generate_idle_frame() -> rtc.VideoFrame:
idle_frame = background.copy()
fps = self._av_sync.actual_fps if self._av_sync else None
wave_visualizer.draw(
idle_frame,
audio_samples=np.zeros((1, self._options.audio_channels)),
fps=fps,
)
return self._np_to_video_frame(idle_frame)
def _generate_active_frames(
audio_frame: rtc.AudioFrame | AudioSegmentEnd,
) -> Generator[tuple[rtc.VideoFrame, rtc.AudioFrame], None, None]:
samples_per_frame = self._audio_samples_per_frame
if isinstance(audio_frame, rtc.AudioFrame):
audio_samples = np.frombuffer(audio_frame.data, dtype=np.int16).reshape(
-1, audio_frame.num_channels
) # (n_samples, n_channels)
else:
# fill the buffer with zeros if the buffer is not multiple of samples_per_frame
n_fill_samples = (
(samples_per_frame - len(self._audio_buffer) % samples_per_frame)
if len(self._audio_buffer) > 0
else 0
)
audio_samples = np.zeros(
[n_fill_samples, self._audio_buffer.shape[1]],
dtype=self._audio_buffer.dtype,
)
self._audio_buffer = np.concatenate([self._audio_buffer, audio_samples], axis=0)
# generate video frames with audio in buffer
while len(self._audio_buffer) >= samples_per_frame:
sub_samples = self._audio_buffer[:samples_per_frame]
self._audio_buffer = self._audio_buffer[samples_per_frame:]
canvas = background.copy()
fps = self._av_sync.actual_fps if self._av_sync else None
wave_visualizer.draw(canvas, sub_samples, fps=fps)
video_frame = self._np_to_video_frame(canvas)
sub_audio_frame = rtc.AudioFrame(
data=sub_samples.tobytes(),
sample_rate=self._options.audio_sample_rate,
num_channels=sub_samples.shape[1],
samples_per_channel=sub_samples.shape[0],
)
yield video_frame, sub_audio_frame
while True:
try:
# timeout has to be shorter than the frame interval to avoid starvation
frame = await asyncio.wait_for(
self._audio_queue.get(), timeout=0.5 / self._options.video_fps
)
except asyncio.TimeoutError:
# generate frame without audio (e.g. silence state)
if self._av_sync and self._av_sync._video_queue.qsize() > 1:
# skip if there are already video frames in the queue
continue
video_frame = _generate_idle_frame()
yield video_frame
await asyncio.sleep(0)
continue
for video_frame, audio_frame in _generate_active_frames(frame):
yield video_frame
yield audio_frame
if isinstance(frame, AudioSegmentEnd):
yield frame
self._reset_audio_buffer()
def set_av_sync(self, av_sync: rtc.AVSynchronizer | None) -> None:
self._av_sync = av_sync
def _reset_audio_buffer(self) -> None:
self._audio_buffer = np.zeros((0, self._options.audio_channels), dtype=np.int16)
def _np_to_video_frame(self, image: np.ndarray) -> rtc.VideoFrame:
return rtc.VideoFrame(
width=image.shape[1],
height=image.shape[0],
type=rtc.VideoBufferType.RGBA,
data=image.tobytes(),
)
async def main(room: rtc.Room):
"""Main application logic for the avatar worker"""
runner: AvatarRunner | None = None
stop_event = asyncio.Event()
try:
# Initialize and start worker
avatar_options = AvatarOptions(
video_width=1280,
video_height=720,
video_fps=30,
audio_sample_rate=24000,
audio_channels=1,
)
video_gen = AudioWaveGenerator(avatar_options)
runner = AvatarRunner(
room,
audio_recv=DataStreamAudioReceiver(room),
video_gen=video_gen,
options=avatar_options,
)
video_gen.set_av_sync(runner.av_sync)
await runner.start()
# Set up disconnect handler
async def handle_disconnect(participant: rtc.RemoteParticipant):
if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT:
logging.info("Agent %s disconnected, stopping worker...", participant.identity)
stop_event.set()
room.on(
"participant_disconnected",
lambda p: asyncio.create_task(handle_disconnect(p)),
)
room.on("disconnected", lambda _: stop_event.set())
# Wait until stopped
await stop_event.wait()
except Exception as e:
logging.error("Unexpected error: %s", e)
raise
finally:
if runner:
await runner.aclose()
async def run_service(url: str, token: str):
"""Run the avatar worker service"""
room = rtc.Room()
try:
# Connect to LiveKit room
logging.info("Connecting to %s", url)
await room.connect(url, token)
logging.info("Connected to room %s", room.name)
# Run main application logic
await main(room)
except rtc.ConnectError as e:
logging.error("Failed to connect to room: %s", e)
raise
finally:
await room.disconnect()
if __name__ == "__main__":
import sys
from argparse import ArgumentParser
def parse_args():
"""Parse command line arguments"""
parser = ArgumentParser()
parser.add_argument("--url", required=True, help="LiveKit server URL")
parser.add_argument("--token", required=True, help="Token for joining room")
parser.add_argument("--room", help="Room name")
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level",
)
return parser.parse_args()
def setup_logging(room: Optional[str], level: str):
"""Set up logging configuration"""
log_format = "%(asctime)s - %(levelname)s - %(message)s"
if room:
log_format = f"[{room}] {log_format}"
logging.basicConfig(level=getattr(logging, level.upper()), format=log_format)
args = parse_args()
setup_logging(args.room, args.log_level)
try:
asyncio.run(run_service(args.url, args.token))
except KeyboardInterrupt:
logging.info("Received interrupt signal, shutting down...")
except Exception as e:
logging.error("Fatal error: %s", e)
sys.exit(1)
finally:
logging.info("Shutting down...")
import asyncio
import logging
import subprocess
import sys
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import uvicorn
from fastapi import FastAPI, HTTPException
logger = logging.getLogger("avatar-dispatcher")
logging.basicConfig(level=logging.INFO)
THIS_DIR = Path(__file__).parent.absolute()
@dataclass
class AvatarConnectionInfo:
room_name: str
url: str # LiveKit server URL
token: str # Token for avatar worker to join
class WorkerLauncher:
"""Local implementation that launches workers as subprocesses"""
@dataclass
class WorkerInfo:
room_name: str
process: subprocess.Popen
def __init__(self):
self.workers: dict[str, WorkerLauncher.WorkerInfo] = {}
self._monitor_task: Optional[asyncio.Task] = None
async def start(self) -> None:
self._monitor_task = asyncio.create_task(self._monitor())
def close(self) -> None:
if self._monitor_task:
self._monitor_task.cancel()
for worker in self.workers.values():
worker.process.terminate()
try:
worker.process.wait(timeout=5)
except subprocess.TimeoutExpired:
worker.process.kill()
async def launch_worker(self, connection_info: AvatarConnectionInfo) -> None:
if connection_info.room_name in self.workers:
worker = self.workers[connection_info.room_name]
worker.process.terminate()
try:
worker.process.wait(timeout=5)
except subprocess.TimeoutExpired:
worker.process.kill()
# Launch new worker process
cmd = [
sys.executable,
str(THIS_DIR / "avatar_runner.py"),
"--url",
connection_info.url,
"--token",
connection_info.token,
"--room",
connection_info.room_name,
]
try:
room_name = connection_info.room_name
process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr)
self.workers[room_name] = WorkerLauncher.WorkerInfo(
room_name=room_name, process=process
)
logger.info(f"Launched avatar worker for room: {room_name}")
except Exception as e:
logger.error(f"Failed to launch worker: {e}")
raise HTTPException(status_code=500, detail=str(e)) # noqa: B904
async def _monitor(self) -> None:
while True:
for worker in list(self.workers.values()):
if worker.process.poll() is not None:
logger.info(
f"Worker for room {worker.room_name} exited with code {worker.process.returncode}" # noqa: E501
)
self.workers.pop(worker.room_name)
await asyncio.sleep(1)
class AvatarDispatcher:
def __init__(self):
self.launcher = WorkerLauncher()
@asynccontextmanager
async def lifespan(app: FastAPI):
await self.launcher.start()
yield
self.launcher.close()
self.app = FastAPI(title="Avatar Dispatcher", lifespan=lifespan)
self.app.post("/launch")(self.handle_launch)
async def handle_launch(self, connection_info: AvatarConnectionInfo) -> dict:
"""Handle request to launch an avatar worker"""
try:
await self.launcher.launch_worker(connection_info)
return {
"status": "success",
"message": f"Avatar worker launched for room: {connection_info.room_name}",
}
except Exception as e:
logger.error(f"Error handling launch request: {e}")
raise HTTPException(status_code=500, detail=f"Failed to launch worker: {str(e)}") # noqa: B904
def run_server(host: str = "0.0.0.0", port: int = 8089):
dispatcher = AvatarDispatcher()
uvicorn.run(dispatcher.app, host=host, port=port, log_level="info")
if __name__ == "__main__":
from argparse import ArgumentParser
parser = ArgumentParser()
parser.add_argument("--host", default="0.0.0.0", help="Host to run server on")
parser.add_argument("--port", default=8089, help="Port to run server on")
args = parser.parse_args()
run_server(args.host, args.port)
fastapi
uvicorn
opencv-python
import time
from collections import deque
from typing import Optional
import cv2
import numpy as np
class WaveformVisualizer:
def __init__(
self,
history_length: int = 500,
sample_rate: int = 24000,
n_fft: int = 512,
freq_bands: int = 128,
):
"""Initialize the waveform visualizer"""
self.history_length = history_length
self.sample_rate = sample_rate
self.n_fft = n_fft # FFT window size
self.freq_bands = freq_bands # Number of frequency bands to display
self.nyquist_freq = sample_rate // 2 # Highest frequency we can analyze
# Initialize volume history buffer
self.volume_history: deque[float] = deque(maxlen=history_length)
for _ in range(history_length):
self.volume_history.append(0)
# For FFT smoothing
self.prev_fft = np.zeros(freq_bands)
self.smoothing_factor = 0.3
self.noise_gate = 0.05 # Values below this are considered silence
self.start_time = time.time()
def draw_timestamp(self, canvas: np.ndarray, fps: Optional[float] = None):
height, width = canvas.shape[:2]
text = f"{time.time() - self.start_time:.1f}s"
if fps is not None:
text = f"{text} @ {fps:.1f}fps"
font_face = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 2.0
thickness = 2
(text_width, text_height), baseline = cv2.getTextSize(
text, font_face, font_scale, thickness
)
x = (width - text_width) // 2
y = int((height - text_height) * 0.2 + baseline)
cv2.putText(canvas, text, (x, y), font_face, font_scale, (0, 0, 0), thickness)
def draw_current_wave(self, canvas: np.ndarray, audio_samples: np.ndarray) -> float:
height, width = canvas.shape[:2]
center_y = int(height * 0.6)
# Convert audio to frequency domain using FFT
normalized_samples = audio_samples.astype(np.float32) / 32767.0
normalized_samples = normalized_samples.mean(axis=1)
if len(normalized_samples) >= self.n_fft:
window = np.hanning(self.n_fft)
fft_data = np.abs(np.fft.rfft(normalized_samples[: self.n_fft] * window))
# Compute RMS volume from frequency domain
volume = np.sqrt(np.mean(np.square(fft_data)))
volume = np.clip(volume * 0.5, 0, 1) # Scale and clip
# Rest of FFT processing for visualization
fft_data = 20 * np.log10(fft_data + 1e-10)
fft_data = (fft_data + 80) / 80
fft_data = np.clip(fft_data, 0, 1)
bands = np.array_split(fft_data, self.freq_bands)
plot_data = np.array([band.mean() for band in bands])
# Apply noise gate
if volume < self.noise_gate:
volume = 0
plot_data = np.zeros_like(plot_data)
self.prev_fft *= 0.5
# Apply temporal smoothing
self.prev_fft = (
self.prev_fft * (1 - self.smoothing_factor) + plot_data * self.smoothing_factor
)
else:
volume = 0
self.prev_fft *= 0.5
# Create smooth interpolated curve
x_coords = np.linspace(0, width, self.freq_bands)
y_coords = center_y - self.prev_fft * 150
x_smooth = np.linspace(0, width, width)
y_smooth = np.interp(x_smooth, x_coords, y_coords)
# Draw the spectrum visualization
points = np.column_stack((x_smooth, y_smooth)).astype(np.int32)
bottom_points = np.column_stack((x_smooth, np.full_like(x_smooth, center_y))).astype(
np.int32
)
wave_points = np.vstack((points, bottom_points[::-1]))
# Draw filled area with transparency
overlay = canvas.copy()
cv2.fillPoly(overlay, [wave_points.astype(np.int32)], (0, 255, 0, 50))
cv2.addWeighted(overlay, 0.3, canvas, 0.7, 0, canvas)
# Draw outline
for i in range(len(points) - 1):
cv2.line(canvas, tuple(points[i]), tuple(points[i + 1]), (0, 255, 0), 2)
return volume
def draw_volume_history(self, canvas: np.ndarray, current_volume: float):
height, width = canvas.shape[:2]
bottom_y = int(height * 0.95)
# Apply noise gate to volume
current_volume = current_volume if current_volume > self.noise_gate else 0
self.volume_history.append(current_volume)
cv2.line(canvas, (0, bottom_y), (width, bottom_y), (200, 200, 200), 1)
volume_x = np.linspace(0, width, len(self.volume_history), dtype=int)
volume_y = bottom_y - (np.array(self.volume_history) * 100)
points = np.column_stack((volume_x, volume_y)).astype(np.int32)
pts = np.vstack((points, [[width, bottom_y], [0, bottom_y]])).astype(np.int32)
overlay = canvas.copy()
cv2.fillPoly(overlay, [pts], (255, 0, 0, 30))
cv2.addWeighted(overlay, 0.3, canvas, 0.7, 0, canvas)
for i in range(len(points) - 1):
cv2.line(canvas, tuple(points[i]), tuple(points[i + 1]), (255, 0, 0), 2)
# Draw volume label
cv2.putText(
canvas,
"Volume",
(10, bottom_y - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(100, 100, 100),
1,
)
def draw(
self,
canvas: np.ndarray,
audio_samples: np.ndarray,
fps: Optional[float] = None,
):
self.draw_timestamp(canvas, fps)
volume = self.draw_current_wave(canvas, audio_samples)
self.draw_volume_history(canvas, volume)
# LiveKit Beyond Presence Avatar Example
This example demonstrates how to create an animated avatar using Beyond Presence that responds to audio input using LiveKit's agent system.
The avatar worker generates synchronized video and audio based on received audio input using the Beyond Presence API.
## How it Works
1. The LiveKit agent and the Beyond Presence avatar worker both join into the same LiveKit room as the user.
2. The LiveKit agent listens to the user and generates a conversational response, as usual.
3. However, instead of sending audio directly into the room, the agent sends the audio via WebRTC data channel to the Beyond Presence avatar worker.
4. The avatar worker only listens to the audio from the data channel, generates the corresponding avatar video, synchronizes audio and video, and publishes both back into the room for the user to experience.
## Detailed Call Flow
```mermaid
sequenceDiagram
participant User as User
participant Room as LiveKit Room
participant Agent as LiveKit Agent
participant Avatar as Bey Avatar
User->>Room: Connect from a client
Agent->>Room: Dispatched to room
Agent->>Avatar: Start session (REST API)
Avatar->>Room: Connect to room
User->>Room: Send audio (WebRTC)
Room->>Agent: Forward user audio (WebRTC)
Agent->>Avatar: Send TTS Audio (WebRTC data channel)
Avatar->>Room: Send synched avatar video & audio (WebRTC)
Room->>User: Deliver avatar audio & video (WebRTC)
# Beyond Presence Config
export BEY_API_KEY="..."
# OpenAI config (or other models, tts, stt)
export OPENAI_API_KEY="..."
# LiveKit config
export LIVEKIT_API_KEY="..."
export LIVEKIT_API_SECRET="..."
export LIVEKIT_URL="..."
# You can specify a different avatar if you want
# export BEY_AVATAR_ID=your-avatar-id
python examples/avatar_agents/bey/agent_worker.py dev
## examples/avatar_agents/bey/agent_worker.py
```py
import logging
import os
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomOutputOptions,
WorkerOptions,
WorkerType,
cli,
)
from livekit.plugins import bey, openai
logger = logging.getLogger("bey-avatar-example")
logger.setLevel(logging.INFO)
load_dotenv()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
llm=openai.realtime.RealtimeModel(voice="alloy"),
)
avatar_id = os.getenv("BEY_AVATAR_ID")
bey_avatar = bey.AvatarSession(avatar_id=avatar_id)
await bey_avatar.start(session, room=ctx.room)
await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
# audio is forwarded to the avatar, so we disable room audio output
room_output_options=RoomOutputOptions(audio_enabled=False),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, worker_type=WorkerType.ROOM))
# BitHuman Avatar Example
This example demonstrates how to integrate the BitHuman SDK with LiveKit Agents to create an interactive visual agent on local CPU device.
## Prerequisites
1. BitHuman API Secret
2. BitHuman Avatar Model (.imx file)
## Setup Instructions
1. Get API Secret from [bitHuman website](https://bithuman.io)
2. Download Avatar Model (.imx file). You can use a sample model as below
or create new models on the [platform](https://console.bithuman.io/imagineX)
```bash
wget https://repo.one.bithuman.io/resources/rt_models/samples/albert_einstein.imx
```
3. Install Dependencies
```bash
pip install bithuman
```
### 4. Configuration
Create a `.env` file in the root directory with the following:
BITHUMAN_API_SECRET=your_api_secret_here BITHUMAN_MODEL_PATH=/path/to/model.imx
## Running the Example
To run the agent with a BitHuman avatar (the first time loading on MacOS may take a while for warmup):
```bash
python examples/avatar_agents/bithuman/agent_worker.py dev
This example integrates BitHuman directly within the agent worker process:
For more information about BitHuman SDK, refer to the official documentation.
## examples/avatar_agents/bithuman/agent_worker.py
```py
import logging
import os
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomOutputOptions,
WorkerOptions,
WorkerType,
cli,
)
from livekit.plugins import bithuman, openai
logger = logging.getLogger("bithuman-avatar-example")
logger.setLevel(logging.INFO)
load_dotenv()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
llm=openai.realtime.RealtimeModel(voice="ash"),
)
logger.info("staring bithuman runtime")
bithuman_avatar = bithuman.AvatarSession(
model_path=os.getenv("BITHUMAN_MODEL_PATH"),
api_secret=os.getenv("BITHUMAN_API_SECRET"),
)
await bithuman_avatar.start(session, room=ctx.room)
await session.start(
agent=Agent(instructions="Your are Einstein, talk to me!"),
room=ctx.room,
# audio is forwarded to the avatar, so we disable room audio output
room_output_options=RoomOutputOptions(audio_enabled=False),
)
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint, worker_type=WorkerType.ROOM, job_memory_warn_mb=1500
)
)
bithuman~=0.5.3
opencv-python
# LiveKit Tavus Avatar Agent
This example demonstrates how to create a animated avatar using [Tavus](https://platform.tavus.io/).
## Usage
* Update the environment:
```bash
# Tavus Config
export TAVUS_API_KEY="..."
export TAVUS_REPLICA_ID="..."
# OpenAI config (or other models, tts, stt)
export OPENAI_API_KEY="..."
# LiveKit config
export LIVEKIT_API_KEY="..."
export LIVEKIT_API_SECRET="..."
export LIVEKIT_URL="..."
python examples/avatar_agents/tavus/agent_worker.py dev
## examples/avatar_agents/tavus/agent_worker.py
```py
import logging
import os
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomOutputOptions,
WorkerOptions,
WorkerType,
cli,
)
from livekit.plugins import openai, tavus
logger = logging.getLogger("tavus-avatar-example")
logger.setLevel(logging.INFO)
load_dotenv()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
llm=openai.realtime.RealtimeModel(voice="alloy"),
)
persona_id = os.getenv("TAVUS_PERSONA_ID")
replica_id = os.getenv("TAVUS_REPLICA_ID")
tavus_avatar = tavus.AvatarSession(persona_id=persona_id, replica_id=replica_id)
await tavus_avatar.start(session, room=ctx.room)
await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
# audio is forwarded to the avatar, so we disable room audio output
room_output_options=RoomOutputOptions(audio_enabled=False),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, worker_type=WorkerType.ROOM))
import logging
from dataclasses import dataclass, field
from typing import Annotated, Optional
import yaml
from dotenv import load_dotenv
from pydantic import Field
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.agents.llm import function_tool
from livekit.agents.voice import Agent, AgentSession, RunContext
from livekit.agents.voice.room_io import RoomInputOptions
from livekit.plugins import cartesia, deepgram, openai, silero
# from livekit.plugins import noise_cancellation
logger = logging.getLogger("restaurant-example")
logger.setLevel(logging.INFO)
load_dotenv()
voices = {
"greeter": "794f9389-aac1-45b6-b726-9d9369183238",
"reservation": "156fb8d2-335b-4950-9cb3-a2d33befec77",
"takeaway": "6f84f4b8-58a2-430c-8c79-688dad597532",
"checkout": "39b376fc-488e-4d0c-8b37-e00b72059fdd",
}
@dataclass
class UserData:
customer_name: Optional[str] = None
customer_phone: Optional[str] = None
reservation_time: Optional[str] = None
order: Optional[list[str]] = None
customer_credit_card: Optional[str] = None
customer_credit_card_expiry: Optional[str] = None
customer_credit_card_cvv: Optional[str] = None
expense: Optional[float] = None
checked_out: Optional[bool] = None
agents: dict[str, Agent] = field(default_factory=dict)
prev_agent: Optional[Agent] = None
def summarize(self) -> str:
data = {
"customer_name": self.customer_name or "unknown",
"customer_phone": self.customer_phone or "unknown",
"reservation_time": self.reservation_time or "unknown",
"order": self.order or "unknown",
"credit_card": {
"number": self.customer_credit_card or "unknown",
"expiry": self.customer_credit_card_expiry or "unknown",
"cvv": self.customer_credit_card_cvv or "unknown",
}
if self.customer_credit_card
else None,
"expense": self.expense or "unknown",
"checked_out": self.checked_out or False,
}
# summarize in yaml performs better than json
return yaml.dump(data)
RunContext_T = RunContext[UserData]
# common functions
@function_tool()
async def update_name(
name: Annotated[str, Field(description="The customer's name")],
context: RunContext_T,
) -> str:
"""Called when the user provides their name.
Confirm the spelling with the user before calling the function."""
userdata = context.userdata
userdata.customer_name = name
return f"The name is updated to {name}"
@function_tool()
async def update_phone(
phone: Annotated[str, Field(description="The customer's phone number")],
context: RunContext_T,
) -> str:
"""Called when the user provides their phone number.
Confirm the spelling with the user before calling the function."""
userdata = context.userdata
userdata.customer_phone = phone
return f"The phone number is updated to {phone}"
@function_tool()
async def to_greeter(context: RunContext_T) -> Agent:
"""Called when user asks any unrelated questions or requests
any other services not in your job description."""
curr_agent: BaseAgent = context.session.current_agent
return await curr_agent._transfer_to_agent("greeter", context)
class BaseAgent(Agent):
async def on_enter(self) -> None:
agent_name = self.__class__.__name__
logger.info(f"entering task {agent_name}")
userdata: UserData = self.session.userdata
chat_ctx = self.chat_ctx.copy()
# add the previous agent's chat history to the current agent
if isinstance(userdata.prev_agent, Agent):
truncated_chat_ctx = userdata.prev_agent.chat_ctx.copy(
exclude_instructions=True, exclude_function_call=False
).truncate(max_items=6)
existing_ids = {item.id for item in chat_ctx.items}
items_copy = [item for item in truncated_chat_ctx.items if item.id not in existing_ids]
chat_ctx.items.extend(items_copy)
# add an instructions including the user data as assistant message
chat_ctx.add_message(
role="system", # role=system works for OpenAI's LLM and Realtime API
content=f"You are {agent_name} agent. Current user data is {userdata.summarize()}",
)
await self.update_chat_ctx(chat_ctx)
self.session.generate_reply(tool_choice="none")
async def _transfer_to_agent(self, name: str, context: RunContext_T) -> tuple[Agent, str]:
userdata = context.userdata
current_agent = context.session.current_agent
next_agent = userdata.agents[name]
userdata.prev_agent = current_agent
return next_agent, f"Transferring to {name}."
class Greeter(BaseAgent):
def __init__(self, menu: str) -> None:
super().__init__(
instructions=(
f"You are a friendly restaurant receptionist. The menu is: {menu}\n"
"Your jobs are to greet the caller and understand if they want to "
"make a reservation or order takeaway. Guide them to the right agent using tools."
),
llm=openai.LLM(parallel_tool_calls=False),
tts=cartesia.TTS(voice=voices["greeter"]),
)
self.menu = menu
@function_tool()
async def to_reservation(self, context: RunContext_T) -> tuple[Agent, str]:
"""Called when user wants to make or update a reservation.
This function handles transitioning to the reservation agent
who will collect the necessary details like reservation time,
customer name and phone number."""
return await self._transfer_to_agent("reservation", context)
@function_tool()
async def to_takeaway(self, context: RunContext_T) -> tuple[Agent, str]:
"""Called when the user wants to place a takeaway order.
This includes handling orders for pickup, delivery, or when the user wants to
proceed to checkout with their existing order."""
return await self._transfer_to_agent("takeaway", context)
class Reservation(BaseAgent):
def __init__(self) -> None:
super().__init__(
instructions="You are a reservation agent at a restaurant. Your jobs are to ask for "
"the reservation time, then customer's name, and phone number. Then "
"confirm the reservation details with the customer.",
tools=[update_name, update_phone, to_greeter],
tts=cartesia.TTS(voice=voices["reservation"]),
)
@function_tool()
async def update_reservation_time(
self,
time: Annotated[str, Field(description="The reservation time")],
context: RunContext_T,
) -> str:
"""Called when the user provides their reservation time.
Confirm the time with the user before calling the function."""
userdata = context.userdata
userdata.reservation_time = time
return f"The reservation time is updated to {time}"
@function_tool()
async def confirm_reservation(self, context: RunContext_T) -> str | tuple[Agent, str]:
"""Called when the user confirms the reservation."""
userdata = context.userdata
if not userdata.customer_name or not userdata.customer_phone:
return "Please provide your name and phone number first."
if not userdata.reservation_time:
return "Please provide reservation time first."
return await self._transfer_to_agent("greeter", context)
class Takeaway(BaseAgent):
def __init__(self, menu: str) -> None:
super().__init__(
instructions=(
f"Your are a takeaway agent that takes orders from the customer. "
f"Our menu is: {menu}\n"
"Clarify special requests and confirm the order with the customer."
),
tools=[to_greeter],
tts=cartesia.TTS(voice=voices["takeaway"]),
)
@function_tool()
async def update_order(
self,
items: Annotated[list[str], Field(description="The items of the full order")],
context: RunContext_T,
) -> str:
"""Called when the user create or update their order."""
userdata = context.userdata
userdata.order = items
return f"The order is updated to {items}"
@function_tool()
async def to_checkout(self, context: RunContext_T) -> str | tuple[Agent, str]:
"""Called when the user confirms the order."""
userdata = context.userdata
if not userdata.order:
return "No takeaway order found. Please make an order first."
return await self._transfer_to_agent("checkout", context)
class Checkout(BaseAgent):
def __init__(self, menu: str) -> None:
super().__init__(
instructions=(
f"You are a checkout agent at a restaurant. The menu is: {menu}\n"
"Your are responsible for confirming the expense of the "
"order and then collecting customer's name, phone number and credit card "
"information, including the card number, expiry date, and CVV step by step."
),
tools=[update_name, update_phone, to_greeter],
tts=cartesia.TTS(voice=voices["checkout"]),
)
@function_tool()
async def confirm_expense(
self,
expense: Annotated[float, Field(description="The expense of the order")],
context: RunContext_T,
) -> str:
"""Called when the user confirms the expense."""
userdata = context.userdata
userdata.expense = expense
return f"The expense is confirmed to be {expense}"
@function_tool()
async def update_credit_card(
self,
number: Annotated[str, Field(description="The credit card number")],
expiry: Annotated[str, Field(description="The expiry date of the credit card")],
cvv: Annotated[str, Field(description="The CVV of the credit card")],
context: RunContext_T,
) -> str:
"""Called when the user provides their credit card number, expiry date, and CVV.
Confirm the spelling with the user before calling the function."""
userdata = context.userdata
userdata.customer_credit_card = number
userdata.customer_credit_card_expiry = expiry
userdata.customer_credit_card_cvv = cvv
return f"The credit card number is updated to {number}"
@function_tool()
async def confirm_checkout(self, context: RunContext_T) -> str | tuple[Agent, str]:
"""Called when the user confirms the checkout."""
userdata = context.userdata
if not userdata.expense:
return "Please confirm the expense first."
if (
not userdata.customer_credit_card
or not userdata.customer_credit_card_expiry
or not userdata.customer_credit_card_cvv
):
return "Please provide the credit card information first."
userdata.checked_out = True
return await to_greeter(context)
@function_tool()
async def to_takeaway(self, context: RunContext_T) -> tuple[Agent, str]:
"""Called when the user wants to update their order."""
return await self._transfer_to_agent("takeaway", context)
async def entrypoint(ctx: JobContext):
await ctx.connect()
menu = "Pizza: $10, Salad: $5, Ice Cream: $3, Coffee: $2"
userdata = UserData()
userdata.agents.update(
{
"greeter": Greeter(menu),
"reservation": Reservation(),
"takeaway": Takeaway(menu),
"checkout": Checkout(menu),
}
)
session = AgentSession[UserData](
userdata=userdata,
stt=deepgram.STT(),
llm=openai.LLM(),
tts=cartesia.TTS(),
vad=silero.VAD.load(),
max_tool_steps=5,
# to use realtime model, replace the stt, llm, tts and vad with the following
# llm=openai.realtime.RealtimeModel(voice="alloy"),
)
await session.start(
agent=userdata.agents["greeter"],
room=ctx.room,
room_input_options=RoomInputOptions(
# noise_cancellation=noise_cancellation.BVC(),
),
)
# await agent.say("Welcome to our restaurant! How may I assist you today?")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.plugins import browser
WIDTH = 1920
HEIGHT = 1080
load_dotenv()
async def entrypoint(job: JobContext):
await job.connect()
ctx = browser.BrowserContext(dev_mode=True)
await ctx.initialize()
page = await ctx.new_page(url="www.livekit.io")
source = rtc.VideoSource(WIDTH, HEIGHT)
track = rtc.LocalVideoTrack.create_video_track("single-color", source)
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
publication = await job.room.local_participant.publish_track(track, options)
logging.info("published track", extra={"track_sid": publication.sid})
@page.on("paint")
def on_paint(paint_data):
source.capture_frame(paint_data.frame)
async def _test_cycle():
urls = [
"https://www.livekit.io",
"https://www.google.com",
]
i = 0
async with ctx.playwright() as browser:
while True:
i += 1
await asyncio.sleep(5)
defaultContext = browser.contexts[0]
defaultPage = defaultContext.pages[0]
try:
await defaultPage.goto(urls[i % len(urls)])
except Exception:
logging.exception(f"failed to navigate to {urls[i % len(urls)]}")
await _test_cycle()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
from livekit.plugins import browser
ctx = browser.BrowserContext(dev_mode=True)
# DataStream Audio Example
This example demonstrates how to use LiveKit's DataStream feature to send and receive audio between agents.
## Overview
The example consists of two main components:
1. **Audio Receiver (`audio_receiver.py`)**: Receives audio from a sender, streams it to a LiveKit room, and handles interruptions and playback notifications.
2. **Agent Worker (`agent_worker.py`)**: Implements an agent with audio capabilities that can connect to a LiveKit room.
### Starting the Agent Worker
```bash
python examples/other/datastream-audio/agent_worker.py dev
python examples/other/datastream-audio/audio_receiver.py <room-name>
Replace <room-name>
with the name of the LiveKit room you want to connect to.
## examples/other/datastream-audio/agent_worker.py
```py
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomOutputOptions,
WorkerOptions,
cli,
)
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.io import PlaybackFinishedEvent
from livekit.plugins import openai
logger = logging.getLogger("basic-agent")
load_dotenv()
## This is the audio sender. It sends audio to another participant in the room through a datastream.
RECEIVER_IDENTITY = "agent-receiver"
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
llm=openai.realtime.RealtimeModel(),
)
# stream audio to another participant in the room through a datastream
session.output.audio = DataStreamAudioOutput(
room=ctx.room, destination_identity=RECEIVER_IDENTITY, sample_rate=24000
)
await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
room_output_options=RoomOutputOptions(audio_enabled=False),
)
@session.output.audio.on("playback_finished")
def _on_playback_finished(ev: PlaybackFinishedEvent):
logger.info(f"playback finished: {ev.playback_position} interrupted: {ev.interrupted}")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import os
import sys
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents.voice.avatar import AudioSegmentEnd, DataStreamAudioReceiver
load_dotenv()
## This is the audio receiver. It receives audio from the audio sender and streams it to the room.
## It also handles the interruption and playback notification.
RECEIVER_IDENTITY = "agent-receiver"
async def main(room_name: str):
url = os.getenv("LIVEKIT_URL")
if not url:
print("Please set LIVEKIT_URL environment variable")
return
room = rtc.Room()
token = (
api.AccessToken()
.with_identity(RECEIVER_IDENTITY)
.with_name("Agent Receiver")
.with_grants(api.VideoGrants(room_join=True, room=room_name))
.to_jwt()
)
print(f"Connecting to room: {room_name}")
await room.connect(url, token)
# read audio from the datastream
audio_receiver = DataStreamAudioReceiver(room=room)
await audio_receiver.start() # wait for the sender to join the room
print(f"Audio receiver connected to {audio_receiver._remote_participant.identity}")
audio_source = rtc.AudioSource(sample_rate=24000, num_channels=1, queue_size_ms=10_000)
track = rtc.LocalAudioTrack.create_audio_track("audio_receiver_output", audio_source)
await room.local_participant.publish_track(track)
# stream audio to the room and handle the interruption and playback notification
pushed_duration = 0
interrupted_event = asyncio.Event()
def _on_clear_buffer():
if not pushed_duration:
return
print("clear buffer called")
interrupted_event.set()
async def _wait_for_playout():
nonlocal pushed_duration
wait_for_interruption = asyncio.create_task(interrupted_event.wait())
wait_for_playout = asyncio.create_task(audio_source.wait_for_playout())
await asyncio.wait(
[wait_for_playout, wait_for_interruption],
return_when=asyncio.FIRST_COMPLETED,
)
interrupted = wait_for_interruption.done()
played_duration = pushed_duration
if interrupted:
played_duration = max(pushed_duration - audio_source.queued_duration, 0)
audio_source.clear_queue()
wait_for_playout.cancel()
else:
wait_for_interruption.cancel()
interrupted_event.clear()
pushed_duration = 0
print(f"playback finished: {played_duration} interrupted: {interrupted}")
await audio_receiver.notify_playback_finished(
playback_position=played_duration, interrupted=interrupted
)
audio_receiver.on("clear_buffer", _on_clear_buffer)
async for frame in audio_receiver:
if isinstance(frame, AudioSegmentEnd):
print("audio segment end")
await _wait_for_playout()
continue
await audio_source.capture_frame(frame)
if frame.duration and not pushed_duration:
print("==========")
print("new audio segment start")
pushed_duration += frame.duration
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python audio_receiver.py <room-name>")
sys.exit(1)
room_name = sys.argv[1]
asyncio.run(main(room_name=room_name))
import asyncio
import logging
import os
import sys
from dataclasses import dataclass
from itertools import cycle
from typing import Optional
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import utils
from livekit.agents.types import (
ATTRIBUTE_TRANSCRIPTION_FINAL,
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID,
ATTRIBUTE_TRANSCRIPTION_TRACK_ID,
TOPIC_TRANSCRIPTION,
)
logger = logging.getLogger("text-only")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates a text-only agent.
## Send text input using TextStream to topic `lk.chat` (https://docs.livekit.io/home/client/data/text-streams)
## The agent output is sent through TextStream to the `lk.transcription` topic
# Add color constants
COLORS = [
"\033[36m", # Cyan
"\033[32m", # Green
"\033[33m", # Yellow
"\033[35m", # Magenta
"\033[34m", # Blue
]
RESET = "\033[0m" # Reset color
@dataclass
class Chunk:
stream_id: str
participant_identity: str
track_id: Optional[str]
segment_id: str
content: str
final: Optional[bool] = None
class TextStreamPrinter:
def __init__(self):
self._text_chunk_queue = asyncio.Queue[Chunk | None]()
self.running = True
self._color_cycle = cycle(COLORS)
self._color_map: dict[str, str] = {}
self._current_segment_id: str | None = None
# track the stream id for each segment id, overwrite if new stream with the same segment id
self._segment_to_stream: dict[str, str] = {}
self._tasks = set[asyncio.Task]()
self._main_atask = asyncio.create_task(self._main_task())
def _get_color(self, identity: str) -> str:
if identity not in self._color_map:
self._color_map[identity] = next(self._color_cycle)
return self._color_map[identity]
async def _main_task(self):
header = "[{participant_identity}][{type}][{segment_id}][{overwrite}]"
while self.running:
chunk = await self._text_chunk_queue.get()
if chunk is None:
break
color = self._get_color(chunk.participant_identity)
if self._current_segment_id != chunk.segment_id:
# in cli we don't actually overwrite the line, just add a flag
overwrite = (
"overwrite"
if chunk.segment_id in self._segment_to_stream
and self._segment_to_stream[chunk.segment_id] != chunk.stream_id
else "new"
)
type = "transcript" if chunk.track_id else "chat"
# header: [participant_identity][type][segment_id]
line_header = header.format(
participant_identity=chunk.participant_identity,
type=type,
segment_id=chunk.segment_id,
overwrite=overwrite,
)
print(f"\n{color}{line_header}:{RESET} ", end="", flush=True)
self._current_segment_id = chunk.segment_id
if chunk.final is not None:
print(f" {color}[final={chunk.final}]{RESET}", end="", flush=True)
self._current_segment_id = None
else:
print(chunk.content, end="", flush=True)
self._segment_to_stream[chunk.segment_id] = chunk.stream_id
def on_text_received(self, reader: rtc.TextStreamReader, participant_identity: str):
async def _on_text_received():
stream_id = reader.info.stream_id
segment_id = reader.info.attributes.get(ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID, None)
# new stream with the same segment_id should overwrite the previous one
if not segment_id:
logger.warning("No segment id found for text stream")
return
track_id = reader.info.attributes.get(ATTRIBUTE_TRANSCRIPTION_TRACK_ID, None)
async for chunk in reader:
await self._text_chunk_queue.put(
Chunk(stream_id, participant_identity, track_id, segment_id, content=chunk)
)
# update the final flag
final = reader.info.attributes.get(ATTRIBUTE_TRANSCRIPTION_FINAL, "null")
await self._text_chunk_queue.put(
Chunk(
stream_id, participant_identity, track_id, segment_id, content="", final=final
)
)
task = asyncio.create_task(_on_text_received())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def aclose(self):
self.running = False
await self._text_chunk_queue.put(None)
await self._main_atask
await utils.aio.cancel_and_wait(self._tasks)
async def main(room_name: str):
url = os.getenv("LIVEKIT_URL")
if not url:
print("Please set LIVEKIT_URL environment variable")
return
room = rtc.Room()
token = (
api.AccessToken()
.with_identity("chat-listener")
.with_name("Chat Listener")
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
)
)
.to_jwt()
)
print(f"Connecting to room: {room_name}")
await room.connect(url, token)
stop_event = asyncio.Event()
try:
text_printer = TextStreamPrinter()
room.register_text_stream_handler(
topic=TOPIC_TRANSCRIPTION, handler=text_printer.on_text_received
)
print("Listening for chat messages. Press Ctrl+C to exit...")
await stop_event.wait() # run forever
finally:
await text_printer.aclose()
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python datastream-chat-listener.py <room-name>")
sys.exit(1)
room_name = sys.argv[1]
asyncio.run(main(room_name=room_name))
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
ATTRIBUTE_AGENT_STATE,
AgentState,
AutoSubscribe,
JobContext,
WorkerOptions,
cli,
)
from livekit.agents.vad import VADEventType
from livekit.plugins import silero
load_dotenv()
logger = logging.getLogger("echo-agent")
# An example agent that echos each utterance from the user back to them
# the example uses a queue to buffer incoming streams, and uses VAD to detect
# when the user is done speaking.
async def entrypoint(ctx: JobContext):
logger.info(f"connecting to room {ctx.room.name}")
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# wait for the first participant to connect
participant: rtc.Participant = await ctx.wait_for_participant()
stream = rtc.AudioStream.from_participant(
participant=participant,
track_source=rtc.TrackSource.SOURCE_MICROPHONE,
)
vad = silero.VAD.load(
min_speech_duration=0.2,
min_silence_duration=0.6,
)
vad_stream = vad.stream()
source = rtc.AudioSource(sample_rate=48000, num_channels=1)
track = rtc.LocalAudioTrack.create_audio_track("echo", source)
await ctx.room.local_participant.publish_track(
track,
rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE),
)
# speech queue holds AudioFrames
queue = asyncio.Queue(maxsize=1000) # 10 seconds of audio (1000 frames * 10ms)
is_speaking = False
is_echoing = False
async def _set_state(state: AgentState):
await ctx.room.local_participant.set_attributes({ATTRIBUTE_AGENT_STATE: state})
await _set_state("listening")
async def _process_input():
async for audio_event in stream:
if is_echoing: # Skip processing while echoing
continue
vad_stream.push_frame(audio_event.frame)
try:
queue.put_nowait(audio_event.frame)
except asyncio.QueueFull:
# Remove oldest frame when queue is full
queue.get_nowait()
queue.put_nowait(audio_event.frame)
async def _process_vad():
nonlocal is_speaking, is_echoing
async for vad_event in vad_stream:
if is_echoing: # Skip VAD processing while echoing
continue
if vad_event.type == VADEventType.START_OF_SPEECH:
is_speaking = True
frames_to_keep = 100
frames = []
while not queue.empty():
frames.append(queue.get_nowait())
for frame in frames[-frames_to_keep:]:
queue.put_nowait(frame)
elif vad_event.type == VADEventType.END_OF_SPEECH:
is_speaking = False
is_echoing = True
logger.info("end of speech, playing back")
await _set_state("speaking")
try:
while not queue.empty():
frame = queue.get_nowait()
await source.capture_frame(frame)
except asyncio.QueueEmpty:
pass
finally:
is_echoing = False # Reset echoing flag after playback
await _set_state("listening")
await asyncio.gather(
_process_input(),
_process_vad(),
)
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
),
)
# LiveKit realtime moderation agent using Hive
This is an agent that performs visual moderation of every participant's video in a room. It does this moderation using the Visual Content Moderation model from [Hive](https://thehive.ai) [[docs](https://docs.thehive.ai/docs/visual-content-moderation#visual-content-moderation)].
## Prerequisites
Before running this agent, you'll need:
1. A LiveKit Cloud project (or a self-hosted LiveKit server).
2. An API key from Hive to access the above mentioned model.
## Configuration
Currently, this agent is configured entirely from the `agent.py` source code and the environment.
### Environment Variables
| configuration | description | example value |
|---------------|-------------|---------------|
| `LIVEKIT_URL` | Your LiveKit URL | `wss://test-abc123de.livekit.cloud` |
| `LIVEKIT_API_KEY` | Your LiveKit API key | |
| `LIVEKIT_API_SECRET` | Your LiveKit API secret | |
| `HIVE_API_KEY` | The API key from Hive to access the `Visual Content Moderation` model | `abc1deFgHIjK23KLMNOp45QrsTuv6wx8` |
### Code
| configuration | description | example value |
|---------------|-------------|---------------|
| `MOD_FRAME_INTERVAL` | Minimum number of seconds to wait between frames | 5.0 |
| `HIVE_HEADERS` | The headers to send with every request to the Hive API | `{}` |
| `CONFIDENCE_THRESHOLD` | The minimum score Hive's moderation class must meet before it is considered a problem | 0.9 |
## Running
Run this code like you would any other [LiveKit agent](https://docs.livekit.io/agents/build/anatomy/#starting-the-worker):
python3 agent.py start
Once running, the agent will join all new LiveKit rooms by default and begin moderation.
"""
LiveKit agent that connects to a room and performs visual moderation on the video
of all participants using the Visual Content Moderation model from Hive
(https://docs.thehive.ai/docs/visual-content-moderation#visual-content-moderation).
The agent periodically sends a frame from the participant's video to Hive's API
for a moderation check. If the results of that check show a confidence score
of 0.9 or higher for any of the positive classes, it logs the result and adds a
message to the room's chat. This can easily be extended to take additional
actions like removing a participant or ending a livestream, etc.
"""
import asyncio
import logging
import os
import time
from io import BytesIO
import aiohttp
from dotenv import load_dotenv
from hive_data_classes import HiveResponse, from_dict
from PIL import Image
from livekit import agents, rtc
load_dotenv()
MOD_FRAME_INTERVAL = 5.0 # check 1 frame every 5 seconds
"""
How often to check a frame (in seconds)
"""
HIVE_HEADERS = {
"Authorization": f"Token {os.getenv('HIVE_API_KEY')}",
"accept": "application/json",
}
"""
The default headers included with every request to thehive.ai
"""
CONFIDENCE_THRESHOLD = 0.9
"""
THe threshold level for scores returned by thehive.ai. See details in this doc:
https://docs.thehive.ai/docs/visual-content-moderation#choosing-thresholds-for-visual-moderation
"""
logger = logging.getLogger("hive-moderation-agent")
logger.setLevel(logging.INFO)
async def request_fnc(req: agents.JobRequest):
"""
The request handler for the agent. We use this to set the name of the
agent that is displayed to users
"""
# accept the job request and name the agent participant so users know what this is
await req.accept(
name="Moderator",
identity="hive-moderator",
)
async def entrypoint(ctx: agents.JobContext):
"""
The entrypoint of the agent. This is called every time the moderator
agent joins a room.
"""
# connect to the room and automatically subscribe to all participants' video
await ctx.connect(auto_subscribe=agents.AutoSubscribe.VIDEO_ONLY)
chat = rtc.ChatManager(ctx.room)
@ctx.room.on("track_subscribed")
def on_track_subscribed(
track: rtc.Track,
_publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
"""
Event handler for video tracks. We automatically subscribe to all video
tracks when a participant joins the room. This event is triggered
once we have completed subscription to that video track.
This creates a backgrond task to process frames from each track
"""
asyncio.create_task(process_track(participant, track))
async def process_track(participant: rtc.RemoteParticipant, track: rtc.VideoTrack):
"""
This function is running in a background task once for each video track
(i.e., once for each participant). It handles processing a frame
from the video once every MOD_FRAME INTERVAL seconds.
"""
video_stream = rtc.VideoStream(track)
last_processed_time = 0
async for frame in video_stream:
current_time = time.time()
if (current_time - last_processed_time) >= MOD_FRAME_INTERVAL:
last_processed_time = current_time
await check_frame(participant, frame)
async def check_frame(participant: rtc.RemoteParticipant, frame: rtc.VideoFrame):
"""
Uses thehive.ai API to check the frame for any classifications we care about
"""
# get the current frame and convert to png format
argb_frame = frame.frame.convert(rtc.VideoBufferType.RGBA)
image = Image.frombytes("RGBA", (argb_frame.width, argb_frame.height), argb_frame.data)
buffer = BytesIO()
image.save(buffer, format="PNG")
buffer.seek(0) # reset buffer position to beginning after writing
data = aiohttp.FormData()
data.add_field("image", buffer, filename="image.png", content_type="image/png")
# submit the image to Hive
logger.info("submitting image to hive")
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.thehive.ai/api/v2/task/sync",
headers=HIVE_HEADERS,
data=data,
) as response:
response.raise_for_status()
response_dict = await response.json()
hive_response: HiveResponse = from_dict(HiveResponse, response_dict)
if (
hive_response.code == 200
and len(hive_response.status) > 0
and len(hive_response.status[0].response.output) > 0
):
results = hive_response.status[0].response.output[0].classes
# filter to anything with a confidence score > threshold
for mod_class in results:
if mod_class.class_[0:4] == "yes_":
# TODO: should also include "general_nsfw" class
if mod_class.score >= CONFIDENCE_THRESHOLD:
class_name = mod_class.class_[4:]
message = f'FOUND {class_name} for participant "{participant.identity}" (confidence score: {mod_class.score:0.3f})' # noqa: E501
logger.info(message)
await chat.send_message(message)
await ctx.wait_for_participant()
await chat.send_message(
"I'm a moderation agent,"
"I will detect and notify you of all inappropriate material in your video stream"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
agents.cli.run_app(agents.WorkerOptions(entrypoint, request_fnc=request_fnc))
from dataclasses import dataclass, is_dataclass
from typing import get_type_hints
def from_dict(cls, data):
if is_dataclass(cls) and isinstance(data, dict):
# Get type hints for all fields in the dataclass
field_types = get_type_hints(cls)
# Special handling for reserved words like 'class'
reserved_word_mappings = {"class": "class_"} # Map 'class' to 'class_'
processed_data = {}
for key, value in data.items():
# Check if the key is a reserved word and map it accordingly
field_name = reserved_word_mappings.get(key, key)
# Only include keys that have corresponding fields in the dataclass
if field_name in field_types:
field_type = field_types[field_name]
# Determine if the field_type is itself a dataclass
if is_dataclass(field_type):
processed_value = from_dict(field_type, value)
elif hasattr(field_type, "__origin__") and issubclass(field_type.__origin__, list):
# Handle List fields, assuming all elements are of the same type
item_type = field_type.__args__[0]
processed_value = [from_dict(item_type, item) for item in value]
else:
processed_value = value
processed_data[field_name] = processed_value
return cls(**processed_data)
elif isinstance(data, list):
# This assumes that the function was called with a list type as `cls`,
# which might not work as expected without context on the list's element type.
# A better approach might be needed for handling lists of dataclasses.
return [
from_dict(cls.__args__[0], item) if hasattr(cls, "__args__") else item for item in data
]
else:
return data
@dataclass
class Status:
code: str
message: str
@dataclass
class ModInput:
id: str
charge: float
config_tag: SyntaxWarning
config_version: float
created_on: str
model: str
model_type: str
model_version: float
project_id: int
user_id: int
@dataclass
class ModClass:
class_: str
score: float
@dataclass
class ModOutput:
time: int
classes: list[ModClass]
@dataclass
class Response:
input: ModInput
output: list[ModOutput]
@dataclass
class ModResponse:
status: Status
response: Response
@dataclass
class HiveResponse:
id: str
code: int
project_id: int
user_id: int
created_on: str
status: list[ModResponse]
from_cache: bool
livekit
livekit-agents<1.0.0
python-dotenv
Pillow
aiohttp
import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentSession, JobContext, JobProcess, WorkerOptions, cli, metrics
from livekit.agents.voice import MetricsCollectedEvent
from livekit.plugins import deepgram, openai, silero
logger = logging.getLogger("kokoro-tts-agent")
load_dotenv()
# This example demonstrates how to use the Kokoro TTS model with LiveKit Agents
# with OpenAI-compatible endpoint of Kokoro-FastAPI https://github.com/remsky/Kokoro-FastAPI
# Kokoro-FastAPI can run locally on CPU or GPU devices with docker under linux and MacOS
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="Your name is Kelly. You would interact with users via voice."
"with that in mind keep your responses concise and to the point."
"You are curious and friendly, and have a sense of humor.",
)
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
async def entrypoint(ctx: JobContext):
# each log entry will include these fields
ctx.log_context_fields = {
"room": ctx.room.name,
"user_id": "your user_id",
}
await ctx.connect()
session = AgentSession(
vad=ctx.proc.userdata["vad"],
# any combination of STT, LLM, TTS, or realtime API can be used
llm=openai.LLM(model="gpt-4o-mini"),
stt=deepgram.STT(model="nova-3", language="multi"),
tts=openai.TTS(
model="kokoro",
voice="af_alloy",
api_key="not-needed",
base_url="http://localhost:8880/v1",
response_format="wav",
),
)
@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent):
metrics.log_metrics(ev.metrics)
await session.start(agent=MyAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
# Participant Entrypoint Example
This example shows how to do things when participants join. For example, a common use case is to fetch some external data based on the participant's attributes.
## Run
### Setup and activate a virtual env:
`python -m venv venv`
`source venv/bin/activate`
### Set environment variables:
```bash
export LIVEKIT_URL=<your LiveKit server URL>
export LIVEKIT_API_KEY=<your API Key>
export LIVEKIT_API_SECRET=<your API Secret>
pip install -r requirements.txt
python participant_entrypoint.py dev
We’ve built Agents Playground so you don’t have to build your own frontend while you iterate on your agent.
## examples/other/participant-entrypoint/participant_entrypoint.py
```py
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
load_dotenv()
logger = logging.getLogger("my-worker")
logger.setLevel(logging.INFO)
async def entrypoint(ctx: JobContext):
logger.info("starting entrypoint")
async def participant_task_1(ctx: JobContext, p: rtc.RemoteParticipant):
# You can filter out participants you are not interested in
# if p.identity != "some_identity_of_interest":
# return
logger.info(f"participant task 1 starting for {p.identity}")
# Do something with p.attributes, p.identity, p.metadata, etc.
# my_stuff = await fetch_stuff_from_my_db(p)
# Do something
await asyncio.sleep(60)
logger.info(f"participant task done for {p.identity}")
async def participant_task_2(ctx: JobContext, p: rtc.RemoteParticipant):
# multiple tasks can be run concurrently for each participant
logger.info(f"participant task 2 starting for {p.identity}")
await asyncio.sleep(10)
# Add participant entrypoints before calling ctx.connect
ctx.add_participant_entrypoint(entrypoint_fnc=participant_task_1)
ctx.add_participant_entrypoint(entrypoint_fnc=participant_task_2)
await ctx.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_ALL)
logger.info("connected to the room")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
livekit-agents>=0.12.18
python-dotenv~=1.0
# Simple-color
This small exmple publishes a solid color video frame.
import asyncio
import logging
import random
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import JobContext, WorkerOptions, cli
# Load environment variables
load_dotenv()
WIDTH = 640
HEIGHT = 480
async def entrypoint(job: JobContext):
await job.connect()
room = job.room
source = rtc.VideoSource(WIDTH, HEIGHT)
track = rtc.LocalVideoTrack.create_video_track("single-color", source)
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
publication = await room.local_participant.publish_track(track, options)
logging.info("published track", extra={"track_sid": publication.sid})
async def _draw_color():
argb_frame = bytearray(WIDTH * HEIGHT * 4)
while True:
await asyncio.sleep(0.1) # 100ms
# Create a new random color
r, g, b = [random.randint(0, 255) for _ in range(3)]
color = bytes([r, g, b, 255])
# Fill the frame with the new random color
argb_frame[:] = color * WIDTH * HEIGHT
frame = rtc.VideoFrame(WIDTH, HEIGHT, rtc.VideoBufferType.RGBA, argb_frame)
source.capture_frame(frame)
await _draw_color()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
livekit-agents>=0.12.18
python-dotenv~=1.0
# Speech-to-text
This example shows realtime transcription from voice to text.
It uses OpenAI's Whisper STT API, but supports other STT plugins by changing this line:
```python
stt = openai.STT()
To render the transcriptions into your client application, refer to the full documentation.
export LIVEKIT_URL=wss://yourhost.livekit.cloud
export LIVEKIT_API_KEY=livekit-api-key
export LIVEKIT_API_SECRET=your-api-secret
export OPENAI_API_KEY=your-api-key
python3 transcriber.py start
Then connect to any room. For an example frontend, you can use LiveKit’s Agents Playground.
## examples/other/speech-to-text/requirements.txt
```txt
livekit-agents>=0.12.18
livekit-plugins-deepgram>=0.7.2
python-dotenv~=1.0
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
AutoSubscribe,
JobContext,
WorkerOptions,
cli,
stt,
transcription,
)
from livekit.plugins import openai, silero
load_dotenv()
logger = logging.getLogger("transcriber")
async def _forward_transcription(
stt_stream: stt.SpeechStream, stt_forwarder: transcription.STTSegmentsForwarder
):
"""Forward the transcription to the client and log the transcript in the console"""
async for ev in stt_stream:
if ev.type == stt.SpeechEventType.INTERIM_TRANSCRIPT:
# you may not want to log interim transcripts, they are not final and may be incorrect
pass
elif ev.type == stt.SpeechEventType.FINAL_TRANSCRIPT:
print(" -> ", ev.alternatives[0].text)
elif ev.type == stt.SpeechEventType.RECOGNITION_USAGE:
logger.debug(f"metrics: {ev.recognition_usage}")
stt_forwarder.update(ev)
async def entrypoint(ctx: JobContext):
logger.info(f"starting transcriber (speech to text) example, room: {ctx.room.name}")
# this example uses OpenAI Whisper, but you can use assemblyai, deepgram, google, azure, etc.
stt_impl = openai.STT()
if not stt_impl.capabilities.streaming:
# wrap with a stream adapter to use streaming semantics
stt_impl = stt.StreamAdapter(
stt=stt_impl,
vad=silero.VAD.load(
min_silence_duration=0.2,
),
)
async def transcribe_track(participant: rtc.RemoteParticipant, track: rtc.Track):
audio_stream = rtc.AudioStream(track)
stt_forwarder = transcription.STTSegmentsForwarder(
room=ctx.room, participant=participant, track=track
)
stt_stream = stt_impl.stream()
asyncio.create_task(_forward_transcription(stt_stream, stt_forwarder))
async for ev in audio_stream:
stt_stream.push_frame(ev.frame)
@ctx.room.on("track_subscribed")
def on_track_subscribed(
track: rtc.Track,
publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
# spin up a task to transcribe each track
if track.kind == rtc.TrackKind.KIND_AUDIO:
asyncio.create_task(transcribe_track(participant, track))
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
# Text-to-speech
This small example shows how you can generate real-time audio data from text.
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.plugins import cartesia
load_dotenv()
logger = logging.getLogger("cartesia-tts-demo")
logger.setLevel(logging.INFO)
async def entrypoint(job: JobContext):
logger.info("starting tts example agent")
tts = cartesia.TTS(
# speed="fastest",
# emotion=["surprise:highest"],
)
source = rtc.AudioSource(tts.sample_rate, tts.num_channels)
track = rtc.LocalAudioTrack.create_audio_track("agent-mic", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await job.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_NONE)
publication = await job.room.local_participant.publish_track(track, options)
await publication.wait_for_subscription()
stream = tts.stream()
async def _playback_task():
async for audio in stream:
await source.capture_frame(audio.frame)
task = asyncio.create_task(_playback_task())
text = "hello from Cartesia. I hope you are having a great day."
# split into two word chunks to simulate LLM streaming
words = text.split()
for i in range(0, len(words), 2):
chunk = " ".join(words[i : i + 2])
if chunk:
logger.info(f'pushing chunk: "{chunk} "')
stream.push_text(chunk + " ")
# Mark end of input segment
stream.flush()
stream.end_input()
await asyncio.gather(task)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from typing import Optional
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.plugins import elevenlabs
logger = logging.getLogger("elevenlabs-tts-demo")
logger.setLevel(logging.INFO)
load_dotenv()
def _text_to_chunks(text: str) -> list[str]:
"""Split the text into chunks of 2, 3, and 4 words"""
sizes = [2, 3, 4]
chunks, i = [], 0
for size in sizes:
while i + size <= len(text):
chunks.append(text[i : i + size])
i += size
chunks.append(text[i:]) # remaining
return chunks
async def _playout_task(playout_q: asyncio.Queue, audio_source: rtc.AudioSource) -> None:
"""Playout audio frames from the queue to the audio source"""
while True:
frame = await playout_q.get()
if frame is None:
break
await audio_source.capture_frame(frame)
async def entrypoint(job: JobContext):
# use another voice for this demo
# you can get a list of the voices using 'await tts_11labs.list_voices()'
tts_11labs = elevenlabs.TTS(voice_id="ODq5zmih8GrVes37Dizd", model="eleven_multilingual_v2")
source = rtc.AudioSource(tts_11labs.sample_rate, tts_11labs.num_channels)
track = rtc.LocalAudioTrack.create_audio_track("agent-mic", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await job.connect()
publication = await job.room.local_participant.publish_track(track, options)
await publication.wait_for_subscription()
logger.info('Saying "Bonjour, comment allez-vous?"')
async for output in tts_11labs.synthesize("Bonjour, comment allez-vous?"):
await source.capture_frame(output.frame)
await asyncio.sleep(1)
logger.info('Saying "Au revoir."')
async for output in tts_11labs.synthesize("Au revoir."):
await source.capture_frame(output.frame)
await asyncio.sleep(1)
streamed_text = "Bonjour, ceci est un autre example avec la méthode utilisant un websocket."
logger.info('Streaming text "%s"', streamed_text)
stream = tts_11labs.stream()
for chunk in _text_to_chunks(streamed_text): # split into chunk just for the demonstration
stream.push_text(chunk)
stream.flush()
stream.end_input()
playout_q = asyncio.Queue[Optional[rtc.AudioFrame]]()
async def _synth_task():
async for ev in stream:
playout_q.put_nowait(ev.frame)
playout_q.put_nowait(None)
synth_task = asyncio.create_task(_synth_task())
playout_task = asyncio.create_task(_playout_task(playout_q, source))
await asyncio.gather(synth_task, playout_task)
await stream.aclose()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.plugins import neuphonic
load_dotenv()
logger = logging.getLogger("neuphonic-tts-demo")
logger.setLevel(logging.INFO)
async def entrypoint(job: JobContext):
logger.info("starting tts example agent")
SAMPLE_RATE = 22050
NUM_CHANNELS = 1
tts = neuphonic.TTS(
# voice_id=<uuid>,
sample_rate=SAMPLE_RATE # defaults to 22050
)
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
track = rtc.LocalAudioTrack.create_audio_track("agent-mic", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await job.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_NONE)
publication = await job.room.local_participant.publish_track(track, options)
await publication.wait_for_subscription()
stream = tts.stream()
async def _playback_task():
async for audio in stream:
await source.capture_frame(audio.frame)
task = asyncio.create_task(_playback_task())
text = "Hello from Neuphonic. You have just successfully run the example!"
# split into two word chunks to simulate LLM streaming
words = text.split()
for i in range(0, len(words), 2):
chunk = " ".join(words[i : i + 2])
if chunk:
logger.info(f'pushing chunk: "{chunk} "')
stream.push_text(chunk + " ")
# Mark end of input segment
stream.flush()
stream.end_input()
await asyncio.gather(task)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.plugins import openai
load_dotenv()
logger = logging.getLogger("openai-tts-demo")
logger.setLevel(logging.INFO)
async def entrypoint(job: JobContext):
logger.info("starting tts example agent")
tts = openai.TTS(model="tts-1", voice="nova")
source = rtc.AudioSource(tts.sample_rate, tts.num_channels)
track = rtc.LocalAudioTrack.create_audio_track("agent-mic", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await job.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_NONE)
publication = await job.room.local_participant.publish_track(track, options)
await publication.wait_for_subscription()
logger.info('Saying "Hello!"')
async for output in tts.synthesize("Hello!"):
await source.capture_frame(output.frame)
await asyncio.sleep(1)
logger.info('Saying "Goodbye."')
async for output in tts.synthesize("Goodbye."):
await source.capture_frame(output.frame)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
livekit-agents>=0.12.18
livekit-plugins-openai>=0.12.2
livekit-plugins-cartesia>=0.4.11
livekit-plugins-elevenlabs>=0.8.1
livekit-plugins-speechify>=0.1.0
python-dotenv~=1.0
import asyncio
import logging
from typing import Optional
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
AutoSubscribe,
JobContext,
WorkerOptions,
cli,
transcription,
tts,
)
from livekit.plugins import elevenlabs
load_dotenv()
logger = logging.getLogger("transcription-forwarding-demo")
logger.setLevel(logging.INFO)
async def entrypoint(ctx: JobContext):
logger.info("starting transcription protocol example")
tts_11labs = elevenlabs.TTS()
# publish an audio track
source = rtc.AudioSource(tts_11labs.sample_rate, tts_11labs.num_channels)
track = rtc.LocalAudioTrack.create_audio_track("agent-mic", source)
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
await ctx.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_NONE)
publication = await ctx.room.local_participant.publish_track(track, options)
await publication.wait_for_subscription()
# start the transcription examples
tts_forwarder = transcription.TTSSegmentsForwarder(
room=ctx.room, participant=ctx.room.local_participant
)
await _eg_single_segment(tts_forwarder, tts_11labs, source)
await asyncio.sleep(2)
await _eg_streamed_tts_stream(tts_forwarder, tts_11labs, source)
async def _eg_single_segment(
tts_forwarder: transcription.TTSSegmentsForwarder,
tts_11labs: tts.TTS,
source: rtc.AudioSource,
):
"""Transcription example without streaming (single string)"""
text = "Hello world, this is a single segment"
logger.info("pushing text %s", text)
tts_forwarder.push_text(text)
tts_forwarder.mark_text_segment_end()
playout_q = asyncio.Queue[Optional[rtc.AudioFrame]]()
playout_task = asyncio.create_task(_playout_task(tts_forwarder, playout_q, source))
async for output in tts_11labs.synthesize(text):
tts_forwarder.push_audio(output.frame)
playout_q.put_nowait(output.frame)
tts_forwarder.mark_audio_segment_end()
playout_q.put_nowait(None)
await playout_task
async def _eg_streamed_tts_stream(
tts_forwarder: transcription.TTSSegmentsForwarder,
tts_11labs: tts.TTS,
source: rtc.AudioSource,
):
"""Transcription example using a tts stream (we split text into chunks just for the example)"""
# this tts_forwarder will forward the transcription to the client and sync with the audio
tts_stream = tts_11labs.stream()
streamed_text = "Hello world, this text is going to be splitted into small chunks"
logger.info("pushing text %s", streamed_text)
for chunk in _text_to_chunks(streamed_text):
tts_stream.push_text(chunk)
tts_forwarder.push_text(chunk)
tts_stream.flush()
tts_stream.end_input()
tts_forwarder.mark_text_segment_end()
playout_q = asyncio.Queue[Optional[rtc.AudioFrame]]()
async def _synth_task() -> None:
async for ev in tts_stream:
playout_q.put_nowait(ev.frame)
tts_forwarder.push_audio(ev.frame)
tts_forwarder.mark_audio_segment_end()
playout_q.put_nowait(None)
await tts_stream.aclose()
playout_task = asyncio.create_task(_playout_task(tts_forwarder, playout_q, source))
synth_task = asyncio.create_task(_synth_task())
await asyncio.gather(synth_task, playout_task)
await tts_forwarder.aclose()
async def _playout_task(
tts_forwarder: transcription.TTSSegmentsForwarder,
playout_q: asyncio.Queue,
audio_source: rtc.AudioSource,
) -> None:
"""Playout audio frames from the queue to the audio source"""
tts_forwarder.segment_playout_started()
while True:
frame = await playout_q.get()
if frame is None:
break
await audio_source.capture_frame(frame)
tts_forwarder.segment_playout_finished()
def _text_to_chunks(text: str) -> list[str]:
"""Split the text into chunks of 2, 3, and 4 words"""
sizes = [2, 3, 4]
chunks, i = [], 0
for size in sizes:
while i + size <= len(text):
chunks.append(text[i : i + size])
i += size
chunks.append(text[i:]) # remaining
return chunks
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomInputOptions,
RoomOutputOptions,
WorkerOptions,
cli,
)
from livekit.plugins import openai
logger = logging.getLogger("text-only")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates a text-only agent.
## Send text input using TextStream to topic `lk.chat` (https://docs.livekit.io/home/client/data/text-streams)
## The agent output is sent through TextStream to the `lk.transcription` topic
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="You are a helpful assistant.",
llm=openai.LLM(model="gpt-4o-mini"),
)
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession()
await session.start(
agent=MyAgent(),
room=ctx.room,
room_input_options=RoomInputOptions(text_enabled=True, audio_enabled=False),
room_output_options=RoomOutputOptions(transcription_enabled=True, audio_enabled=False),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from enum import Enum
from typing import Annotated, Literal # noqa: F401
from dotenv import load_dotenv
from pydantic import Field # noqa: F401
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.agents.llm import function_tool
from livekit.plugins import cartesia, deepgram, openai, silero
logger = logging.getLogger("annotated-tool-args")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates how to use function tools with type hints and descriptions
## The Args in docstring will be parsed as arg descriptions for the LLM
## You can also use enums and pydantic.Field to add descriptions
## For dynamic tool creation, check out dynamic_tool_creation.py
class RoomName(str, Enum):
BEDROOM = "bedroom"
LIVING_ROOM = "living room"
KITCHEN = "kitchen"
BATHROOM = "bathroom"
OFFICE = "office"
GARAGE = "garage"
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions=("You are a helpful assistatn."),
)
@function_tool
async def get_weather(self, location: str) -> str:
"""
Called when the user asks about the weather.
Args:
location: The location to get the weather for
"""
# LLM will see location as a string argument with the description defined in docstring
# {
# "description": "The location to get the weather for"
# "title": "Location"
# "type": "string",
# }
# Another way to add descriptions to the arguments
# location: Annotated[str, Field(description="The location to get the weather for")]
logger.info(f"Getting weather for {location}")
return f"The weather in {location} is sunny today."
@function_tool
async def toggle_light(self, room: RoomName, switch_to: Literal["on", "off"]) -> str:
"""
Called when the user asks to turn on or off the light.
Args:
room: The room to turn the light in
switch_to: The state to turn the light to
"""
logger.info(f"Turning light to {switch_to} in {room}")
return f"The light in the {room.value} is now {switch_to}."
async def entrypoint(ctx: JobContext):
await ctx.connect()
agent = AgentSession(
vad=silero.VAD.load(),
stt=deepgram.STT(),
llm=openai.LLM(model="gpt-4o-mini"),
tts=cartesia.TTS(),
)
await agent.start(agent=MyAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
AudioConfig,
BackgroundAudioPlayer,
BuiltinAudioClip,
JobContext,
WorkerOptions,
cli,
function_tool,
)
from livekit.plugins import openai
logger = logging.getLogger("background-audio")
load_dotenv()
## Example demonstrates how to play background audio / sound effects in an agent session.
## It uses the BackgroundAudioPlayer class to manage audio playback to the room.
## Background audio could make the agent feel more realistic, versus perfect silence
## in the background.
class FakeWebSearchAgent(Agent):
def __init__(self):
super().__init__(instructions="You are a helpful assistant")
@function_tool
async def search_web(self, query: str) -> str:
"""
Search the web for information based on the given query.
Always use this function whenever the user requests a web search
Args:
query: The search query to look up on the web.
"""
# simulate a long web search to demonstrate the background "thinking" audio
logger.info("FakeWebSearchAgent thinking...")
await asyncio.sleep(5)
return "The request failed, give the users some information based on your knowledge"
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(llm=openai.realtime.RealtimeModel())
await session.start(FakeWebSearchAgent(), room=ctx.room)
background_audio = BackgroundAudioPlayer(
# play office ambience sound looping in the background
ambient_sound=AudioConfig(BuiltinAudioClip.OFFICE_AMBIENCE, volume=0.8),
# play keyboard typing sound when the agent is thinking
thinking_sound=[
AudioConfig(BuiltinAudioClip.KEYBOARD_TYPING, volume=0.8),
AudioConfig(BuiltinAudioClip.KEYBOARD_TYPING2, volume=0.7),
],
)
await background_audio.start(room=ctx.room, agent_session=session)
# Play another audio file at any time using the play method:
# background_audio.play("filepath.ogg")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
JobProcess,
RoomInputOptions,
RoomOutputOptions,
RunContext,
WorkerOptions,
cli,
metrics,
)
from livekit.agents.llm import function_tool
from livekit.agents.voice import MetricsCollectedEvent
from livekit.plugins import deepgram, openai, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
# uncomment to enable Krisp background voice/noise cancellation
# currently supported on Linux and MacOS
# from livekit.plugins import noise_cancellation
logger = logging.getLogger("basic-agent")
load_dotenv()
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="Your name is Kelly. You would interact with users via voice."
"with that in mind keep your responses concise and to the point."
"You are curious and friendly, and have a sense of humor.",
)
async def on_enter(self):
# when the agent is added to the session, it'll generate a reply
# according to its instructions
self.session.generate_reply()
# all functions annotated with @function_tool will be passed to the LLM when this
# agent is active
@function_tool
async def lookup_weather(
self,
context: RunContext,
location: str,
latitude: str,
longitude: str,
):
"""Called when the user asks for weather related information.
Ensure the user's location (city or region) is provided.
When given a location, please estimate the latitude and longitude of the location and
do not ask the user for them.
Args:
location: The location they are asking for
latitude: The latitude of the location
longitude: The longitude of the location
"""
logger.info(f"Looking up weather for {location}")
return "sunny with a temperature of 70 degrees."
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
async def entrypoint(ctx: JobContext):
# each log entry will include these fields
ctx.log_context_fields = {
"room": ctx.room.name,
}
await ctx.connect()
session = AgentSession(
vad=ctx.proc.userdata["vad"],
# any combination of STT, LLM, TTS, or realtime API can be used
llm=openai.LLM(model="gpt-4o-mini"),
stt=deepgram.STT(model="nova-3", language="multi"),
tts=openai.TTS(voice="ash"),
# use LiveKit's turn detection model
turn_detection=MultilingualModel(),
)
# log metrics as they are emitted, and total usage after session is over
usage_collector = metrics.UsageCollector()
@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent):
metrics.log_metrics(ev.metrics)
usage_collector.collect(ev.metrics)
async def log_usage():
summary = usage_collector.get_summary()
logger.info(f"Usage: {summary}")
# shutdown callbacks are triggered when the session is over
ctx.add_shutdown_callback(log_usage)
# wait for a participant to join the room
await ctx.wait_for_participant()
await session.start(
agent=MyAgent(),
room=ctx.room,
room_input_options=RoomInputOptions(
# uncomment to enable Krisp BVC noise cancellation
# noise_cancellation=noise_cancellation.BVC(),
),
room_output_options=RoomOutputOptions(transcription_enabled=True),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
import logging
import random
from enum import Enum
from typing import Literal
from dotenv import load_dotenv
from pydantic import BaseModel
from livekit.agents import (
Agent,
AgentSession,
ChatContext,
FunctionTool,
JobContext,
ModelSettings,
WorkerOptions,
cli,
function_tool,
)
from livekit.plugins import openai, silero
logger = logging.getLogger("grok-agent")
logger.setLevel(logging.INFO)
load_dotenv()
## This example shows how to create tools dynamically
## There are 3 options:
## 1. Create tools when the agent is created
## 2. Update tools after the agent is created using agent.update_tools()
## 3. Add temporal tools only for this call of llm_node
class MyAgent(Agent):
def __init__(self, instructions: str, tools: list[FunctionTool]) -> None:
super().__init__(instructions=instructions, tools=tools)
async def llm_node(
self, chat_ctx: ChatContext, tools: list[FunctionTool], model_settings: ModelSettings
):
# Option 3: add temporal tools only for this call of llm_node
async def _get_weather(location: str) -> str:
return f"The weather in {location} is sunny."
# modify the tools list in place
tools.append(
function_tool(
_get_weather,
name="get_weather",
description="Get the weather in a specific location",
)
)
return Agent.default.llm_node(self, chat_ctx, tools, model_settings)
async def _get_course_list_from_db() -> list[str]:
"""
This function simulates a database call but actually returns a hardcoded list.
In a real application, you would replace this with logic to retrieve data
from a real database or external data source.
"""
return [
"Applied mathematics",
"Data Science",
"Machine Learning",
"Deep Learning",
"Voice Agents",
]
async def entrypoint(ctx: JobContext):
await ctx.connect()
# Option 1: create tools when the agent is created
courses = await _get_course_list_from_db()
# enums will automatically be recognized by the LLMs
CourseType = Enum("CourseType", {c.replace(" ", "_"): c for c in courses})
class CourseInfo(BaseModel):
course: CourseType # type: ignore
location: Literal["online", "in-person"]
# BaseModel can also be created using create_model
# https://docs.pydantic.dev/2.3/usage/models/#dynamic-model-creation
async def _get_course_info(info: CourseInfo) -> str:
logger.info(f"get_course_info called: {info}")
return f"Imagine a course about {info.course}."
agent = MyAgent(
instructions="You are a helpful assistant that can answer questions and help with tasks.",
tools=[
function_tool(
_get_course_info,
name="get_course_info",
description="Get information about a course",
)
],
)
# Option 2: update tools after the agent is created using agent.update_tools()
async def _random_number() -> int:
num = random.randint(0, 100)
logger.info(f"random_number called: {num}")
return num
await agent.update_tools(
agent.tools
+ [function_tool(_random_number, name="random_number", description="Get a random number")]
)
session = AgentSession(
vad=silero.VAD.load(),
stt=openai.STT(use_realtime=True),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(),
)
await session.start(agent, room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
import os
import pathlib
from dotenv import load_dotenv
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.agents.utils.audio import audio_frames_from_file
from livekit.agents.voice import Agent, AgentSession
from livekit.agents.voice.events import CloseEvent, ErrorEvent
from livekit.plugins import cartesia, deepgram, openai, silero
from livekit.rtc import ParticipantKind
logger = logging.getLogger("my-worker")
logger.setLevel(logging.INFO)
load_dotenv()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
stt=deepgram.STT(),
llm=openai.LLM(),
tts=cartesia.TTS(),
vad=silero.VAD.load(),
)
custom_error_audio = os.path.join(pathlib.Path(__file__).parent.absolute(), "error_message.ogg")
@session.on("error")
def on_error(ev: ErrorEvent):
if ev.error.recoverable:
return
logger.info(f"session is closing due to unrecoverable error {ev.error}")
# To bypass the TTS service in case it's unavailable, we use a custom audio file instead
session.say(
"I'm having trouble connecting right now. Let me transfer your call.",
audio=audio_frames_from_file(custom_error_audio),
allow_interruptions=False,
)
@session.on("close")
def on_close(_: CloseEvent):
logger.info("Session is closing")
# Assume there is only one caller in the room
participant = [
p
for p in ctx.room.remote_participants.values()
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP
][0]
def on_sip_transfer_done(f: asyncio.Future):
if f.exception():
logger.error(f"Error transferring SIP participant: {f.exception()}")
else:
logger.info("SIP participant transferred")
ctx.delete_room()
# See https://docs.livekit.io/sip/ on how to set up SIP participants
if participant.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
ctx.transfer_sip_participant(participant, "tel:+18003310500").add_done_callback(
on_sip_transfer_done
)
await session.start(agent=Agent(instructions="You are a helpful assistant."), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from collections.abc import AsyncIterable
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
WorkerOptions,
cli,
llm,
)
from livekit.agents.llm.chat_context import ChatContext, ChatMessage
from livekit.plugins import deepgram, groq, openai, silero
logger = logging.getLogger("pre-reseponse-agent")
load_dotenv()
class PreResponseAgent(Agent):
def __init__(self):
super().__init__(
instructions="You are a helpful assistant",
llm=groq.LLM(model="llama-3.3-70b-versatile"),
)
self._fast_llm = groq.LLM(model="llama-3.1-8b-instant")
self._fast_llm_prompt = llm.ChatMessage(
role="system",
content=[
"Generate a short instant response to the user's message with 5 to 10 words.",
"Do not answer the questions directly. Examples:, let me think about that, "
"wait a moment, that's a good question, etc.",
],
)
async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage):
# Create a short "silence filler" response to quickly acknowledge the user's input
fast_llm_ctx = turn_ctx.copy(
exclude_instructions=True, exclude_function_call=True
).truncate(max_items=3)
fast_llm_ctx.items.insert(0, self._fast_llm_prompt)
fast_llm_ctx.items.append(new_message)
# # Intentionally not awaiting SpeechHandle to allow the main response generation to
# # run concurrently
# self.session.say(
# self._fast_llm.chat(chat_ctx=fast_llm_ctx).to_str_iterable(),
# add_to_chat_ctx=False,
# )
# Alternatively, if you want the reply to be aware of this "silence filler" response,
# you can await the fast llm done and add the message to the turn context. But note
# that not all llm supports completing from an existing assistant message.
fast_llm_fut = asyncio.Future[str]()
async def _fast_llm_reply() -> AsyncIterable[str]:
filler_response: str = ""
async for chunk in self._fast_llm.chat(chat_ctx=fast_llm_ctx).to_str_iterable():
filler_response += chunk
yield chunk
fast_llm_fut.set_result(filler_response)
self.session.say(_fast_llm_reply(), add_to_chat_ctx=False)
filler_response = await fast_llm_fut
logger.info(f"Fast response: {filler_response}")
turn_ctx.add_message(role="assistant", content=filler_response, interrupted=False)
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
stt=deepgram.STT(),
tts=openai.TTS(),
vad=silero.VAD.load(),
)
await session.start(PreResponseAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RoomInputOptions,
RoomOutputOptions,
WorkerOptions,
cli,
)
from livekit.plugins import google, silero
logger = logging.getLogger("gemini-video-agent")
load_dotenv()
class GeminiAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="You are gemini, a helpful assistant",
llm=google.beta.realtime.RealtimeModel(),
# By default, additional video frames are transmitted while the user is speaking
vad=silero.VAD.load(),
)
async def on_enter(self):
self.session.generate_reply(
instructions="introduce yourself very briefly and ask about the user's day"
)
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession()
await session.start(
agent=GeminiAgent(),
room=ctx.room,
# by default, video is disabled
room_input_options=RoomInputOptions(video_enabled=True),
room_output_options=RoomOutputOptions(transcription_enabled=True),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
# RAG Example using LlamaIndex
This repository showcases three ways to build a voice assistant with Retrieval-Augmented Generation (RAG) using LlamaIndex:
1. **`chat_engine.py`**: Utilizes LlamaIndex's `as_chat_engine` for a straightforward, integrated solution. **Trade-off**: Lacks function calling support, limiting advanced interactions.
2. **`query_engine.py`**: Uses an LLM that supports function calling (e.g., OpenAI's models) to define custom functions like `query_info` for retrieval. **Trade-off**: Requires additional setup but offers greater flexibility.
3. **`retrieval.py`**: Manually injects retrieved context into the system prompt using LlamaIndex's retriever. **Trade-off**: Provides fine-grained control but involves complex prompt engineering.
**Current recommended way**: Use **`query_engine.py`** for its balance of flexibility and control, enabling function calling and custom behaviors without excessive complexity.
from collections.abc import AsyncIterable
from pathlib import Path
from dotenv import load_dotenv
from llama_index.core import (
SimpleDirectoryReader,
StorageContext,
VectorStoreIndex,
load_index_from_storage,
)
from llama_index.core.chat_engine.types import ChatMode
from llama_index.core.llms import ChatMessage, MessageRole
from livekit.agents import Agent, AgentSession, AutoSubscribe, JobContext, WorkerOptions, cli, llm
from livekit.agents.voice.agent import ModelSettings
from livekit.plugins import deepgram, openai, silero
load_dotenv()
# check if storage already exists
THIS_DIR = Path(__file__).parent
PERSIST_DIR = THIS_DIR / "chat-engine-storage"
if not PERSIST_DIR.exists():
# load the documents and create the index
documents = SimpleDirectoryReader(THIS_DIR / "data").load_data()
index = VectorStoreIndex.from_documents(documents)
# store it for later
index.storage_context.persist(persist_dir=PERSIST_DIR)
else:
# load the existing index
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
class DummyLLM(llm.LLM):
async def chat(self, *args, **kwargs):
raise NotImplementedError("DummyLLM does not support chat")
class ChatEngineAgent(Agent):
def __init__(self, index: VectorStoreIndex):
super().__init__(
instructions=(
"You are a voice assistant created by LiveKit. Your interface "
"with users will be voice. You should use short and concise "
"responses, and avoiding usage of unpronouncable punctuation."
),
vad=silero.VAD.load(),
stt=deepgram.STT(),
llm=DummyLLM(), # use a dummy LLM to enable the pipeline reply
tts=openai.TTS(),
)
self.index = index
self.chat_engine = index.as_chat_engine(chat_mode=ChatMode.CONTEXT, llm="default")
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[llm.FunctionTool],
model_settings: ModelSettings,
) -> AsyncIterable[str]:
user_msg = chat_ctx.items.pop()
assert isinstance(user_msg, llm.ChatMessage) and user_msg.role == "user"
user_query = user_msg.text_content
assert user_query is not None
llama_chat_messages = [
ChatMessage(content=msg.text_content, role=MessageRole(msg.role))
for msg in chat_ctx.items
if isinstance(msg, llm.ChatMessage)
]
stream = await self.chat_engine.astream_chat(user_query, chat_history=llama_chat_messages)
async for delta in stream.async_response_gen():
yield delta
async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
agent = ChatEngineAgent(index)
session = AgentSession()
await session.start(agent=agent, room=ctx.room)
await session.say("Hey, how can I help you today?", allow_interruptions=True)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
Cloud Architecture
LiveKit Cloud gives you the flexibility of LiveKit's WebRTC stack, combined with global, CDN-scale infrastructure offering 99.99% uptime.
Built with LiveKit SFU
LiveKit Cloud builds on our open-source SFU. This means it supports the exact same client and server APIs as the open-source stack.
Maintaining compatibility with LiveKit's Open Source stack (OSS) is important to us. We didn't want any developer locked into using Cloud, or needing to integrate a different set of features, APIs or SDKs for their applications to work with it. Our design goal: a developer should be able to switch between Cloud or self-hosted without changing a line of code.
Distributed Mesh Architecture
In contrast to traditional WebRTC architectures, LiveKit Cloud runs multiple SFU instances in a mesh formation. We've developed capabilities for media servers to discover and connect to one another, in order to relay media between servers. This key capability allows us to bypass the single-server limitation that exists in traditional SFU and MCU architectures.
Multi-home
Cloud multi-home architecture
With a multi-home architecture, participants no longer need to connect to the same server. When participants from different regions join the same meeting, they'll each connect to the SFU closest to them, minimizing latency and transmission loss between the participant and SFU.
Each SFU instance establishes connections to other instances over optimized inter-data center networks. Inter-data center networks often run close to internet backbones, delivering high throughput with a minimal number of network hops.
No SPOF
Anything that can fail, will. LiveKit Cloud is designed to anticipate (and recover from) failures in every software and hardware component.
Layers of redundancy are built into the system. A media server failure is recovered from by moving impacted participants to another instance. We isolate shared infrastructure, like our message bus, to individual data centers.
When an entire data center fails, customer traffic is automatically migrated to the next closest data center. LiveKit's client SDKs will perform a "session migration": moving existing WebRTC sessions to a different media server without service interruption for your users.
Globally distributed
To serve end users around the world, our infrastructure runs across multiple Cloud vendors and data centers. Today we have data centers in North America, South America, Southeast Asia, East Asia, and Europe, delivering under 100ms of latency for users in those regions.
Designed to scale
When you need to place many viewers on a media track, like in a livestream, LiveKit Cloud handles that capacity dynamically by forming a distribution mesh, similar to a CDN. It's important to note that this process happens automatically as your sessions scales up. There are no special configurations necessary. Every LiveKit Cloud project scales automatically.
The theoretical limits of this architecture is on the order of millions per room/session. For practical purposes, we've placed a limit of 100k simulteneous participants in the same session. If you have a realtime application operating at a scale larger than this, you can request a limit increase in your Cloud dashboard or get in touch with us.
from pathlib import Path
from dotenv import load_dotenv
from llama_index.core import (
SimpleDirectoryReader,
StorageContext,
VectorStoreIndex,
load_index_from_storage,
)
from livekit.agents import Agent, AgentSession, AutoSubscribe, JobContext, WorkerOptions, cli, llm
from livekit.plugins import deepgram, openai, silero
load_dotenv()
# check if storage already exists
THIS_DIR = Path(__file__).parent
PERSIST_DIR = THIS_DIR / "query-engine-storage"
if not PERSIST_DIR.exists():
# load the documents and create the index
documents = SimpleDirectoryReader(THIS_DIR / "data").load_data()
index = VectorStoreIndex.from_documents(documents)
# store it for later
index.storage_context.persist(persist_dir=PERSIST_DIR)
else:
# load the existing index
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
@llm.function_tool
async def query_info(query: str) -> str:
"""Get more information about a specific topic"""
query_engine = index.as_query_engine(use_async=True)
res = await query_engine.aquery(query)
print("Query result:", res)
return str(res)
async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
agent = Agent(
instructions=(
"You are a voice assistant created by LiveKit. Your interface "
"with users will be voice. You should use short and concise "
"responses, and avoiding usage of unpronouncable punctuation."
),
vad=silero.VAD.load(),
stt=deepgram.STT(),
llm=openai.LLM(),
tts=openai.TTS(),
tools=[query_info],
)
session = AgentSession()
await session.start(agent=agent, room=ctx.room)
await session.say("Hey, how can I help you today?", allow_interruptions=True)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
from pathlib import Path
from dotenv import load_dotenv
from llama_index.core import (
SimpleDirectoryReader,
StorageContext,
VectorStoreIndex,
load_index_from_storage,
)
from llama_index.core.schema import MetadataMode
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
WorkerOptions,
cli,
llm,
)
from livekit.agents.voice.agent import ModelSettings
from livekit.plugins import deepgram, openai, silero
load_dotenv()
# check if storage already exists
THIS_DIR = Path(__file__).parent
PERSIST_DIR = THIS_DIR / "retrieval-engine-storage"
if not PERSIST_DIR.exists():
# load the documents and create the index
documents = SimpleDirectoryReader(THIS_DIR / "data").load_data()
index = VectorStoreIndex.from_documents(documents)
# store it for later
index.storage_context.persist(persist_dir=PERSIST_DIR)
else:
# load the existing index
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
class RetrievalAgent(Agent):
def __init__(self, index: VectorStoreIndex):
super().__init__(
instructions=(
"You are a voice assistant created by LiveKit. Your interface "
"with users will be voice. You should use short and concise "
"responses, and avoiding usage of unpronouncable punctuation."
),
vad=silero.VAD.load(),
stt=deepgram.STT(),
llm=openai.LLM(),
tts=openai.TTS(),
)
self.index = index
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[llm.FunctionTool],
model_settings: ModelSettings,
):
user_msg = chat_ctx.items[-1]
assert isinstance(user_msg, llm.ChatMessage) and user_msg.role == "user"
user_query = user_msg.text_content
assert user_query is not None
retriever = self.index.as_retriever()
nodes = await retriever.aretrieve(user_query)
instructions = "Context that might help answer the user's question:"
for node in nodes:
node_content = node.get_content(metadata_mode=MetadataMode.LLM)
instructions += f"\n\n{node_content}"
print(f"update instructions: {instructions[:100].replace('\n', '\\n')}...")
await self.update_instructions(instructions)
return Agent.default.llm_node(self, chat_ctx, tools, model_settings)
async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
agent = RetrievalAgent(index)
session = AgentSession()
await session.start(agent=agent, room=ctx.room)
await session.say("Hey, how can I help you today?", allow_interruptions=True)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dataclasses import dataclass
from typing import Optional
from dotenv import load_dotenv
from livekit import api
from livekit.agents import (
Agent,
AgentSession,
ChatContext,
JobContext,
JobProcess,
RoomInputOptions,
RoomOutputOptions,
RunContext,
WorkerOptions,
cli,
metrics,
)
from livekit.agents.job import get_job_context
from livekit.agents.llm import function_tool
from livekit.agents.voice import MetricsCollectedEvent
from livekit.plugins import deepgram, openai, silero
# uncomment to enable Krisp BVC noise cancellation, currently supported on Linux and MacOS
# from livekit.plugins import noise_cancellation
## The storyteller agent is a multi-agent that can handoff the session to another agent.
## This example demonstrates more complex workflows with multiple agents.
## Each agent could have its own instructions, as well as different STT, LLM, TTS,
## or realtime models.
logger = logging.getLogger("multi-agent")
load_dotenv()
common_instructions = (
"Your name is Echo. You are a story teller that interacts with the user via voice."
"You are curious and friendly, with a sense of humor."
)
@dataclass
class StoryData:
# Shared data that's used by the storyteller agent.
# This structure is passed as a parameter to function calls.
name: Optional[str] = None
location: Optional[str] = None
class IntroAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions=f"{common_instructions} Your goal is to gather a few pieces of "
"information from the user to make the story personalized and engaging."
"You should ask the user for their name and where they are from."
"Start the conversation with a short introduction.",
)
async def on_enter(self):
# when the agent is added to the session, it'll generate a reply
# according to its instructions
self.session.generate_reply()
@function_tool
async def information_gathered(
self,
context: RunContext[StoryData],
name: str,
location: str,
):
"""Called when the user has provided the information needed to make the story
personalized and engaging.
Args:
name: The name of the user
location: The location of the user
"""
context.userdata.name = name
context.userdata.location = location
story_agent = StoryAgent(name, location)
# by default, StoryAgent will start with a new context, to carry through the current
# chat history, pass in the chat_ctx
# story_agent = StoryAgent(name, location, chat_ctx=context.chat_ctx)
logger.info(
"switching to the story agent with the provided user data: %s", context.userdata
)
return story_agent, "Let's start the story!"
class StoryAgent(Agent):
def __init__(self, name: str, location: str, *, chat_ctx: Optional[ChatContext] = None) -> None:
super().__init__(
instructions=f"{common_instructions}. You should use the user's information in "
"order to make the story personalized."
"create the entire story, weaving in elements of their information, and make it "
"interactive, occasionally interating with the user."
"do not end on a statement, where the user is not expected to respond."
"when interrupted, ask if the user would like to continue or end."
f"The user's name is {name}, from {location}.",
# each agent could override any of the model services, including mixing
# realtime and non-realtime models
llm=openai.realtime.RealtimeModel(voice="echo"),
tts=None,
chat_ctx=chat_ctx,
)
async def on_enter(self):
# when the agent is added to the session, we'll initiate the conversation by
# using the LLM to generate a reply
self.session.generate_reply()
@function_tool
async def story_finished(self, context: RunContext[StoryData]):
"""When you are fininshed telling the story (and the user confirms they don't
want anymore), call this function to end the conversation."""
# interrupt any existing generation
self.session.interrupt()
# generate a goodbye message and hang up
# awaiting it will ensure the message is played out before returning
await self.session.generate_reply(
instructions=f"say goodbye to {context.userdata.name}", allow_interruptions=False
)
job_ctx = get_job_context()
await job_ctx.api.room.delete_room(api.DeleteRoomRequest(room=job_ctx.room.name))
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession[StoryData](
vad=ctx.proc.userdata["vad"],
# any combination of STT, LLM, TTS, or realtime API can be used
llm=openai.LLM(model="gpt-4o-mini"),
stt=deepgram.STT(model="nova-3"),
tts=openai.TTS(voice="echo"),
userdata=StoryData(),
)
# log metrics as they are emitted, and total usage after session is over
usage_collector = metrics.UsageCollector()
@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent):
metrics.log_metrics(ev.metrics)
usage_collector.collect(ev.metrics)
async def log_usage():
summary = usage_collector.get_summary()
logger.info(f"Usage: {summary}")
ctx.add_shutdown_callback(log_usage)
await session.start(
agent=IntroAgent(),
room=ctx.room,
room_input_options=RoomInputOptions(
# uncomment to enable Krisp BVC noise cancellation
# noise_cancellation=noise_cancellation.BVC(),
),
room_output_options=RoomOutputOptions(transcription_enabled=True),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import Agent, AgentSession, JobContext, RoomIO, WorkerOptions, cli
from livekit.agents.llm import ChatContext, ChatMessage, StopResponse
from livekit.plugins import cartesia, deepgram, openai
logger = logging.getLogger("push-to-talk")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates how to use the push-to-talk for multi-participant
## conversations with a voice agent
## It disables audio input by default, and only enables it when the client explicitly
## triggers the `start_turn` RPC method
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="You are a helpful assistant.",
stt=deepgram.STT(),
llm=openai.LLM(model="gpt-4o-mini"),
tts=cartesia.TTS(),
# llm=openai.realtime.RealtimeModel(voice="alloy", turn_detection=None),
)
async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage) -> None:
# callback before generating a reply after user turn committed
if not new_message.text_content:
# for example, raise StopResponse to stop the agent from generating a reply
logger.info("ignore empty user turn")
raise StopResponse()
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(turn_detection="manual")
room_io = RoomIO(session, room=ctx.room)
await room_io.start()
agent = MyAgent()
await session.start(agent=agent)
# disable input audio at the start
session.input.set_audio_enabled(False)
@ctx.room.local_participant.register_rpc_method("start_turn")
async def start_turn(data: rtc.RpcInvocationData):
session.interrupt()
session.clear_user_turn()
# listen to the caller if multi-user
room_io.set_participant(data.caller_identity)
session.input.set_audio_enabled(True)
@ctx.room.local_participant.register_rpc_method("end_turn")
async def end_turn(data: rtc.RpcInvocationData):
session.input.set_audio_enabled(False)
session.commit_user_turn()
@ctx.room.local_participant.register_rpc_method("cancel_turn")
async def cancel_turn(data: rtc.RpcInvocationData):
session.input.set_audio_enabled(False)
session.clear_user_turn()
logger.info("cancel turn")
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RunContext,
WorkerOptions,
cli,
function_tool,
)
from livekit.plugins import openai, silero # noqa: F401
# This demo defines an agent using a raw function tool to open predefined gates via enum input.
# When using raw function tools, compatibility across LLM providers is not guaranteed,
# as different models may interpret or format raw schemas differently.
#
# The raw_schema provided to @function_tool is a direct passthrough to the OpenAI API.
# This allows leveraging OpenAI's native function calling feature as documented at:
# https://platform.openai.com/docs/guides/function-calling?api-mode=responses
logger = logging.getLogger("raw-function-description")
load_dotenv()
class RawFunctionAgent(Agent):
def __init__(self):
super().__init__(instructions="You are a helpful assistant")
@function_tool(
raw_schema={
"name": "open_gate",
"description": "Opens a specified gate from a predefined set of access points.",
"parameters": {
"type": "object",
"properties": {
"gate_id": {
"type": "string",
"description": (
"Identifier of the gate to open. Must be one of the "
"system's predefined access points."
),
"enum": [
"main_entrance",
"north_parking",
"loading_dock",
"side_gate",
"service_entry",
],
}
},
"required": ["gate_id"],
},
}
)
async def open_gate(self, raw_arguments: dict[str, object], ctx: RunContext):
gate_id = raw_arguments["gate_id"]
logger.info(f"Opening gate: {gate_id}")
return f"Gate {gate_id} opened successfully"
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
# stt=openai.STT(),
# llm=openai.LLM(),
# tts=openai.TTS(),
# vad=silero.VAD.load(),
llm=openai.realtime.RealtimeModel()
)
await session.start(RawFunctionAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli, llm
from livekit.plugins import openai
## This example shows how to load chat history for OpenAI Realtime Model
logger = logging.getLogger("realtime-load-chat-history")
load_dotenv()
async def entrypoint(ctx: JobContext):
await ctx.connect()
chat_history = [
{
"role": "assistant",
"content": "Hello, I am a travel planner. How can I help you?",
},
{
"role": "user",
"content": "I want to go to Paris this summer.",
},
{
"role": "assistant",
"content": "Paris is a beautiful city. How many days will you be staying?",
},
{
"role": "user",
"content": "I'll have four days. What are the main attractions I should see?",
},
]
chat_ctx = llm.ChatContext.empty()
for item in chat_history:
chat_ctx.add_message(role=item["role"], content=item["content"])
session = AgentSession()
agent = Agent(
instructions="You are a helpful travel planner.",
llm=openai.realtime.RealtimeModel(),
chat_ctx=chat_ctx,
)
await session.start(agent=agent, room=ctx.room)
session.interrupt()
session.generate_reply()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentSession, JobContext, JobProcess, WorkerOptions, cli
from livekit.plugins import deepgram, openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
logger = logging.getLogger("realtime-turn-detector")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates how to use LiveKit's turn detection model with a realtime LLM.
## Since the current turn detection model runs in text space, it will need to be combined
## with a STT model, even though the audio is going directly to the Realtime API.
## In this example, speech is being processed in parallel by both the STT and the realtime API
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
allow_interruptions=True,
turn_detection=EnglishModel(),
vad=ctx.proc.userdata["vad"],
stt=deepgram.STT(),
llm=openai.realtime.RealtimeModel(
voice="alloy",
# it's necessary to turn off turn detection in the OpenAI Realtime API in order to use
# LiveKit's turn detection model
turn_detection=None,
input_audio_transcription=None, # we use Deepgram STT instead
),
)
await session.start(agent=Agent(instructions="You are a helpful assistant."), room=ctx.room)
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
livekit-agents[openai, cartesia, elevenlabs, deepgram, silero, turn-detector]>=1.0
python-dotenv>=1.0
duckduckgo-search>=8.0
import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.agents.llm import function_tool
from livekit.plugins import cartesia, deepgram, openai, silero
logger = logging.getLogger("silent-function-call")
logger.setLevel(logging.INFO)
load_dotenv()
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions=(
"You are a voice agent. Call the turn_on_light function when user asks to turn on the light." # noqa: E501
),
)
self.light_on = False
@function_tool()
async def turn_on_light(self):
"""Called when user asks to turn on the light."""
self.light_on = True
logger.info("Light is now on")
@function_tool()
async def turn_off_light(self):
"""Called when user asks to turn off the light."""
self.light_on = False
logger.info("Light is now off")
async def entrypoint(ctx: JobContext):
await ctx.connect()
agent = AgentSession(
stt=deepgram.STT(),
llm=openai.LLM(model="gpt-4o-mini"),
tts=cartesia.TTS(),
vad=silero.VAD.load(),
# llm=openai.realtime.RealtimeModel(voice="alloy"),
)
await ctx.wait_for_participant()
await agent.start(agent=MyAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from collections.abc import AsyncIterable
import numpy as np
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
Agent,
AgentSession,
JobContext,
JobProcess,
ModelSettings,
WorkerOptions,
cli,
utils,
)
from livekit.plugins import deepgram, openai, silero
try:
import librosa
except ImportError:
raise ImportError(
"librosa is required to run this example, install it with `pip install librosa`"
) from None
logger = logging.getLogger("speedup-output-audio")
logging.getLogger("numba").setLevel(logging.WARNING)
load_dotenv()
## This example demonstrates how to add post-processing to the output audio of the agent.
class MyAgent(Agent):
def __init__(self, *, speed_factor: float = 1.2) -> None:
super().__init__(
instructions="Your name is Jenna. You would interact with users via voice."
"with that in mind keep your responses concise and to the point."
"You are curious and friendly, and have a sense of humor.",
)
self.speed_factor = speed_factor
async def tts_node(self, text: AsyncIterable[str], model_settings: ModelSettings):
return self._process_audio_stream(Agent.default.tts_node(self, text, model_settings))
async def realtime_audio_output_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> AsyncIterable[rtc.AudioFrame]:
return self._process_audio_stream(
Agent.default.realtime_audio_output_node(self, audio, model_settings)
)
async def _process_audio_stream(
self, audio: AsyncIterable[rtc.AudioFrame]
) -> AsyncIterable[rtc.AudioFrame]:
stream: utils.audio.AudioByteStream | None = None
async for frame in audio:
if stream is None:
stream = utils.audio.AudioByteStream(
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
samples_per_channel=frame.sample_rate // 10, # 100ms
)
# TODO: find a streamed way to process the audio
for f in stream.push(frame.data):
yield self._process_audio(f)
for f in stream.flush():
yield self._process_audio(f)
def _process_audio(self, frame: rtc.AudioFrame) -> rtc.AudioFrame:
# time-stretch without pitch change
audio_data = np.frombuffer(frame.data, dtype=np.int16)
stretched = librosa.effects.time_stretch(
audio_data.astype(np.float32) / np.iinfo(np.int16).max,
rate=self.speed_factor,
)
stretched = (stretched * np.iinfo(np.int16).max).astype(np.int16)
return rtc.AudioFrame(
data=stretched.tobytes(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
samples_per_channel=stretched.shape[-1],
)
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
# warmup the librosa JIT
librosa.effects.time_stretch(np.random.randn(16000).astype(np.float32), rate=1.2)
async def entrypoint(ctx: JobContext):
# each log entry will include these fields
ctx.log_context_fields = {
"room": ctx.room.name,
"user_id": "your user_id",
}
await ctx.connect()
session = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=openai.LLM(model="gpt-4o-mini"),
stt=deepgram.STT(model="nova-3"),
tts=openai.TTS(voice="ash"),
# llm=openai.realtime.RealtimeModel(voice="alloy"),
)
await session.start(agent=MyAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
import logging
from collections.abc import AsyncIterable
from typing import Annotated, Callable, Optional, cast
from dotenv import load_dotenv
from pydantic import Field
from pydantic_core import from_json
from typing_extensions import TypedDict
from livekit.agents import (
NOT_GIVEN,
Agent,
AgentSession,
ChatContext,
FunctionTool,
JobContext,
ModelSettings,
WorkerOptions,
cli,
)
from livekit.plugins import openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
logger = logging.getLogger("structured-output")
load_dotenv()
## This example demonstrates how to use structured output from the LLM to control the TTS.
## The LLM is instructed to provide a TTS directive, which is returned as a ResponseEmotion object.
## before generating the response
class ResponseEmotion(TypedDict):
voice_instructions: Annotated[
str,
Field(..., description="Concise TTS directive for tone, emotion, intonation, and speed"),
]
response: str
async def process_structured_output(
text: AsyncIterable[str],
callback: Optional[Callable[[ResponseEmotion], None]] = None,
) -> AsyncIterable[str]:
last_response = ""
acc_text = ""
async for chunk in text:
acc_text += chunk
try:
resp: ResponseEmotion = from_json(acc_text, allow_partial="trailing-strings")
except ValueError:
continue
if callback:
callback(resp)
if not resp.get("response"):
continue
new_delta = resp["response"][len(last_response) :]
if new_delta:
yield new_delta
last_response = resp["response"]
class MyAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions=(
"Your name is Echo. You are an extraordinarily expressive voice assistant "
"with mastery over vocal dynamics and emotions. Adapt your voice—modulate tone, "
"pitch, speed, intonation, and convey emotions such as happiness, sadness, "
"excitement, or calmness—to match the conversation context. "
"Keep responses concise, clear, and engaging, turning every interaction into a "
"captivating auditory performance."
),
stt=openai.STT(model="gpt-4o-transcribe"),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(model="gpt-4o-mini-tts"),
)
async def llm_node(
self, chat_ctx: ChatContext, tools: list[FunctionTool], model_settings: ModelSettings
):
# not all LLMs support structured output, so we need to cast to the specific LLM type
llm = cast(openai.LLM, self.llm)
tool_choice = model_settings.tool_choice if model_settings else NOT_GIVEN
async with llm.chat(
chat_ctx=chat_ctx,
tools=tools,
tool_choice=tool_choice,
response_format=ResponseEmotion,
) as stream:
async for chunk in stream:
yield chunk
async def tts_node(self, text: AsyncIterable[str], model_settings: ModelSettings):
instruction_updated = False
def output_processed(resp: ResponseEmotion):
nonlocal instruction_updated
if resp.get("voice_instructions") and resp.get("response") and not instruction_updated:
# when the response isn't empty, we can assume voice_instructions is complete.
# (if the LLM sent the fields in the right order)
instruction_updated = True
logger.info(
f"Applying TTS instructions before generating response audio: "
f'"{resp["voice_instructions"]}"'
)
tts = cast(openai.TTS, self.tts)
tts.update_options(instructions=resp["voice_instructions"])
# process_structured_output strips the TTS instructions and only synthesizes the verbal part
# of the LLM output
return Agent.default.tts_node(
self, process_structured_output(text, callback=output_processed), model_settings
)
async def transcription_node(self, text: AsyncIterable[str], model_settings: ModelSettings):
# transcription_node needs to return what the agent would say, minus the TTS instructions
return Agent.default.transcription_node(
self, process_structured_output(text), model_settings
)
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(
vad=silero.VAD.load(),
turn_detection=EnglishModel(),
)
await session.start(agent=MyAgent(), room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import Agent, AgentSession, JobContext, RoomIO, WorkerOptions, cli
from livekit.plugins import openai
logger = logging.getLogger("toggle-io")
logger.setLevel(logging.INFO)
load_dotenv()
## This example demonstrates a more complex application that allows the user to
## toggle audio and text input/output on the fly.
## The example makes use of LiveKit's RPC system to exchange messages between the
## client and the server.
async def entrypoint(ctx: JobContext):
await ctx.connect()
session = AgentSession(llm=openai.realtime.RealtimeModel())
room_io = RoomIO(session, room=ctx.room)
await room_io.start()
await session.start(
agent=Agent(
instructions="You are a helpful assistant that interfaces with the user via voice."
)
)
@ctx.room.local_participant.register_rpc_method("set_participant")
async def on_set_participant(data: rtc.RpcInvocationData) -> None:
target_identity = data.payload or data.caller_identity
logger.info(
"set participant called",
extra={
"caller_identity": data.caller_identity,
"payload": data.payload,
"target_identity": target_identity,
},
)
room_io.set_participant(target_identity)
@ctx.room.local_participant.register_rpc_method("unset_participant")
async def on_unset_participant(data: rtc.RpcInvocationData) -> None:
logger.info(
"unset participant called",
extra={"caller_identity": data.caller_identity, "payload": data.payload},
)
room_io.unset_participant()
@ctx.room.local_participant.register_rpc_method("toggle_input")
async def on_toggle_input(data: rtc.RpcInvocationData) -> None:
logger.info(
"toggle input called",
extra={"caller_identity": data.caller_identity, "payload": data.payload},
)
if data.payload == "audio_on":
session.input.set_audio_enabled(True)
elif data.payload == "audio_off":
session.input.set_audio_enabled(False)
@ctx.room.local_participant.register_rpc_method("toggle_output")
async def on_toggle_output(data: rtc.RpcInvocationData) -> None:
logger.info(
"toggle output called",
extra={"caller_identity": data.caller_identity, "payload": data.payload},
)
if data.payload == "audio_on":
session.output.set_audio_enabled(True)
elif data.payload == "audio_off":
session.output.set_audio_enabled(False)
elif data.payload == "transcription_on":
session.output.set_transcription_enabled(True)
elif data.payload == "transcription_off":
session.output.set_transcription_enabled(False)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import logging
import aiohttp
from dotenv import load_dotenv
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.agents.llm import function_tool
from livekit.agents.voice import Agent, AgentSession
from livekit.agents.voice.room_io import RoomInputOptions, RoomOutputOptions
from livekit.plugins import openai
logger = logging.getLogger("weather-example")
logger.setLevel(logging.INFO)
load_dotenv()
class WeatherAgent(Agent):
def __init__(self) -> None:
super().__init__(
instructions="You are a weather agent.",
llm=openai.realtime.RealtimeModel(),
)
@function_tool
async def get_weather(
self,
latitude: str,
longitude: str,
):
"""Called when the user asks about the weather. This function will return the weather for
the given location. When given a location, please estimate the latitude and longitude of the
location and do not ask the user for them.
Args:
latitude: The latitude of the location
longitude: The longitude of the location
"""
logger.info(f"getting weather for {latitude}, {longitude}")
url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m"
weather_data = {}
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
data = await response.json()
# response from the function call is returned to the LLM
weather_data = {
"temperature": data["current"]["temperature_2m"],
"temperature_unit": "Celsius",
}
else:
raise Exception(f"Failed to get weather data, status code: {response.status}")
return weather_data
async def entrypoint(ctx: JobContext):
await ctx.connect()
# each log entry will include these fields
ctx.log_context_fields = {
"room_name": ctx.room.name,
"user_id": "your user_id",
}
session = AgentSession()
await session.start(
agent=WeatherAgent(),
room=ctx.room,
room_input_options=RoomInputOptions(),
room_output_options=RoomOutputOptions(transcription_enabled=True),
)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
import asyncio
import logging
from dataclasses import dataclass
from dotenv import load_dotenv
from duckduckgo_search import DDGS
from livekit.agents import (
Agent,
AgentSession,
JobContext,
RunContext,
ToolError,
WorkerOptions,
cli,
function_tool,
)
from livekit.plugins import openai
load_dotenv()
logger = logging.getLogger("web_search")
@dataclass
class AppData:
ddgs_client: DDGS
@function_tool
async def search_web(ctx: RunContext[AppData], query: str):
"""
Performs a web search using the DuckDuckGo search engine.
Args:
query: The search term or question you want to look up online.
"""
ddgs_client = ctx.userdata.ddgs_client
logger.info(f"Searching for {query}")
# using asyncio.to_thread because the DDGS client is not asyncio compatible
search = await asyncio.to_thread(ddgs_client.text, query)
if len(search) == 0:
raise ToolError("Tell the user that no results were found for the query.")
return search
async def entrypoint(ctx: JobContext):
await ctx.connect()
app_data = AppData(ddgs_client=DDGS())
agent = Agent(instructions="You are a helpful assistant.", tools=[search_web])
session = AgentSession(llm=openai.realtime.RealtimeModel(), userdata=app_data)
await session.start(agent=agent, room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
# LiveKit Agents
The core LiveKit Agents Framework. See top-level README for more information.
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from . import cli, ipc, llm, metrics, stt, tokenize, tts, utils, vad # noqa: F401
from ._exceptions import (
APIConnectionError,
APIError,
APIStatusError,
APITimeoutError,
AssignmentTimeoutError,
)
from .job import (
AutoSubscribe,
JobContext,
JobExecutorType,
JobProcess,
JobRequest,
get_job_context,
)
from .llm.chat_context import (
ChatContent,
ChatContext,
ChatItem,
ChatMessage,
ChatRole,
FunctionCall,
FunctionCallOutput,
)
from .llm.tool_context import FunctionTool, StopResponse, ToolError, function_tool
from .plugin import Plugin
from .types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGiven,
NotGivenOr,
)
from .version import __version__
from .voice import (
Agent,
AgentEvent,
AgentSession,
AgentStateChangedEvent,
CloseEvent,
ConversationItemAddedEvent,
ErrorEvent,
MetricsCollectedEvent,
ModelSettings,
RunContext,
SpeechCreatedEvent,
UserInputTranscribedEvent,
UserStateChangedEvent,
io,
)
from .voice.background_audio import AudioConfig, BackgroundAudioPlayer, BuiltinAudioClip
from .voice.room_io import RoomInputOptions, RoomIO, RoomOutputOptions
from .worker import SimulateJobInfo, Worker, WorkerOptions, WorkerPermissions, WorkerType
__all__ = [
"__version__",
"Worker",
"WorkerOptions",
"WorkerType",
"WorkerPermissions",
"JobProcess",
"JobContext",
"JobRequest",
"get_job_context",
"JobExecutorType",
"AutoSubscribe",
"FunctionTool",
"function_tool",
"ChatContext",
"ChatItem",
"RoomIO",
"RoomInputOptions",
"RoomOutputOptions",
"ChatMessage",
"ChatRole",
"ChatContent",
"ErrorEvent",
"CloseEvent",
"ConversationItemAddedEvent",
"AgentStateChangedEvent",
"UserInputTranscribedEvent",
"UserStateChangedEvent",
"SpeechCreatedEvent",
"MetricsCollectedEvent",
"io",
"FunctionCall",
"FunctionCallOutput",
"StopResponse",
"ToolError",
"RunContext",
"Plugin",
"AgentSession",
"AgentEvent",
"ModelSettings",
"Agent",
"AssignmentTimeoutError",
"APIConnectionError",
"APIError",
"APIStatusError",
"APITimeoutError",
"APIConnectOptions",
"NotGiven",
"NOT_GIVEN",
"NotGivenOr",
"DEFAULT_API_CONNECT_OPTIONS",
"BackgroundAudioPlayer",
"BuiltinAudioClip",
"AudioConfig",
"SimulateJobInfo",
]
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
from __future__ import annotations
class AssignmentTimeoutError(Exception):
"""Raised when accepting a job but not receiving an assignment within the specified timeout.
The server may have chosen another worker to handle this job."""
pass
# errors used by our plugins
class APIError(Exception):
"""Raised when an API request failed.
This is used on our TTS/STT/LLM plugins."""
message: str
"""
The error message returned by the API.
"""
body: object | None
"""The API response body, if available.
If the API returned a valid json, the body will contains
the decodede result.
"""
retryable: bool = False
"""Whether the error can be retried."""
def __init__(self, message: str, *, body: object | None, retryable: bool = True) -> None:
super().__init__(message)
self.message = message
self.body = body
self.retryable = retryable
class APIStatusError(APIError):
"""Raised when an API response has a status code of 4xx or 5xx."""
status_code: int
"""The status code of the API response."""
request_id: str | None
"""The request ID of the API response, if available."""
def __init__(
self,
message: str,
*,
status_code: int = -1,
request_id: str | None = None,
body: object | None = None,
retryable: bool | None = None,
) -> None:
if retryable is None:
retryable = True
# 4xx errors are not retryable
if status_code >= 400 and status_code < 500:
retryable = False
super().__init__(message, body=body, retryable=retryable)
self.status_code = status_code
self.request_id = request_id
def __str__(self):
return (
f"{self.message} "
f"(status_code={self.status_code}, request_id={self.request_id}, body={self.body})"
)
class APIConnectionError(APIError):
"""Raised when an API request failed due to a connection error."""
def __init__(self, message: str = "Connection error.", *, retryable: bool = True) -> None:
super().__init__(message, body=None, retryable=retryable)
class APITimeoutError(APIConnectionError):
"""Raised when an API request timed out."""
def __init__(self, message: str = "Request timed out.", *, retryable: bool = True) -> None:
super().__init__(message, retryable=retryable)
from .cli import run_app
__all__ = ["run_app"]
from __future__ import annotations # noqa: I001
import asyncio
import pathlib
import signal
import sys
import threading
from .. import utils
from ..log import logger
from ..worker import Worker
from . import proto
from .log import setup_logging
def run_dev(
args: proto.CliArgs,
):
if args.watch:
from .watcher import WatchServer
setup_logging(args.log_level, args.devmode, args.console)
main_file = pathlib.Path(sys.argv[0]).parent
async def _run_loop():
server = WatchServer(run_worker, main_file, args, loop=asyncio.get_event_loop())
await server.run()
try:
asyncio.run(_run_loop())
except KeyboardInterrupt:
pass
else:
run_worker(args)
def _esc(*codes: int) -> str:
return "\033[" + ";".join(str(c) for c in codes) + "m"
def run_worker(args: proto.CliArgs, *, jupyter: bool = False) -> None:
setup_logging(args.log_level, args.devmode, args.console)
args.opts.validate_config(args.devmode)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if args.console:
print(_esc(34) + "=" * 50 + _esc(0))
print(_esc(34) + " Livekit Agents - Console" + _esc(0))
print(_esc(34) + "=" * 50 + _esc(0))
print("Press [Ctrl+B] to toggle between Text/Audio mode, [Q] to quit.\n")
worker = Worker(args.opts, devmode=args.devmode, register=args.register, loop=loop)
loop.set_debug(args.asyncio_debug)
loop.slow_callback_duration = 0.1 # 100ms
utils.aio.debug.hook_slow_callbacks(2)
@worker.once("worker_started")
def _worker_started():
if args.simulate_job and args.reload_count == 0:
loop.create_task(worker.simulate_job(args.simulate_job))
if args.devmode:
logger.info(
f"{_esc(1)}see tracing information at http://localhost:{worker.worker_info.http_port}/debug{_esc(0)}"
)
else:
logger.info(
f"see tracing information at http://localhost:{worker.worker_info.http_port}/debug"
)
try:
def _signal_handler():
raise KeyboardInterrupt
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _signal_handler)
except NotImplementedError:
# TODO(theomonnom): add_signal_handler is not implemented on win
pass
async def _worker_run(worker: Worker) -> None:
try:
await worker.run()
except Exception:
logger.exception("worker failed")
watch_client = None
if args.watch:
from .watcher import WatchClient
watch_client = WatchClient(worker, args, loop=loop)
watch_client.start()
try:
main_task = loop.create_task(_worker_run(worker), name="agent_runner")
try:
loop.run_until_complete(main_task)
except KeyboardInterrupt:
pass
try:
if not args.devmode:
loop.run_until_complete(worker.drain(timeout=args.drain_timeout))
loop.run_until_complete(worker.aclose())
if watch_client:
loop.run_until_complete(watch_client.aclose())
except KeyboardInterrupt:
if not jupyter:
logger.warning("exiting forcefully")
import os
os._exit(1) # TODO(theomonnom): add aclose(force=True) in worker
finally:
if jupyter:
loop.close() # close can only be called from the main thread
return # noqa: B012
try:
tasks = asyncio.all_tasks(loop)
for task in tasks:
task.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
finally:
loop.close()
from __future__ import annotations # noqa: I001
import click
from .. import utils
from ..log import logger
from ..plugin import Plugin
from ..types import NOT_GIVEN, NotGivenOr
from ..worker import JobExecutorType, WorkerOptions, SimulateJobInfo
from . import proto, _run
from .log import setup_logging
CLI_ARGUMENTS: proto.CliArgs | None = None
def run_app(
opts: WorkerOptions,
*,
hot_reload: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
"""Run the CLI to interact with the worker"""
cli = click.Group()
@cli.command(help="Start the worker in production mode.")
@click.option(
"--log-level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False),
help="Set the logging level",
)
@click.option(
"--url",
envvar="LIVEKIT_URL",
help="LiveKit server or Cloud project's websocket URL",
)
@click.option(
"--api-key",
envvar="LIVEKIT_API_KEY",
help="LiveKit server or Cloud project's API key",
)
@click.option(
"--api-secret",
envvar="LIVEKIT_API_SECRET",
help="LiveKit server or Cloud project's API secret",
)
@click.option(
"--drain-timeout",
default=60,
help="Time in seconds to wait for jobs to finish before shutting down",
)
def start(log_level: str, url: str, api_key: str, api_secret: str, drain_timeout: int) -> None:
opts.ws_url = url or opts.ws_url
opts.api_key = api_key or opts.api_key
opts.api_secret = api_secret or opts.api_secret
args = proto.CliArgs(
opts=opts,
log_level=log_level,
devmode=False,
asyncio_debug=False,
register=True,
watch=False,
drain_timeout=drain_timeout,
)
global CLI_ARGUMENTS
CLI_ARGUMENTS = args
_run.run_worker(args)
@cli.command(help="Start the worker in development mode")
@click.option(
"--log-level",
default="DEBUG",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False),
help="Set the logging level",
)
@click.option(
"--url",
envvar="LIVEKIT_URL",
help="LiveKit server or Cloud project's websocket URL",
)
@click.option(
"--api-key",
envvar="LIVEKIT_API_KEY",
help="LiveKit server or Cloud project's API key",
)
@click.option(
"--api-secret",
envvar="LIVEKIT_API_SECRET",
help="LiveKit server or Cloud project's API secret",
)
@click.option(
"--asyncio-debug/--no-asyncio-debug",
default=False,
help="Enable debugging feature of asyncio",
)
@click.option(
"--watch/--no-watch",
default=hot_reload if utils.is_given(hot_reload) else True,
help="Watch for changes in the current directory and plugins in editable mode",
)
def dev(
log_level: str,
url: str,
api_key: str,
api_secret: str,
asyncio_debug: bool,
watch: bool,
) -> None:
opts.ws_url = url or opts.ws_url
opts.api_key = api_key or opts.api_key
opts.api_secret = api_secret or opts.api_secret
args = proto.CliArgs(
opts=opts,
log_level=log_level,
devmode=True,
asyncio_debug=asyncio_debug,
watch=watch,
drain_timeout=0,
register=True,
)
global CLI_ARGUMENTS
CLI_ARGUMENTS = args
_run.run_dev(args)
@cli.command(help="Start a new chat")
@click.option(
"--url",
envvar="LIVEKIT_URL",
help="LiveKit server or Cloud project's websocket URL",
)
@click.option(
"--api-key",
envvar="LIVEKIT_API_KEY",
help="LiveKit server or Cloud project's API key",
)
@click.option(
"--api-secret",
envvar="LIVEKIT_API_SECRET",
help="LiveKit server or Cloud project's API secret",
)
def console(
url: str,
api_key: str,
api_secret: str,
) -> None:
# keep everything inside the same process when using the chat mode
opts.job_executor_type = JobExecutorType.THREAD
opts.ws_url = url or opts.ws_url or "ws://localhost:7881/fake_console_url"
opts.api_key = api_key or opts.api_key or "fake_console_key"
opts.api_secret = api_secret or opts.api_secret or "fake_console_secret"
args = proto.CliArgs(
opts=opts,
log_level="DEBUG",
devmode=True,
asyncio_debug=False,
watch=False,
console=True,
drain_timeout=0,
register=False,
simulate_job=SimulateJobInfo(room="mock-console"),
)
global CLI_ARGUMENTS
CLI_ARGUMENTS = args
_run.run_worker(args)
@cli.command(help="Connect to a specific room")
@click.option(
"--log-level",
default="DEBUG",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False),
help="Set the logging level",
)
@click.option(
"--url",
envvar="LIVEKIT_URL",
help="LiveKit server or Cloud project's websocket URL",
)
@click.option(
"--api-key",
envvar="LIVEKIT_API_KEY",
help="LiveKit server or Cloud project's API key",
)
@click.option(
"--api-secret",
envvar="LIVEKIT_API_SECRET",
help="LiveKit server or Cloud project's API secret",
)
@click.option(
"--asyncio-debug/--no-asyncio-debug",
default=False,
help="Enable debugging feature of asyncio",
)
@click.option(
"--watch/--no-watch",
default=True,
help="Watch for changes in the current directory and plugins in editable mode",
)
@click.option("--room", help="Room name to connect to", required=True)
@click.option("--participant-identity", help="Participant identity (JobType.JT_PUBLISHER)")
def connect(
log_level: str,
url: str,
api_key: str,
api_secret: str,
asyncio_debug: bool,
watch: bool,
room: str,
participant_identity: str,
) -> None:
opts.ws_url = url or opts.ws_url
opts.api_key = api_key or opts.api_key
opts.api_secret = api_secret or opts.api_secret
args = proto.CliArgs(
opts=opts,
log_level=log_level,
devmode=True,
register=False,
asyncio_debug=asyncio_debug,
watch=watch,
drain_timeout=0,
simulate_job=SimulateJobInfo(room=room, participant_identity=participant_identity),
)
global CLI_ARGUMENTS
CLI_ARGUMENTS = args
_run.run_dev(args)
@cli.command(help="Download plugin dependency files")
@click.option(
"--log-level",
default="DEBUG",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False),
help="Set the logging level",
)
def download_files(log_level: str) -> None:
setup_logging(log_level, True, False)
for plugin in Plugin.registered_plugins:
logger.info(f"Downloading files for {plugin}")
plugin.download_files()
logger.info(f"Finished downloading files for {plugin}")
cli()
from __future__ import annotations
import json
import logging
import re
import sys
import traceback
from collections import OrderedDict
from datetime import date, datetime, time, timezone
from inspect import istraceback
from typing import Any
from ..plugin import Plugin
# noisy loggers are set to warn by default
NOISY_LOGGERS = [
"httpx",
"httpcore",
"openai",
"watchfiles",
"anthropic",
"websockets.client",
"aiohttp.access",
"livekit",
"botocore",
"aiobotocore",
]
def _silence_noisy_loggers() -> None:
for noisy_logger in NOISY_LOGGERS:
logger = logging.getLogger(noisy_logger)
if logger.level == logging.NOTSET:
logger.setLevel(logging.WARN)
# skip default LogRecord attributes
# http://docs.python.org/library/logging.html#logrecord-attributes
_RESERVED_ATTRS: tuple[str, ...] = (
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
"taskName",
)
def _merge_record_extra(record: logging.LogRecord, target: dict[Any, Any]):
for key, value in record.__dict__.items():
if key not in _RESERVED_ATTRS and not (hasattr(key, "startswith") and key.startswith("_")):
target[key] = value
def _parse_style(formatter: logging.Formatter) -> list[str]:
"""parse the list of fields required by the style"""
if isinstance(formatter._style, logging.StringTemplateStyle):
formatter_style_pattern = re.compile(r"\$\{(.+?)\}", re.IGNORECASE)
elif isinstance(formatter._style, logging.StrFormatStyle):
formatter_style_pattern = re.compile(r"\{(.+?)\}", re.IGNORECASE)
elif isinstance(formatter._style, logging.PercentStyle):
formatter_style_pattern = re.compile(r"%\((.+?)\)", re.IGNORECASE)
else:
raise ValueError(f"Invalid format: {formatter._fmt}")
if formatter._fmt:
return formatter_style_pattern.findall(formatter._fmt)
else:
return []
class JsonFormatter(logging.Formatter):
class JsonEncoder(json.JSONEncoder):
def default(self, o: Any):
if isinstance(o, (date, datetime, time)):
return o.isoformat()
elif istraceback(o):
return "".join(traceback.format_tb(o)).strip()
elif type(o) is Exception or isinstance(o, Exception) or type(o) is type:
return str(o)
# extra values are formatted as str() if the encoder raises TypeError
try:
return super().default(o)
except TypeError:
try:
return str(o)
except Exception:
return None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._required_fields = _parse_style(self)
def format(self, record: logging.LogRecord) -> str:
"""Formats a log record and serializes to json"""
message_dict: dict[str, Any] = {}
message_dict["level"] = record.levelname
message_dict["name"] = record.name
if isinstance(record.msg, dict):
message_dict = record.msg
record.message = ""
else:
record.message = record.getMessage()
if "asctime" in self._required_fields:
record.asctime = self.formatTime(record, self.datefmt)
if record.exc_info and not message_dict.get("exc_info"):
message_dict["exc_info"] = self.formatException(record.exc_info)
if not message_dict.get("exc_info") and record.exc_text:
message_dict["exc_info"] = record.exc_text
if record.stack_info and not message_dict.get("stack_info"):
message_dict["stack_info"] = self.formatStack(record.stack_info)
log_record: dict[str, Any] = OrderedDict()
for field in self._required_fields:
log_record[field] = record.__dict__.get(field)
log_record.update(message_dict)
_merge_record_extra(record, log_record)
log_record["timestamp"] = datetime.fromtimestamp(record.created, tz=timezone.utc)
return json.dumps(log_record, cls=JsonFormatter.JsonEncoder, ensure_ascii=True)
class ColoredFormatter(logging.Formatter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._esc_codes = {
"esc_reset": self._esc(0),
"esc_red": self._esc(31),
"esc_green": self._esc(32),
"esc_yellow": self._esc(33),
"esc_blue": self._esc(34),
"esc_purple": self._esc(35),
"esc_cyan": self._esc(36),
"esc_gray": self._esc(90),
"esc_bold_red": self._esc(1, 31),
}
self._level_colors = {
"DEBUG": self._esc_codes["esc_cyan"],
"INFO": self._esc_codes["esc_green"],
"WARNING": self._esc_codes["esc_yellow"],
"ERROR": self._esc_codes["esc_red"],
"CRITICAL": self._esc_codes["esc_bold_red"],
"DEV": self._esc_codes["esc_purple"],
}
self._required_fields = _parse_style(self)
@classmethod
def _esc(cls, *codes: int) -> str:
return "\033[" + ";".join(str(code) for code in codes) + "m"
def formatMessage(self, record: logging.LogRecord) -> str:
"""Formats a log record with colors"""
extra: dict[Any, Any] = {}
_merge_record_extra(record, extra)
args = {}
for field in self._required_fields:
args[field] = record.__dict__.get(field)
args["esc_levelcolor"] = self._level_colors.get(record.levelname, "")
args["extra"] = ""
args.update(self._esc_codes)
if extra:
args["extra"] = json.dumps(extra, cls=JsonFormatter.JsonEncoder, ensure_ascii=True)
for field in self._required_fields:
if field in extra:
del extra[field]
msg = self._style._fmt % args
return msg + self._esc_codes["esc_reset"]
def setup_logging(log_level: str, devmode: bool, console: bool) -> None:
root = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
if devmode:
# colorful logs for dev (improves readability)
if console:
# reset the line before each log message
colored_formatter = ColoredFormatter(
"\r%(asctime)s - %(esc_levelcolor)s%(levelname)-4s%(esc_reset)s %(name)s - %(message)s %(esc_gray)s%(extra)s" # noqa: E501
)
else:
colored_formatter = ColoredFormatter(
"%(asctime)s - %(esc_levelcolor)s%(levelname)-4s%(esc_reset)s %(name)s - %(message)s %(esc_gray)s%(extra)s" # noqa: E501
)
handler.setFormatter(colored_formatter)
else:
# production logs (serialized of json)
json_formatter = JsonFormatter()
handler.setFormatter(json_formatter)
root.addHandler(handler)
root.setLevel(log_level)
_silence_noisy_loggers()
from ..log import logger
if logger.level == logging.NOTSET:
logger.setLevel(log_level)
def _configure_plugin_logger(plugin: Plugin) -> None:
if plugin.logger is not None and plugin.logger.level == logging.NOTSET:
plugin.logger.setLevel(log_level)
for plugin in Plugin.registered_plugins:
_configure_plugin_logger(plugin)
Plugin.emitter.on("plugin_registered", _configure_plugin_logger)
from __future__ import annotations # noqa: I001
import io
import socket
from dataclasses import dataclass, field
from typing import ClassVar
from livekit.protocol import agent
from ..ipc import channel
from ..job import JobAcceptArguments, RunningJobInfo
from ..worker import WorkerOptions, SimulateJobInfo
@dataclass
class CliArgs:
opts: WorkerOptions
log_level: str
devmode: bool
asyncio_debug: bool
watch: bool
drain_timeout: int
console: bool = False
# whether to run the worker in console mode (console subcommand
# register the worker to the worker pool
register: bool = True
simulate_job: SimulateJobInfo | str | None = None
# amount of time this worker has been reloaded
reload_count: int = 0
# pipe used for the communication between the watch server and the watch client
# when reload/dev mode is enabled
mp_cch: socket.socket | None = None
@dataclass
class ActiveJobsRequest:
MSG_ID: ClassVar[int] = 1
@dataclass
class ActiveJobsResponse:
MSG_ID: ClassVar[int] = 2
jobs: list[RunningJobInfo] = field(default_factory=list)
reload_count: int = 0
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, len(self.jobs))
for running_job in self.jobs:
accept_args = running_job.accept_arguments
channel.write_bytes(b, running_job.job.SerializeToString())
channel.write_string(b, accept_args.name)
channel.write_string(b, accept_args.identity)
channel.write_string(b, accept_args.metadata)
channel.write_string(b, running_job.url)
channel.write_string(b, running_job.token)
channel.write_string(b, running_job.worker_id)
channel.write_int(b, self.reload_count)
def read(self, b: io.BytesIO) -> None:
for _ in range(channel.read_int(b)):
job = agent.Job()
job.ParseFromString(channel.read_bytes(b))
self.jobs.append(
RunningJobInfo(
accept_arguments=JobAcceptArguments(
name=channel.read_string(b),
identity=channel.read_string(b),
metadata=channel.read_string(b),
),
job=job,
url=channel.read_string(b),
token=channel.read_string(b),
worker_id=channel.read_string(b),
)
)
self.reload_count = channel.read_int(b)
@dataclass
class ReloadJobsRequest:
MSG_ID: ClassVar[int] = 3
@dataclass
class ReloadJobsResponse(ActiveJobsResponse):
MSG_ID: ClassVar[int] = 4
@dataclass
class Reloaded:
MSG_ID: ClassVar[int] = 5
IPC_MESSAGES = {
ActiveJobsRequest.MSG_ID: ActiveJobsRequest,
ActiveJobsResponse.MSG_ID: ActiveJobsResponse,
ReloadJobsRequest.MSG_ID: ReloadJobsRequest,
ReloadJobsResponse.MSG_ID: ReloadJobsResponse,
Reloaded.MSG_ID: Reloaded,
}
from __future__ import annotations
import asyncio
import contextlib
import json
import pathlib
import socket
import urllib.parse
import urllib.request
from importlib.metadata import Distribution, PackageNotFoundError
from typing import Any, Callable
import watchfiles
from .. import utils
from ..ipc import channel
from ..log import DEV_LEVEL, logger
from ..plugin import Plugin
from ..worker import Worker
from . import proto
def _find_watchable_paths(main_file: pathlib.Path) -> list[pathlib.Path]:
packages: list[Distribution] = []
# also watch agents plugins in editable mode
def _try_add(name: str) -> bool:
nonlocal packages
try:
dist = Distribution.from_name(name)
packages.append(dist)
return True
except PackageNotFoundError:
return False
if not _try_add("livekit.agents"):
_try_add("livekit-agents")
for plugin in Plugin.registered_plugins:
if not _try_add(plugin.package):
_try_add(plugin.package.replace(".", "-"))
paths: list[pathlib.Path] = [main_file.absolute()]
for pkg in packages:
# https://packaging.python.org/en/latest/specifications/direct-url/
durl = pkg.read_text("direct_url.json")
if not durl:
continue
durl_json: dict[str, Any] = json.loads(durl)
dir_info = durl_json.get("dir_info", {})
if dir_info.get("editable", False):
path: str | None = durl_json.get("url")
if path and path.startswith("file://"):
parsed_url = urllib.parse.urlparse(path)
file_url_path = urllib.parse.unquote(parsed_url.path)
local_path = urllib.request.url2pathname(file_url_path)
file_path = pathlib.Path(local_path)
paths.append(file_path)
return paths
class WatchServer:
def __init__(
self,
worker_runner: Callable[[proto.CliArgs], Any],
main_file: pathlib.Path,
cli_args: proto.CliArgs,
loop: asyncio.AbstractEventLoop,
) -> None:
self._mp_pch, cli_args.mp_cch = socket.socketpair()
self._cli_args = cli_args
self._worker_runner = worker_runner
self._main_file = main_file
self._loop = loop
self._recv_jobs_fut = asyncio.Future[None]()
self._worker_reloading = False
async def run(self) -> None:
watch_paths = _find_watchable_paths(self._main_file)
for pth in watch_paths:
logger.log(DEV_LEVEL, f"Watching {pth}")
self._pch = await utils.aio.duplex_unix._AsyncDuplex.open(self._mp_pch)
read_ipc_task = self._loop.create_task(self._read_ipc_task())
try:
await watchfiles.arun_process(
*watch_paths,
target=self._worker_runner,
args=(self._cli_args,),
watch_filter=watchfiles.filters.PythonFilter(),
callback=self._on_reload,
)
finally:
await utils.aio.cancel_and_wait(read_ipc_task)
await self._pch.aclose()
async def _on_reload(self, _: set[watchfiles.main.FileChange]) -> None:
if self._worker_reloading:
return
self._worker_reloading = True
try:
await channel.asend_message(self._pch, proto.ActiveJobsRequest())
self._recv_jobs_fut = asyncio.Future()
with contextlib.suppress(asyncio.TimeoutError):
# wait max 1.5s to get the active jobs
await asyncio.wait_for(self._recv_jobs_fut, timeout=1.5)
finally:
self._cli_args.reload_count += 1
@utils.log_exceptions(logger=logger)
async def _read_ipc_task(self) -> None:
active_jobs = []
while True:
msg = await channel.arecv_message(self._pch, proto.IPC_MESSAGES)
if isinstance(msg, proto.ActiveJobsResponse):
if msg.reload_count != self._cli_args.reload_count:
continue
active_jobs = msg.jobs
with contextlib.suppress(asyncio.InvalidStateError):
self._recv_jobs_fut.set_result(None)
if isinstance(msg, proto.ReloadJobsRequest):
await channel.asend_message(self._pch, proto.ReloadJobsResponse(jobs=active_jobs))
if isinstance(msg, proto.Reloaded):
self._worker_reloading = False
class WatchClient:
def __init__(
self,
worker: Worker,
cli_args: proto.CliArgs,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._loop = loop or asyncio.get_event_loop()
self._worker = worker
self._cli_args = cli_args
def start(self) -> None:
self._main_task = self._loop.create_task(self._run())
@utils.log_exceptions(logger=logger)
async def _run(self) -> None:
assert self._cli_args.mp_cch
try:
self._cch = await utils.aio.duplex_unix._AsyncDuplex.open(self._cli_args.mp_cch)
await channel.asend_message(self._cch, proto.ReloadJobsRequest())
while True:
try:
msg = await channel.arecv_message(self._cch, proto.IPC_MESSAGES)
except utils.aio.duplex_unix.DuplexClosed:
break
if isinstance(msg, proto.ActiveJobsRequest):
jobs = self._worker.active_jobs
await channel.asend_message(
self._cch,
proto.ActiveJobsResponse(
jobs=jobs, reload_count=self._cli_args.reload_count
),
)
elif isinstance(msg, proto.ReloadJobsResponse):
# TODO(theomonnom): wait for the worker to be fully initialized/connected
await self._worker._reload_jobs(msg.jobs)
await channel.asend_message(self._cch, proto.Reloaded())
except utils.aio.duplex_unix.DuplexClosed:
pass
async def aclose(self) -> None:
if not self._main_task:
return
self._main_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._main_task
await self._cch.aclose()
from .tracing import Tracing, TracingGraph, TracingHandle
__all__ = [
"Tracing",
"TracingGraph",
"TracingHandle",
]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>lkagents - tracing</title>
<style>
body {
font-family: sans-serif;
margin: 8px;
padding: 0;
}
.section {
padding: 8px;
font-size: 0.9em;
margin-top: 8px;
}
.collapsible-title {
display: block;
cursor: pointer;
user-select: none;
}
.collapsible-title::before {
content: "▶ ";
}
.collapsible-title.expanded::before {
content: "▼ ";
}
.collapsible-content {
display: none;
margin-left: 20px;
/* optional indent for nested content */
}
.nested-collapsible-title {}
.nested-collapsible-content {}
.horizontal-group {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.refresh-icon {
font-size: 16px;
font-weight: bold;
margin-right: 4px;
}
canvas {
border: 1px solid #ccc;
}
.graph-title {
font-weight: bold;
margin-top: 8px;
}
</style>
</head>
<body>
<!-- Worker Section -->
<div class="section">
<div class="horizontal-group">
<h2 style="margin: 0 8px 0 0">Worker</h2>
<button onclick="refreshWorker()">
<span class="refresh-icon">⟳</span>Refresh
</button>
</div>
<div id="workerSection"></div>
</div>
<!-- Runners List -->
<div class="section">
<div class="horizontal-group">
<h2 style="margin: 0 8px 0 0">Runners</h2>
<button onclick="refreshRunners()">
<span class="refresh-icon">⟳</span>Refresh
</button>
</div>
<div id="runnersList"></div>
</div>
<script>
// Global state to remember which collapsibles are open
// runnerOpenState[runnerId] = { open: true/false, sub: { "Key/Value": bool, "Events": bool }, ... }
// We'll also store 'Worker' as a special ID => runnerOpenState["__WORKER__"] for worker KV / Events
const runnerOpenState = {};
const $ = (id) => document.getElementById(id);
// ------------------------------
// HTTP Utility
// ------------------------------
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error("Network error");
return r.json();
}
// ------------------------------
// Collapsible toggle logic
// ------------------------------
function toggleCollapsible(titleEl, contentEl) {
const isOpen = contentEl.style.display === "block";
contentEl.style.display = isOpen ? "none" : "block";
titleEl.classList.toggle("expanded", !isOpen);
}
// Re-apply state if we know something should be open
function applyOpenState(titleEl, contentEl, open) {
if (open) {
contentEl.style.display = "block";
titleEl.classList.add("expanded");
} else {
contentEl.style.display = "none";
titleEl.classList.remove("expanded");
}
}
// ------------------------------
// Time label
// ------------------------------
function timeLabel(val) {
const d = new Date(val * 1000);
let hh = String(d.getHours()).padStart(2, "0");
let mm = String(d.getMinutes()).padStart(2, "0");
let ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
// ------------------------------
// Export Utility
// ------------------------------
function exportEventsToJSON(events) {
const dataStr = JSON.stringify(events, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
// Create a temporary link and auto-click to download
const link = document.createElement("a");
link.href = url;
link.download = "events.json";
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// ------------------------------
// Rendering Tracing Data
// ------------------------------
function renderKeyValue(container, kv) {
const ul = document.createElement("ul");
Object.entries(kv).forEach(([k, v]) => {
const li = document.createElement("li");
li.textContent = `${k}: ${JSON.stringify(v)}`;
ul.appendChild(li);
});
container.appendChild(ul);
}
//
// Keep each event on a single line. Don't show "click to expand" if data is null.
//
function renderEvents(container, events) {
const ul = document.createElement("ul");
events.forEach((e) => {
// Each event => list item
const li = document.createElement("li");
// Create a wrapper span for the event name/time
const titleLine = document.createElement("span");
titleLine.textContent = `${new Date(
e.timestamp * 1000
).toLocaleTimeString()} - ${e.name}`;
li.appendChild(titleLine);
// Only show the collapsible "Data" button if e.data is not null
if (e.data != null) {
const dataTitle = document.createElement("span");
dataTitle.style.fontSize = "0.8em";
dataTitle.style.marginLeft = "10px";
dataTitle.style.cursor = "pointer";
dataTitle.textContent = "[Data (click to expand)]";
// Collapsible content block (hidden by default)
const dataContent = document.createElement("div");
dataContent.className =
"collapsible-content nested-collapsible-content";
dataContent.style.display = "none";
// Pretty-print JSON with 2-space indentation
const pre = document.createElement("pre");
pre.textContent = JSON.stringify(e.data, null, 2);
dataContent.appendChild(pre);
li.appendChild(dataTitle);
li.appendChild(dataContent);
// Wire up the click event to toggle the data display
dataTitle.addEventListener("click", () => {
toggleCollapsible(dataTitle, dataContent);
});
}
ul.appendChild(li);
});
container.appendChild(ul);
}
function drawGraph(canvas, g) {
const ctx = canvas.getContext("2d");
const w = canvas.width,
h = canvas.height,
pad = 40;
ctx.clearRect(0, 0, w, h);
if (!g.data?.length) {
ctx.fillText("No data", w / 2 - 20, h / 2);
return;
}
const xs = g.data.map((d) => d[0]);
const ys = g.data.map((d) => d[1]);
let [minX, maxX] = [Math.min(...xs), Math.max(...xs)];
if (minX === maxX) [minX, maxX] = [0, 1];
let [minY, maxY] = [Math.min(...ys), Math.max(...ys)];
if (g.y_range) [minY, maxY] = g.y_range;
else if (minY === maxY) [minY, maxY] = [0, 1];
// Axes
ctx.strokeStyle = "#000";
ctx.beginPath();
ctx.moveTo(pad, h - pad);
ctx.lineTo(w - pad, h - pad);
ctx.moveTo(pad, pad);
ctx.lineTo(pad, h - pad);
ctx.stroke();
const pw = w - 2 * pad,
ph = h - 2 * pad;
const toCX = (x) => pad + (x - minX) * (pw / (maxX - minX));
const toCY = (y) => h - pad - (y - minY) * (ph / (maxY - minY));
// Graph line
ctx.strokeStyle = "red";
ctx.beginPath();
ctx.moveTo(toCX(xs[0]), toCY(ys[0]));
for (let i = 1; i < xs.length; i++) {
ctx.lineTo(toCX(xs[i]), toCY(ys[i]));
}
ctx.stroke();
// Ticks
ctx.strokeStyle = "#000";
ctx.fillStyle = "#000";
ctx.font = "10px sans-serif";
// X
for (let i = 0; i <= 5; i++) {
let vx = minX + (i * (maxX - minX)) / 5;
let cx = toCX(vx),
cy = h - pad;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx, cy + 5);
ctx.stroke();
let label = g.x_type === "time" ? timeLabel(vx) : vx.toFixed(2);
let tw = ctx.measureText(label).width;
ctx.fillText(label, cx - tw / 2, cy + 15);
}
// Y
for (let i = 0; i <= 5; i++) {
let vy = minY + (i * (maxY - minY)) / 5;
let cx = pad,
cy = toCY(vy);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx - 5, cy);
ctx.stroke();
let lbl = vy.toFixed(2),
tw = ctx.measureText(lbl).width;
ctx.fillText(lbl, cx - tw - 6, cy + 3);
}
// Labels
if (g.x_label) {
let tw = ctx.measureText(g.x_label).width;
ctx.fillText(g.x_label, w / 2 - tw / 2, h - 5);
}
if (g.y_label) {
ctx.save();
ctx.translate(10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText(g.y_label, 0, 0);
ctx.restore();
}
}
function renderGraphs(container, graphs) {
graphs.forEach((g) => {
const gt = document.createElement("div");
gt.className = "graph-title";
gt.innerText = g.title;
container.appendChild(gt);
const c = document.createElement("canvas");
c.width = 400;
c.height = 200;
container.appendChild(c);
drawGraph(c, g);
});
}
// Render top-level Key/Value, Events, Graphs
function renderTracing(container, tracing, runnerId = "__WORKER__") {
if (!tracing) {
container.textContent = "No tracing data";
return;
}
// Key/Value
if (tracing.kv) {
const kvTitle = document.createElement("div");
kvTitle.className = "collapsible-title nested-collapsible-title";
kvTitle.innerText = "Key/Value";
container.appendChild(kvTitle);
const kvContent = document.createElement("div");
kvContent.className =
"collapsible-content nested-collapsible-content";
container.appendChild(kvContent);
// Ensure the open state matches what we have in runnerOpenState
let subKey = "Key/Value";
applyOpenState(
kvTitle,
kvContent,
getSubSectionOpen(runnerId, subKey)
);
kvTitle.onclick = () => {
toggleCollapsible(kvTitle, kvContent);
setSubSectionOpen(
runnerId,
subKey,
kvContent.style.display === "block"
);
};
renderKeyValue(kvContent, tracing.kv);
}
// Events
if (tracing.events) {
const eTitle = document.createElement("div");
eTitle.className = "collapsible-title nested-collapsible-title";
eTitle.innerText = "Events";
container.appendChild(eTitle);
const eContent = document.createElement("div");
eContent.className = "collapsible-content nested-collapsible-content";
container.appendChild(eContent);
let subKey = "Events";
applyOpenState(eTitle, eContent, getSubSectionOpen(runnerId, subKey));
eTitle.onclick = () => {
toggleCollapsible(eTitle, eContent);
setSubSectionOpen(
runnerId,
subKey,
eContent.style.display === "block"
);
};
// Create a button to export the events to JSON
const exportBtn = document.createElement("button");
exportBtn.textContent = "Export Events to JSON";
exportBtn.style.marginBottom = "8px";
exportBtn.onclick = () => exportEventsToJSON(tracing.events);
eContent.appendChild(exportBtn);
// Render the events
renderEvents(eContent, tracing.events);
}
// Graphs
if (tracing.graph) {
renderGraphs(container, tracing.graph);
}
}
// ------------------------------
// Global State Accessors
// ------------------------------
function getRunnerState(id) {
if (!runnerOpenState[id]) {
runnerOpenState[id] = { open: false, sub: {} };
}
return runnerOpenState[id];
}
function isRunnerOpen(id) {
return getRunnerState(id).open;
}
function setRunnerOpen(id, open) {
getRunnerState(id).open = open;
}
function getSubSectionOpen(runnerId, subsection) {
return getRunnerState(runnerId).sub[subsection] === true;
}
function setSubSectionOpen(runnerId, subsection, open) {
getRunnerState(runnerId).sub[subsection] = open;
}
// ------------------------------
// Worker
// ------------------------------
async function refreshWorker() {
const sec = $("workerSection");
sec.textContent = "Loading...";
try {
const data = await fetchJSON("/debug/worker/");
sec.innerHTML = "";
renderTracing(sec, data.tracing, "__WORKER__"); // use a special ID
} catch (e) {
sec.textContent = "Error: " + e;
}
}
// ------------------------------
// Runners
// ------------------------------
async function refreshRunners() {
const rl = $("runnersList");
rl.textContent = "Loading...";
try {
const data = await fetchJSON("/debug/runners/");
rl.innerHTML = "";
data.runners.forEach((r) => {
const runnerId = String(r.id);
const wrap = document.createElement("div");
wrap.style.marginBottom = "16px";
// Collapsible runner title
const title = document.createElement("div");
title.className = "collapsible-title";
title.innerText = `room: ${r.room} — status: ${r.status}, job_id: ${r.job_id} ${r.id}`;
wrap.appendChild(title);
// Collapsible content
const content = document.createElement("div");
content.className = "collapsible-content";
wrap.appendChild(content);
// Apply saved open state from runnerOpenState
applyOpenState(title, content, isRunnerOpen(runnerId));
// On title click => toggle + fetch details (only if we open)
title.onclick = async () => {
if (content.style.display !== "block") {
// about to open
content.textContent = "Loading...";
toggleCollapsible(title, content);
setRunnerOpen(runnerId, true);
await fetchRunnerDetails(runnerId, content);
} else {
// about to close
toggleCollapsible(title, content);
setRunnerOpen(runnerId, false);
}
};
rl.appendChild(wrap);
// If runner is open from before, we fetch details right away
if (isRunnerOpen(runnerId)) {
fetchRunnerDetails(runnerId, content);
}
});
} catch (e) {
rl.textContent = "Error: " + e;
}
}
async function fetchRunnerDetails(id, container) {
try {
const data = await fetchJSON(
`/debug/runner/?id=${encodeURIComponent(id)}`
);
container.innerHTML = "";
const dataDiv = document.createElement("div");
container.appendChild(dataDiv);
await loadRunnerTracing(id, dataDiv);
} catch (e) {
container.textContent = "Error: " + e;
}
}
async function loadRunnerTracing(id, container) {
try {
const d = await fetchJSON(
`/debug/runner/?id=${encodeURIComponent(id)}`
);
container.innerHTML = "";
renderTracing(container, d.tracing, id);
} catch (e) {
container.textContent = "Error: " + e;
}
}
// Initial calls
refreshWorker();
refreshRunners();
</script>
</body>
</html>
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any, Literal
from aiohttp import web
from .. import job
if TYPE_CHECKING:
from ..worker import Worker
class TracingGraph:
def __init__(
self,
title: str,
y_label: str,
x_label: str,
y_range: tuple[float, float] | None,
x_type: Literal["time", "value"],
max_data_points: int,
) -> None:
self._title = title
self._y_label = y_label
self._x_label = x_label
self._y_range = y_range
self._max_data_points = max_data_points
self._x_type = x_type
self._data: list[tuple[float | int, float]] = []
def plot(self, x: float | int, y: float) -> None:
self._data.append((x, y))
if len(self._data) > self._max_data_points:
self._data.pop(0)
class TracingHandle:
def __init__(self) -> None:
self._kv = {}
self._events: list[dict] = []
self._graphs: list[TracingGraph] = []
def store_kv(self, key: str, value: str | dict) -> None:
self._kv[key] = value
def log_event(self, name: str, data: dict | None) -> None:
self._events.append({"name": name, "data": data, "timestamp": time.time()})
def add_graph(
self,
*,
title: str,
x_label: str,
y_label: str,
y_range: tuple[float, float] | None = None,
x_type: Literal["time", "value"] = "value",
max_data_points: int = 512,
) -> TracingGraph:
graph = TracingGraph(title, y_label, x_label, y_range, x_type, max_data_points)
self._graphs.append(graph)
return graph
def _export(self) -> dict[str, Any]:
return {
"kv": self._kv,
"events": self._events,
"graph": [
{
"title": chart._title,
"x_label": chart._x_label,
"y_label": chart._y_label,
"y_range": chart._y_range,
"x_type": chart._x_type,
"data": chart._data,
}
for chart in self._graphs
],
}
class Tracing:
_instance = None
def __init__(self):
self._handles: dict[str, TracingHandle] = {}
@classmethod
def with_handle(cls, handle: str) -> TracingHandle:
if cls._instance is None:
cls._instance = cls()
if handle not in cls._instance._handles:
cls._instance._handles[handle] = TracingHandle()
return cls._instance._handles[handle]
@staticmethod
def _get_current_handle() -> TracingHandle:
try:
job_id = job.get_job_context().job.id
return Tracing._get_job_handle(job_id)
except RuntimeError:
pass
return Tracing.with_handle("global")
@staticmethod
def _get_job_handle(job_id: str) -> TracingHandle:
return Tracing.with_handle(f"job_{job_id}")
@staticmethod
def store_kv(key: str, value: str | dict) -> None:
Tracing._get_current_handle().store_kv(key, value)
@staticmethod
def log_event(name: str, data: dict | None = None) -> None:
Tracing._get_current_handle().log_event(name, data)
@staticmethod
def add_graph(
*,
title: str,
x_label: str,
y_label: str,
y_range: tuple[float, float] | None = None,
x_type: Literal["time", "value"] = "value",
max_data_points: int = 512,
) -> TracingGraph:
return Tracing._get_current_handle().add_graph(
title=title,
x_label=x_label,
y_label=y_label,
y_range=y_range,
x_type=x_type,
max_data_points=max_data_points,
)
def _create_tracing_app(w: Worker) -> web.Application:
async def tracing_index(request: web.Request) -> web.Response:
import importlib.resources
import aiofiles
with importlib.resources.path("livekit.agents.debug", "index.html") as path:
async with aiofiles.open(path) as f:
content = await f.read()
return web.Response(text=content, content_type="text/html")
async def runners(request: web.Request) -> web.Response:
data = {
"runners": [
{
"id": runner.id,
"status": runner.status.name,
"job_id": runner.running_job.job.id if runner.running_job else None,
"room": runner.running_job.job.room.name if runner.running_job else None,
}
for runner in w._proc_pool.processes
if runner.started and runner.running_job
]
}
return web.json_response(data)
async def runner(request: web.Request) -> web.Response:
runner_id = request.query.get("id")
if not runner_id:
return web.Response(status=400)
# TODO: avoid
runner = next((r for r in w._proc_pool.processes if r.id == runner_id), None)
if not runner:
return web.Response(status=404)
info = await asyncio.wait_for(runner.tracing_info(), timeout=5.0) # proc could be stuck
return web.json_response({"tracing": info})
async def worker(request: web.Request) -> web.Response:
return web.json_response(
{
"id": w.id,
"tracing": Tracing.with_handle("global")._export(),
}
)
app = web.Application()
app.add_routes([web.get("", tracing_index)])
app.add_routes([web.get("/", tracing_index)])
app.add_routes([web.get("/runners/", runners)])
app.add_routes([web.get("/runner/", runner)])
app.add_routes([web.get("/worker/", worker)])
return app
from __future__ import annotations
import asyncio
from aiohttp import web
class HttpServer:
def __init__(self, host: str, port: int, loop: asyncio.AbstractEventLoop | None = None) -> None:
self._loop = loop or asyncio.get_event_loop()
self._host = host
self._port = port
self._app = web.Application(loop=self._loop)
self._lock = asyncio.Lock()
@property
def app(self) -> web.Application:
return self._app
@property
def port(self) -> int:
return self._port
async def start(self) -> None:
async with self._lock:
handler = self._app.make_handler()
self._server = await self._loop.create_server(handler, self._host, self._port)
if self._port == 0:
self._port = self._server.sockets[0].getsockname()[1]
await self._server.start_serving()
async def aclose(self) -> None:
async with self._lock:
self._server.close()
await self._server.wait_closed()
from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from typing import ClassVar, Protocol
class _RunnerMeta(Protocol):
INFERENCE_METHOD: ClassVar[str]
_RunnersDict = dict[str, type["_InferenceRunner"]]
# kept private until we stabilize the API (only used for EOU today)
class _InferenceRunner(ABC, _RunnerMeta):
registered_runners: _RunnersDict = {}
@classmethod
def register_runner(cls, runner_class: type[_InferenceRunner]) -> None:
if threading.current_thread() != threading.main_thread():
raise RuntimeError("InferenceRunner must be registered on the main thread")
if runner_class.INFERENCE_METHOD in cls.registered_runners:
raise ValueError(f"InferenceRunner {runner_class.INFERENCE_METHOD} already registered")
cls.registered_runners[runner_class.INFERENCE_METHOD] = runner_class
@abstractmethod
def initialize(self) -> None:
"""Initialize the runner. This is used to load models, etc."""
...
@abstractmethod
def run(self, data: bytes) -> bytes | None:
"""Run inference on the given data."""
...
from . import (
channel,
inference_proc_executor,
job_executor,
job_proc_executor,
job_thread_executor,
proc_pool,
proto,
)
__all__ = [
"proto",
"channel",
"proc_pool",
"job_proc_executor",
"job_thread_executor",
"inference_proc_executor",
"job_executor",
]
from __future__ import annotations
import io
import struct
from typing import ClassVar, Protocol, runtime_checkable
from .. import utils
class Message(Protocol):
MSG_ID: ClassVar[int]
@runtime_checkable
class DataMessage(Message, Protocol):
def write(self, b: io.BytesIO) -> None: ...
def read(self, b: io.BytesIO) -> None: ...
MessagesDict = dict[int, type[Message]]
def _read_message(data: bytes, messages: MessagesDict) -> Message:
bio = io.BytesIO(data)
msg_id = read_int(bio)
msg = messages[msg_id]()
if isinstance(msg, DataMessage):
msg.read(bio)
return msg
def _write_message(msg: Message) -> bytes:
bio = io.BytesIO()
write_int(bio, msg.MSG_ID)
if isinstance(msg, DataMessage):
msg.write(bio)
return bio.getvalue()
async def arecv_message(
dplx: utils.aio.duplex_unix._AsyncDuplex, messages: MessagesDict
) -> Message:
return _read_message(await dplx.recv_bytes(), messages)
async def asend_message(dplx: utils.aio.duplex_unix._AsyncDuplex, msg: Message) -> None:
await dplx.send_bytes(_write_message(msg))
def recv_message(dplx: utils.aio.duplex_unix._Duplex, messages: MessagesDict) -> Message:
return _read_message(dplx.recv_bytes(), messages)
def send_message(dplx: utils.aio.duplex_unix._Duplex, msg: Message) -> None:
dplx.send_bytes(_write_message(msg))
def write_bytes(b: io.BytesIO, buf: bytes) -> None:
b.write(len(buf).to_bytes(4, "big"))
b.write(buf)
def read_bytes(b: io.BytesIO) -> bytes:
length = int.from_bytes(b.read(4), "big")
return b.read(length)
def write_string(b: io.BytesIO, s: str) -> None:
encoded = s.encode("utf-8")
b.write(len(encoded).to_bytes(4, "big"))
b.write(encoded)
def read_string(b: io.BytesIO) -> str:
length = int.from_bytes(b.read(4), "big")
return b.read(length).decode("utf-8")
def write_int(b: io.BytesIO, i: int) -> None:
b.write(i.to_bytes(4, "big"))
def read_int(b: io.BytesIO) -> int:
return int.from_bytes(b.read(4), "big")
def write_bool(b: io.BytesIO, bi: bool) -> None:
b.write(bi.to_bytes(1, "big"))
def read_bool(b: io.BytesIO) -> bool:
return bool.from_bytes(b.read(1), "big")
def write_float(b: io.BytesIO, f: float) -> None:
b.write(struct.pack("f", f))
def read_float(b: io.BytesIO) -> float:
return struct.unpack("f", b.read(4))[0]
def write_double(b: io.BytesIO, d: float) -> None:
b.write(struct.pack("d", d))
def read_double(b: io.BytesIO) -> float:
return struct.unpack("d", b.read(8))[0]
def write_long(b: io.BytesIO, long: int) -> None:
b.write(long.to_bytes(8, "big"))
def read_long(b: io.BytesIO) -> int:
return int.from_bytes(b.read(8), "big")
from __future__ import annotations
from typing import Protocol
class InferenceExecutor(Protocol):
async def do_inference(self, method: str, data: bytes) -> bytes | None: ...
from __future__ import annotations
import asyncio
import contextlib
import multiprocessing as mp
import socket
from multiprocessing.context import BaseContext
from ..inference_runner import _RunnersDict
from ..log import logger
from ..utils import aio, log_exceptions, shortuuid
from . import channel, proto
from .inference_proc_lazy_main import ProcStartArgs, proc_main
from .supervised_proc import SupervisedProc
class InferenceProcExecutor(SupervisedProc):
def __init__(
self,
*,
runners: _RunnersDict,
initialize_timeout: float,
close_timeout: float,
memory_warn_mb: float,
memory_limit_mb: float,
ping_interval: float,
ping_timeout: float,
high_ping_threshold: float,
mp_ctx: BaseContext,
loop: asyncio.AbstractEventLoop,
http_proxy: str | None,
) -> None:
super().__init__(
initialize_timeout=initialize_timeout,
close_timeout=close_timeout,
memory_warn_mb=memory_warn_mb,
memory_limit_mb=memory_limit_mb,
ping_interval=ping_interval,
ping_timeout=ping_timeout,
high_ping_threshold=high_ping_threshold,
mp_ctx=mp_ctx,
loop=loop,
http_proxy=http_proxy,
)
self._runners = runners
self._active_requests: dict[str, asyncio.Future[proto.InferenceResponse]] = {}
def _create_process(self, cch: socket.socket, log_cch: socket.socket) -> mp.Process:
proc_args = ProcStartArgs(
log_cch=log_cch,
mp_cch=cch,
runners=self._runners,
)
return self._mp_ctx.Process( # type: ignore
target=proc_main,
args=(proc_args,),
name="inference_proc",
)
@log_exceptions(logger=logger)
async def _main_task(self, ipc_ch: aio.ChanReceiver[channel.Message]) -> None:
async for msg in ipc_ch:
if isinstance(msg, proto.InferenceResponse):
fut = self._active_requests.pop(msg.request_id, None)
if fut is None:
logger.warning(
"received unexpected inference response",
extra={"request_id": msg.request_id},
)
return
with contextlib.suppress(asyncio.InvalidStateError):
fut.set_result(msg)
async def do_inference(self, method: str, data: bytes) -> bytes | None:
if not self.started:
raise RuntimeError("process not started")
request_id = shortuuid("inference_req_")
fut = asyncio.Future[proto.InferenceResponse]()
await channel.asend_message(
self._pch,
proto.InferenceRequest(request_id=request_id, method=method, data=data),
)
self._active_requests[request_id] = fut
inf_resp = await fut
if inf_resp.error:
raise RuntimeError(f"inference of {method} failed: {inf_resp.error}")
return inf_resp.data
def logging_extra(self):
extra = super().logging_extra()
extra["inference"] = True
return extra
from multiprocessing import current_process
if current_process().name == "inference_proc":
import signal
import sys
# ignore signals in the inference process (the parent process will handle them)
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
def _no_traceback_excepthook(exc_type, exc_val, traceback):
if isinstance(exc_val, KeyboardInterrupt):
return
sys.__excepthook__(exc_type, exc_val, traceback)
sys.excepthook = _no_traceback_excepthook
import asyncio
import socket
from dataclasses import dataclass
from ..inference_runner import _RunnersDict
from ..log import logger
from ..utils import aio, log_exceptions
from . import proto
from .channel import Message
from .proc_client import _ProcClient
@dataclass
class ProcStartArgs:
log_cch: socket.socket
mp_cch: socket.socket
runners: _RunnersDict
def proc_main(args: ProcStartArgs) -> None:
from .proc_client import _ProcClient
inf_proc = _InferenceProc(args.runners)
client = _ProcClient(
args.mp_cch,
args.log_cch,
inf_proc.initialize,
inf_proc.entrypoint,
)
client.initialize_logger()
pid = current_process().pid
logger.info("initializing inference process", extra={"pid": pid})
client.initialize()
logger.info("inference process initialized", extra={"pid": pid})
client.run()
class _InferenceProc:
def __init__(self, runners: _RunnersDict) -> None:
# create an instance of each runner (the ctor must not requires any argument)
self._runners = {name: runner() for name, runner in runners.items()}
def initialize(self, init_req: proto.InitializeRequest, client: _ProcClient) -> None:
self._client = client
for runner in self._runners.values():
logger.debug(
"initializing inference runner",
extra={"runner": runner.__class__.INFERENCE_METHOD},
)
runner.initialize()
@log_exceptions(logger=logger)
async def entrypoint(self, cch: aio.ChanReceiver[Message]) -> None:
async for msg in cch:
if isinstance(msg, proto.InferenceRequest):
await self._handle_inference_request(msg)
if isinstance(msg, proto.ShutdownRequest):
await self._client.send(proto.Exiting(reason=msg.reason))
break
async def _handle_inference_request(self, msg: proto.InferenceRequest) -> None:
loop = asyncio.get_running_loop()
if msg.method not in self._runners:
logger.warning("unknown inference method", extra={"method": msg.method})
try:
data = await loop.run_in_executor(None, self._runners[msg.method].run, msg.data)
await self._client.send(
proto.InferenceResponse(
request_id=msg.request_id,
data=data,
)
)
except Exception as e:
logger.exception("error running inference")
await self._client.send(
proto.InferenceResponse(request_id=msg.request_id, error=str(e))
)
from __future__ import annotations
from enum import Enum
from typing import Any, Protocol
from ..job import RunningJobInfo
class JobExecutor(Protocol):
@property
def id(self) -> str: ...
@property
def started(self) -> bool: ...
@property
def user_arguments(self) -> Any | None: ...
@user_arguments.setter
def user_arguments(self, value: Any | None) -> None: ...
@property
def running_job(self) -> RunningJobInfo | None: ...
@property
def status(self) -> JobStatus: ...
async def start(self) -> None: ...
async def join(self) -> None: ...
async def initialize(self) -> None: ...
async def aclose(self) -> None: ...
async def launch_job(self, info: RunningJobInfo) -> None: ...
async def tracing_info(self) -> dict[str, Any]: ...
class JobStatus(Enum):
RUNNING = "running"
FAILED = "failed"
SUCCESS = "success"
from __future__ import annotations
import asyncio
import contextlib
import multiprocessing as mp
import socket
from collections.abc import Awaitable
from multiprocessing.context import BaseContext
from typing import Any, Callable
from ..job import JobContext, JobProcess, RunningJobInfo
from ..log import logger
from ..utils import aio, log_exceptions, shortuuid
from . import channel, proto
from .inference_executor import InferenceExecutor
from .job_executor import JobStatus
from .job_proc_lazy_main import ProcStartArgs, proc_main
from .supervised_proc import SupervisedProc
class ProcJobExecutor(SupervisedProc):
def __init__(
self,
*,
initialize_process_fnc: Callable[[JobProcess], Any],
job_entrypoint_fnc: Callable[[JobContext], Awaitable[None]],
inference_executor: InferenceExecutor | None,
initialize_timeout: float,
close_timeout: float,
memory_warn_mb: float,
memory_limit_mb: float,
ping_interval: float,
ping_timeout: float,
high_ping_threshold: float,
http_proxy: str | None,
mp_ctx: BaseContext,
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__(
initialize_timeout=initialize_timeout,
close_timeout=close_timeout,
memory_warn_mb=memory_warn_mb,
memory_limit_mb=memory_limit_mb,
ping_interval=ping_interval,
ping_timeout=ping_timeout,
high_ping_threshold=high_ping_threshold,
mp_ctx=mp_ctx,
loop=loop,
http_proxy=http_proxy,
)
self._user_args: Any | None = None
self._job_status: JobStatus | None = None
self._running_job: RunningJobInfo | None = None
self._initialize_process_fnc = initialize_process_fnc
self._job_entrypoint_fnc = job_entrypoint_fnc
self._inference_executor = inference_executor
self._inference_tasks: list[asyncio.Task[None]] = []
self._id = shortuuid("PCEXEC_")
self._tracing_requests = dict[str, asyncio.Future[proto.TracingResponse]]()
@property
def id(self) -> str:
return self._id
async def tracing_info(self) -> dict[str, Any]:
if not self.started:
raise RuntimeError("process not started")
tracing_req = proto.TracingRequest()
tracing_req.request_id = shortuuid("trace_req_")
fut = asyncio.Future[proto.TracingResponse]()
self._tracing_requests[tracing_req.request_id] = fut
await channel.asend_message(self._pch, tracing_req)
resp = await fut
return resp.info
@property
def status(self) -> JobStatus:
if self._job_status is None:
raise RuntimeError("job status not available")
return self._job_status
@property
def user_arguments(self) -> Any | None:
return self._user_args
@user_arguments.setter
def user_arguments(self, value: Any | None) -> None:
self._user_args = value
@property
def running_job(self) -> RunningJobInfo | None:
return self._running_job
def _create_process(self, cch: socket.socket, log_cch: socket.socket) -> mp.Process:
proc_args = ProcStartArgs(
initialize_process_fnc=self._initialize_process_fnc,
job_entrypoint_fnc=self._job_entrypoint_fnc,
log_cch=log_cch,
mp_cch=cch,
user_arguments=self._user_args,
)
return self._mp_ctx.Process( # type: ignore
target=proc_main,
args=(proc_args,),
name="job_proc",
)
@log_exceptions(logger=logger)
async def _main_task(self, ipc_ch: aio.ChanReceiver[channel.Message]) -> None:
try:
async for msg in ipc_ch:
if isinstance(msg, proto.InferenceRequest):
self._inference_tasks.append(asyncio.create_task(self._do_inference_task(msg)))
elif isinstance(msg, proto.TracingResponse):
fut = self._tracing_requests.pop(msg.request_id)
with contextlib.suppress(asyncio.InvalidStateError):
fut.set_result(msg)
finally:
await aio.cancel_and_wait(*self._inference_tasks)
@log_exceptions(logger=logger)
async def _supervise_task(self) -> None:
try:
await super()._supervise_task()
finally:
self._job_status = JobStatus.SUCCESS if self.exitcode == 0 else JobStatus.FAILED
async def _do_inference_task(self, inf_req: proto.InferenceRequest) -> None:
if self._inference_executor is None:
logger.warning("inference request received but no inference executor")
await channel.asend_message(
self._pch,
proto.InferenceResponse(
request_id=inf_req.request_id, error="no inference executor"
),
)
return
try:
inf_res = await self._inference_executor.do_inference(inf_req.method, inf_req.data)
await channel.asend_message(
self._pch,
proto.InferenceResponse(request_id=inf_req.request_id, data=inf_res),
)
except Exception as e:
await channel.asend_message(
self._pch,
proto.InferenceResponse(request_id=inf_req.request_id, error=str(e)),
)
async def launch_job(self, info: RunningJobInfo) -> None:
"""start/assign a job to the process"""
if self._running_job is not None:
raise RuntimeError("process already has a running job")
if not self._initialize_fut.done():
raise RuntimeError("process not initialized")
self._job_status = JobStatus.RUNNING
self._running_job = info
start_req = proto.StartJobRequest()
start_req.running_job = info
await channel.asend_message(self._pch, start_req)
def logging_extra(self):
extra = super().logging_extra()
if self._running_job:
extra["job_id"] = self._running_job.job.id
return extra
from __future__ import annotations
from multiprocessing import current_process
if current_process().name == "job_proc":
import signal
import sys
# ignore signals in the jobs process (the parent process will handle them)
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
def _no_traceback_excepthook(exc_type, exc_val, traceback):
if isinstance(exc_val, KeyboardInterrupt):
return
sys.__excepthook__(exc_type, exc_val, traceback)
sys.excepthook = _no_traceback_excepthook
import asyncio
import contextlib
import socket
import threading
from dataclasses import dataclass
from typing import Any, Callable
from livekit import rtc
from ..cli import cli
from ..debug import tracing
from ..job import JobContext, JobExecutorType, JobProcess, _JobContextVar
from ..log import logger
from ..utils import aio, http_context, log_exceptions, shortuuid
from .channel import Message
from .inference_executor import InferenceExecutor
from .proc_client import _ProcClient
from .proto import (
Exiting,
InferenceRequest,
InferenceResponse,
InitializeRequest,
ShutdownRequest,
StartJobRequest,
TracingRequest,
TracingResponse,
)
@dataclass
class ProcStartArgs:
initialize_process_fnc: Callable[[JobProcess], Any]
job_entrypoint_fnc: Callable[[JobContext], Any]
mp_cch: socket.socket
log_cch: socket.socket
user_arguments: Any | None = None
def proc_main(args: ProcStartArgs) -> None:
from .proc_client import _ProcClient
job_proc = _JobProc(
args.initialize_process_fnc,
args.job_entrypoint_fnc,
JobExecutorType.PROCESS,
args.user_arguments,
)
client = _ProcClient(
args.mp_cch,
args.log_cch,
job_proc.initialize,
job_proc.entrypoint,
)
client.initialize_logger()
pid = current_process().pid
logger.info("initializing job process", extra={"pid": pid})
try:
client.initialize()
except Exception:
return # initialization failed, exit
logger.info("job process initialized", extra={"pid": pid})
client.run()
class _InfClient(InferenceExecutor):
def __init__(self, proc_client: _ProcClient) -> None:
self._client = proc_client
self._active_requests: dict[str, asyncio.Future[InferenceResponse]] = {}
async def do_inference(self, method: str, data: bytes) -> bytes | None:
request_id = shortuuid("inference_job_")
fut = asyncio.Future[InferenceResponse]()
await self._client.send(
InferenceRequest(request_id=request_id, method=method, data=data),
)
self._active_requests[request_id] = fut
inf_resp = await fut
if inf_resp.error:
raise RuntimeError(f"inference of {method} failed: {inf_resp.error}")
return inf_resp.data
def _on_inference_response(self, resp: InferenceResponse) -> None:
fut = self._active_requests.pop(resp.request_id, None)
if fut is None:
logger.warning("received unexpected inference response", extra={"resp": resp})
return
with contextlib.suppress(asyncio.InvalidStateError):
fut.set_result(resp)
@dataclass
class _ShutdownInfo:
user_initiated: bool
reason: str
class _JobProc:
def __init__(
self,
initialize_process_fnc: Callable[[JobProcess], Any],
job_entrypoint_fnc: Callable[[JobContext], Any],
executor_type: JobExecutorType,
user_arguments: Any | None = None,
) -> None:
self._executor_type = executor_type
self._user_arguments = user_arguments
self._initialize_process_fnc = initialize_process_fnc
self._job_entrypoint_fnc = job_entrypoint_fnc
self._job_task: asyncio.Task | None = None
# used to warn users if both connect and shutdown are not called inside the job_entry
self._ctx_connect_called = False
self._ctx_shutdown_called = False
@property
def has_running_job(self) -> bool:
return self._job_task is not None
def initialize(self, init_req: InitializeRequest, client: _ProcClient) -> None:
self._client = client
self._inf_client = _InfClient(client)
self._job_proc = JobProcess(
executor_type=self._executor_type,
user_arguments=self._user_arguments,
http_proxy=init_req.http_proxy or None,
)
self._initialize_process_fnc(self._job_proc)
@log_exceptions(logger=logger)
async def entrypoint(self, cch: aio.ChanReceiver[Message]) -> None:
self._exit_proc_flag = asyncio.Event()
self._shutdown_fut: asyncio.Future[_ShutdownInfo] = asyncio.Future()
@log_exceptions(logger=logger)
async def _read_ipc_task():
async for msg in cch:
if isinstance(msg, StartJobRequest):
if self.has_running_job:
logger.warning("trying to start a new job while one is already running")
continue
self._start_job(msg)
if isinstance(msg, ShutdownRequest):
if not self.has_running_job:
self._exit_proc_flag.set()
break # exit immediately
with contextlib.suppress(asyncio.InvalidStateError):
self._shutdown_fut.set_result(
_ShutdownInfo(reason=msg.reason, user_initiated=False)
)
if isinstance(msg, InferenceResponse):
self._inf_client._on_inference_response(msg)
if isinstance(msg, TracingRequest):
if not self.has_running_job:
logger.warning("tracing request received without running job")
return
try:
job_ctx_token = _JobContextVar.set(self._job_ctx)
tracing_tasks = []
for callback in self._job_ctx._tracing_callbacks:
tracing_tasks.append(
asyncio.create_task(callback(), name="job_tracing_callback")
)
await asyncio.gather(*tracing_tasks)
_JobContextVar.reset(job_ctx_token)
except Exception:
logger.exception("error while exeuting tracing tasks")
await self._client.send(
TracingResponse(
request_id=msg.request_id,
info=tracing.Tracing._get_job_handle(self._job_ctx.job.id)._export(),
)
)
read_task = asyncio.create_task(_read_ipc_task(), name="job_ipc_read")
await self._exit_proc_flag.wait()
await aio.cancel_and_wait(read_task)
def _start_job(self, msg: StartJobRequest) -> None:
if cli.CLI_ARGUMENTS is not None and cli.CLI_ARGUMENTS.console:
from .mock_room import MockRoom
self._room = MockRoom
else:
self._room = rtc.Room()
@self._room.on("disconnected")
def _on_room_disconnected(*args):
with contextlib.suppress(asyncio.InvalidStateError):
self._shutdown_fut.set_result(
_ShutdownInfo(user_initiated=False, reason="room disconnected")
)
def _on_ctx_connect() -> None:
self._ctx_connect_called = True
def _on_ctx_shutdown(reason: str) -> None:
self._ctx_shutdown_called = True
with contextlib.suppress(asyncio.InvalidStateError):
self._shutdown_fut.set_result(_ShutdownInfo(user_initiated=True, reason=reason))
self._room._info.name = msg.running_job.job.room.name
self._job_ctx = JobContext(
proc=self._job_proc,
info=msg.running_job,
room=self._room,
on_connect=_on_ctx_connect,
on_shutdown=_on_ctx_shutdown,
inference_executor=self._inf_client,
)
self._job_task = asyncio.create_task(self._run_job_task(), name="job_task")
def _exit_proc_cb(_: asyncio.Task) -> None:
self._exit_proc_flag.set()
self._job_task.add_done_callback(_exit_proc_cb)
async def _run_job_task(self) -> None:
job_ctx_token = _JobContextVar.set(self._job_ctx)
http_context._new_session_ctx()
job_entry_task = asyncio.create_task(
self._job_entrypoint_fnc(self._job_ctx), name="job_user_entrypoint"
)
async def _warn_not_connected_task():
if cli.CLI_ARGUMENTS is not None and cli.CLI_ARGUMENTS.console:
return
await asyncio.sleep(10)
if not self._ctx_connect_called and not self._ctx_shutdown_called:
logger.warning(
"The room connection was not established within 10 seconds after calling job_entry. " # noqa: E501
"This may indicate that job_ctx.connect() was not called. "
)
warn_unconnected_task = asyncio.create_task(_warn_not_connected_task())
job_entry_task.add_done_callback(lambda _: warn_unconnected_task.cancel())
def log_exception(t: asyncio.Task) -> None:
if not t.cancelled() and t.exception():
logger.error(
"unhandled exception while running the job task",
exc_info=t.exception(),
)
elif not self._ctx_connect_called and not self._ctx_shutdown_called:
if cli.CLI_ARGUMENTS is not None and cli.CLI_ARGUMENTS.console:
return
logger.warning(
"The job task completed without establishing a connection or performing a proper shutdown. " # noqa: E501
"Ensure that job_ctx.connect()/job_ctx.shutdown() is called and the job is correctly finalized." # noqa: E501
)
job_entry_task.add_done_callback(log_exception)
shutdown_info = await self._shutdown_fut
logger.debug(
"shutting down job task",
extra={
"reason": shutdown_info.reason,
"user_initiated": shutdown_info.user_initiated,
},
)
await self._client.send(Exiting(reason=shutdown_info.reason))
await self._room.disconnect()
try:
shutdown_tasks = []
for callback in self._job_ctx._shutdown_callbacks:
shutdown_tasks.append(
asyncio.create_task(
callback(shutdown_info.reason), name="job_shutdown_callback"
)
)
await asyncio.gather(*shutdown_tasks)
except Exception:
logger.exception("error while shutting down the job")
await http_context._close_http_ctx()
_JobContextVar.reset(job_ctx_token)
@dataclass
class ThreadStartArgs:
initialize_process_fnc: Callable[[JobProcess], Any]
job_entrypoint_fnc: Callable[[JobContext], Any]
join_fnc: Callable[[], None]
mp_cch: socket.socket
user_arguments: Any | None
def thread_main(
args: ThreadStartArgs,
) -> None:
"""main function for the job process when using the ThreadedJobRunner"""
tid = threading.get_native_id()
try:
from .proc_client import _ProcClient
job_proc = _JobProc(
args.initialize_process_fnc,
args.job_entrypoint_fnc,
JobExecutorType.THREAD,
args.user_arguments,
)
client = _ProcClient(
args.mp_cch,
None,
job_proc.initialize,
job_proc.entrypoint,
)
logger.info("initializing job runner", extra={"tid": tid})
client.initialize()
logger.info("job runner initialized", extra={"tid": tid})
client.run()
finally:
args.join_fnc()
from __future__ import annotations
import asyncio
import contextlib
import socket
import threading
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import Any, Callable
from .. import utils
from ..job import JobContext, JobProcess, RunningJobInfo
from ..log import logger
from ..utils.aio import duplex_unix
from . import channel, job_proc_lazy_main, proto
from .inference_executor import InferenceExecutor
from .job_executor import JobStatus
@dataclass
class _ProcOpts:
initialize_process_fnc: Callable[[JobProcess], Any]
job_entrypoint_fnc: Callable[[JobContext], Awaitable[None]]
initialize_timeout: float
close_timeout: float
ping_interval: float
high_ping_threshold: float
http_proxy: str | None
class ThreadJobExecutor:
def __init__(
self,
*,
initialize_process_fnc: Callable[[JobProcess], Any],
job_entrypoint_fnc: Callable[[JobContext], Awaitable[None]],
inference_executor: InferenceExecutor | None,
initialize_timeout: float,
close_timeout: float,
ping_interval: float,
high_ping_threshold: float,
http_proxy: str | None,
loop: asyncio.AbstractEventLoop,
) -> None:
self._loop = loop
self._opts = _ProcOpts(
initialize_process_fnc=initialize_process_fnc,
job_entrypoint_fnc=job_entrypoint_fnc,
initialize_timeout=initialize_timeout,
close_timeout=close_timeout,
ping_interval=ping_interval,
high_ping_threshold=high_ping_threshold,
http_proxy=http_proxy,
)
self._user_args: Any | None = None
self._job_status: JobStatus | None = None
self._running_job: RunningJobInfo | None = None
self._main_atask: asyncio.Task[None] | None = None
self._initialize_fut = asyncio.Future[None]()
self._closing = False
self._lock = asyncio.Lock()
self._inference_executor = inference_executor
self._inference_tasks: list[asyncio.Task[None]] = []
self._id = utils.shortuuid("THEXEC_")
self._tracing_requests = dict[str, asyncio.Future[proto.TracingResponse]]()
@property
def id(self) -> str:
return self._id
async def tracing_info(self) -> dict[str, Any]:
if not self.started:
raise RuntimeError("thread not started")
tracing_req = proto.TracingRequest()
tracing_req.request_id = utils.shortuuid("trace_req_")
fut = asyncio.Future[proto.TracingResponse]()
self._tracing_requests[tracing_req.request_id] = fut
await channel.asend_message(self._pch, tracing_req)
resp = await fut
return resp.info
@property
def status(self) -> JobStatus:
if self._job_status is None:
raise RuntimeError("job status not available")
return self._job_status
@property
def started(self) -> bool:
return self._main_atask is not None
@property
def user_arguments(self) -> Any | None:
return self._user_args
@user_arguments.setter
def user_arguments(self, value: Any | None) -> None:
self._user_args = value
@property
def running_job(self) -> RunningJobInfo | None:
return self._running_job
async def start(self) -> None:
if self.started:
raise RuntimeError("runner already started")
if self._closing:
raise RuntimeError("runner is closed")
await asyncio.shield(self._start())
async def _start(self) -> None:
async with self._lock:
# to simplify the runners implementation, we also use a duplex in the threaded executor
# (ThreadedRunners), so we can use the same protocol
mp_pch, mp_cch = socket.socketpair()
self._pch = await duplex_unix._AsyncDuplex.open(mp_pch)
self._join_fut = asyncio.Future[None]()
def _on_join() -> None:
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(self._join_fut.set_result, None)
targs = job_proc_lazy_main.ThreadStartArgs(
mp_cch=mp_cch,
initialize_process_fnc=self._opts.initialize_process_fnc,
job_entrypoint_fnc=self._opts.job_entrypoint_fnc,
user_arguments=self._user_args,
join_fnc=_on_join,
)
self._thread = t = threading.Thread(
target=job_proc_lazy_main.thread_main,
args=(targs,),
name="job_thread_runner",
)
t.start()
self._main_atask = asyncio.create_task(self._main_task())
async def join(self) -> None:
"""wait for the thread to finish"""
if not self.started:
raise RuntimeError("runner not started")
async with self._lock:
if self._main_atask:
await asyncio.shield(self._main_atask)
async def initialize(self) -> None:
await channel.asend_message(
self._pch, proto.InitializeRequest(http_proxy=self._opts.http_proxy or "")
)
try:
init_res = await asyncio.wait_for(
channel.arecv_message(self._pch, proto.IPC_MESSAGES),
timeout=self._opts.initialize_timeout,
)
assert isinstance(init_res, proto.InitializeResponse), (
"first message must be InitializeResponse"
)
except asyncio.TimeoutError:
self._initialize_fut.set_exception(
asyncio.TimeoutError("runner initialization timed out")
)
logger.error(
"job initialization is taking too much time..",
extra=self.logging_extra(),
)
raise
except Exception as e: # should be channel.ChannelClosed most of the time
self._initialize_fut.set_exception(e)
raise
else:
self._initialize_fut.set_result(None)
async def aclose(self) -> None:
"""
attempt to gracefully close the job. warn if it takes too long to close
(in the threaded executor, the job can't be "killed")
"""
if not self.started:
return
self._closing = True
with contextlib.suppress(utils.aio.duplex_unix.DuplexClosed):
await channel.asend_message(self._pch, proto.ShutdownRequest())
try:
if self._main_atask:
await asyncio.wait_for(
asyncio.shield(self._main_atask), timeout=self._opts.close_timeout
)
except asyncio.TimeoutError:
logger.error("job shutdown is taking too much time..", extra=self.logging_extra())
async with self._lock:
if self._main_atask:
await asyncio.shield(self._main_atask)
async def _do_inference_task(self, inf_req: proto.InferenceRequest) -> None:
if self._inference_executor is None:
logger.warning("inference request received but no inference executor")
await channel.asend_message(
self._pch,
proto.InferenceResponse(
request_id=inf_req.request_id, error="no inference executor"
),
)
return
try:
inf_res = await self._inference_executor.do_inference(inf_req.method, inf_req.data)
await channel.asend_message(
self._pch,
proto.InferenceResponse(request_id=inf_req.request_id, data=inf_res),
)
except Exception as e:
await channel.asend_message(
self._pch,
proto.InferenceResponse(request_id=inf_req.request_id, error=str(e)),
)
async def launch_job(self, info: RunningJobInfo) -> None:
"""start/assign a job to the executor"""
if self._running_job is not None:
raise RuntimeError("executor already has a running job")
if not self._initialize_fut.done():
raise RuntimeError("executor not initialized")
self._running_job = info
self._job_status = JobStatus.RUNNING
start_req = proto.StartJobRequest()
start_req.running_job = info
await channel.asend_message(self._pch, start_req)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
try:
await self._initialize_fut
except asyncio.TimeoutError:
pass # this happens when the initialization takes longer than self._initialize_timeout
except Exception:
pass # initialization failed
ping_task = asyncio.create_task(self._ping_task())
monitor_task = asyncio.create_task(self._monitor_task())
await self._join_fut
await utils.aio.cancel_and_wait(ping_task, monitor_task)
await utils.aio.cancel_and_wait(*self._inference_tasks)
with contextlib.suppress(duplex_unix.DuplexClosed):
await self._pch.aclose()
self._job_status = JobStatus.SUCCESS
@utils.log_exceptions(logger=logger)
async def _monitor_task(self) -> None:
while True:
try:
msg = await channel.arecv_message(self._pch, proto.IPC_MESSAGES)
except utils.aio.duplex_unix.DuplexClosed:
break
if isinstance(msg, proto.PongResponse):
delay = utils.time_ms() - msg.timestamp
if delay > self._opts.high_ping_threshold * 1000:
logger.warning(
"job executor is unresponsive",
extra={"delay": delay, **self.logging_extra()},
)
if isinstance(msg, proto.Exiting):
logger.debug("job exiting", extra={"reason": msg.reason, **self.logging_extra()})
if isinstance(msg, proto.InferenceRequest):
self._inference_tasks.append(asyncio.create_task(self._do_inference_task(msg)))
if isinstance(msg, proto.TracingResponse):
fut = self._tracing_requests.pop(msg.request_id)
with contextlib.suppress(asyncio.InvalidStateError):
fut.set_result(msg)
@utils.log_exceptions(logger=logger)
async def _ping_task(self) -> None:
ping_interval = utils.aio.interval(self._opts.ping_interval)
while True:
await ping_interval.tick()
try:
await channel.asend_message(self._pch, proto.PingRequest(timestamp=utils.time_ms()))
except utils.aio.duplex_unix.DuplexClosed:
break
def logging_extra(self):
extra: dict[str, Any] = {
"tid": self._thread.native_id,
}
if self._running_job:
extra["job_id"] = self._running_job.job.id
return extra
from __future__ import annotations
import copy
import logging
import pickle
import queue
import sys
import threading
from typing import Callable, Optional
from .. import utils
from ..utils.aio import duplex_unix
class LogQueueListener:
def __init__(
self,
duplex: utils.aio.duplex_unix._Duplex,
prepare_fnc: Callable[[logging.LogRecord], None],
):
self._thread: threading.Thread | None = None
self._duplex = duplex
self._prepare_fnc = prepare_fnc
def start(self) -> None:
self._thread = threading.Thread(target=self._monitor, name="ipc_log_listener")
self._thread.start()
def stop(self) -> None:
if self._thread is None:
return
self._duplex.close()
self._thread.join()
self._thread = None
def handle(self, record: logging.LogRecord) -> None:
self._prepare_fnc(record)
lger = logging.getLogger(record.name)
if not lger.isEnabledFor(record.levelno):
return
lger.callHandlers(record)
def _monitor(self):
while True:
try:
data = self._duplex.recv_bytes()
except utils.aio.duplex_unix.DuplexClosed:
break
record = pickle.loads(data)
self.handle(record)
class LogQueueHandler(logging.Handler):
_sentinal = None
def __init__(self, duplex: utils.aio.duplex_unix._Duplex) -> None:
super().__init__()
self._duplex = duplex
self._send_q = queue.SimpleQueue[Optional[bytes]]()
self._send_thread = threading.Thread(target=self._forward_logs, name="ipc_log_forwarder")
self._send_thread.start()
def _forward_logs(self):
while True:
serialized_record = self._send_q.get()
if serialized_record is None:
break
try:
self._duplex.send_bytes(serialized_record)
except duplex_unix.DuplexClosed:
break
self._duplex.close()
def emit(self, record: logging.LogRecord) -> None:
try:
# Check if Python is shutting down
if sys.is_finalizing():
return
# from https://github.com/python/cpython/blob/91b7f2e7f6593acefda4fa860250dd87d6f849bf/Lib/logging/handlers.py#L1453
msg = self.format(record)
record = copy.copy(record)
record.message = msg
record.msg = msg
record.args = None
record.exc_info = None
record.exc_text = None
record.stack_info = None
# https://websockets.readthedocs.io/en/stable/topics/logging.html#logging-to-json
# webosckets library add "websocket" attribute to log records, which is not pickleable
if hasattr(record, "websocket"):
record.websocket = None
self._send_q.put_nowait(pickle.dumps(record))
except Exception:
self.handleError(record)
def close(self) -> None:
super().close()
self._send_q.put_nowait(self._sentinal)
from unittest.mock import create_autospec
from livekit import rtc
MockRoom = create_autospec(rtc.Room, instance=True)
MockRoom.local_participant = create_autospec(rtc.LocalParticipant, instance=True)
MockRoom._info = create_autospec(rtc.room.proto_room.RoomInfo, instance=True)
MockRoom.isconnected.return_value = True
MockRoom.name = "fake_room"
mock_remote_participant = create_autospec(rtc.RemoteParticipant, instance=True)
mock_remote_participant.identity = "fake_human"
mock_remote_participant.sid = "PA_fake_human"
mock_remote_participant.kind = rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD
MockRoom.remote_participants = {mock_remote_participant.sid: mock_remote_participant}
if __name__ == "__main__":
mock_room = MockRoom
print("local_participant", mock_room.local_participant)
print("isconnected", mock_room.isconnected())
print("remote_participants", mock_room.remote_participants)
from __future__ import annotations
import asyncio
import contextlib
import logging
import socket
import sys
from collections.abc import Coroutine
from typing import Callable
from ..log import logger
from ..utils import aio, log_exceptions, time_ms
from .channel import Message, arecv_message, asend_message, recv_message, send_message
from .log_queue import LogQueueHandler
from .proto import (
IPC_MESSAGES,
InitializeRequest,
InitializeResponse,
PingRequest,
PongResponse,
)
class _ProcClient:
def __init__(
self,
mp_cch: socket.socket,
log_cch: socket.socket | None,
initialize_fnc: Callable[[InitializeRequest, _ProcClient], None],
main_task_fnc: Callable[[aio.ChanReceiver[Message]], Coroutine[None, None, None]],
) -> None:
self._mp_cch = mp_cch
self._log_cch = log_cch
self._initialize_fnc = initialize_fnc
self._main_task_fnc = main_task_fnc
self._initialized = False
self._log_handler: LogQueueHandler | None = None
def initialize_logger(self) -> None:
if self._log_cch is None:
raise RuntimeError("cannot initialize logger without log channel")
root_logger = logging.getLogger()
root_logger.setLevel(logging.NOTSET)
log_cch = aio.duplex_unix._Duplex.open(self._log_cch)
self._log_handler = LogQueueHandler(log_cch)
root_logger.addHandler(self._log_handler)
def initialize(self) -> None:
try:
cch = aio.duplex_unix._Duplex.open(self._mp_cch)
first_req = recv_message(cch, IPC_MESSAGES)
assert isinstance(first_req, InitializeRequest), (
"first message must be proto.InitializeRequest"
)
self._init_req = first_req
try:
self._initialize_fnc(self._init_req, self)
send_message(cch, InitializeResponse())
except Exception as e:
send_message(cch, InitializeResponse(error=str(e)))
raise
self._initialized = True
cch.detach()
except aio.duplex_unix.DuplexClosed as e:
raise RuntimeError("failed to initialize proc_client") from e
def run(self) -> None:
if not self._initialized:
raise RuntimeError("proc_client not initialized")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.set_debug(self._init_req.asyncio_debug)
loop.slow_callback_duration = 0.1 # 100ms
aio.debug.hook_slow_callbacks(2.0)
try:
self._task = loop.create_task(self._monitor_task(), name="proc_client_main")
while not self._task.done():
try:
loop.run_until_complete(self._task)
except KeyboardInterrupt:
# ignore the keyboard interrupt, we handle the process shutdown ourselves on the worker process # noqa: E501
# (See proto.ShutdownRequest)
pass
except KeyboardInterrupt:
pass
finally:
if self._log_handler is not None:
self._log_handler.close()
loop.run_until_complete(loop.shutdown_default_executor())
async def send(self, msg: Message) -> None:
await asend_message(self._acch, msg)
async def _monitor_task(self) -> None:
self._acch = await aio.duplex_unix._AsyncDuplex.open(self._mp_cch)
try:
exit_flag = asyncio.Event()
ping_timeout = aio.sleep(self._init_req.ping_timeout)
ipc_ch = aio.Chan[Message]()
@log_exceptions(logger=logger)
async def _read_ipc_task():
while True:
try:
msg = await arecv_message(self._acch, IPC_MESSAGES)
except aio.duplex_unix.DuplexClosed:
break
with contextlib.suppress(aio.SleepFinished):
ping_timeout.reset()
if isinstance(msg, PingRequest):
await asend_message(
self._acch,
PongResponse(last_timestamp=msg.timestamp, timestamp=time_ms()),
)
ipc_ch.send_nowait(msg)
@log_exceptions(logger=logger)
async def _self_health_check():
await ping_timeout
print(
"worker process is not responding.. worker crashed?",
file=sys.stderr,
)
read_task = asyncio.create_task(_read_ipc_task(), name="ipc_read")
health_check_task: asyncio.Task | None = None
if self._init_req.ping_interval > 0:
health_check_task = asyncio.create_task(_self_health_check(), name="health_check")
main_task = asyncio.create_task(
self._main_task_fnc(ipc_ch), name="main_task_entrypoint"
)
def _done_cb(_: asyncio.Task) -> None:
with contextlib.suppress(asyncio.InvalidStateError):
exit_flag.set()
ipc_ch.close()
read_task.add_done_callback(_done_cb)
if health_check_task is not None:
health_check_task.add_done_callback(_done_cb)
main_task.add_done_callback(_done_cb)
await exit_flag.wait()
await aio.cancel_and_wait(read_task, main_task)
if health_check_task is not None:
await aio.cancel_and_wait(health_check_task)
finally:
await self._acch.aclose()
from __future__ import annotations
import asyncio
import math
from collections.abc import Awaitable
from multiprocessing.context import BaseContext
from typing import Any, Callable, Literal
from .. import utils
from ..job import JobContext, JobExecutorType, JobProcess, RunningJobInfo
from ..log import logger
from ..utils import aio
from ..utils.hw.cpu import get_cpu_monitor
from . import inference_executor, job_proc_executor, job_thread_executor
from .job_executor import JobExecutor
EventTypes = Literal[
"process_created",
"process_started",
"process_ready",
"process_closed",
"process_job_launched",
]
MAX_CONCURRENT_INITIALIZATIONS = math.ceil(get_cpu_monitor().cpu_count())
class ProcPool(utils.EventEmitter[EventTypes]):
def __init__(
self,
*,
initialize_process_fnc: Callable[[JobProcess], Any],
job_entrypoint_fnc: Callable[[JobContext], Awaitable[None]],
num_idle_processes: int,
initialize_timeout: float,
close_timeout: float,
inference_executor: inference_executor.InferenceExecutor | None,
job_executor_type: JobExecutorType,
mp_ctx: BaseContext,
memory_warn_mb: float,
memory_limit_mb: float,
http_proxy: str | None,
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__()
self._job_executor_type = job_executor_type
self._mp_ctx = mp_ctx
self._initialize_process_fnc = initialize_process_fnc
self._job_entrypoint_fnc = job_entrypoint_fnc
self._close_timeout = close_timeout
self._inf_executor = inference_executor
self._initialize_timeout = initialize_timeout
self._loop = loop
self._memory_limit_mb = memory_limit_mb
self._memory_warn_mb = memory_warn_mb
self._default_num_idle_processes = num_idle_processes
self._http_proxy = http_proxy
self._target_idle_processes = num_idle_processes
self._init_sem = asyncio.Semaphore(MAX_CONCURRENT_INITIALIZATIONS)
self._warmed_proc_queue = asyncio.Queue[JobExecutor]()
self._executors: list[JobExecutor] = []
self._spawn_tasks: set[asyncio.Task] = set()
self._monitor_tasks: set[asyncio.Task] = set()
self._started = False
self._closed = False
self._idle_ready = asyncio.Event()
self._jobs_waiting_for_process = 0
@property
def processes(self) -> list[JobExecutor]:
return self._executors
def get_by_job_id(self, job_id: str) -> JobExecutor | None:
return next(
(x for x in self._executors if x.running_job and x.running_job.job.id == job_id),
None,
)
async def start(self) -> None:
if self._started:
return
self._started = True
self._main_atask = asyncio.create_task(self._main_task())
if self._default_num_idle_processes > 0:
# wait for the idle processes to be warmed up (by the main task)
await self._idle_ready.wait()
async def aclose(self) -> None:
if not self._started:
return
self._closed = True
await aio.cancel_and_wait(self._main_atask)
async def launch_job(self, info: RunningJobInfo) -> None:
self._jobs_waiting_for_process += 1
if (
self._warmed_proc_queue.empty()
and len(self._spawn_tasks) < self._jobs_waiting_for_process
):
# spawn a new process if there are no idle processes
task = asyncio.create_task(self._proc_spawn_task())
self._spawn_tasks.add(task)
task.add_done_callback(self._spawn_tasks.discard)
proc = await self._warmed_proc_queue.get()
self._jobs_waiting_for_process -= 1
await proc.launch_job(info)
self.emit("process_job_launched", proc)
def set_target_idle_processes(self, num_idle_processes: int) -> None:
self._target_idle_processes = num_idle_processes
@property
def target_idle_processes(self) -> int:
return self._target_idle_processes
@utils.log_exceptions(logger=logger)
async def _proc_spawn_task(self) -> None:
proc: JobExecutor
if self._job_executor_type == JobExecutorType.THREAD:
proc = job_thread_executor.ThreadJobExecutor(
initialize_process_fnc=self._initialize_process_fnc,
job_entrypoint_fnc=self._job_entrypoint_fnc,
initialize_timeout=self._initialize_timeout,
close_timeout=self._close_timeout,
inference_executor=self._inf_executor,
ping_interval=2.5,
high_ping_threshold=0.5,
http_proxy=self._http_proxy,
loop=self._loop,
)
elif self._job_executor_type == JobExecutorType.PROCESS:
proc = job_proc_executor.ProcJobExecutor(
initialize_process_fnc=self._initialize_process_fnc,
job_entrypoint_fnc=self._job_entrypoint_fnc,
initialize_timeout=self._initialize_timeout,
close_timeout=self._close_timeout,
inference_executor=self._inf_executor,
mp_ctx=self._mp_ctx,
loop=self._loop,
ping_interval=2.5,
ping_timeout=60,
high_ping_threshold=0.5,
memory_warn_mb=self._memory_warn_mb,
memory_limit_mb=self._memory_limit_mb,
http_proxy=self._http_proxy,
)
else:
raise ValueError(f"unsupported job executor: {self._job_executor_type}")
self._executors.append(proc)
async with self._init_sem:
if self._closed:
self._executors.remove(proc)
return
self.emit("process_created", proc)
await proc.start()
self.emit("process_started", proc)
try:
await proc.initialize()
# process where initialization times out will never fire "process_ready"
# neither be used to launch jobs
self.emit("process_ready", proc)
self._warmed_proc_queue.put_nowait(proc)
if self._warmed_proc_queue.qsize() >= self._default_num_idle_processes:
self._idle_ready.set()
except Exception:
pass
monitor_task = asyncio.create_task(self._monitor_process_task(proc))
self._monitor_tasks.add(monitor_task)
monitor_task.add_done_callback(self._monitor_tasks.discard)
@utils.log_exceptions(logger=logger)
async def _monitor_process_task(self, proc: JobExecutor) -> None:
try:
await proc.join()
self.emit("process_closed", proc)
finally:
self._executors.remove(proc)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
try:
while not self._closed:
current_pending = self._warmed_proc_queue.qsize() + len(self._spawn_tasks)
to_spawn = (
min(self._target_idle_processes, self._default_num_idle_processes)
- current_pending
)
for _ in range(to_spawn):
task = asyncio.create_task(self._proc_spawn_task())
self._spawn_tasks.add(task)
task.add_done_callback(self._spawn_tasks.discard)
await asyncio.sleep(0.1)
except asyncio.CancelledError:
await asyncio.gather(*[proc.aclose() for proc in self._executors])
await asyncio.gather(*self._spawn_tasks)
await asyncio.gather(*self._monitor_tasks)
from __future__ import annotations
import io
import pickle
from dataclasses import dataclass, field
from typing import Any, ClassVar
from livekit.protocol import agent
from ..job import JobAcceptArguments, RunningJobInfo
from . import channel
@dataclass
class InitializeRequest:
"""sent by the main process to the subprocess to initialize it. this is going to call initialize_process_fnc""" # noqa: E501
MSG_ID: ClassVar[int] = 0
asyncio_debug: bool = False
ping_interval: float = 0
ping_timeout: float = 0 # if no response, process is considered dead
# if ping is higher than this, process is considered unresponsive
high_ping_threshold: float = 0
http_proxy: str = "" # empty = None
def write(self, b: io.BytesIO) -> None:
channel.write_bool(b, self.asyncio_debug)
channel.write_float(b, self.ping_interval)
channel.write_float(b, self.ping_timeout)
channel.write_float(b, self.high_ping_threshold)
channel.write_string(b, self.http_proxy)
def read(self, b: io.BytesIO) -> None:
self.asyncio_debug = channel.read_bool(b)
self.ping_interval = channel.read_float(b)
self.ping_timeout = channel.read_float(b)
self.high_ping_threshold = channel.read_float(b)
self.http_proxy = channel.read_string(b)
@dataclass
class InitializeResponse:
"""mark the process as initialized"""
MSG_ID: ClassVar[int] = 1
error: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.error)
def read(self, b: io.BytesIO) -> None:
self.error = channel.read_string(b)
@dataclass
class PingRequest:
"""sent by the main process to the subprocess to check if it is still alive"""
MSG_ID: ClassVar[int] = 2
timestamp: int = 0
def write(self, b: io.BytesIO) -> None:
channel.write_long(b, self.timestamp)
def read(self, b: io.BytesIO) -> None:
self.timestamp = channel.read_long(b)
@dataclass
class PongResponse:
"""response to a PingRequest"""
MSG_ID: ClassVar[int] = 3
last_timestamp: int = 0
timestamp: int = 0
def write(self, b: io.BytesIO) -> None:
channel.write_long(b, self.last_timestamp)
channel.write_long(b, self.timestamp)
def read(self, b: io.BytesIO) -> None:
self.last_timestamp = channel.read_long(b)
self.timestamp = channel.read_long(b)
@dataclass
class StartJobRequest:
"""sent by the main process to the subprocess to start a job, the subprocess will only
receive this message if the process is fully initialized (after sending a InitializeResponse).""" # noqa: E501
MSG_ID: ClassVar[int] = 4
running_job: RunningJobInfo = field(init=False)
def write(self, b: io.BytesIO) -> None:
accept_args = self.running_job.accept_arguments
channel.write_bytes(b, self.running_job.job.SerializeToString())
channel.write_string(b, accept_args.name)
channel.write_string(b, accept_args.identity)
channel.write_string(b, accept_args.metadata)
channel.write_string(b, self.running_job.url)
channel.write_string(b, self.running_job.token)
channel.write_string(b, self.running_job.worker_id)
def read(self, b: io.BytesIO) -> None:
job = agent.Job()
job.ParseFromString(channel.read_bytes(b))
self.running_job = RunningJobInfo(
accept_arguments=JobAcceptArguments(
name=channel.read_string(b),
identity=channel.read_string(b),
metadata=channel.read_string(b),
),
job=job,
url=channel.read_string(b),
token=channel.read_string(b),
worker_id=channel.read_string(b),
)
@dataclass
class ShutdownRequest:
"""sent by the main process to the subprocess to indicate that it should shut down
gracefully. the subprocess will follow with a ExitInfo message"""
MSG_ID: ClassVar[int] = 5
reason: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.reason)
def read(self, b: io.BytesIO) -> None:
self.reason = channel.read_string(b)
@dataclass
class Exiting:
"""sent by the subprocess to the main process to indicate that it is exiting"""
MSG_ID: ClassVar[int] = 6
reason: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.reason)
def read(self, b: io.BytesIO) -> None:
self.reason = channel.read_string(b)
@dataclass
class InferenceRequest:
"""sent by a subprocess to the main process to request inference"""
MSG_ID: ClassVar[int] = 7
method: str = ""
request_id: str = ""
data: bytes = b""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.method)
channel.write_string(b, self.request_id)
channel.write_bytes(b, self.data)
def read(self, b: io.BytesIO) -> None:
self.method = channel.read_string(b)
self.request_id = channel.read_string(b)
self.data = channel.read_bytes(b)
@dataclass
class InferenceResponse:
"""response to an InferenceRequest"""
MSG_ID: ClassVar[int] = 8
request_id: str = ""
data: bytes | None = None
error: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.request_id)
channel.write_bool(b, self.data is not None)
if self.data is not None:
channel.write_bytes(b, self.data)
channel.write_string(b, self.error)
def read(self, b: io.BytesIO) -> None:
self.request_id = channel.read_string(b)
has_data = channel.read_bool(b)
if has_data:
self.data = channel.read_bytes(b)
self.error = channel.read_string(b)
@dataclass
class TracingRequest:
MSG_ID: ClassVar[int] = 9
request_id: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.request_id)
def read(self, b: io.BytesIO) -> None:
self.request_id = channel.read_string(b)
@dataclass
class TracingResponse:
MSG_ID: ClassVar[int] = 10
request_id: str = ""
info: dict[str, Any] = field(default_factory=dict)
def write(self, b: io.BytesIO) -> None:
channel.write_string(b, self.request_id)
channel.write_bytes(b, pickle.dumps(self.info))
def read(self, b: io.BytesIO) -> None:
self.request_id = channel.read_string(b)
self.info = pickle.loads(channel.read_bytes(b))
IPC_MESSAGES = {
InitializeRequest.MSG_ID: InitializeRequest,
InitializeResponse.MSG_ID: InitializeResponse,
PingRequest.MSG_ID: PingRequest,
PongResponse.MSG_ID: PongResponse,
StartJobRequest.MSG_ID: StartJobRequest,
ShutdownRequest.MSG_ID: ShutdownRequest,
Exiting.MSG_ID: Exiting,
InferenceRequest.MSG_ID: InferenceRequest,
InferenceResponse.MSG_ID: InferenceResponse,
TracingRequest.MSG_ID: TracingRequest,
TracingResponse.MSG_ID: TracingResponse,
}
from __future__ import annotations
import asyncio
import contextlib
import logging
import multiprocessing as mp
import socket
import sys
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from multiprocessing.context import BaseContext
from typing import Any
import psutil
from ..log import logger
from ..utils import aio, log_exceptions, time_ms
from ..utils.aio import duplex_unix
from . import channel, proto
from .log_queue import LogQueueListener
@dataclass
class _ProcOpts:
initialize_timeout: float
close_timeout: float
memory_warn_mb: float
memory_limit_mb: float
ping_interval: float
ping_timeout: float
high_ping_threshold: float
http_proxy: str | None
class SupervisedProc(ABC):
def __init__(
self,
*,
initialize_timeout: float,
close_timeout: float,
memory_warn_mb: float,
memory_limit_mb: float,
ping_interval: float,
ping_timeout: float,
high_ping_threshold: float,
http_proxy: str | None,
mp_ctx: BaseContext,
loop: asyncio.AbstractEventLoop,
) -> None:
self._loop = loop
self._mp_ctx = mp_ctx
self._opts = _ProcOpts(
initialize_timeout=initialize_timeout,
close_timeout=close_timeout,
memory_warn_mb=memory_warn_mb,
memory_limit_mb=memory_limit_mb,
ping_interval=ping_interval,
ping_timeout=ping_timeout,
high_ping_threshold=high_ping_threshold,
http_proxy=http_proxy,
)
self._exitcode: int | None = None
self._pid: int | None = None
self._supervise_atask: asyncio.Task[None] | None = None
self._closing = False
self._kill_sent = False
self._initialize_fut = asyncio.Future[None]()
self._lock = asyncio.Lock()
@abstractmethod
def _create_process(self, cch: socket.socket, log_cch: socket.socket) -> mp.Process: ...
@abstractmethod
async def _main_task(self, ipc_ch: aio.ChanReceiver[channel.Message]) -> None: ...
@property
def exitcode(self) -> int | None:
return self._exitcode
@property
def killed(self) -> bool:
return self._kill_sent
@property
def pid(self) -> int | None:
return self._pid
@property
def started(self) -> bool:
return self._supervise_atask is not None
async def start(self) -> None:
"""start the supervised process"""
if self.started:
raise RuntimeError("process already started")
if self._closing:
raise RuntimeError("process is closed")
await asyncio.shield(self._start())
async def _start(self) -> None:
def _add_proc_ctx_log(record: logging.LogRecord) -> None:
extra = self.logging_extra()
for key, value in extra.items():
setattr(record, key, value)
async with self._lock:
mp_pch, mp_cch = socket.socketpair()
mp_log_pch, mp_log_cch = socket.socketpair()
self._pch = await duplex_unix._AsyncDuplex.open(mp_pch)
log_pch = duplex_unix._Duplex.open(mp_log_pch)
log_listener = LogQueueListener(log_pch, _add_proc_ctx_log)
log_listener.start()
self._proc = self._create_process(mp_cch, mp_log_cch)
await self._loop.run_in_executor(None, self._proc.start)
mp_log_cch.close()
mp_cch.close()
self._pid = self._proc.pid
self._join_fut = asyncio.Future[None]()
def _sync_run():
self._proc.join()
log_listener.stop()
try:
self._loop.call_soon_threadsafe(self._join_fut.set_result, None)
except RuntimeError:
pass
thread = threading.Thread(target=_sync_run, name="proc_join_thread")
thread.start()
self._supervise_atask = asyncio.create_task(self._supervise_task())
async def join(self) -> None:
"""wait for the process to finish"""
if not self.started:
raise RuntimeError("process not started")
if self._supervise_atask:
await asyncio.shield(self._supervise_atask)
async def initialize(self) -> None:
"""initialize the process, this is sending a InitializeRequest message and waiting for a
InitializeResponse with a timeout"""
await channel.asend_message(
self._pch,
proto.InitializeRequest(
asyncio_debug=self._loop.get_debug(),
ping_interval=self._opts.ping_interval,
ping_timeout=self._opts.ping_timeout,
high_ping_threshold=self._opts.high_ping_threshold,
http_proxy=self._opts.http_proxy or "",
),
)
# wait for the process to become ready
try:
init_res = await asyncio.wait_for(
channel.arecv_message(self._pch, proto.IPC_MESSAGES),
timeout=self._opts.initialize_timeout,
)
assert isinstance(init_res, proto.InitializeResponse), (
"first message must be InitializeResponse"
)
if init_res.error:
logger.error(
f"process initialization failed: {init_res.error}",
extra=self.logging_extra(),
)
raise RuntimeError(f"process initialization failed: {init_res.error}")
else:
self._initialize_fut.set_result(None)
except asyncio.TimeoutError:
self._initialize_fut.set_exception(
asyncio.TimeoutError("process initialization timed out")
)
logger.error("initialization timed out, killing process", extra=self.logging_extra())
self._send_kill_signal()
raise
except Exception as e:
# should be channel.ChannelClosed most of the time (or init_res error)
self._initialize_fut.set_exception(e)
raise
async def aclose(self) -> None:
"""attempt to gracefully close the supervised process"""
if not self.started:
return
self._closing = True
with contextlib.suppress(duplex_unix.DuplexClosed):
await channel.asend_message(self._pch, proto.ShutdownRequest())
try:
if self._supervise_atask:
await asyncio.wait_for(
asyncio.shield(self._supervise_atask),
timeout=self._opts.close_timeout,
)
except asyncio.TimeoutError:
logger.error(
"process did not exit in time, killing process",
extra=self.logging_extra(),
)
self._send_kill_signal()
async with self._lock:
if self._supervise_atask:
await asyncio.shield(self._supervise_atask)
async def kill(self) -> None:
"""forcefully kill the supervised process"""
if not self.started:
raise RuntimeError("process not started")
self._closing = True
self._send_kill_signal()
async with self._lock:
if self._supervise_atask:
await asyncio.shield(self._supervise_atask)
def _send_kill_signal(self) -> None:
"""forcefully kill the process"""
try:
if not self._proc.is_alive():
return
except ValueError:
return
logger.info("killing process", extra=self.logging_extra())
if sys.platform == "win32":
self._proc.terminate()
else:
self._proc.kill()
self._kill_sent = True
@log_exceptions(logger=logger)
async def _supervise_task(self) -> None:
try:
await self._initialize_fut
except asyncio.TimeoutError:
pass # this happens when the initialization takes longer than self._initialize_timeout
except Exception:
pass # initialization failed
# the process is killed if it doesn't respond to ping requests
pong_timeout = aio.sleep(self._opts.ping_timeout)
ipc_ch = aio.Chan[channel.Message]()
main_task = asyncio.create_task(self._main_task(ipc_ch))
read_ipc_task = asyncio.create_task(self._read_ipc_task(ipc_ch, pong_timeout))
ping_task = asyncio.create_task(self._ping_pong_task(pong_timeout))
read_ipc_task.add_done_callback(lambda _: ipc_ch.close())
memory_monitor_task: asyncio.Task[None] | None = None
if self._opts.memory_limit_mb > 0 or self._opts.memory_warn_mb > 0:
memory_monitor_task = asyncio.create_task(self._memory_monitor_task())
await self._join_fut
self._exitcode = self._proc.exitcode
self._proc.close()
await aio.cancel_and_wait(ping_task, read_ipc_task, main_task)
if memory_monitor_task is not None:
await aio.cancel_and_wait(memory_monitor_task)
with contextlib.suppress(duplex_unix.DuplexClosed):
await self._pch.aclose()
if self._exitcode != 0 and not self._kill_sent:
logger.error(
f"process exited with non-zero exit code {self.exitcode}",
extra=self.logging_extra(),
)
@log_exceptions(logger=logger)
async def _read_ipc_task(
self, ipc_ch: aio.Chan[channel.Message], pong_timeout: aio.Sleep
) -> None:
while True:
try:
msg = await channel.arecv_message(self._pch, proto.IPC_MESSAGES)
except duplex_unix.DuplexClosed:
break
if isinstance(msg, proto.PongResponse):
delay = time_ms() - msg.timestamp
if delay > self._opts.high_ping_threshold * 1000:
logger.warning(
"process is unresponsive",
extra={"delay": delay, **self.logging_extra()},
)
with contextlib.suppress(aio.SleepFinished):
pong_timeout.reset()
if isinstance(msg, proto.Exiting):
logger.info(
"process exiting",
extra={"reason": msg.reason, **self.logging_extra()},
)
ipc_ch.send_nowait(msg)
@log_exceptions(logger=logger)
async def _ping_pong_task(self, pong_timeout: aio.Sleep) -> None:
ping_interval = aio.interval(self._opts.ping_interval)
async def _send_ping_co():
while True:
await ping_interval.tick()
try:
await channel.asend_message(self._pch, proto.PingRequest(timestamp=time_ms()))
except duplex_unix.DuplexClosed:
break
async def _pong_timeout_co():
await pong_timeout
logger.error("process is unresponsive, killing process", extra=self.logging_extra())
self._send_kill_signal()
tasks = [
asyncio.create_task(_send_ping_co()),
asyncio.create_task(_pong_timeout_co()),
]
try:
await asyncio.gather(*tasks)
finally:
await aio.cancel_and_wait(*tasks)
@log_exceptions(logger=logger)
async def _memory_monitor_task(self) -> None:
"""Monitor memory usage and kill the process if it exceeds the limit."""
while not self._closing and not self._kill_sent:
try:
if not self._pid:
await asyncio.sleep(5)
continue
# get process memory info
process = psutil.Process(self._pid)
memory_info = process.memory_info()
memory_mb = memory_info.rss / (1024 * 1024) # Convert to MB
if self._opts.memory_limit_mb > 0 and memory_mb > self._opts.memory_limit_mb:
logger.error(
"process exceeded memory limit, killing process",
extra={
"memory_usage_mb": memory_mb,
"memory_limit_mb": self._opts.memory_limit_mb,
**self.logging_extra(),
},
)
self._send_kill_signal()
elif self._opts.memory_warn_mb > 0 and memory_mb > self._opts.memory_warn_mb:
logger.warning(
"process memory usage is high",
extra={
"memory_usage_mb": memory_mb,
"memory_warn_mb": self._opts.memory_warn_mb,
"memory_limit_mb": self._opts.memory_limit_mb,
**self.logging_extra(),
},
)
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
if self._closing or self._kill_sent:
return
logger.warning(
"Failed to get memory info for process",
extra=self.logging_extra(),
exc_info=e,
)
# don't bother rechecking if we cannot get process info
return
except Exception:
if self._closing or self._kill_sent:
return
logger.exception(
"Error in memory monitoring task",
extra=self.logging_extra(),
)
await asyncio.sleep(5) # check every 5 seconds
def logging_extra(self):
extra: dict[str, Any] = {
"pid": self.pid,
}
return extra
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextvars
import functools
import logging
import multiprocessing as mp
from collections.abc import Coroutine
from dataclasses import dataclass
from enum import Enum, unique
from typing import Any, Callable
from livekit import api, rtc
from livekit.protocol import agent, models
from .ipc.inference_executor import InferenceExecutor
from .log import logger
from .types import NotGivenOr
from .utils import http_context, wait_for_participant
_JobContextVar = contextvars.ContextVar["JobContext"]("agents_job_context")
def get_job_context() -> JobContext:
ctx = _JobContextVar.get(None)
if ctx is None:
raise RuntimeError(
"no job context found, are you running this code inside a job entrypoint?"
)
return ctx
get_current_job_context = get_job_context
@unique
class JobExecutorType(Enum):
PROCESS = "process"
THREAD = "thread"
class AutoSubscribe(str, Enum):
SUBSCRIBE_ALL = "subscribe_all"
SUBSCRIBE_NONE = "subscribe_none"
AUDIO_ONLY = "audio_only"
VIDEO_ONLY = "video_only"
@dataclass
class JobAcceptArguments:
name: str
identity: str
metadata: str
attributes: dict[str, str] | None = None
@dataclass
class RunningJobInfo:
accept_arguments: JobAcceptArguments
job: agent.Job
url: str
token: str
worker_id: str
DEFAULT_PARTICIPANT_KINDS: list[rtc.ParticipantKind.ValueType] = [
rtc.ParticipantKind.PARTICIPANT_KIND_SIP,
rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD,
]
class JobContext:
# private ctor
def __init__(
self,
*,
proc: JobProcess,
info: RunningJobInfo,
room: rtc.Room,
on_connect: Callable[[], None],
on_shutdown: Callable[[str], None],
inference_executor: InferenceExecutor,
) -> None:
self._proc = proc
self._info = info
self._room = room
self._on_connect = on_connect
self._on_shutdown = on_shutdown
self._shutdown_callbacks: list[Callable[[str], Coroutine[None, None, None]]] = []
self._tracing_callbacks: list[Callable[[], Coroutine[None, None, None]]] = []
self._participant_entrypoints: list[
tuple[
Callable[[JobContext, rtc.RemoteParticipant], Coroutine[None, None, None]],
list[rtc.ParticipantKind.ValueType] | rtc.ParticipantKind.ValueType,
]
] = []
self._participant_tasks = dict[tuple[str, Callable], asyncio.Task[None]]()
self._pending_tasks = list[asyncio.Task]()
self._room.on("participant_connected", self._participant_available)
self._inf_executor = inference_executor
self._init_log_factory()
self._log_fields = {}
def _init_log_factory(self) -> None:
old_factory = logging.getLogRecordFactory()
def record_factory(*args, **kwargs) -> logging.LogRecord:
record = old_factory(*args, **kwargs)
if self.proc.executor_type != JobExecutorType.PROCESS:
try:
ctx = get_job_context()
except RuntimeError:
return record
else:
if ctx != self:
return record
for key, value in self._log_fields.items():
setattr(record, key, value)
return record
logging.setLogRecordFactory(record_factory)
@property
def inference_executor(self) -> InferenceExecutor:
return self._inf_executor
@functools.cached_property
def api(self) -> api.LiveKitAPI:
return api.LiveKitAPI(session=http_context.http_session())
@property
def proc(self) -> JobProcess:
"""Returns the process running the job. Useful for storing process-specific state."""
return self._proc
@property
def job(self) -> agent.Job:
"""Returns the current job that the worker is executing."""
return self._info.job
@property
def worker_id(self) -> str:
"""Returns the id of the worker."""
return self._info.worker_id
@property
def room(self) -> rtc.Room:
"""The Room object is the main interface that the worker should interact with.
When the entrypoint is called, the worker has not connected to the Room yet.
Certain properties of Room would not be available before calling JobContext.connect()
"""
return self._room
@property
def agent(self) -> rtc.LocalParticipant:
return self._room.local_participant
@property
def log_context_fields(self) -> dict[str, Any]:
"""
Returns the current dictionary of log fields that will be injected into log records.
These fields enable enriched structured logging and can include job metadata,
worker ID, trace IDs, or other diagnostic context.
The returned dictionary can be directly edited, or entirely replaced via assignment
(e.g., `job_context.log_context_fields = {...}`)
"""
return self._log_fields
@log_context_fields.setter
def log_context_fields(self, fields: dict[str, Any]) -> None:
"""
Sets the log fields to be injected into future log records.
Args:
fields (dict[str, Any]): A dictionary of key-value pairs representing
structured data to attach to each log entry. Typically includes contextual
information like job ID, trace information, or worker metadata.
"""
self._log_fields = fields
def add_tracing_callback(
self,
callback: Callable[[], Coroutine[None, None, None]],
) -> None:
"""
Add a callback to be called when the job is about to receive a new tracing request.
"""
self._tracing_callbacks.append(callback)
def add_shutdown_callback(
self,
callback: Callable[[], Coroutine[None, None, None]]
| Callable[[str], Coroutine[None, None, None]],
) -> None:
"""
Add a callback to be called when the job is shutting down.
Optionally the callback can take a single argument, the shutdown reason.
"""
if callback.__code__.co_argcount > 0:
self._shutdown_callbacks.append(callback) # type: ignore
else:
async def wrapper(_: str) -> None:
await callback() # type: ignore
self._shutdown_callbacks.append(wrapper)
async def wait_for_participant(
self,
*,
identity: str | None = None,
kind: list[rtc.ParticipantKind.ValueType]
| rtc.ParticipantKind.ValueType = DEFAULT_PARTICIPANT_KINDS,
) -> rtc.RemoteParticipant:
"""
Returns a participant that matches the given identity. If identity is None, the first
participant that joins the room will be returned.
If the participant has already joined, the function will return immediately.
"""
return await wait_for_participant(self._room, identity=identity, kind=kind)
async def connect(
self,
*,
e2ee: rtc.E2EEOptions | None = None,
auto_subscribe: AutoSubscribe = AutoSubscribe.SUBSCRIBE_ALL,
rtc_config: rtc.RtcConfiguration | None = None,
) -> None:
"""Connect to the room. This method should be called only once.
Args:
e2ee: End-to-end encryption options. If provided, the Agent will utilize end-to-end encryption. Note: clients will also need to handle E2EE.
auto_subscribe: Whether to automatically subscribe to tracks. Default is AutoSubscribe.SUBSCRIBE_ALL.
rtc_config: Custom RTC configuration to use when connecting to the room.
""" # noqa: E501
room_options = rtc.RoomOptions(
e2ee=e2ee,
auto_subscribe=auto_subscribe == AutoSubscribe.SUBSCRIBE_ALL,
rtc_config=rtc_config,
)
await self._room.connect(self._info.url, self._info.token, options=room_options)
self._on_connect()
for p in self._room.remote_participants.values():
self._participant_available(p)
_apply_auto_subscribe_opts(self._room, auto_subscribe)
def delete_room(self) -> asyncio.Future[api.DeleteRoomResponse]:
"""Deletes the room and disconnects all participants."""
task = asyncio.create_task(
self.api.room.delete_room(api.DeleteRoomRequest(room=self._room.name))
)
self._pending_tasks.append(task)
task.add_done_callback(lambda _: self._pending_tasks.remove(task))
return task
def add_sip_participant(
self,
*,
call_to: str,
trunk_id: str,
participant_identity: str,
participant_name: str | NotGivenOr[str] = "SIP-participant",
) -> asyncio.Future[api.SIPParticipantInfo]:
"""
Add a SIP participant to the room.
Args:
call_to: The number or SIP destination to transfer the participant to.
This can either be a number (+12345555555) or a
sip host (sip:<user>@<host>)
trunk_id: The ID of the SIP trunk to use
participant_identity: The identity of the participant to add
participant_name: The name of the participant to add
Make sure you have an outbound SIP trunk created in LiveKit.
See https://docs.livekit.io/sip/trunk-outbound/ for more information.
"""
task = asyncio.create_task(
self.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=self._room.name,
participant_identity=participant_identity,
sip_trunk_id=trunk_id,
sip_call_to=call_to,
participant_name=participant_name,
)
),
)
self._pending_tasks.append(task)
task.add_done_callback(lambda _: self._pending_tasks.remove(task))
return task
def transfer_sip_participant(
self,
participant: rtc.RemoteParticipant | str,
transfer_to: str,
play_dialtone: bool = False,
) -> asyncio.Future[api.SIPParticipantInfo]:
"""Transfer a SIP participant to another number.
Args:
participant: The participant to transfer
transfer_to: The number or SIP destination to transfer the participant to.
This can either be a number (+12345555555) or a
sip host (sip:<user>@<host>)
play_dialtone: Whether to play a dialtone during transfer. Defaults to True.
Returns:
Future that completes when the transfer is complete
Make sure you have enabled call transfer on your provider SIP trunk.
See https://docs.livekit.io/sip/transfer-cold/ for more information.
"""
assert participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP, (
"Participant must be a SIP participant"
)
task = asyncio.create_task(
self.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=self._room.name,
participant_identity=participant.identity,
transfer_to=transfer_to,
play_dialtone=play_dialtone,
)
),
)
self._pending_tasks.append(task)
task.add_done_callback(lambda _: self._pending_tasks.remove(task))
return task
def shutdown(self, reason: str = "") -> None:
self._on_shutdown(reason)
def add_participant_entrypoint(
self,
entrypoint_fnc: Callable[[JobContext, rtc.RemoteParticipant], Coroutine[None, None, None]],
*_,
kind: list[rtc.ParticipantKind.ValueType]
| rtc.ParticipantKind.ValueType = DEFAULT_PARTICIPANT_KINDS,
):
"""Adds an entrypoint function to be run when a participant joins the room. In cases where
the participant has already joined, the entrypoint will be run immediately. Multiple unique entrypoints can be
added and they will each be run in parallel for each participant.
""" # noqa: E501
if entrypoint_fnc in [e for (e, _) in self._participant_entrypoints]:
raise ValueError("entrypoints cannot be added more than once")
self._participant_entrypoints.append((entrypoint_fnc, kind))
def _participant_available(self, p: rtc.RemoteParticipant) -> None:
for coro, kind in self._participant_entrypoints:
if isinstance(kind, list):
if p.kind not in kind:
continue
else:
if p.kind != kind:
continue
if (p.identity, coro) in self._participant_tasks:
logger.warning(
f"a participant has joined before a prior participant task matching the same identity has finished: '{p.identity}'" # noqa: E501
)
task_name = f"part-entry-{p.identity}-{coro.__name__}"
task = asyncio.create_task(coro(self, p), name=task_name)
self._participant_tasks[(p.identity, coro)] = task
task.add_done_callback(
lambda _, coro=coro: self._participant_tasks.pop((p.identity, coro))
)
def _apply_auto_subscribe_opts(room: rtc.Room, auto_subscribe: AutoSubscribe) -> None:
if auto_subscribe not in (AutoSubscribe.AUDIO_ONLY, AutoSubscribe.VIDEO_ONLY):
return
def _subscribe_if_needed(pub: rtc.RemoteTrackPublication):
if (
auto_subscribe == AutoSubscribe.AUDIO_ONLY and pub.kind == rtc.TrackKind.KIND_AUDIO
) or (auto_subscribe == AutoSubscribe.VIDEO_ONLY and pub.kind == rtc.TrackKind.KIND_VIDEO):
pub.set_subscribed(True)
for p in room.remote_participants.values():
for pub in p.track_publications.values():
_subscribe_if_needed(pub)
@room.on("track_published")
def on_track_published(pub: rtc.RemoteTrackPublication, _: rtc.RemoteParticipant):
_subscribe_if_needed(pub)
class JobProcess:
def __init__(
self,
*,
executor_type: JobExecutorType,
user_arguments: Any | None,
http_proxy: str | None,
) -> None:
self._executor_type = executor_type
self._mp_proc = mp.current_process()
self._userdata: dict[str, Any] = {}
self._user_arguments = user_arguments
self._http_proxy: str | None = http_proxy
@property
def executor_type(self) -> JobExecutorType:
return self._executor_type
@property
def pid(self) -> int | None:
return self._mp_proc.pid
@property
def userdata(self) -> dict:
return self._userdata
@property
def user_arguments(self) -> Any | None:
return self._user_arguments
@property
def http_proxy(self) -> str | None:
return self._http_proxy
class JobRequest:
def __init__(
self,
*,
job: agent.Job,
on_reject: Callable[[], Coroutine[None, None, None]],
on_accept: Callable[[JobAcceptArguments], Coroutine[None, None, None]],
) -> None:
self._job = job
self._lock = asyncio.Lock()
self._on_reject = on_reject
self._on_accept = on_accept
@property
def id(self) -> str:
return self._job.id
@property
def job(self) -> agent.Job:
return self._job
@property
def room(self) -> models.Room:
return self._job.room
@property
def publisher(self) -> models.ParticipantInfo | None:
return self._job.participant
@property
def agent_name(self) -> str:
return self._job.agent_name
async def reject(self) -> None:
"""Reject the job request. The job may be assigned to another worker"""
await self._on_reject()
async def accept(
self,
*,
name: str = "",
identity: str = "",
metadata: str = "",
attributes: dict[str, str] | None = None,
) -> None:
"""Accept the job request, and start the job if the LiveKit SFU assigns the job to our worker.""" # noqa: E501
if not identity:
identity = "agent-" + self.id
accept_arguments = JobAcceptArguments(
name=name,
identity=identity,
metadata=metadata,
attributes=attributes,
)
await self._on_accept(accept_arguments)
import asyncio
import datetime
import logging
import os
import sys
import uuid
import aiohttp
import nest_asyncio
from livekit import api
from livekit.rtc.jupyter import display_room
from .cli import _run, proto
from .types import NOT_GIVEN, NotGivenOr
from .worker import JobExecutorType, WorkerOptions
def run_app(
opts: WorkerOptions,
*,
jupyter_url: NotGivenOr[str] = NOT_GIVEN,
) -> None:
IN_COLAB = "google.colab" in sys.modules
nest_asyncio.apply()
if IN_COLAB:
from google.colab import userdata
if not jupyter_url:
opts.ws_url = userdata.get("LIVEKIT_URL")
opts.api_key = userdata.get("LIVEKIT_API_KEY")
opts.api_secret = userdata.get("LIVEKIT_API_SECRET")
else:
opts.ws_url = os.environ.get("LIVEKIT_URL", "")
opts.api_key = os.environ.get("LIVEKIT_API_KEY", "")
opts.api_secret = os.environ.get("LIVEKIT_API_SECRET", "")
if not jupyter_url and (not opts.ws_url or not opts.api_key or not opts.api_secret):
raise ValueError(
"Failed to get LIVEKIT_URL, LIVEKIT_API_KEY, or LIVEKIT_API_SECRET from environment variables. " # noqa: E501
"Alternatively, you can use `jupyter_url`, which generates and uses join tokens for authentication." # noqa: E501
)
if jupyter_url:
async def fetch_join_tokens(url: str):
async with aiohttp.ClientSession() as session:
async with session.post(url) as response:
data = await response.json()
return data["livekit_url"], data["user_token"], data["agent_token"]
try:
opts.ws_url, user_token, agent_token = asyncio.run(fetch_join_tokens(jupyter_url))
except Exception as e:
raise ValueError(
f"Failed to fetch join tokens via jupyter_url. Error: {e}\n"
"You can still use your own LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET from environment variables instead." # noqa: E501
) from None
opts.api_key = "fake_jupyter_key"
opts.api_secret = "fake_jupyter_secret"
else:
# manually create the user_token and agent_token using the provided api key and secret
room_name = f"jupyter-room-{uuid.uuid4()}"
user_token = (
api.AccessToken(opts.api_key, opts.api_secret)
.with_identity("user-jupyter")
.with_grants(
api.VideoGrants(
can_publish=True, can_subscribe=True, room_join=True, room=room_name
)
)
.with_ttl(datetime.timedelta(minutes=1))
.to_jwt()
)
agent_token = (
api.AccessToken(opts.api_key, opts.api_secret)
.with_identity("agent-jupyter")
.with_kind("agent")
.with_grants(
api.VideoGrants(
agent=True,
can_publish=True,
can_subscribe=True,
room_join=True,
can_update_own_metadata=True,
room=room_name,
)
)
.with_ttl(datetime.timedelta(minutes=1))
.to_jwt()
)
display_room(opts.ws_url, user_token)
root = logging.getLogger()
for handler in root.handlers[:]:
if isinstance(handler, logging.StreamHandler):
root.removeHandler(handler)
opts.job_executor_type = JobExecutorType.THREAD
args = proto.CliArgs(
opts=opts,
log_level="DEBUG",
devmode=True,
asyncio_debug=False,
watch=False,
drain_timeout=0,
register=False,
simulate_job=agent_token,
)
_run.run_worker(args, jupyter=True)
from . import remote_chat_context, utils
from .chat_context import (
AudioContent,
ChatContent,
ChatContext,
ChatItem,
ChatMessage,
ChatRole,
FunctionCall,
FunctionCallOutput,
ImageContent,
)
from .fallback_adapter import AvailabilityChangedEvent, FallbackAdapter
from .llm import (
LLM,
ChatChunk,
ChoiceDelta,
CompletionUsage,
FunctionToolCall,
LLMError,
LLMStream,
)
from .realtime import (
GenerationCreatedEvent,
InputSpeechStartedEvent,
InputSpeechStoppedEvent,
InputTranscriptionCompleted,
MessageGeneration,
RealtimeCapabilities,
RealtimeError,
RealtimeModel,
RealtimeModelError,
RealtimeSession,
)
from .tool_context import (
FunctionTool,
RawFunctionTool,
StopResponse,
ToolChoice,
ToolContext,
ToolError,
find_function_tools,
function_tool,
is_function_tool,
is_raw_function_tool,
)
__all__ = [
"LLM",
"LLMStream",
"ChatContext",
"ChatRole",
"ChatMessage",
"ChatContent",
"FunctionCall",
"FunctionCallOutput",
"AudioContent",
"ImageContent",
"ChatItem",
"ChatContext",
"ChoiceDelta",
"ChatChunk",
"CompletionUsage",
"FallbackAdapter",
"AvailabilityChangedEvent",
"ToolChoice",
"is_function_tool",
"function_tool",
"find_function_tools",
"FunctionTool",
"is_raw_function_tool",
"RawFunctionTool",
"ToolContext",
"ToolError",
"StopResponse",
"utils",
"remote_chat_context",
"FunctionToolCall",
"RealtimeModel",
"RealtimeError",
"RealtimeModelError",
"RealtimeCapabilities",
"RealtimeSession",
"InputTranscriptionCompleted",
"InputTranscriptionFailed",
"InputSpeechStartedEvent",
"InputSpeechStoppedEvent",
"GenerationCreatedEvent",
"MessageGeneration",
"LLMError",
]
from __future__ import annotations
from typing import Any, TypeVar
from pydantic import BaseModel, TypeAdapter
from typing_extensions import TypeGuard
_T = TypeVar("_T")
def to_strict_json_schema(model: type[BaseModel] | TypeAdapter[Any]) -> dict[str, Any]:
if isinstance(model, TypeAdapter):
schema = model.json_schema()
else:
schema = model.model_json_schema()
return _ensure_strict_json_schema(schema, path=(), root=schema)
# from https://platform.openai.com/docs/guides/function-calling?api-mode=responses&strict-mode=disabled#strict-mode
# Strict mode
# Setting strict to true will ensure function calls reliably adhere to the function schema,
# instead of being best effort. We recommend always enabling strict mode.
#
# Under the hood, strict mode works by leveraging our structured outputs feature and therefore
# introduces a couple requirements:
#
# additionalProperties must be set to false for each object in the parameters.
# All fields in properties must be marked as required.
# You can denote optional fields by adding null as a type option (see example below).
def _ensure_strict_json_schema(
json_schema: object,
*,
path: tuple[str, ...],
root: dict[str, object],
) -> dict[str, Any]:
"""Mutates the given JSON schema to ensure it conforms to the `strict` standard
that the API expects.
"""
if not is_dict(json_schema):
raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}")
defs = json_schema.get("$defs")
if is_dict(defs):
for def_name, def_schema in defs.items():
_ensure_strict_json_schema(def_schema, path=(*path, "$defs", def_name), root=root)
definitions = json_schema.get("definitions")
if is_dict(definitions):
for definition_name, definition_schema in definitions.items():
_ensure_strict_json_schema(
definition_schema,
path=(*path, "definitions", definition_name),
root=root,
)
typ = json_schema.get("type")
if typ == "object" and "additionalProperties" not in json_schema:
json_schema["additionalProperties"] = False
# object types
# { 'type': 'object', 'properties': { 'a': {...} } }
properties = json_schema.get("properties")
if is_dict(properties):
json_schema["required"] = list(properties.keys())
json_schema["properties"] = {
key: _ensure_strict_json_schema(prop_schema, path=(*path, "properties", key), root=root)
for key, prop_schema in properties.items()
}
# arrays
# { 'type': 'array', 'items': {...} }
items = json_schema.get("items")
if is_dict(items):
json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root)
# unions
any_of = json_schema.get("anyOf")
if is_list(any_of):
json_schema["anyOf"] = [
_ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root)
for i, variant in enumerate(any_of)
]
# unions (oneOf)
one_of = json_schema.get("oneOf")
if is_list(one_of):
json_schema["oneOf"] = [
_ensure_strict_json_schema(variant, path=(*path, "oneOf", str(i)), root=root)
for i, variant in enumerate(one_of)
]
# intersections
all_of = json_schema.get("allOf")
if is_list(all_of):
if len(all_of) == 1:
json_schema.update(
_ensure_strict_json_schema(all_of[0], path=(*path, "allOf", "0"), root=root)
)
json_schema.pop("allOf")
else:
json_schema["allOf"] = [
_ensure_strict_json_schema(entry, path=(*path, "allOf", str(i)), root=root)
for i, entry in enumerate(all_of)
]
# strict mode doesn't support default
if json_schema.get("default") is not None:
json_schema.pop("default", None)
# Treat any parameter with a default value as optional. If the parameter’s type doesn't
# support None, the default will be used instead.
t = json_schema.get("type")
if isinstance(t, str):
json_schema["type"] = [t, "null"]
elif isinstance(t, list):
types = t.copy()
if "null" not in types:
types.append("null")
json_schema["type"] = types
json_schema.pop("title", None)
json_schema.pop("discriminator", None)
# we can't use `$ref`s if there are also other properties defined, e.g.
# `{"$ref": "...", "description": "my description"}`
#
# so we unravel the ref
# `{"type": "string", "description": "my description"}`
ref = json_schema.get("$ref")
if ref and has_more_than_n_keys(json_schema, 1):
assert isinstance(ref, str), f"Received non-string $ref - {ref}"
resolved = resolve_ref(root=root, ref=ref)
if not is_dict(resolved):
raise ValueError(
f"Expected `$ref: {ref}` to resolved to a dictionary but got {resolved}"
)
# properties from the json schema take priority over the ones on the `$ref`
json_schema.update({**resolved, **json_schema})
json_schema.pop("$ref")
# Since the schema expanded from `$ref` might not have `additionalProperties: false` applied, # noqa: E501
# we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid. # noqa: E501
return _ensure_strict_json_schema(json_schema, path=path, root=root)
# simplify nullable unions (“anyOf” or “oneOf”)
for union_key in ("anyOf", "oneOf"):
variants = json_schema.get(union_key)
if is_list(variants) and len(variants) == 2 and {"type": "null"} in variants:
# pick out the non-null branch
non_null = next(
(item for item in variants if item != {"type": "null"}),
None,
)
assert is_dict(non_null)
t = non_null["type"]
if isinstance(t, str):
non_null["type"] = [t, "null"]
merged = {k: v for k, v in json_schema.items() if k not in ("anyOf", "oneOf")}
merged.update(non_null)
json_schema = merged
break
return json_schema
def resolve_ref(*, root: dict[str, object], ref: str) -> object:
if not ref.startswith("#/"):
raise ValueError(f"Unexpected $ref format {ref!r}; Does not start with #/")
path = ref[2:].split("/")
resolved = root
for key in path:
value = resolved[key]
assert is_dict(value), (
f"encountered non-dictionary entry while resolving {ref} - {resolved}"
)
resolved = value
return resolved
def is_dict(obj: object) -> TypeGuard[dict[str, object]]:
# just pretend that we know there are only `str` keys
# as that check is not worth the performance cost
return isinstance(obj, dict)
def is_list(obj: object) -> TypeGuard[list[object]]:
return isinstance(obj, list)
def has_more_than_n_keys(obj: dict[str, object], n: int) -> bool:
i = 0
for _ in obj.keys():
i += 1
if i > n:
return True
return False
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Annotated, Any, Literal, Union
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter
from typing_extensions import TypeAlias
from livekit import rtc
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils.misc import is_given
from .. import utils
from ..log import logger
if TYPE_CHECKING:
from ..llm import FunctionTool, RawFunctionTool
class ImageContent(BaseModel):
"""
ImageContent is used to input images into the ChatContext on supported LLM providers / plugins.
You may need to consult your LLM provider's documentation on supported URL types.
```python
# Pass a VideoFrame directly, which will be automatically converted to a JPEG data URL internally
async for event in rtc.VideoStream(video_track):
chat_image = ImageContent(image=event.frame)
# this instance is now available for your ChatContext
# Encode your VideoFrame yourself for more control, and pass the result as a data URL (see EncodeOptions for more details)
from livekit.agents.utils.images import encode, EncodeOptions, ResizeOptions
image_bytes = encode(
event.frame,
EncodeOptions(
format="PNG",
resize_options=ResizeOptions(width=512, height=512, strategy="scale_aspect_fit"),
),
)
chat_image = ImageContent(
image=f"data:image/png;base64,{base64.b64encode(image_bytes).decode('utf-8')}"
)
# With an external URL
chat_image = ImageContent(image="https://example.com/image.jpg")
```
""" # noqa: E501
id: str = Field(default_factory=lambda: utils.shortuuid("img_"))
"""
Unique identifier for the image
"""
type: Literal["image_content"] = Field(default="image_content")
image: str | rtc.VideoFrame
"""
Either a string URL or a VideoFrame object
"""
inference_width: int | None = None
"""
Resizing parameter for rtc.VideoFrame inputs (ignored for URL images)
"""
inference_height: int | None = None
"""
Resizing parameter for rtc.VideoFrame inputs (ignored for URL images)
"""
inference_detail: Literal["auto", "high", "low"] = "auto"
"""
Detail parameter for LLM provider, if supported.
Currently only supported by OpenAI (see https://platform.openai.com/docs/guides/vision?lang=node#low-or-high-fidelity-image-understanding)
"""
mime_type: str | None = None
"""
MIME type of the image
"""
_cache: dict[int, Any] = PrivateAttr(default_factory=dict)
class AudioContent(BaseModel):
type: Literal["audio_content"] = Field(default="audio_content")
frame: list[rtc.AudioFrame]
transcript: str | None = None
ChatRole: TypeAlias = Literal["developer", "system", "user", "assistant"]
class ChatMessage(BaseModel):
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
type: Literal["message"] = "message"
role: ChatRole
content: list[ChatContent]
interrupted: bool = False
hash: bytes | None = None
created_at: float = Field(default_factory=time.time)
@property
def text_content(self) -> str | None:
"""
Returns a string of all text content in the message.
Multiple text content items will be joined by a newline.
"""
text_parts = [c for c in self.content if isinstance(c, str)]
if not text_parts:
return None
return "\n".join(text_parts)
ChatContent: TypeAlias = Union[ImageContent, AudioContent, str]
class FunctionCall(BaseModel):
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
type: Literal["function_call"] = "function_call"
call_id: str
arguments: str
name: str
class FunctionCallOutput(BaseModel):
id: str = Field(default_factory=lambda: utils.shortuuid("item_"))
name: str = Field(default="")
type: Literal["function_call_output"] = Field(default="function_call_output")
call_id: str
output: str
is_error: bool
ChatItem = Annotated[
Union[ChatMessage, FunctionCall, FunctionCallOutput], Field(discriminator="type")
]
class ChatContext:
def __init__(self, items: NotGivenOr[list[ChatItem]] = NOT_GIVEN):
self._items: list[ChatItem] = items if is_given(items) else []
@classmethod
def empty(cls) -> ChatContext:
return cls([])
@property
def items(self) -> list[ChatItem]:
return self._items
@items.setter
def items(self, items: list[ChatItem]):
self._items = items
def add_message(
self,
*,
role: ChatRole,
content: list[ChatContent] | str,
id: NotGivenOr[str] = NOT_GIVEN,
interrupted: NotGivenOr[bool] = NOT_GIVEN,
created_at: NotGivenOr[float] = NOT_GIVEN,
) -> ChatMessage:
kwargs = {}
if is_given(id):
kwargs["id"] = id
if is_given(interrupted):
kwargs["interrupted"] = interrupted
if is_given(created_at):
kwargs["created_at"] = created_at
if isinstance(content, str):
message = ChatMessage(role=role, content=[content], **kwargs)
else:
message = ChatMessage(role=role, content=content, **kwargs)
self._items.append(message)
return message
def get_by_id(self, item_id: str) -> ChatItem | None:
return next((item for item in self.items if item.id == item_id), None)
def index_by_id(self, item_id: str) -> int | None:
return next((i for i, item in enumerate(self.items) if item.id == item_id), None)
def copy(
self,
*,
exclude_function_call: bool = False,
exclude_instructions: bool = False,
tools: NotGivenOr[list[FunctionTool | RawFunctionTool | str | Any]] = NOT_GIVEN,
) -> ChatContext:
items = []
from .tool_context import (
get_function_info,
get_raw_function_info,
is_function_tool,
is_raw_function_tool,
)
valid_tools = set()
if is_given(tools):
for tool in tools:
if isinstance(tool, str):
valid_tools.add(tool)
elif is_function_tool(tool):
valid_tools.add(get_function_info(tool).name)
elif is_raw_function_tool(tool):
valid_tools.add(get_raw_function_info(tool).name)
# TODO(theomonnom): other tools
for item in self.items:
if exclude_function_call and item.type in [
"function_call",
"function_call_output",
]:
continue
if (
exclude_instructions
and item.type == "message"
and item.role in ["system", "developer"]
):
continue
if (
is_given(tools)
and item.type in ["function_call", "function_call_output"]
and item.name not in valid_tools
):
continue
items.append(item)
return ChatContext(items)
def truncate(self, *, max_items: int) -> ChatContext:
"""Truncate the chat context to the last N items in place.
Removes leading function calls to avoid partial function outputs.
Preserves the first system message by adding it back to the beginning.
"""
instructions = next(
(item for item in self._items if item.type == "message" and item.role == "system"),
None,
)
new_items = self._items[-max_items:]
# chat ctx shouldn't start with function_call or function_call_output
while new_items and new_items[0].type in [
"function_call",
"function_call_output",
]:
new_items.pop(0)
if instructions:
new_items.insert(0, instructions)
self._items[:] = new_items
return self
def to_dict(
self,
*,
exclude_image: bool = True,
exclude_audio: bool = True,
exclude_timestamp: bool = True,
exclude_function_call: bool = False,
) -> dict:
items = []
for item in self.items:
if exclude_function_call and item.type in [
"function_call",
"function_call_output",
]:
continue
if item.type == "message":
item = item.model_copy()
if exclude_image:
item.content = [c for c in item.content if not isinstance(c, ImageContent)]
if exclude_audio:
item.content = [c for c in item.content if not isinstance(c, AudioContent)]
items.append(item)
exclude_fields = set()
if exclude_timestamp:
exclude_fields.add("created_at")
return {
"items": [
item.model_dump(
mode="json",
exclude_none=True,
exclude_defaults=False,
exclude=exclude_fields,
)
for item in items
],
}
def find_insertion_index(self, *, created_at: float) -> int:
"""
Returns the index to insert an item by creation time.
Iterates in reverse, assuming items are sorted by `created_at`.
Finds the position after the last item with `created_at <=` the given timestamp.
"""
for i in reversed(range(len(self._items))):
item = self._items[i]
if item.type == "message" and item.created_at <= created_at:
return i + 1
return 0
@classmethod
def from_dict(cls, data: dict) -> ChatContext:
item_adapter = TypeAdapter(list[ChatItem])
items = item_adapter.validate_python(data["items"])
return cls(items)
@property
def readonly(self) -> bool:
return False
class _ReadOnlyChatContext(ChatContext):
"""A read-only wrapper for ChatContext that prevents modifications."""
error_msg = (
"trying to modify a read-only chat context, "
"please use .copy() and agent.update_chat_ctx() to modify the chat context"
)
class _ImmutableList(list):
def _raise_error(self, *args, **kwargs):
logger.error(_ReadOnlyChatContext.error_msg)
raise RuntimeError(_ReadOnlyChatContext.error_msg)
# override all mutating methods to raise errors
append = extend = pop = remove = clear = sort = reverse = _raise_error # type: ignore
__setitem__ = __delitem__ = __iadd__ = __imul__ = _raise_error # type: ignore
def copy(self):
return list(self)
def __init__(self, items: list[ChatItem]):
self._items = self._ImmutableList(items)
@property
def readonly(self) -> bool:
return True
from __future__ import annotations
import asyncio
import dataclasses
import time
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any, Literal
from livekit.agents._exceptions import APIConnectionError, APIError
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from .chat_context import ChatContext
from .llm import LLM, ChatChunk, LLMStream, ToolChoice
from .tool_context import FunctionTool, ToolContext
DEFAULT_FALLBACK_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
@dataclass
class _LLMStatus:
available: bool
recovering_task: asyncio.Task | None
@dataclass
class AvailabilityChangedEvent:
llm: LLM
available: bool
class FallbackAdapter(
LLM[Literal["llm_availability_changed"]],
):
def __init__(
self,
llm: list[LLM],
*,
attempt_timeout: float = 10.0,
max_retry_per_llm: int = 1,
retry_interval: float = 5,
) -> None:
if len(llm) < 1:
raise ValueError("at least one LLM instance must be provided.")
super().__init__()
self._llm_instances = llm
self._attempt_timeout = attempt_timeout
self._max_retry_per_llm = max_retry_per_llm
self._retry_interval = retry_interval
self._status = [
_LLMStatus(available=True, recovering_task=None) for _ in self._llm_instances
]
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
return FallbackLLMStream(
llm=self,
conn_options=conn_options,
chat_ctx=chat_ctx,
tools=tools,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
extra_kwargs=extra_kwargs,
)
class FallbackLLMStream(LLMStream):
def __init__(
self,
llm: FallbackAdapter,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None,
conn_options: APIConnectOptions,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._fallback_adapter = llm
self._parallel_tool_calls = parallel_tool_calls
self._tool_choice = tool_choice
self._extra_kwargs = extra_kwargs
self._current_stream: LLMStream | None = None
@property
def chat_ctx(self) -> ChatContext:
if self._current_stream is None:
return self._chat_ctx
return self._current_stream.chat_ctx
@property
def tools(self) -> ToolContext | None:
if self._current_stream is None:
return self._tools
return self._current_stream.tools
async def _try_generate(
self, *, llm: LLM, check_recovery: bool = False
) -> AsyncIterable[ChatChunk]:
"""
Try to generate with the given LLM.
Args:
llm: The LLM instance to generate with
check_recovery: When True, indicates this is a background recovery check and the
result will not be used. Recovery checks verify if a previously
failed LLM has become available again.
"""
try:
async with llm.chat(
chat_ctx=self._chat_ctx,
tools=self._tools,
parallel_tool_calls=self._parallel_tool_calls,
tool_choice=self._tool_choice,
extra_kwargs=self._extra_kwargs,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_llm,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
) as stream:
should_set_current = not check_recovery
async for chunk in stream:
if should_set_current:
should_set_current = False
self._current_stream = stream
yield chunk
except asyncio.TimeoutError:
if check_recovery:
logger.warning(f"{llm.label} recovery timed out")
raise
logger.warning(
f"{llm.label} timed out, switching to next LLM",
)
raise
except APIError as e:
if check_recovery:
logger.warning(
f"{llm.label} recovery failed",
exc_info=e,
)
raise
logger.warning(
f"{llm.label} failed, switching to next LLM",
exc_info=e,
)
raise
except Exception:
if check_recovery:
logger.exception(
f"{llm.label} recovery unexpected error",
)
raise
logger.exception(
f"{llm.label} unexpected error, switching to next LLM",
)
raise
def _try_recovery(self, llm: LLM) -> None:
llm_status = self._fallback_adapter._status[
self._fallback_adapter._llm_instances.index(llm)
]
if llm_status.recovering_task is None or llm_status.recovering_task.done():
async def _recover_llm_task(llm: LLM) -> None:
try:
async for _ in self._try_generate(llm=llm, check_recovery=True):
pass
llm_status.available = True
logger.info(f"llm.FallbackAdapter, {llm.label} recovered")
self._fallback_adapter.emit(
"llm_availability_changed",
AvailabilityChangedEvent(llm=llm, available=True),
)
except Exception:
return
llm_status.recovering_task = asyncio.create_task(_recover_llm_task(llm))
async def _run(self) -> None:
start_time = time.time()
all_failed = all(not llm_status.available for llm_status in self._fallback_adapter._status)
if all_failed:
logger.error("all LLMs are unavailable, retrying..")
for i, llm in enumerate(self._fallback_adapter._llm_instances):
llm_status = self._fallback_adapter._status[i]
if llm_status.available or all_failed:
chunk_sent = False
try:
async for result in self._try_generate(llm=llm, check_recovery=False):
chunk_sent = True
self._event_ch.send_nowait(result)
return
except Exception: # exceptions already logged inside _try_synthesize
if llm_status.available:
llm_status.available = False
self._fallback_adapter.emit(
"llm_availability_changed",
AvailabilityChangedEvent(llm=llm, available=False),
)
if chunk_sent:
raise
self._try_recovery(llm)
raise APIConnectionError(
f"all LLMs failed ({[llm.label for llm in self._fallback_adapter._llm_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
from __future__ import annotations
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator
from types import TracebackType
from typing import Any, Generic, Literal, TypeVar, Union
from pydantic import BaseModel, ConfigDict, Field
from livekit import rtc
from livekit.agents._exceptions import APIConnectionError, APIError
from .. import utils
from ..log import logger
from ..metrics import LLMMetrics
from ..types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ..utils import aio
from .chat_context import ChatContext, ChatRole
from .tool_context import FunctionTool, ToolChoice
class CompletionUsage(BaseModel):
completion_tokens: int
prompt_tokens: int
prompt_cached_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
total_tokens: int
class FunctionToolCall(BaseModel):
type: Literal["function"] = "function"
name: str
arguments: str
call_id: str
class ChoiceDelta(BaseModel):
role: ChatRole | None = None
content: str | None = None
tool_calls: list[FunctionToolCall] = Field(default_factory=list)
class ChatChunk(BaseModel):
id: str
delta: ChoiceDelta | None = None
usage: CompletionUsage | None = None
class LLMError(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["llm_error"] = "llm_error"
timestamp: float
label: str
error: APIError = Field(..., exclude=True)
recoverable: bool
TEvent = TypeVar("TEvent")
class LLM(
ABC,
rtc.EventEmitter[Union[Literal["metrics_collected", "error"], TEvent]],
Generic[TEvent],
):
def __init__(self) -> None:
super().__init__()
self._label = f"{type(self).__module__}.{type(self).__name__}"
@property
def label(self) -> str:
return self._label
@abstractmethod
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream: ...
async def aclose(self) -> None: ...
async def __aenter__(self) -> LLM:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
class LLMStream(ABC):
def __init__(
self,
llm: LLM,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool],
conn_options: APIConnectOptions,
) -> None:
self._llm = llm
self._chat_ctx = chat_ctx
self._tools = tools
self._conn_options = conn_options
self._event_ch = aio.Chan[ChatChunk]()
self._event_aiter, monitor_aiter = aio.itertools.tee(self._event_ch, 2)
self._current_attempt_has_error = False
self._metrics_task = asyncio.create_task(
self._metrics_monitor_task(monitor_aiter), name="LLM._metrics_task"
)
self._task = asyncio.create_task(self._main_task())
self._task.add_done_callback(lambda _: self._event_ch.close())
@abstractmethod
async def _run(self) -> None: ...
async def _main_task(self) -> None:
for i in range(self._conn_options.max_retry + 1):
try:
return await self._run()
except APIError as e:
if self._conn_options.max_retry == 0 or not e.retryable:
self._emit_error(e, recoverable=False)
raise
elif i == self._conn_options.max_retry:
self._emit_error(e, recoverable=False)
raise APIConnectionError(
f"failed to generate LLM completion after {self._conn_options.max_retry + 1} attempts", # noqa: E501
) from e
else:
self._emit_error(e, recoverable=True)
logger.warning(
f"failed to generate LLM completion, retrying in {self._conn_options.retry_interval}s", # noqa: E501
exc_info=e,
extra={
"llm": self._llm._label,
"attempt": i + 1,
},
)
await asyncio.sleep(self._conn_options.retry_interval)
# Reset the flag when retrying
self._current_attempt_has_error = False
def _emit_error(self, api_error: APIError, recoverable: bool):
self._current_attempt_has_error = True
self._llm.emit(
"error",
LLMError(
timestamp=time.time(),
label=self._llm._label,
error=api_error,
recoverable=recoverable,
),
)
@utils.log_exceptions(logger=logger)
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[ChatChunk]) -> None:
start_time = time.perf_counter()
ttft = -1.0
request_id = ""
usage: CompletionUsage | None = None
async for ev in event_aiter:
request_id = ev.id
if ttft == -1.0:
ttft = time.perf_counter() - start_time
if ev.usage is not None:
usage = ev.usage
duration = time.perf_counter() - start_time
if self._current_attempt_has_error:
return
metrics = LLMMetrics(
timestamp=time.time(),
request_id=request_id,
ttft=ttft,
duration=duration,
cancelled=self._task.cancelled(),
label=self._llm._label,
completion_tokens=usage.completion_tokens if usage else 0,
prompt_tokens=usage.prompt_tokens if usage else 0,
prompt_cached_tokens=usage.prompt_cached_tokens if usage else 0,
total_tokens=usage.total_tokens if usage else 0,
tokens_per_second=usage.completion_tokens / duration if usage else 0.0,
)
self._llm.emit("metrics_collected", metrics)
@property
def chat_ctx(self) -> ChatContext:
return self._chat_ctx
@property
def tools(self) -> list[FunctionTool]:
return self._tools
async def aclose(self) -> None:
await aio.cancel_and_wait(self._task)
await self._metrics_task
async def __anext__(self) -> ChatChunk:
try:
val = await self._event_aiter.__anext__()
except StopAsyncIteration:
if not self._task.cancelled() and (exc := self._task.exception()):
raise exc # noqa: B904
raise StopAsyncIteration from None
return val
def __aiter__(self) -> AsyncIterator[ChatChunk]:
return self
async def __aenter__(self) -> LLMStream:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
def to_str_iterable(self) -> AsyncIterable[str]:
"""
Convert the LLMStream to an async iterable of strings.
This assumes the stream will not call any tools.
"""
async def _iterable():
async with self:
async for chunk in self:
if chunk.delta and chunk.delta.content:
yield chunk.delta.content
return _iterable()
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any, Generic, Literal, TypeVar, Union
from pydantic import BaseModel, ConfigDict, Field
from livekit import rtc
from livekit.agents._exceptions import APIError
from ..types import NOT_GIVEN, NotGivenOr
from .chat_context import ChatContext, FunctionCall
from .tool_context import FunctionTool, RawFunctionTool, ToolChoice, ToolContext
@dataclass
class InputSpeechStartedEvent:
pass
@dataclass
class InputSpeechStoppedEvent:
user_transcription_enabled: bool
@dataclass
class MessageGeneration:
message_id: str
text_stream: AsyncIterable[str] # could be io.TimedString
audio_stream: AsyncIterable[rtc.AudioFrame]
@dataclass
class GenerationCreatedEvent:
message_stream: AsyncIterable[MessageGeneration]
function_stream: AsyncIterable[FunctionCall]
user_initiated: bool
"""True if the message was generated by the user using generate_reply()"""
class RealtimeModelError(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["realtime_model_error"] = "realtime_model_error"
timestamp: float
label: str
error: APIError = Field(..., exclude=True)
recoverable: bool
@dataclass
class RealtimeCapabilities:
message_truncation: bool
turn_detection: bool
user_transcription: bool
class RealtimeError(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
class RealtimeModel:
def __init__(self, *, capabilities: RealtimeCapabilities) -> None:
self._capabilities = capabilities
self._label = f"{type(self).__module__}.{type(self).__name__}"
@property
def capabilities(self) -> RealtimeCapabilities:
return self._capabilities
@abstractmethod
def session(self) -> RealtimeSession: ...
@abstractmethod
async def aclose(self) -> None: ...
EventTypes = Literal[
"input_speech_started", # serverside VAD (also used for interruptions)
"input_speech_stopped", # serverside VAD
"input_audio_transcription_completed",
"generation_created",
"error",
]
TEvent = TypeVar("TEvent")
@dataclass
class InputTranscriptionCompleted:
item_id: str
"""id of the item"""
transcript: str
"""transcript of the input audio"""
class RealtimeSession(ABC, rtc.EventEmitter[Union[EventTypes, TEvent]], Generic[TEvent]):
def __init__(self, realtime_model: RealtimeModel) -> None:
super().__init__()
self._realtime_model = realtime_model
@property
def realtime_model(self) -> RealtimeModel:
return self._realtime_model
@property
@abstractmethod
def chat_ctx(self) -> ChatContext: ...
@property
@abstractmethod
def tools(self) -> ToolContext: ...
@abstractmethod
async def update_instructions(self, instructions: str) -> None: ...
@abstractmethod
async def update_chat_ctx(
self, chat_ctx: ChatContext
) -> None: ... # can raise RealtimeError on Timeout
@abstractmethod
async def update_tools(self, tools: list[FunctionTool | RawFunctionTool | Any]) -> None: ...
@abstractmethod
def update_options(self, *, tool_choice: NotGivenOr[ToolChoice | None] = NOT_GIVEN) -> None: ...
@abstractmethod
def push_audio(self, frame: rtc.AudioFrame) -> None: ...
@abstractmethod
def push_video(self, frame: rtc.VideoFrame) -> None: ...
@abstractmethod
def generate_reply(
self,
*,
instructions: NotGivenOr[str] = NOT_GIVEN,
) -> asyncio.Future[GenerationCreatedEvent]: ... # can raise RealtimeError on Timeout
# commit the input audio buffer to the server
@abstractmethod
def commit_audio(self) -> None: ...
# clear the input audio buffer to the server
@abstractmethod
def clear_audio(self) -> None: ...
# cancel the current generation (do nothing if no generation is in progress)
@abstractmethod
def interrupt(self) -> None: ...
# message_id is the ID of the message to truncate (inside the ChatCtx)
@abstractmethod
def truncate(self, *, message_id: str, audio_end_ms: int) -> None: ...
@abstractmethod
async def aclose(self) -> None: ...
from __future__ import annotations
from dataclasses import dataclass, field
from .chat_context import ChatContext, ChatItem
__all__ = ["RemoteChatContext"]
@dataclass
class _RemoteChatItem:
item: ChatItem
_prev: _RemoteChatItem | None = field(default=None, repr=False)
_next: _RemoteChatItem | None = field(default=None, repr=False)
class RemoteChatContext:
def __init__(self) -> None:
self._head: _RemoteChatItem | None = None
self._tail: _RemoteChatItem | None = None
self._id_to_item: dict[str, _RemoteChatItem] = {}
def to_chat_ctx(self) -> ChatContext:
items: list[ChatItem] = []
current_node = self._head
while current_node is not None:
items.append(current_node.item)
current_node = current_node._next
return ChatContext(items=items)
def get(self, item_id: str) -> _RemoteChatItem | None:
return self._id_to_item.get(item_id)
def insert(self, previous_item_id: str | None, message: ChatItem) -> None:
"""
Insert `message` after the node with ID `previous_item_id`.
If `previous_item_id` is None, insert at the head.
"""
item_id = message.id
if item_id in self._id_to_item:
raise ValueError(f"Item with ID {item_id} already exists.")
new_node = _RemoteChatItem(item=message)
if previous_item_id is None:
if self._head is not None:
new_node._next = self._head
self._head._prev = new_node
else:
self._tail = new_node
self._head = new_node
self._id_to_item[item_id] = new_node
return
prev_node = self._id_to_item.get(previous_item_id)
if prev_node is None:
raise ValueError(f"previous_item_id `{previous_item_id}` not found")
new_node._prev = prev_node
new_node._next = prev_node._next
prev_node._next = new_node
if new_node._next is not None:
new_node._next._prev = new_node
else:
self._tail = new_node
self._id_to_item[item_id] = new_node
def delete(self, item_id: str) -> None:
node = self._id_to_item.get(item_id)
if node is None:
raise ValueError(f"item_id `{item_id}` not found")
prev_node = node._prev
next_node = node._next
if self._head == node:
self._head = next_node
if self._head is not None:
self._head._prev = None
else:
if prev_node is not None:
prev_node._next = next_node
if self._tail == node:
self._tail = prev_node
if self._tail is not None:
self._tail._next = None
else:
if next_node is not None:
next_node._prev = prev_node
del self._id_to_item[item_id]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import inspect
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import (
Any,
Callable,
Literal,
Protocol,
TypeVar,
Union,
cast,
overload,
runtime_checkable,
)
from typing_extensions import NotRequired, Required, TypedDict, TypeGuard
# Used by ToolChoice
class Function(TypedDict, total=False):
name: Required[str]
class NamedToolChoice(TypedDict, total=False):
type: Required[Literal["function"]]
function: Required[Function]
ToolChoice = Union[NamedToolChoice, Literal["auto", "required", "none"]]
class ToolError(Exception):
def __init__(self, message: str) -> None:
"""
Exception raised within AI functions.
This exception should be raised by users when an error occurs
in the context of AI operations. The provided message will be
visible to the LLM, allowing it to understand the context of
the error during FunctionOutput generation.
"""
super().__init__(message)
self._message = message
@property
def message(self) -> str:
return self._message
class StopResponse(Exception):
def __init__(self) -> None:
"""
Exception raised within AI functions.
This exception can be raised by the user to indicate that
the agent should not generate a response for the current
function call.
"""
super().__init__()
@dataclass
class _FunctionToolInfo:
name: str
description: str | None
@runtime_checkable
class FunctionTool(Protocol):
__livekit_tool_info: _FunctionToolInfo
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
class RawFunctionDescription(TypedDict):
"""
Represents the raw function schema format used in LLM function calling APIs.
This structure directly maps to OpenAI's function definition format as documented at:
https://platform.openai.com/docs/guides/function-calling?api-mode=responses
It is also compatible with other LLM providers that support raw JSON Schema-based
function definitions.
"""
name: str
description: NotRequired[str]
parameters: dict[str, object]
@dataclass
class _RawFunctionToolInfo:
name: str
raw_schema: dict
@runtime_checkable
class RawFunctionTool(Protocol):
__livekit_raw_tool_info: _RawFunctionToolInfo
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
Raw_F = TypeVar("Raw_F", bound=Callable[..., Awaitable[Any]])
@overload
def function_tool(f: Raw_F, *, raw_schema: RawFunctionDescription | dict) -> RawFunctionTool: ...
@overload
def function_tool(
f: None = None, *, raw_schema: RawFunctionDescription | dict
) -> Callable[[Raw_F], RawFunctionTool]: ...
@overload
def function_tool(
f: F, *, name: str | None = None, description: str | None = None
) -> FunctionTool: ...
@overload
def function_tool(
f: None = None, *, name: str | None = None, description: str | None = None
) -> Callable[[F], FunctionTool]: ...
def function_tool(
f: F | Raw_F | None = None,
*,
name: str | None = None,
description: str | None = None,
raw_schema: RawFunctionDescription | dict | None = None,
) -> FunctionTool | RawFunctionTool | Callable[[F | Raw_F], FunctionTool | RawFunctionTool]:
def deco(func: F | Raw_F) -> RawFunctionTool | FunctionTool:
if raw_schema is not None:
if not raw_schema.get("name") or not raw_schema.get("parameters"):
raise ValueError("raw function description must contain a name and parameters key")
info = _RawFunctionToolInfo(raw_schema={**raw_schema}, name=raw_schema["name"])
setattr(func, "__livekit_raw_tool_info", info)
return cast(RawFunctionTool, func)
else:
from docstring_parser import parse_from_object
docstring = parse_from_object(func)
info = _FunctionToolInfo(
name=name or func.__name__,
description=description or docstring.description,
)
setattr(func, "__livekit_tool_info", info)
return cast(FunctionTool, func)
if f is not None:
return deco(f)
return deco
def is_function_tool(f: Callable) -> TypeGuard[FunctionTool]:
return hasattr(f, "__livekit_tool_info")
def get_function_info(f: FunctionTool) -> _FunctionToolInfo:
return getattr(f, "__livekit_tool_info")
def is_raw_function_tool(f: Callable) -> TypeGuard[RawFunctionTool]:
return hasattr(f, "__livekit_raw_tool_info")
def get_raw_function_info(f: RawFunctionTool) -> _RawFunctionToolInfo:
return getattr(f, "__livekit_raw_tool_info")
def find_function_tools(cls_or_obj: Any) -> list[FunctionTool | RawFunctionTool]:
methods: list[FunctionTool | RawFunctionTool] = []
for _, member in inspect.getmembers(cls_or_obj):
if is_function_tool(member) or is_raw_function_tool(member):
methods.append(member)
return methods
class ToolContext:
"""Stateless container for a set of AI functions"""
def __init__(self, tools: list[FunctionTool | RawFunctionTool]) -> None:
self.update_tools(tools)
@classmethod
def empty(cls) -> ToolContext:
return cls([])
@property
def function_tools(self) -> dict[str, FunctionTool | RawFunctionTool]:
return self._tools_map.copy()
def update_tools(self, tools: list[FunctionTool | RawFunctionTool]) -> None:
self._tools = tools.copy()
for method in find_function_tools(self):
tools.append(method)
self._tools_map = {}
for tool in tools:
if is_raw_function_tool(tool):
info = get_raw_function_info(tool)
elif is_function_tool(tool):
info = get_function_info(tool)
else:
# TODO(theomonnom): MCP servers & other tools
raise ValueError(f"unknown tool type: {type(tool)}")
if info.name in self._tools_map:
raise ValueError(f"duplicate function name: {info.name}")
self._tools_map[info.name] = tool
def copy(self) -> ToolContext:
return ToolContext(self._tools.copy())
from __future__ import annotations
import base64
import inspect
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Callable,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel, TypeAdapter, create_model
from pydantic.fields import Field, FieldInfo
from pydantic_core import PydanticUndefined, from_json
from typing_extensions import TypeVar
from livekit import rtc
from livekit.agents import llm, utils
from ..log import logger
from . import _strict
from .chat_context import ChatContext
from .tool_context import (
FunctionTool,
RawFunctionTool,
get_function_info,
is_function_tool,
is_raw_function_tool,
)
if TYPE_CHECKING:
from ..voice.events import RunContext
def _compute_lcs(old_ids: list[str], new_ids: list[str]) -> list[str]:
"""
Standard dynamic-programming LCS to get the common subsequence
of IDs (in order) that appear in both old_ids and new_ids.
"""
n, m = len(old_ids), len(new_ids)
dp = [[0] * (m + 1) for _ in range(n + 1)]
# Fill DP table
for i in range(1, n + 1):
for j in range(1, m + 1):
if old_ids[i - 1] == new_ids[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# Backtrack to find the actual LCS sequence
lcs_ids = []
i, j = n, m
while i > 0 and j > 0:
if old_ids[i - 1] == new_ids[j - 1]:
lcs_ids.append(old_ids[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] > dp[i][j - 1]:
i -= 1
else:
j -= 1
return list(reversed(lcs_ids))
@dataclass
class DiffOps:
to_remove: list[str]
to_create: list[
tuple[str | None, str]
] # (previous_item_id, id), if previous_item_id is None, add to the root
def compute_chat_ctx_diff(old_ctx: ChatContext, new_ctx: ChatContext) -> DiffOps:
"""Computes the minimal list of create/remove operations to transform old_ctx into new_ctx."""
# TODO(theomonnom): Make ChatMessage hashable and also add update ops
old_ids = [m.id for m in old_ctx.items]
new_ids = [m.id for m in new_ctx.items]
lcs_ids = set(_compute_lcs(old_ids, new_ids))
to_remove = [msg.id for msg in old_ctx.items if msg.id not in lcs_ids]
to_create: list[tuple[str | None, str]] = []
last_id_in_sequence: str | None = None
for new_msg in new_ctx.items:
if new_msg.id in lcs_ids:
last_id_in_sequence = new_msg.id
else:
if last_id_in_sequence is None:
prev_id = None # root
else:
prev_id = last_id_in_sequence
to_create.append((prev_id, new_msg.id))
last_id_in_sequence = new_msg.id
return DiffOps(to_remove=to_remove, to_create=to_create)
def is_context_type(ty: type) -> bool:
from ..voice.events import RunContext
origin = get_origin(ty)
is_call_context = ty is RunContext or origin is RunContext
return is_call_context
@dataclass
class SerializedImage:
inference_detail: str
mime_type: str | None
data_bytes: bytes | None = None
external_url: str | None = None
def serialize_image(image: llm.ImageContent) -> SerializedImage:
if isinstance(image.image, str):
if image.image.startswith("data:"):
header, b64_data = image.image.split(",", 1)
encoded_data = base64.b64decode(b64_data)
header_mime = header.split(";")[0].split(":")[1]
if image.mime_type and image.mime_type != header_mime:
logger.warning(
f"""Provided mime_type '{image.mime_type}' does not match data URL mime type
'{header_mime}'. Using provided mime_type."""
)
mime_type = image.mime_type
else:
mime_type = header_mime
supported_types = {"image/jpeg", "image/png", "image/webp", "image/gif"}
if mime_type not in supported_types:
raise ValueError(
f"Unsupported mime_type {mime_type}. Must be jpeg, png, webp, or gif"
)
return SerializedImage(
data_bytes=encoded_data,
mime_type=mime_type,
inference_detail=image.inference_detail,
)
else:
return SerializedImage(
mime_type=image.mime_type,
inference_detail=image.inference_detail,
external_url=image.image,
)
elif isinstance(image.image, rtc.VideoFrame):
opts = utils.images.EncodeOptions()
if image.inference_width and image.inference_height:
opts.resize_options = utils.images.ResizeOptions(
width=image.inference_width,
height=image.inference_height,
strategy="scale_aspect_fit",
)
encoded_data = utils.images.encode(image.image, opts)
return SerializedImage(
data_bytes=encoded_data,
mime_type="image/jpeg",
inference_detail=image.inference_detail,
)
raise ValueError("Unsupported image type")
def build_legacy_openai_schema(
function_tool: FunctionTool, *, internally_tagged: bool = False
) -> dict[str, Any]:
"""non-strict mode tool description
see https://serde.rs/enum-representations.html for the internally tagged representation"""
model = function_arguments_to_pydantic_model(function_tool)
info = get_function_info(function_tool)
schema = model.model_json_schema()
if internally_tagged:
return {
"name": info.name,
"description": info.description or "",
"parameters": schema,
"type": "function",
}
else:
return {
"type": "function",
"function": {
"name": info.name,
"description": info.description or "",
"parameters": schema,
},
}
def build_strict_openai_schema(
function_tool: FunctionTool,
) -> dict[str, Any]:
"""strict mode tool description"""
model = function_arguments_to_pydantic_model(function_tool)
info = get_function_info(function_tool)
schema = _strict.to_strict_json_schema(model)
return {
"type": "function",
"function": {
"name": info.name,
"strict": True,
"description": info.description or "",
"parameters": schema,
},
}
ResponseFormatT = TypeVar("ResponseFormatT", default=None)
def is_typed_dict(cls) -> bool:
return isinstance(cls, type) and issubclass(cls, dict) and hasattr(cls, "__annotations__")
# mostly from https://github.com/openai/openai-python/blob/main/src/openai/lib/_parsing/_completions.py
# and https://github.com/instructor-ai/instructor/blob/be7821e34fb10f7dabf658d684135297a2e40ef3/instructor/process_response.py#L812C1-L816C10
def to_response_format_param(
response_format: type | dict,
) -> tuple[str, type[BaseModel] | TypeAdapter[Any]]:
if isinstance(response_format, dict):
# TODO(theomonnom): better type validation, copy TypedDict from OpenAI
if response_format.get("type", "") not in ("text", "json_schema", "json_object"):
raise TypeError("Unsupported response_format type")
return response_format
# add support for TypedDict
if is_typed_dict(response_format):
response_format = create_model(
response_format.__name__,
**{k: (v, ...) for k, v in response_format.__annotations__.items()}, # type: ignore
)
json_schema_type: type[BaseModel] | TypeAdapter[Any] | None = None
if inspect.isclass(response_format) and issubclass(response_format, BaseModel):
name = response_format.__name__
json_schema_type = response_format
elif inspect.isclass(response_format) and hasattr(
response_format, "__pydantic_config__"
): # @pydantic.dataclass
name = response_format.__name__
json_schema_type = TypeAdapter(response_format)
else:
raise TypeError(f"Unsupported response_format type - {response_format}")
return name, json_schema_type
def to_openai_response_format(response_format: type | dict) -> dict:
name, json_schema_type = to_response_format_param(response_format)
schema = _strict.to_strict_json_schema(json_schema_type)
return {
"type": "json_schema",
"json_schema": {
"schema": schema,
"name": name,
"strict": True,
},
}
def function_arguments_to_pydantic_model(func: Callable) -> type[BaseModel]:
"""Create a Pydantic model from a function’s signature. (excluding context types)"""
from docstring_parser import parse_from_object
fnc_name = func.__name__.split("_")
fnc_name = "".join(x.capitalize() for x in fnc_name)
model_name = fnc_name + "Args"
docstring = parse_from_object(func)
param_docs = {p.arg_name: p.description for p in docstring.params}
signature = inspect.signature(func)
type_hints = get_type_hints(func, include_extras=True)
# field_name -> (type, FieldInfo or default)
fields: dict[str, Any] = {}
for param_name, param in signature.parameters.items():
type_hint = type_hints[param_name]
if is_context_type(type_hint):
continue
default_value = param.default if param.default is not param.empty else ...
field_info = Field()
# Annotated[str, Field(description="...")]
if get_origin(type_hint) is Annotated:
annotated_args = get_args(type_hint)
type_hint = annotated_args[0]
field_info = next(
(x for x in annotated_args[1:] if isinstance(x, FieldInfo)), field_info
)
if default_value is not ... and field_info.default is PydanticUndefined:
field_info.default = default_value
if field_info.description is None:
field_info.description = param_docs.get(param_name, None)
fields[param_name] = (type_hint, field_info)
return create_model(model_name, **fields)
def prepare_function_arguments(
*,
fnc: FunctionTool | RawFunctionTool,
json_arguments: str, # raw function output from the LLM
call_ctx: RunContext | None = None,
) -> tuple[tuple[Any, ...], dict[str, Any]]: # returns args, kwargs
"""
Create the positional and keyword arguments to call a function tool from
the raw function output from the LLM.
"""
signature = inspect.signature(fnc)
type_hints = get_type_hints(fnc, include_extras=True)
args_dict = from_json(json_arguments)
if is_function_tool(fnc):
model_type = function_arguments_to_pydantic_model(fnc)
# Function arguments with default values are treated as optional
# when converted to strict LLM function descriptions. (e.g., we convert default
# parameters to type: ["string", "null"]).
# The following make sure to use the default value when we receive None.
# (Only if the type can't be Optional)
for param_name, param in signature.parameters.items():
type_hint = type_hints[param_name]
if param_name in args_dict and args_dict[param_name] is None:
if not _is_optional_type(type_hint):
if param.default is not inspect.Parameter.empty:
args_dict[param_name] = param.default
else:
raise ValueError(
f"Received None for required parameter '{param_name} ;"
"this argument cannot be None and no default is available."
)
model = model_type.model_validate(args_dict) # can raise ValidationError
raw_fields = _shallow_model_dump(model)
elif is_raw_function_tool(fnc):
# e.g async def open_gate(self, raw_arguments: dict[str, object]):
# raw_arguments is required when using raw function tools
raw_fields = {
"raw_arguments": args_dict,
}
else:
raise ValueError(f"Unsupported function tool type: {type(fnc)}")
# inject RunContext if needed
context_dict = {}
for param_name, _ in signature.parameters.items():
type_hint = type_hints[param_name]
if is_context_type(type_hint) and call_ctx is not None:
context_dict[param_name] = call_ctx
bound = signature.bind(**{**raw_fields, **context_dict})
bound.apply_defaults()
return bound.args, bound.kwargs
def _is_optional_type(hint: Any) -> bool:
origin = get_origin(hint)
return origin is Union and type(None) in get_args(hint)
def _shallow_model_dump(model: BaseModel, *, by_alias: bool = False) -> dict[str, Any]:
result = {}
for name, field in model.model_fields.items():
key = field.alias if by_alias and field.alias else name
result[key] = getattr(model, name)
return result
import logging
DEV_LEVEL = 23
logging.addLevelName(DEV_LEVEL, "DEV")
logger = logging.getLogger("livekit.agents")
from .base import (
AgentMetrics,
EOUMetrics,
LLMMetrics,
STTMetrics,
TTSMetrics,
VADMetrics,
)
from .usage_collector import UsageCollector, UsageSummary
from .utils import log_metrics
__all__ = [
"LLMMetrics",
"AgentMetrics",
"VADMetrics",
"EOUMetrics",
"STTMetrics",
"TTSMetrics",
"UsageSummary",
"UsageCollector",
"log_metrics",
]
from __future__ import annotations
from typing import Literal, Union
from pydantic import BaseModel
class LLMMetrics(BaseModel):
type: Literal["llm_metrics"] = "llm_metrics"
label: str
request_id: str
timestamp: float
duration: float
ttft: float
cancelled: bool
completion_tokens: int
prompt_tokens: int
prompt_cached_tokens: int
total_tokens: int
tokens_per_second: float
speech_id: str | None = None
class STTMetrics(BaseModel):
type: Literal["stt_metrics"] = "stt_metrics"
label: str
request_id: str
timestamp: float
duration: float
"""The request duration in seconds, 0.0 if the STT is streaming."""
audio_duration: float
"""The duration of the pushed audio in seconds."""
streamed: bool
"""Whether the STT is streaming (e.g using websocket)."""
class TTSMetrics(BaseModel):
type: Literal["tts_metrics"] = "tts_metrics"
label: str
request_id: str
timestamp: float
ttfb: float
duration: float
audio_duration: float
cancelled: bool
characters_count: int
streamed: bool
speech_id: str | None = None
class VADMetrics(BaseModel):
type: Literal["vad_metrics"] = "vad_metrics"
label: str
timestamp: float
idle_time: float
inference_duration_total: float
inference_count: int
class EOUMetrics(BaseModel):
type: Literal["eou_metrics"] = "eou_metrics"
timestamp: float
end_of_utterance_delay: float
"""Amount of time between the end of speech from VAD and the decision to end the user's turn."""
transcription_delay: float
"""Time taken to obtain the transcript after the end of the user's speech."""
on_user_turn_completed_delay: float
"""Time taken to invoke the user's `Agent.on_user_turn_completed` callback."""
speech_id: str | None = None
AgentMetrics = Union[
STTMetrics,
LLMMetrics,
TTSMetrics,
VADMetrics,
EOUMetrics,
]
from copy import deepcopy
from dataclasses import dataclass
from .base import AgentMetrics, LLMMetrics, STTMetrics, TTSMetrics
@dataclass
class UsageSummary:
llm_prompt_tokens: int
llm_prompt_cached_tokens: int
llm_completion_tokens: int
tts_characters_count: int
stt_audio_duration: float
class UsageCollector:
def __init__(self) -> None:
self._summary = UsageSummary(0, 0, 0, 0, 0.0)
def __call__(self, metrics: AgentMetrics) -> None:
self.collect(metrics)
def collect(self, metrics: AgentMetrics) -> None:
if isinstance(metrics, LLMMetrics):
self._summary.llm_prompt_tokens += metrics.prompt_tokens
self._summary.llm_prompt_cached_tokens += metrics.prompt_cached_tokens
self._summary.llm_completion_tokens += metrics.completion_tokens
elif isinstance(metrics, TTSMetrics):
self._summary.tts_characters_count += metrics.characters_count
elif isinstance(metrics, STTMetrics):
self._summary.stt_audio_duration += metrics.audio_duration
def get_summary(self) -> UsageSummary:
return deepcopy(self._summary)
from __future__ import annotations
import logging
from ..log import logger as default_logger
from .base import AgentMetrics, EOUMetrics, LLMMetrics, STTMetrics, TTSMetrics
def log_metrics(metrics: AgentMetrics, *, logger: logging.Logger | None = None):
if logger is None:
logger = default_logger
if isinstance(metrics, LLMMetrics):
logger.info(
f"LLM metrics: ttft={metrics.ttft:.2f}, input_tokens={metrics.prompt_tokens}, cached_input_tokens={metrics.prompt_cached_tokens}, output_tokens={metrics.completion_tokens}, tokens_per_second={metrics.tokens_per_second:.2f}" # noqa: E501
)
elif isinstance(metrics, TTSMetrics):
logger.info(
f"TTS metrics: ttfb={metrics.ttfb}, audio_duration={metrics.audio_duration:.2f}"
)
elif isinstance(metrics, EOUMetrics):
logger.info(
f"EOU metrics: end_of_utterance_delay={metrics.end_of_utterance_delay:.2f}, transcription_delay={metrics.transcription_delay:.2f}" # noqa: E501
)
elif isinstance(metrics, STTMetrics):
logger.info(f"STT metrics: audio_duration={metrics.audio_duration:.2f}")
from __future__ import annotations
import logging
import threading
from abc import ABC
from typing import Literal
from . import utils
EventTypes = Literal["plugin_registered",]
class Plugin(ABC): # noqa: B024
registered_plugins: list[Plugin] = []
emitter: utils.EventEmitter[EventTypes] = utils.EventEmitter()
# TODO(theomonnom): make logger mandatory once all plugins have been updated
def __init__(
self,
title: str,
version: str,
package: str,
logger: logging.Logger | None = None,
) -> None:
self._title = title
self._version = version
self._package = package
self._logger = logger
@classmethod
def register_plugin(cls, plugin: Plugin) -> None:
if threading.current_thread() != threading.main_thread():
raise RuntimeError("Plugins must be registered on the main thread")
cls.registered_plugins.append(plugin)
cls.emitter.emit("plugin_registered", plugin)
# plugin can implement an optional download_files method
def download_files(self) -> None: # noqa: B027
pass
@property
def package(self) -> str:
return self._package
@property
def title(self) -> str:
return self._title
@property
def version(self) -> str:
return self._version
@property
def logger(self) -> logging.Logger | None:
return self._logger
keyboard-typing.ogg by Anton -- https://freesound.org/s/137/ -- License: Attribution 4.0
keyboard-typing2.opg by Anton -- https://freesound.org/s/137/ -- License: Attribution 4.0
office-ambience.ogg by klankbeeld -- https://freesound.org/s/255591/ -- License: Attribution 4.0
# ignore
from .fallback_adapter import AvailabilityChangedEvent, FallbackAdapter
from .stream_adapter import StreamAdapter, StreamAdapterWrapper
from .stt import (
STT,
RecognitionUsage,
RecognizeStream,
SpeechData,
SpeechEvent,
SpeechEventType,
SpeechStream,
STTCapabilities,
STTError,
)
__all__ = [
"SpeechEventType",
"SpeechEvent",
"SpeechData",
"RecognizeStream",
"SpeechStream",
"STT",
"STTCapabilities",
"StreamAdapter",
"StreamAdapterWrapper",
"RecognitionUsage",
"FallbackAdapter",
"AvailabilityChangedEvent",
"STTError",
]
from __future__ import annotations
import asyncio
import dataclasses
import time
from dataclasses import dataclass
from typing import Literal
from livekit import rtc
from livekit.agents.utils.audio import AudioBuffer
from .. import utils
from .._exceptions import APIConnectionError, APIError
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from ..utils import aio
from .stt import STT, RecognizeStream, SpeechEvent, SpeechEventType, STTCapabilities
# don't retry when using the fallback adapter
DEFAULT_FALLBACK_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
@dataclass
class AvailabilityChangedEvent:
stt: STT
available: bool
@dataclass
class _STTStatus:
available: bool
recovering_synthesize_task: asyncio.Task | None
recovering_stream_task: asyncio.Task | None
class FallbackAdapter(
STT[Literal["stt_availability_changed"]],
):
def __init__(
self,
stt: list[STT],
*,
attempt_timeout: float = 10.0,
max_retry_per_stt: int = 1,
retry_interval: float = 5,
) -> None:
if len(stt) < 1:
raise ValueError("At least one STT instance must be provided.")
non_streaming_stt = [t for t in stt if not t.capabilities.streaming]
if non_streaming_stt:
labels = ", ".join(t.label for t in non_streaming_stt)
raise ValueError(
f"STTs do not support streaming: {labels}. "
"Wrap them with stt.StreamAdapter to enable streaming."
)
super().__init__(
capabilities=STTCapabilities(
streaming=True,
interim_results=all(t.capabilities.interim_results for t in stt),
)
)
self._stt_instances = stt
self._attempt_timeout = attempt_timeout
self._max_retry_per_stt = max_retry_per_stt
self._retry_interval = retry_interval
self._status: list[_STTStatus] = [
_STTStatus(
available=True,
recovering_synthesize_task=None,
recovering_stream_task=None,
)
for _ in self._stt_instances
]
async def _try_recognize(
self,
*,
stt: STT,
buffer: utils.AudioBuffer,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
recovering: bool = False,
) -> SpeechEvent:
try:
return await stt.recognize(
buffer,
language=language,
conn_options=dataclasses.replace(
conn_options,
max_retry=self._max_retry_per_stt,
timeout=self._attempt_timeout,
retry_interval=self._retry_interval,
),
)
except asyncio.TimeoutError:
if recovering:
logger.warning(f"{stt.label} recovery timed out", extra={"streamed": False})
raise
logger.warning(
f"{stt.label} timed out, switching to next STT",
extra={"streamed": False},
)
raise
except APIError as e:
if recovering:
logger.warning(
f"{stt.label} recovery failed",
exc_info=e,
extra={"streamed": False},
)
raise
logger.warning(
f"{stt.label} failed, switching to next STT",
exc_info=e,
extra={"streamed": False},
)
raise
except Exception:
if recovering:
logger.exception(
f"{stt.label} recovery unexpected error", extra={"streamed": False}
)
raise
logger.exception(
f"{stt.label} unexpected error, switching to next STT",
extra={"streamed": False},
)
raise
def _try_recovery(
self,
*,
stt: STT,
buffer: utils.AudioBuffer,
language: NotGivenOr[str],
conn_options: APIConnectOptions,
) -> None:
stt_status = self._status[self._stt_instances.index(stt)]
if (
stt_status.recovering_synthesize_task is None
or stt_status.recovering_synthesize_task.done()
):
async def _recover_stt_task(stt: STT) -> None:
try:
await self._try_recognize(
stt=stt,
buffer=buffer,
language=language,
conn_options=conn_options,
recovering=True,
)
stt_status.available = True
logger.info(f"{stt.label} recovered")
self.emit(
"stt_availability_changed",
AvailabilityChangedEvent(stt=stt, available=True),
)
except Exception:
return
stt_status.recovering_synthesize_task = asyncio.create_task(_recover_stt_task(stt))
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
):
start_time = time.time()
all_failed = all(not stt_status.available for stt_status in self._status)
if all_failed:
logger.error("all STTs are unavailable, retrying..")
for i, stt in enumerate(self._stt_instances):
stt_status = self._status[i]
if stt_status.available or all_failed:
try:
return await self._try_recognize(
stt=stt,
buffer=buffer,
language=language,
conn_options=conn_options,
recovering=False,
)
except Exception: # exceptions already logged inside _try_recognize
if stt_status.available:
stt_status.available = False
self.emit(
"stt_availability_changed",
AvailabilityChangedEvent(stt=stt, available=False),
)
self._try_recovery(stt=stt, buffer=buffer, language=language, conn_options=conn_options)
raise APIConnectionError(
f"all STTs failed ({[stt.label for stt in self._stt_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
async def recognize(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
) -> SpeechEvent:
return await super().recognize(buffer, language=language, conn_options=conn_options)
def stream(
self,
*,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
) -> RecognizeStream:
return FallbackRecognizeStream(stt=self, language=language, conn_options=conn_options)
async def aclose(self) -> None:
for stt_status in self._status:
if stt_status.recovering_synthesize_task is not None:
await aio.cancel_and_wait(stt_status.recovering_synthesize_task)
if stt_status.recovering_stream_task is not None:
await aio.cancel_and_wait(stt_status.recovering_stream_task)
class FallbackRecognizeStream(RecognizeStream):
def __init__(
self,
*,
stt: FallbackAdapter,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions,
):
super().__init__(stt=stt, conn_options=conn_options, sample_rate=None)
self._language = language
self._fallback_adapter = stt
self._recovering_streams: list[RecognizeStream] = []
async def _run(self) -> None:
start_time = time.time()
all_failed = all(not stt_status.available for stt_status in self._fallback_adapter._status)
if all_failed:
logger.error("all STTs are unavailable, retrying..")
main_stream: RecognizeStream | None = None
forward_input_task: asyncio.Task | None = None
async def _forward_input_task() -> None:
async for data in self._input_ch:
try:
for stream in self._recovering_streams:
if isinstance(data, rtc.AudioFrame):
stream.push_frame(data)
elif isinstance(data, self._FlushSentinel):
stream.flush()
if main_stream is not None:
if isinstance(data, rtc.AudioFrame):
main_stream.push_frame(data)
elif isinstance(data, self._FlushSentinel):
main_stream.flush()
except RuntimeError:
pass
except Exception:
logger.exception("error happened in forwarding input", extra={"streamed": True})
if main_stream is not None:
main_stream.end_input()
for i, stt in enumerate(self._fallback_adapter._stt_instances):
stt_status = self._fallback_adapter._status[i]
if stt_status.available or all_failed:
try:
main_stream = stt.stream(
language=self._language,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_stt,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
)
if forward_input_task is None or forward_input_task.done():
forward_input_task = asyncio.create_task(_forward_input_task())
try:
async with main_stream:
async for ev in main_stream:
self._event_ch.send_nowait(ev)
except asyncio.TimeoutError:
logger.warning(
f"{stt.label} timed out, switching to next STT",
extra={"streamed": True},
)
raise
except APIError as e:
logger.warning(
f"{stt.label} failed, switching to next STT",
exc_info=e,
extra={"streamed": True},
)
raise
except Exception:
logger.exception(
f"{stt.label} unexpected error, switching to next STT",
extra={"streamed": True},
)
raise
return
except Exception:
if stt_status.available:
stt_status.available = False
self._stt.emit(
"stt_availability_changed",
AvailabilityChangedEvent(stt=stt, available=False),
)
self._try_recovery(stt)
if forward_input_task is not None:
await aio.cancel_and_wait(forward_input_task)
await asyncio.gather(*[stream.aclose() for stream in self._recovering_streams])
raise APIConnectionError(
f"all STTs failed ({[stt.label for stt in self._fallback_adapter._stt_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
def _try_recovery(self, stt: STT) -> None:
stt_status = self._fallback_adapter._status[
self._fallback_adapter._stt_instances.index(stt)
]
if stt_status.recovering_stream_task is None or stt_status.recovering_stream_task.done():
stream = stt.stream(
language=self._language,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=0,
timeout=self._fallback_adapter._attempt_timeout,
),
)
self._recovering_streams.append(stream)
async def _recover_stt_task() -> None:
try:
nb_transcript = 0
async with stream:
async for ev in stream:
if ev.type in SpeechEventType.FINAL_TRANSCRIPT:
if not ev.alternatives or not ev.alternatives[0].text:
continue
nb_transcript += 1
break
if nb_transcript == 0:
return
stt_status.available = True
logger.info(f"tts.FallbackAdapter, {stt.label} recovered")
self._fallback_adapter.emit(
"stt_availability_changed",
AvailabilityChangedEvent(stt=stt, available=True),
)
except asyncio.TimeoutError:
logger.warning(
f"{stream._stt.label} recovery timed out",
extra={"streamed": True},
)
except APIError as e:
logger.warning(
f"{stream._stt.label} recovery failed",
exc_info=e,
extra={"streamed": True},
)
except Exception:
logger.exception(
f"{stream._stt.label} recovery unexpected error",
extra={"streamed": True},
)
raise
stt_status.recovering_stream_task = task = asyncio.create_task(_recover_stt_task())
task.add_done_callback(lambda _: self._recovering_streams.remove(stream))
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable
from .. import utils
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from ..vad import VAD, VADEventType
from .stt import STT, RecognizeStream, SpeechEvent, SpeechEventType, STTCapabilities
# already a retry mechanism in STT.recognize, don't retry in stream adapter
DEFAULT_STREAM_ADAPTER_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
class StreamAdapter(STT):
def __init__(self, *, stt: STT, vad: VAD) -> None:
super().__init__(capabilities=STTCapabilities(streaming=True, interim_results=False))
self._vad = vad
self._stt = stt
@self._stt.on("metrics_collected")
def _forward_metrics(*args, **kwargs):
self.emit("metrics_collected", *args, **kwargs)
@property
def wrapped_stt(self) -> STT:
return self._stt
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str],
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
):
return await self._stt.recognize(
buffer=buffer, language=language, conn_options=conn_options
)
def stream(
self,
*,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_STREAM_ADAPTER_API_CONNECT_OPTIONS,
) -> RecognizeStream:
return StreamAdapterWrapper(
self,
vad=self._vad,
wrapped_stt=self._stt,
language=language,
conn_options=conn_options,
)
class StreamAdapterWrapper(RecognizeStream):
def __init__(
self,
stt: STT,
*,
vad: VAD,
wrapped_stt: STT,
language: NotGivenOr[str | None],
conn_options: APIConnectOptions,
) -> None:
super().__init__(stt=stt, conn_options=conn_options)
self._vad = vad
self._wrapped_stt = wrapped_stt
self._vad_stream = self._vad.stream()
self._language = language
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SpeechEvent]) -> None:
pass # do nothing
async def _run(self) -> None:
async def _forward_input():
"""forward input to vad"""
async for input in self._input_ch:
if isinstance(input, self._FlushSentinel):
self._vad_stream.flush()
continue
self._vad_stream.push_frame(input)
self._vad_stream.end_input()
async def _recognize():
"""recognize speech from vad"""
async for event in self._vad_stream:
if event.type == VADEventType.START_OF_SPEECH:
self._event_ch.send_nowait(SpeechEvent(SpeechEventType.START_OF_SPEECH))
elif event.type == VADEventType.END_OF_SPEECH:
self._event_ch.send_nowait(
SpeechEvent(
type=SpeechEventType.END_OF_SPEECH,
)
)
merged_frames = utils.merge_frames(event.frames)
t_event = await self._wrapped_stt.recognize(
buffer=merged_frames,
language=self._language,
conn_options=self._conn_options,
)
if len(t_event.alternatives) == 0:
continue
elif not t_event.alternatives[0].text:
continue
self._event_ch.send_nowait(
SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[t_event.alternatives[0]],
)
)
tasks = [
asyncio.create_task(_forward_input(), name="forward_input"),
asyncio.create_task(_recognize(), name="recognize"),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
from __future__ import annotations
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass, field
from enum import Enum, unique
from types import TracebackType
from typing import Generic, Literal, TypeVar, Union
from pydantic import BaseModel, ConfigDict, Field
from livekit import rtc
from .._exceptions import APIConnectionError, APIError
from ..log import logger
from ..metrics import STTMetrics
from ..types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, APIConnectOptions, NotGivenOr
from ..utils import AudioBuffer, aio, is_given
from ..utils.audio import calculate_audio_duration
@unique
class SpeechEventType(str, Enum):
START_OF_SPEECH = "start_of_speech"
"""indicate the start of speech
if the STT doesn't support this event, this will be emitted as the same time as the first INTERIM_TRANSCRIPT""" # noqa: E501
INTERIM_TRANSCRIPT = "interim_transcript"
"""interim transcript, useful for real-time transcription"""
FINAL_TRANSCRIPT = "final_transcript"
"""final transcript, emitted when the STT is confident enough that a certain
portion of speech will not change"""
RECOGNITION_USAGE = "recognition_usage"
"""usage event, emitted periodically to indicate usage metrics"""
END_OF_SPEECH = "end_of_speech"
"""indicate the end of speech, emitted when the user stops speaking"""
@dataclass
class SpeechData:
language: str
text: str
start_time: float = 0.0
end_time: float = 0.0
confidence: float = 0.0 # [0, 1]
@dataclass
class RecognitionUsage:
audio_duration: float
@dataclass
class SpeechEvent:
type: SpeechEventType
request_id: str = ""
alternatives: list[SpeechData] = field(default_factory=list)
recognition_usage: RecognitionUsage | None = None
@dataclass
class STTCapabilities:
streaming: bool
interim_results: bool
class STTError(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["stt_error"] = "stt_error"
timestamp: float
label: str
error: APIError = Field(..., exclude=True)
recoverable: bool
TEvent = TypeVar("TEvent")
class STT(
ABC,
rtc.EventEmitter[Union[Literal["metrics_collected", "error"], TEvent]],
Generic[TEvent],
):
def __init__(self, *, capabilities: STTCapabilities) -> None:
super().__init__()
self._capabilities = capabilities
self._label = f"{type(self).__module__}.{type(self).__name__}"
@property
def label(self) -> str:
return self._label
@property
def capabilities(self) -> STTCapabilities:
return self._capabilities
@abstractmethod
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> SpeechEvent: ...
async def recognize(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechEvent:
for i in range(conn_options.max_retry + 1):
try:
start_time = time.perf_counter()
event = await self._recognize_impl(
buffer, language=language, conn_options=conn_options
)
duration = time.perf_counter() - start_time
stt_metrics = STTMetrics(
request_id=event.request_id,
timestamp=time.time(),
duration=duration,
label=self._label,
audio_duration=calculate_audio_duration(buffer),
streamed=False,
)
self.emit("metrics_collected", stt_metrics)
return event
except APIError as e:
retry_interval = conn_options._interval_for_retry(i)
if conn_options.max_retry == 0:
raise
elif i == conn_options.max_retry:
raise APIConnectionError(
f"failed to recognize speech after {conn_options.max_retry + 1} attempts",
) from e
else:
logger.warning(
f"failed to recognize speech, retrying in {retry_interval}s",
exc_info=e,
extra={
"tts": self._label,
"attempt": i + 1,
"streamed": False,
},
)
await asyncio.sleep(retry_interval)
raise RuntimeError("unreachable")
def stream(
self,
*,
language: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> RecognizeStream:
raise NotImplementedError(
"streaming is not supported by this STT, please use a different STT or use a StreamAdapter" # noqa: E501
)
async def aclose(self) -> None:
"""Close the STT, and every stream/requests associated with it"""
...
async def __aenter__(self) -> STT:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
class RecognizeStream(ABC):
class _FlushSentinel:
"""Sentinel to mark when it was flushed"""
pass
def __init__(
self,
*,
stt: STT,
conn_options: APIConnectOptions,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
):
"""
Args:
sample_rate : int or None, optional
The desired sample rate for the audio input.
If specified, the audio input will be automatically resampled to match
the given sample rate before being processed for Speech-to-Text.
If not provided (None), the input will retain its original sample rate.
"""
self._stt = stt
self._conn_options = conn_options
self._input_ch = aio.Chan[Union[rtc.AudioFrame, RecognizeStream._FlushSentinel]]()
self._event_ch = aio.Chan[SpeechEvent]()
self._event_aiter, monitor_aiter = aio.itertools.tee(self._event_ch, 2)
self._metrics_task = asyncio.create_task(
self._metrics_monitor_task(monitor_aiter), name="STT._metrics_task"
)
self._task = asyncio.create_task(self._main_task())
self._task.add_done_callback(lambda _: self._event_ch.close())
self._needed_sr = sample_rate if is_given(sample_rate) else None
self._pushed_sr = 0
self._resampler: rtc.AudioResampler | None = None
@abstractmethod
async def _run(self) -> None: ...
async def _main_task(self) -> None:
max_retries = self._conn_options.max_retry
num_retries = 0
while num_retries <= max_retries:
try:
return await self._run()
except APIError as e:
if max_retries == 0:
self._emit_error(e, recoverable=False)
raise
elif num_retries == max_retries:
self._emit_error(e, recoverable=False)
raise APIConnectionError(
f"failed to recognize speech after {num_retries} attempts",
) from e
else:
self._emit_error(e, recoverable=True)
retry_interval = self._conn_options._interval_for_retry(num_retries)
logger.warning(
f"failed to recognize speech, retrying in {retry_interval}s",
exc_info=e,
extra={
"tts": self._stt._label,
"attempt": num_retries,
"streamed": True,
},
)
await asyncio.sleep(retry_interval)
num_retries += 1
def _emit_error(self, api_error: APIError, recoverable: bool):
self._stt.emit(
"error",
STTError(
timestamp=time.time(),
label=self._stt._label,
error=api_error,
recoverable=recoverable,
),
)
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SpeechEvent]) -> None:
"""Task used to collect metrics"""
async for ev in event_aiter:
if ev.type == SpeechEventType.RECOGNITION_USAGE:
assert ev.recognition_usage is not None, (
"recognition_usage must be provided for RECOGNITION_USAGE event"
)
stt_metrics = STTMetrics(
request_id=ev.request_id,
timestamp=time.time(),
duration=0.0,
label=self._stt._label,
audio_duration=ev.recognition_usage.audio_duration,
streamed=True,
)
self._stt.emit("metrics_collected", stt_metrics)
def push_frame(self, frame: rtc.AudioFrame) -> None:
"""Push audio to be recognized"""
self._check_input_not_ended()
self._check_not_closed()
if self._pushed_sr and self._pushed_sr != frame.sample_rate:
raise ValueError("the sample rate of the input frames must be consistent")
self._pushed_sr = frame.sample_rate
if self._needed_sr and self._needed_sr != frame.sample_rate:
if not self._resampler:
self._resampler = rtc.AudioResampler(
frame.sample_rate,
self._needed_sr,
quality=rtc.AudioResamplerQuality.HIGH,
)
if self._resampler:
frames = self._resampler.push(frame)
for frame in frames:
self._input_ch.send_nowait(frame)
else:
self._input_ch.send_nowait(frame)
def flush(self) -> None:
"""Mark the end of the current segment"""
self._check_input_not_ended()
self._check_not_closed()
if self._resampler:
for frame in self._resampler.flush():
self._input_ch.send_nowait(frame)
self._input_ch.send_nowait(self._FlushSentinel())
def end_input(self) -> None:
"""Mark the end of input, no more audio will be pushed"""
self.flush()
self._input_ch.close()
async def aclose(self) -> None:
"""Close ths stream immediately"""
self._input_ch.close()
await aio.cancel_and_wait(self._task)
if self._metrics_task is not None:
await self._metrics_task
async def __anext__(self) -> SpeechEvent:
try:
val = await self._event_aiter.__anext__()
except StopAsyncIteration:
if not self._task.cancelled() and (exc := self._task.exception()):
raise exc # noqa: B904
raise StopAsyncIteration from None
return val
def __aiter__(self) -> AsyncIterator[SpeechEvent]:
return self
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
def _check_input_not_ended(self) -> None:
if self._input_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} input ended")
async def __aenter__(self) -> RecognizeStream:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
SpeechStream = RecognizeStream # deprecated alias
from . import basic, utils
from .token_stream import BufferedSentenceStream, BufferedWordStream
from .tokenizer import (
SentenceStream,
SentenceTokenizer,
TokenData,
WordStream,
WordTokenizer,
)
__all__ = [
"SentenceTokenizer",
"SentenceStream",
"WordTokenizer",
"WordStream",
"TokenData",
"BufferedSentenceStream",
"BufferedWordStream",
"basic",
"utils",
]
from __future__ import annotations
import re
# Frank Liang hyphenator. impl from https://github.com/jfinkels/hyphenate
# This is English only, it is a good default.
# Users that want different languages or more advanced hyphenation should use the livekit-plugins-*
class Hyphenator:
def __init__(self, patterns, exceptions=""):
self.tree = {}
for pattern in patterns.split():
self._insert_pattern(pattern)
self.exceptions = {}
for ex in exceptions.split():
# Convert the hyphenated pattern into a point array for use later.
points = [0] + [int(h == "-") for h in re.split(r"[a-z]", ex)]
self.exceptions[ex.replace("-", "")] = points
def _insert_pattern(self, pattern):
# Convert the a pattern like 'a1bc3d4' into a string of chars 'abcd'
# and a list of points [ 0, 1, 0, 3, 4 ].
chars = re.sub("[0-9]", "", pattern)
points = [int(d or 0) for d in re.split("[.a-z]", pattern)]
# Insert the pattern into the tree. Each character finds a dict
# another level down in the tree, and leaf nodes have the list of
# points.
t = self.tree
for c in chars:
if c not in t:
t[c] = {}
t = t[c]
t[None] = points
def hyphenate_word(self, word: str) -> list[str]:
"""Given a word, returns a list of pieces, broken at the possible
hyphenation points.
"""
# Short words aren't hyphenated.
if len(word) <= 4:
return [word]
# If the word is an exception, get the stored points.
if word.lower() in self.exceptions:
points = self.exceptions[word.lower()]
else:
work = "." + word.lower() + "."
points = [0] * (len(work) + 1)
for i in range(len(work)):
t = self.tree
for c in work[i:]:
if c in t:
t = t[c]
if None in t:
p = t[None]
for j, p_j in enumerate(p):
points[i + j] = max(points[i + j], p_j)
else:
break
# No hyphens in the first two chars or the last two.
points[1] = points[2] = points[-2] = points[-3] = 0
# Examine the points to build the pieces list.
pieces = [""]
for c, p in zip(word, points[2:]):
pieces[-1] += c
if p % 2:
pieces.append("")
return pieces
PATTERNS = (
# Knuth and Liang's original hyphenation patterns from classic TeX.
# In the public domain.
"""
.ach4 .ad4der .af1t .al3t .am5at .an5c .ang4 .ani5m .ant4 .an3te .anti5s
.ar5s .ar4tie .ar4ty .as3c .as1p .as1s .aster5 .atom5 .au1d .av4i .awn4
.ba4g .ba5na .bas4e .ber4 .be5ra .be3sm .be5sto .bri2 .but4ti .cam4pe
.can5c .capa5b .car5ol .ca4t .ce4la .ch4 .chill5i .ci2 .cit5r .co3e .co4r
.cor5ner .de4moi .de3o .de3ra .de3ri .des4c .dictio5 .do4t .du4c .dumb5
.earth5 .eas3i .eb4 .eer4 .eg2 .el5d .el3em .enam3 .en3g .en3s .eq5ui5t
.er4ri .es3 .eu3 .eye5 .fes3 .for5mer .ga2 .ge2 .gen3t4 .ge5og .gi5a .gi4b
.go4r .hand5i .han5k .he2 .hero5i .hes3 .het3 .hi3b .hi3er .hon5ey .hon3o
.hov5 .id4l .idol3 .im3m .im5pin .in1 .in3ci .ine2 .in2k .in3s .ir5r .is4i
.ju3r .la4cy .la4m .lat5er .lath5 .le2 .leg5e .len4 .lep5 .lev1 .li4g
.lig5a .li2n .li3o .li4t .mag5a5 .mal5o .man5a .mar5ti .me2 .mer3c .me5ter
.mis1 .mist5i .mon3e .mo3ro .mu5ta .muta5b .ni4c .od2 .odd5 .of5te .or5ato
.or3c .or1d .or3t .os3 .os4tl .oth3 .out3 .ped5al .pe5te .pe5tit .pi4e
.pio5n .pi2t .pre3m .ra4c .ran4t .ratio5na .ree2 .re5mit .res2 .re5stat
.ri4g .rit5u .ro4q .ros5t .row5d .ru4d .sci3e .self5 .sell5 .se2n .se5rie
.sh2 .si2 .sing4 .st4 .sta5bl .sy2 .ta4 .te4 .ten5an .th2 .ti2 .til4
.tim5o5 .ting4 .tin5k .ton4a .to4p .top5i .tou5s .trib5ut .un1a .un3ce
.under5 .un1e .un5k .un5o .un3u .up3 .ure3 .us5a .ven4de .ve5ra .wil5i .ye4
4ab. a5bal a5ban abe2 ab5erd abi5a ab5it5ab ab5lat ab5o5liz 4abr ab5rog
ab3ul a4car ac5ard ac5aro a5ceou ac1er a5chet 4a2ci a3cie ac1in a3cio
ac5rob act5if ac3ul ac4um a2d ad4din ad5er. 2adi a3dia ad3ica adi4er a3dio
a3dit a5diu ad4le ad3ow ad5ran ad4su 4adu a3duc ad5um ae4r aeri4e a2f aff4
a4gab aga4n ag5ell age4o 4ageu ag1i 4ag4l ag1n a2go 3agog ag3oni a5guer
ag5ul a4gy a3ha a3he ah4l a3ho ai2 a5ia a3ic. ai5ly a4i4n ain5in ain5o
ait5en a1j ak1en al5ab al3ad a4lar 4aldi 2ale al3end a4lenti a5le5o al1i
al4ia. ali4e al5lev 4allic 4alm a5log. a4ly. 4alys 5a5lyst 5alyt 3alyz 4ama
am5ab am3ag ama5ra am5asc a4matis a4m5ato am5era am3ic am5if am5ily am1in
ami4no a2mo a5mon amor5i amp5en a2n an3age 3analy a3nar an3arc anar4i
a3nati 4and ande4s an3dis an1dl an4dow a5nee a3nen an5est. a3neu 2ang
ang5ie an1gl a4n1ic a3nies an3i3f an4ime a5nimi a5nine an3io a3nip an3ish
an3it a3niu an4kli 5anniz ano4 an5ot anoth5 an2sa an4sco an4sn an2sp ans3po
an4st an4sur antal4 an4tie 4anto an2tr an4tw an3ua an3ul a5nur 4ao apar4
ap5at ap5ero a3pher 4aphi a4pilla ap5illar ap3in ap3ita a3pitu a2pl apoc5
ap5ola apor5i apos3t aps5es a3pu aque5 2a2r ar3act a5rade ar5adis ar3al
a5ramete aran4g ara3p ar4at a5ratio ar5ativ a5rau ar5av4 araw4 arbal4
ar4chan ar5dine ar4dr ar5eas a3ree ar3ent a5ress ar4fi ar4fl ar1i ar5ial
ar3ian a3riet ar4im ar5inat ar3io ar2iz ar2mi ar5o5d a5roni a3roo ar2p ar3q
arre4 ar4sa ar2sh 4as. as4ab as3ant ashi4 a5sia. a3sib a3sic 5a5si4t ask3i
as4l a4soc as5ph as4sh as3ten as1tr asur5a a2ta at3abl at5ac at3alo at5ap
ate5c at5ech at3ego at3en. at3era ater5n a5terna at3est at5ev 4ath ath5em
a5then at4ho ath5om 4ati. a5tia at5i5b at1ic at3if ation5ar at3itu a4tog
a2tom at5omiz a4top a4tos a1tr at5rop at4sk at4tag at5te at4th a2tu at5ua
at5ue at3ul at3ura a2ty au4b augh3 au3gu au4l2 aun5d au3r au5sib aut5en
au1th a2va av3ag a5van ave4no av3era av5ern av5ery av1i avi4er av3ig av5oc
a1vor 3away aw3i aw4ly aws4 ax4ic ax4id ay5al aye4 ays4 azi4er azz5i
5ba. bad5ger ba4ge bal1a ban5dag ban4e ban3i barbi5 bari4a bas4si 1bat ba4z
2b1b b2be b3ber bbi4na 4b1d 4be. beak4 beat3 4be2d be3da be3de be3di be3gi
be5gu 1bel be1li be3lo 4be5m be5nig be5nu 4bes4 be3sp be5str 3bet bet5iz
be5tr be3tw be3w be5yo 2bf 4b3h bi2b bi4d 3bie bi5en bi4er 2b3if 1bil
bi3liz bina5r4 bin4d bi5net bi3ogr bi5ou bi2t 3bi3tio bi3tr 3bit5ua b5itz
b1j bk4 b2l2 blath5 b4le. blen4 5blesp b3lis b4lo blun4t 4b1m 4b3n bne5g
3bod bod3i bo4e bol3ic bom4bi bon4a bon5at 3boo 5bor. 4b1ora bor5d 5bore
5bori 5bos4 b5ota both5 bo4to bound3 4bp 4brit broth3 2b5s2 bsor4 2bt bt4l
b4to b3tr buf4fer bu4ga bu3li bumi4 bu4n bunt4i bu3re bus5ie buss4e 5bust
4buta 3butio b5uto b1v 4b5w 5by. bys4 1ca cab3in ca1bl cach4 ca5den 4cag4
2c5ah ca3lat cal4la call5in 4calo can5d can4e can4ic can5is can3iz can4ty
cany4 ca5per car5om cast5er cas5tig 4casy ca4th 4cativ cav5al c3c ccha5
cci4a ccompa5 ccon4 ccou3t 2ce. 4ced. 4ceden 3cei 5cel. 3cell 1cen 3cenc
2cen4e 4ceni 3cent 3cep ce5ram 4cesa 3cessi ces5si5b ces5t cet4 c5e4ta cew4
2ch 4ch. 4ch3ab 5chanic ch5a5nis che2 cheap3 4ched che5lo 3chemi ch5ene
ch3er. ch3ers 4ch1in 5chine. ch5iness 5chini 5chio 3chit chi2z 3cho2 ch4ti
1ci 3cia ci2a5b cia5r ci5c 4cier 5cific. 4cii ci4la 3cili 2cim 2cin c4ina
3cinat cin3em c1ing c5ing. 5cino cion4 4cipe ci3ph 4cipic 4cista 4cisti
2c1it cit3iz 5ciz ck1 ck3i 1c4l4 4clar c5laratio 5clare cle4m 4clic clim4
cly4 c5n 1co co5ag coe2 2cog co4gr coi4 co3inc col5i 5colo col3or com5er
con4a c4one con3g con5t co3pa cop3ic co4pl 4corb coro3n cos4e cov1 cove4
cow5a coz5e co5zi c1q cras5t 5crat. 5cratic cre3at 5cred 4c3reta cre4v cri2
cri5f c4rin cris4 5criti cro4pl crop5o cros4e cru4d 4c3s2 2c1t cta4b ct5ang
c5tant c2te c3ter c4ticu ctim3i ctu4r c4tw cud5 c4uf c4ui cu5ity 5culi
cul4tis 3cultu cu2ma c3ume cu4mi 3cun cu3pi cu5py cur5a4b cu5ria 1cus
cuss4i 3c4ut cu4tie 4c5utiv 4cutr 1cy cze4 1d2a 5da. 2d3a4b dach4 4daf 2dag
da2m2 dan3g dard5 dark5 4dary 3dat 4dativ 4dato 5dav4 dav5e 5day d1b d5c
d1d4 2de. deaf5 deb5it de4bon decan4 de4cil de5com 2d1ed 4dee. de5if deli4e
del5i5q de5lo d4em 5dem. 3demic dem5ic. de5mil de4mons demor5 1den de4nar
de3no denti5f de3nu de1p de3pa depi4 de2pu d3eq d4erh 5derm dern5iz der5s
des2 d2es. de1sc de2s5o des3ti de3str de4su de1t de2to de1v dev3il 4dey
4d1f d4ga d3ge4t dg1i d2gy d1h2 5di. 1d4i3a dia5b di4cam d4ice 3dict 3did
5di3en d1if di3ge di4lato d1in 1dina 3dine. 5dini di5niz 1dio dio5g di4pl
dir2 di1re dirt5i dis1 5disi d4is3t d2iti 1di1v d1j d5k2 4d5la 3dle. 3dled
3dles. 4dless 2d3lo 4d5lu 2dly d1m 4d1n4 1do 3do. do5de 5doe 2d5of d4og
do4la doli4 do5lor dom5iz do3nat doni4 doo3d dop4p d4or 3dos 4d5out do4v
3dox d1p 1dr drag5on 4drai dre4 drea5r 5dren dri4b dril4 dro4p 4drow
5drupli 4dry 2d1s2 ds4p d4sw d4sy d2th 1du d1u1a du2c d1uca duc5er
4duct. 4ducts du5el du4g d3ule dum4be du4n 4dup du4pe d1v d1w d2y 5dyn
dy4se dys5p e1a4b e3act ead1 ead5ie ea4ge ea5ger ea4l eal5er eal3ou eam3er
e5and ear3a ear4c ear5es ear4ic ear4il ear5k ear2t eart3e ea5sp e3ass east3
ea2t eat5en eath3i e5atif e4a3tu ea2v eav3en eav5i eav5o 2e1b e4bel. e4bels
e4ben e4bit e3br e4cad ecan5c ecca5 e1ce ec5essa ec2i e4cib ec5ificat
ec5ifie ec5ify ec3im eci4t e5cite e4clam e4clus e2col e4comm e4compe e4conc
e2cor ec3ora eco5ro e1cr e4crem ec4tan ec4te e1cu e4cul ec3ula 2e2da 4ed3d
e4d1er ede4s 4edi e3dia ed3ib ed3ica ed3im ed1it edi5z 4edo e4dol edon2
e4dri e4dul ed5ulo ee2c eed3i ee2f eel3i ee4ly ee2m ee4na ee4p1 ee2s4 eest4
ee4ty e5ex e1f e4f3ere 1eff e4fic 5efici efil4 e3fine ef5i5nite 3efit
efor5es e4fuse. 4egal eger4 eg5ib eg4ic eg5ing e5git5 eg5n e4go. e4gos
eg1ul e5gur 5egy e1h4 eher4 ei2 e5ic ei5d eig2 ei5gl e3imb e3inf e1ing
e5inst eir4d eit3e ei3th e5ity e1j e4jud ej5udi eki4n ek4la e1la
e4la. e4lac elan4d el5ativ e4law elaxa4 e3lea el5ebra 5elec e4led el3ega
e5len e4l1er e1les el2f el2i e3libe e4l5ic. el3ica e3lier el5igib e5lim
e4l3ing e3lio e2lis el5ish e3liv3 4ella el4lab ello4 e5loc el5og
el3op. el2sh el4ta e5lud el5ug e4mac e4mag e5man em5ana em5b e1me e2mel
e4met em3ica emi4e em5igra em1in2 em5ine em3i3ni e4mis em5ish e5miss em3iz
5emniz emo4g emoni5o em3pi e4mul em5ula emu3n e3my en5amo e4nant ench4er
en3dic e5nea e5nee en3em en5ero en5esi en5est en3etr e3new en5ics e5nie
e5nil e3nio en3ish en3it e5niu 5eniz 4enn 4eno eno4g e4nos en3ov en4sw
ent5age 4enthes en3ua en5uf e3ny. 4en3z e5of eo2g e4oi4 e3ol eop3ar e1or
eo3re eo5rol eos4 e4ot eo4to e5out e5ow e2pa e3pai ep5anc e5pel e3pent
ep5etitio ephe4 e4pli e1po e4prec ep5reca e4pred ep3reh e3pro e4prob ep4sh
ep5ti5b e4put ep5uta e1q equi3l e4q3ui3s er1a era4b 4erand er3ar
4erati. 2erb er4bl er3ch er4che 2ere. e3real ere5co ere3in er5el. er3emo
er5ena er5ence 4erene er3ent ere4q er5ess er3est eret4 er1h er1i e1ria4
5erick e3rien eri4er er3ine e1rio 4erit er4iu eri4v e4riva er3m4 er4nis
4ernit 5erniz er3no 2ero er5ob e5roc ero4r er1ou er1s er3set ert3er 4ertl
er3tw 4eru eru4t 5erwau e1s4a e4sage. e4sages es2c e2sca es5can e3scr es5cu
e1s2e e2sec es5ecr es5enc e4sert. e4serts e4serva 4esh e3sha esh5en e1si
e2sic e2sid es5iden es5igna e2s5im es4i4n esis4te esi4u e5skin es4mi e2sol
es3olu e2son es5ona e1sp es3per es5pira es4pre 2ess es4si4b estan4 es3tig
es5tim 4es2to e3ston 2estr e5stro estruc5 e2sur es5urr es4w eta4b eten4d
e3teo ethod3 et1ic e5tide etin4 eti4no e5tir e5titio et5itiv 4etn et5ona
e3tra e3tre et3ric et5rif et3rog et5ros et3ua et5ym et5z 4eu e5un e3up
eu3ro eus4 eute4 euti5l eu5tr eva2p5 e2vas ev5ast e5vea ev3ell evel3o
e5veng even4i ev1er e5verb e1vi ev3id evi4l e4vin evi4v e5voc e5vu e1wa
e4wag e5wee e3wh ewil5 ew3ing e3wit 1exp 5eyc 5eye. eys4 1fa fa3bl fab3r
fa4ce 4fag fain4 fall5e 4fa4ma fam5is 5far far5th fa3ta fa3the 4fato fault5
4f5b 4fd 4fe. feas4 feath3 fe4b 4feca 5fect 2fed fe3li fe4mo fen2d fend5e
fer1 5ferr fev4 4f1f f4fes f4fie f5fin. f2f5is f4fly f2fy 4fh 1fi fi3a
2f3ic. 4f3ical f3ican 4ficate f3icen fi3cer fic4i 5ficia 5ficie 4fics fi3cu
fi5del fight5 fil5i fill5in 4fily 2fin 5fina fin2d5 fi2ne f1in3g fin4n
fis4ti f4l2 f5less flin4 flo3re f2ly5 4fm 4fn 1fo 5fon fon4de fon4t fo2r
fo5rat for5ay fore5t for4i fort5a fos5 4f5p fra4t f5rea fres5c fri2 fril4
frol5 2f3s 2ft f4to f2ty 3fu fu5el 4fug fu4min fu5ne fu3ri fusi4 fus4s
4futa 1fy 1ga gaf4 5gal. 3gali ga3lo 2gam ga5met g5amo gan5is ga3niz
gani5za 4gano gar5n4 gass4 gath3 4gativ 4gaz g3b gd4 2ge. 2ged geez4 gel4in
ge5lis ge5liz 4gely 1gen ge4nat ge5niz 4geno 4geny 1geo ge3om g4ery 5gesi
geth5 4geto ge4ty ge4v 4g1g2 g2ge g3ger gglu5 ggo4 gh3in gh5out gh4to
5gi. 1gi4a gia5r g1ic 5gicia g4ico gien5 5gies. gil4 g3imen 3g4in. gin5ge
5g4ins 5gio 3gir gir4l g3isl gi4u 5giv 3giz gl2 gla4 glad5i 5glas 1gle
gli4b g3lig 3glo glo3r g1m g4my gn4a g4na. gnet4t g1ni g2nin g4nio g1no
g4non 1go 3go. gob5 5goe 3g4o4g go3is gon2 4g3o3na gondo5 go3ni 5goo go5riz
gor5ou 5gos. gov1 g3p 1gr 4grada g4rai gran2 5graph. g5rapher 5graphic
4graphy 4gray gre4n 4gress. 4grit g4ro gruf4 gs2 g5ste gth3 gu4a 3guard
2gue 5gui5t 3gun 3gus 4gu4t g3w 1gy 2g5y3n gy5ra h3ab4l hach4 hae4m hae4t
h5agu ha3la hala3m ha4m han4ci han4cy 5hand. han4g hang5er hang5o h5a5niz
han4k han4te hap3l hap5t ha3ran ha5ras har2d hard3e har4le harp5en har5ter
has5s haun4 5haz haz3a h1b 1head 3hear he4can h5ecat h4ed he5do5 he3l4i
hel4lis hel4ly h5elo hem4p he2n hena4 hen5at heo5r hep5 h4era hera3p her4ba
here5a h3ern h5erou h3ery h1es he2s5p he4t het4ed heu4 h1f h1h hi5an hi4co
high5 h4il2 himer4 h4ina hion4e hi4p hir4l hi3ro hir4p hir4r his3el his4s
hith5er hi2v 4hk 4h1l4 hlan4 h2lo hlo3ri 4h1m hmet4 2h1n h5odiz h5ods ho4g
hoge4 hol5ar 3hol4e ho4ma home3 hon4a ho5ny 3hood hoon4 hor5at ho5ris
hort3e ho5ru hos4e ho5sen hos1p 1hous house3 hov5el 4h5p 4hr4 hree5 hro5niz
hro3po 4h1s2 h4sh h4tar ht1en ht5es h4ty hu4g hu4min hun5ke hun4t hus3t4
hu4t h1w h4wart hy3pe hy3ph hy2s 2i1a i2al iam4 iam5ete i2an 4ianc ian3i
4ian4t ia5pe iass4 i4ativ ia4tric i4atu ibe4 ib3era ib5ert ib5ia ib3in
ib5it. ib5ite i1bl ib3li i5bo i1br i2b5ri i5bun 4icam 5icap 4icar
i4car. i4cara icas5 i4cay iccu4 4iceo 4ich 2ici i5cid ic5ina i2cip ic3ipa
i4cly i2c5oc 4i1cr 5icra i4cry ic4te ictu2 ic4t3ua ic3ula ic4um ic5uo i3cur
2id i4dai id5anc id5d ide3al ide4s i2di id5ian idi4ar i5die id3io idi5ou
id1it id5iu i3dle i4dom id3ow i4dr i2du id5uo 2ie4 ied4e 5ie5ga ield3
ien5a4 ien4e i5enn i3enti i1er. i3esc i1est i3et 4if. if5ero iff5en if4fr
4ific. i3fie i3fl 4ift 2ig iga5b ig3era ight3i 4igi i3gib ig3il ig3in ig3it
i4g4l i2go ig3or ig5ot i5gre igu5i ig1ur i3h 4i5i4 i3j 4ik i1la il3a4b
i4lade i2l5am ila5ra i3leg il1er ilev4 il5f il1i il3ia il2ib il3io il4ist
2ilit il2iz ill5ab 4iln il3oq il4ty il5ur il3v i4mag im3age ima5ry imenta5r
4imet im1i im5ida imi5le i5mini 4imit im4ni i3mon i2mu im3ula 2in. i4n3au
4inav incel4 in3cer 4ind in5dling 2ine i3nee iner4ar i5ness 4inga 4inge
in5gen 4ingi in5gling 4ingo 4ingu 2ini i5ni. i4nia in3io in1is
i5nite. 5initio in3ity 4ink 4inl 2inn 2i1no i4no4c ino4s i4not 2ins in3se
insur5a 2int. 2in4th in1u i5nus 4iny 2io 4io. ioge4 io2gr i1ol io4m ion3at
ion4ery ion3i io5ph ior3i i4os io5th i5oti io4to i4our 2ip ipe4 iphras4
ip3i ip4ic ip4re4 ip3ul i3qua iq5uef iq3uid iq3ui3t 4ir i1ra ira4b i4rac
ird5e ire4de i4ref i4rel4 i4res ir5gi ir1i iri5de ir4is iri3tu 5i5r2iz
ir4min iro4g 5iron. ir5ul 2is. is5ag is3ar isas5 2is1c is3ch 4ise is3er
3isf is5han is3hon ish5op is3ib isi4d i5sis is5itiv 4is4k islan4 4isms i2so
iso5mer is1p is2pi is4py 4is1s is4sal issen4 is4ses is4ta. is1te is1ti
ist4ly 4istral i2su is5us 4ita. ita4bi i4tag 4ita5m i3tan i3tat 2ite it3era
i5teri it4es 2ith i1ti 4itia 4i2tic it3ica 5i5tick it3ig it5ill i2tim 2itio
4itis i4tism i2t5o5m 4iton i4tram it5ry 4itt it3uat i5tud it3ul 4itz. i1u
2iv iv3ell iv3en. i4v3er. i4vers. iv5il. iv5io iv1it i5vore iv3o3ro i4v3ot
4i5w ix4o 4iy 4izar izi4 5izont 5ja jac4q ja4p 1je jer5s 4jestie 4jesty
jew3 jo4p 5judg 3ka. k3ab k5ag kais4 kal4 k1b k2ed 1kee ke4g ke5li k3en4d
k1er kes4 k3est. ke4ty k3f kh4 k1i 5ki. 5k2ic k4ill kilo5 k4im k4in. kin4de
k5iness kin4g ki4p kis4 k5ish kk4 k1l 4kley 4kly k1m k5nes 1k2no ko5r kosh4
k3ou kro5n 4k1s2 k4sc ks4l k4sy k5t k1w lab3ic l4abo laci4 l4ade la3dy
lag4n lam3o 3land lan4dl lan5et lan4te lar4g lar3i las4e la5tan 4lateli
4lativ 4lav la4v4a 2l1b lbin4 4l1c2 lce4 l3ci 2ld l2de ld4ere ld4eri ldi4
ld5is l3dr l4dri le2a le4bi left5 5leg. 5legg le4mat lem5atic 4len. 3lenc
5lene. 1lent le3ph le4pr lera5b ler4e 3lerg 3l4eri l4ero les2 le5sco 5lesq
3less 5less. l3eva lev4er. lev4era lev4ers 3ley 4leye 2lf l5fr 4l1g4 l5ga
lgar3 l4ges lgo3 2l3h li4ag li2am liar5iz li4as li4ato li5bi 5licio li4cor
4lics 4lict. l4icu l3icy l3ida lid5er 3lidi lif3er l4iff li4fl 5ligate
3ligh li4gra 3lik 4l4i4l lim4bl lim3i li4mo l4im4p l4ina 1l4ine lin3ea
lin3i link5er li5og 4l4iq lis4p l1it l2it. 5litica l5i5tics liv3er l1iz 4lj
lka3 l3kal lka4t l1l l4law l2le l5lea l3lec l3leg l3lel l3le4n l3le4t ll2i
l2lin4 l5lina ll4o lloqui5 ll5out l5low 2lm l5met lm3ing l4mod lmon4 2l1n2
3lo. lob5al lo4ci 4lof 3logic l5ogo 3logu lom3er 5long lon4i l3o3niz lood5
5lope. lop3i l3opm lora4 lo4rato lo5rie lor5ou 5los. los5et 5losophiz
5losophy los4t lo4ta loun5d 2lout 4lov 2lp lpa5b l3pha l5phi lp5ing l3pit
l4pl l5pr 4l1r 2l1s2 l4sc l2se l4sie 4lt lt5ag ltane5 l1te lten4 ltera4
lth3i l5ties. ltis4 l1tr ltu2 ltur3a lu5a lu3br luch4 lu3ci lu3en luf4
lu5id lu4ma 5lumi l5umn. 5lumnia lu3o luo3r 4lup luss4 lus3te 1lut l5ven
l5vet4 2l1w 1ly 4lya 4lyb ly5me ly3no 2lys4 l5yse 1ma 2mab ma2ca ma5chine
ma4cl mag5in 5magn 2mah maid5 4mald ma3lig ma5lin mal4li mal4ty 5mania
man5is man3iz 4map ma5rine. ma5riz mar4ly mar3v ma5sce mas4e mas1t 5mate
math3 ma3tis 4matiza 4m1b mba4t5 m5bil m4b3ing mbi4v 4m5c 4me. 2med
4med. 5media me3die m5e5dy me2g mel5on mel4t me2m mem1o3 1men men4a men5ac
men4de 4mene men4i mens4 mensu5 3ment men4te me5on m5ersa 2mes 3mesti me4ta
met3al me1te me5thi m4etr 5metric me5trie me3try me4v 4m1f 2mh 5mi. mi3a
mid4a mid4g mig4 3milia m5i5lie m4ill min4a 3mind m5inee m4ingl min5gli
m5ingly min4t m4inu miot4 m2is mis4er. mis5l mis4ti m5istry 4mith m2iz 4mk
4m1l m1m mma5ry 4m1n mn4a m4nin mn4o 1mo 4mocr 5mocratiz mo2d1 mo4go mois2
moi5se 4mok mo5lest mo3me mon5et mon5ge moni3a mon4ism mon4ist mo3niz
monol4 mo3ny. mo2r 4mora. mos2 mo5sey mo3sp moth3 m5ouf 3mous mo2v 4m1p
mpara5 mpa5rab mpar5i m3pet mphas4 m2pi mpi4a mp5ies m4p1in m5pir mp5is
mpo3ri mpos5ite m4pous mpov5 mp4tr m2py 4m3r 4m1s2 m4sh m5si 4mt 1mu
mula5r4 5mult multi3 3mum mun2 4mup mu4u 4mw 1na 2n1a2b n4abu 4nac. na4ca
n5act nag5er. nak4 na4li na5lia 4nalt na5mit n2an nanci4 nan4it nank4 nar3c
4nare nar3i nar4l n5arm n4as nas4c nas5ti n2at na3tal nato5miz n2au nau3se
3naut nav4e 4n1b4 ncar5 n4ces. n3cha n5cheo n5chil n3chis nc1in nc4it
ncour5a n1cr n1cu n4dai n5dan n1de nd5est. ndi4b n5d2if n1dit n3diz n5duc
ndu4r nd2we 2ne. n3ear ne2b neb3u ne2c 5neck 2ned ne4gat neg5ativ 5nege
ne4la nel5iz ne5mi ne4mo 1nen 4nene 3neo ne4po ne2q n1er nera5b n4erar
n2ere n4er5i ner4r 1nes 2nes. 4nesp 2nest 4nesw 3netic ne4v n5eve ne4w n3f
n4gab n3gel nge4n4e n5gere n3geri ng5ha n3gib ng1in n5git n4gla ngov4 ng5sh
n1gu n4gum n2gy 4n1h4 nha4 nhab3 nhe4 3n4ia ni3an ni4ap ni3ba ni4bl ni4d
ni5di ni4er ni2fi ni5ficat n5igr nik4 n1im ni3miz n1in 5nine. nin4g ni4o
5nis. nis4ta n2it n4ith 3nitio n3itor ni3tr n1j 4nk2 n5kero n3ket nk3in
n1kl 4n1l n5m nme4 nmet4 4n1n2 nne4 nni3al nni4v nob4l no3ble n5ocl 4n3o2d
3noe 4nog noge4 nois5i no5l4i 5nologis 3nomic n5o5miz no4mo no3my no4n
non4ag non5i n5oniz 4nop 5nop5o5li nor5ab no4rary 4nosc nos4e nos5t no5ta
1nou 3noun nov3el3 nowl3 n1p4 npi4 npre4c n1q n1r nru4 2n1s2 ns5ab nsati4
ns4c n2se n4s3es nsid1 nsig4 n2sl ns3m n4soc ns4pe n5spi nsta5bl n1t nta4b
nter3s nt2i n5tib nti4er nti2f n3tine n4t3ing nti4p ntrol5li nt4s ntu3me
nu1a nu4d nu5en nuf4fe n3uin 3nu3it n4um nu1me n5umi 3nu4n n3uo nu3tr n1v2
n1w4 nym4 nyp4 4nz n3za 4oa oad3 o5a5les oard3 oas4e oast5e oat5i ob3a3b
o5bar obe4l o1bi o2bin ob5ing o3br ob3ul o1ce och4 o3chet ocif3 o4cil
o4clam o4cod oc3rac oc5ratiz ocre3 5ocrit octor5a oc3ula o5cure od5ded
od3ic odi3o o2do4 odor3 od5uct. od5ucts o4el o5eng o3er oe4ta o3ev o2fi
of5ite ofit4t o2g5a5r og5ativ o4gato o1ge o5gene o5geo o4ger o3gie 1o1gis
og3it o4gl o5g2ly 3ogniz o4gro ogu5i 1ogy 2ogyn o1h2 ohab5 oi2 oic3es
oi3der oiff4 oig4 oi5let o3ing oint5er o5ism oi5son oist5en oi3ter o5j 2ok
o3ken ok5ie o1la o4lan olass4 ol2d old1e ol3er o3lesc o3let ol4fi ol2i
o3lia o3lice ol5id. o3li4f o5lil ol3ing o5lio o5lis. ol3ish o5lite o5litio
o5liv olli4e ol5ogiz olo4r ol5pl ol2t ol3ub ol3ume ol3un o5lus ol2v o2ly
om5ah oma5l om5atiz om2be om4bl o2me om3ena om5erse o4met om5etry o3mia
om3ic. om3ica o5mid om1in o5mini 5ommend omo4ge o4mon om3pi ompro5 o2n on1a
on4ac o3nan on1c 3oncil 2ond on5do o3nen on5est on4gu on1ic o3nio on1is
o5niu on3key on4odi on3omy on3s onspi4 onspir5a onsu4 onten4 on3t4i ontif5
on5um onva5 oo2 ood5e ood5i oo4k oop3i o3ord oost5 o2pa ope5d op1er 3opera
4operag 2oph o5phan o5pher op3ing o3pit o5pon o4posi o1pr op1u opy5 o1q
o1ra o5ra. o4r3ag or5aliz or5ange ore5a o5real or3ei ore5sh or5est. orew4
or4gu 4o5ria or3ica o5ril or1in o1rio or3ity o3riu or2mi orn2e o5rof or3oug
or5pe 3orrh or4se ors5en orst4 or3thi or3thy or4ty o5rum o1ry os3al os2c
os4ce o3scop 4oscopi o5scr os4i4e os5itiv os3ito os3ity osi4u os4l o2so
os4pa os4po os2ta o5stati os5til os5tit o4tan otele4g ot3er. ot5ers o4tes
4oth oth5esi oth3i4 ot3ic. ot5ica o3tice o3tif o3tis oto5s ou2 ou3bl ouch5i
ou5et ou4l ounc5er oun2d ou5v ov4en over4ne over3s ov4ert o3vis oviti4
o5v4ol ow3der ow3el ow5est ow1i own5i o4wo oy1a 1pa pa4ca pa4ce pac4t p4ad
5pagan p3agat p4ai pain4 p4al pan4a pan3el pan4ty pa3ny pa1p pa4pu para5bl
par5age par5di 3pare par5el p4a4ri par4is pa2te pa5ter 5pathic pa5thy
pa4tric pav4 3pay 4p1b pd4 4pe. 3pe4a pear4l pe2c 2p2ed 3pede 3pedi pedia4
ped4ic p4ee pee4d pek4 pe4la peli4e pe4nan p4enc pen4th pe5on
p4era. pera5bl p4erag p4eri peri5st per4mal perme5 p4ern per3o per3ti pe5ru
per1v pe2t pe5ten pe5tiz 4pf 4pg 4ph. phar5i phe3no ph4er ph4es. ph1ic
5phie ph5ing 5phisti 3phiz ph2l 3phob 3phone 5phoni pho4r 4phs ph3t 5phu
1phy pi3a pian4 pi4cie pi4cy p4id p5ida pi3de 5pidi 3piec pi3en pi4grap
pi3lo pi2n p4in. pind4 p4ino 3pi1o pion4 p3ith pi5tha pi2tu 2p3k2 1p2l2
3plan plas5t pli3a pli5er 4plig pli4n ploi4 plu4m plum4b 4p1m 2p3n po4c
5pod. po5em po3et5 5po4g poin2 5point poly5t po4ni po4p 1p4or po4ry 1pos
pos1s p4ot po4ta 5poun 4p1p ppa5ra p2pe p4ped p5pel p3pen p3per p3pet
ppo5site pr2 pray4e 5preci pre5co pre3em pref5ac pre4la pre3r p3rese 3press
pre5ten pre3v 5pri4e prin4t3 pri4s pris3o p3roca prof5it pro3l pros3e pro1t
2p1s2 p2se ps4h p4sib 2p1t pt5a4b p2te p2th pti3m ptu4r p4tw pub3 pue4 puf4
pul3c pu4m pu2n pur4r 5pus pu2t 5pute put3er pu3tr put4ted put4tin p3w qu2
qua5v 2que. 3quer 3quet 2rab ra3bi rach4e r5acl raf5fi raf4t r2ai ra4lo
ram3et r2ami rane5o ran4ge r4ani ra5no rap3er 3raphy rar5c rare4 rar5ef
4raril r2as ration4 rau4t ra5vai rav3el ra5zie r1b r4bab r4bag rbi2 rbi4f
r2bin r5bine rb5ing. rb4o r1c r2ce rcen4 r3cha rch4er r4ci4b rc4it rcum3
r4dal rd2i rdi4a rdi4er rdin4 rd3ing 2re. re1al re3an re5arr 5reav re4aw
r5ebrat rec5oll rec5ompe re4cre 2r2ed re1de re3dis red5it re4fac re2fe
re5fer. re3fi re4fy reg3is re5it re1li re5lu r4en4ta ren4te re1o re5pin
re4posi re1pu r1er4 r4eri rero4 re5ru r4es. re4spi ress5ib res2t re5stal
re3str re4ter re4ti4z re3tri reu2 re5uti rev2 re4val rev3el
r5ev5er. re5vers re5vert re5vil rev5olu re4wh r1f rfu4 r4fy rg2 rg3er r3get
r3gic rgi4n rg3ing r5gis r5git r1gl rgo4n r3gu rh4 4rh. 4rhal ri3a ria4b
ri4ag r4ib rib3a ric5as r4ice 4rici 5ricid ri4cie r4ico rid5er ri3enc
ri3ent ri1er ri5et rig5an 5rigi ril3iz 5riman rim5i 3rimo rim4pe r2ina
5rina. rin4d rin4e rin4g ri1o 5riph riph5e ri2pl rip5lic r4iq r2is
r4is. ris4c r3ish ris4p ri3ta3b r5ited. rit5er. rit5ers rit3ic ri2tu rit5ur
riv5el riv3et riv3i r3j r3ket rk4le rk4lin r1l rle4 r2led r4lig r4lis
rl5ish r3lo4 r1m rma5c r2me r3men rm5ers rm3ing r4ming. r4mio r3mit r4my
r4nar r3nel r4ner r5net r3ney r5nic r1nis4 r3nit r3niv rno4 r4nou r3nu
rob3l r2oc ro3cr ro4e ro1fe ro5fil rok2 ro5ker 5role. rom5ete rom4i rom4p
ron4al ron4e ro5n4is ron4ta 1room 5root ro3pel rop3ic ror3i ro5ro ros5per
ros4s ro4the ro4ty ro4va rov5el rox5 r1p r4pea r5pent rp5er. r3pet rp4h4
rp3ing r3po r1r4 rre4c rre4f r4reo rre4st rri4o rri4v rron4 rros4 rrys4
4rs2 r1sa rsa5ti rs4c r2se r3sec rse4cr rs5er. rs3es rse5v2 r1sh r5sha r1si
r4si4b rson3 r1sp r5sw rtach4 r4tag r3teb rten4d rte5o r1ti rt5ib rti4d
r4tier r3tig rtil3i rtil4l r4tily r4tist r4tiv r3tri rtroph4 rt4sh ru3a
ru3e4l ru3en ru4gl ru3in rum3pl ru2n runk5 run4ty r5usc ruti5n rv4e rvel4i
r3ven rv5er. r5vest r3vey r3vic rvi4v r3vo r1w ry4c 5rynge ry3t sa2 2s1ab
5sack sac3ri s3act 5sai salar4 sal4m sa5lo sal4t 3sanc san4de s1ap sa5ta
5sa3tio sat3u sau4 sa5vor 5saw 4s5b scan4t5 sca4p scav5 s4ced 4scei s4ces
sch2 s4cho 3s4cie 5scin4d scle5 s4cli scof4 4scopy scour5a s1cu 4s5d
4se. se4a seas4 sea5w se2c3o 3sect 4s4ed se4d4e s5edl se2g seg3r 5sei se1le
5self 5selv 4seme se4mol sen5at 4senc sen4d s5ened sen5g s5enin 4sentd
4sentl sep3a3 4s1er. s4erl ser4o 4servo s1e4s se5sh ses5t 5se5um 5sev
sev3en sew4i 5sex 4s3f 2s3g s2h 2sh. sh1er 5shev sh1in sh3io 3ship shiv5
sho4 sh5old shon3 shor4 short5 4shw si1b s5icc 3side. 5sides 5sidi si5diz
4signa sil4e 4sily 2s1in s2ina 5sine. s3ing 1sio 5sion sion5a si2r sir5a
1sis 3sitio 5siu 1siv 5siz sk2 4ske s3ket sk5ine sk5ing s1l2 s3lat s2le
slith5 2s1m s3ma small3 sman3 smel4 s5men 5smith smol5d4 s1n4 1so so4ce
soft3 so4lab sol3d2 so3lic 5solv 3som 3s4on. sona4 son4g s4op 5sophic
s5ophiz s5ophy sor5c sor5d 4sov so5vi 2spa 5spai spa4n spen4d 2s5peo 2sper
s2phe 3spher spho5 spil4 sp5ing 4spio s4ply s4pon spor4 4spot squal4l s1r
2ss s1sa ssas3 s2s5c s3sel s5seng s4ses. s5set s1si s4sie ssi4er ss5ily
s4sl ss4li s4sn sspend4 ss2t ssur5a ss5w 2st. s2tag s2tal stam4i 5stand
s4ta4p 5stat. s4ted stern5i s5tero ste2w stew5a s3the st2i s4ti. s5tia
s1tic 5stick s4tie s3tif st3ing 5stir s1tle 5stock stom3a 5stone s4top
3store st4r s4trad 5stratu s4tray s4trid 4stry 4st3w s2ty 1su su1al su4b3
su2g3 su5is suit3 s4ul su2m sum3i su2n su2r 4sv sw2 4swo s4y 4syc 3syl
syn5o sy5rin 1ta 3ta. 2tab ta5bles 5taboliz 4taci ta5do 4taf4 tai5lo ta2l
ta5la tal5en tal3i 4talk tal4lis ta5log ta5mo tan4de tanta3 ta5per ta5pl
tar4a 4tarc 4tare ta3riz tas4e ta5sy 4tatic ta4tur taun4 tav4 2taw tax4is
2t1b 4tc t4ch tch5et 4t1d 4te. tead4i 4teat tece4 5tect 2t1ed te5di 1tee
teg4 te5ger te5gi 3tel. teli4 5tels te2ma2 tem3at 3tenan 3tenc 3tend 4tenes
1tent ten4tag 1teo te4p te5pe ter3c 5ter3d 1teri ter5ies ter3is teri5za
5ternit ter5v 4tes. 4tess t3ess. teth5e 3teu 3tex 4tey 2t1f 4t1g
2th. than4 th2e 4thea th3eas the5at the3is 3thet th5ic. th5ica 4thil 5think
4thl th5ode 5thodic 4thoo thor5it tho5riz 2ths 1tia ti4ab ti4ato 2ti2b
4tick t4ico t4ic1u 5tidi 3tien tif2 ti5fy 2tig 5tigu till5in 1tim 4timp
tim5ul 2t1in t2ina 3tine. 3tini 1tio ti5oc tion5ee 5tiq ti3sa 3tise tis4m
ti5so tis4p 5tistica ti3tl ti4u 1tiv tiv4a 1tiz ti3za ti3zen 2tl t5la tlan4
3tle. 3tled 3tles. t5let. t5lo 4t1m tme4 2t1n2 1to to3b to5crat 4todo 2tof
to2gr to5ic to2ma tom4b to3my ton4ali to3nat 4tono 4tony to2ra to3rie
tor5iz tos2 5tour 4tout to3war 4t1p 1tra tra3b tra5ch traci4 trac4it
trac4te tras4 tra5ven trav5es5 tre5f tre4m trem5i 5tria tri5ces 5tricia
4trics 2trim tri4v tro5mi tron5i 4trony tro5phe tro3sp tro3v tru5i trus4
4t1s2 t4sc tsh4 t4sw 4t3t2 t4tes t5to ttu4 1tu tu1a tu3ar tu4bi tud2 4tue
4tuf4 5tu3i 3tum tu4nis 2t3up. 3ture 5turi tur3is tur5o tu5ry 3tus 4tv tw4
4t1wa twis4 4two 1ty 4tya 2tyl type3 ty5ph 4tz tz4e 4uab uac4 ua5na uan4i
uar5ant uar2d uar3i uar3t u1at uav4 ub4e u4bel u3ber u4bero u1b4i u4b5ing
u3ble. u3ca uci4b uc4it ucle3 u3cr u3cu u4cy ud5d ud3er ud5est udev4 u1dic
ud3ied ud3ies ud5is u5dit u4don ud4si u4du u4ene uens4 uen4te uer4il 3ufa
u3fl ugh3en ug5in 2ui2 uil5iz ui4n u1ing uir4m uita4 uiv3 uiv4er. u5j 4uk
u1la ula5b u5lati ulch4 5ulche ul3der ul4e u1len ul4gi ul2i u5lia ul3ing
ul5ish ul4lar ul4li4b ul4lis 4ul3m u1l4o 4uls uls5es ul1ti ultra3 4ultu
u3lu ul5ul ul5v um5ab um4bi um4bly u1mi u4m3ing umor5o um2p unat4 u2ne
un4er u1ni un4im u2nin un5ish uni3v un3s4 un4sw unt3ab un4ter. un4tes unu4
un5y un5z u4ors u5os u1ou u1pe uper5s u5pia up3ing u3pl up3p upport5 upt5ib
uptu4 u1ra 4ura. u4rag u4ras ur4be urc4 ur1d ure5at ur4fer ur4fr u3rif
uri4fic ur1in u3rio u1rit ur3iz ur2l url5ing. ur4no uros4 ur4pe ur4pi
urs5er ur5tes ur3the urti4 ur4tie u3ru 2us u5sad u5san us4ap usc2 us3ci
use5a u5sia u3sic us4lin us1p us5sl us5tere us1tr u2su usur4 uta4b u3tat
4ute. 4utel 4uten uten4i 4u1t2i uti5liz u3tine ut3ing ution5a u4tis 5u5tiz
u4t1l ut5of uto5g uto5matic u5ton u4tou uts4 u3u uu4m u1v2 uxu3 uz4e 1va
5va. 2v1a4b vac5il vac3u vag4 va4ge va5lie val5o val1u va5mo va5niz va5pi
var5ied 3vat 4ve. 4ved veg3 v3el. vel3li ve4lo v4ely ven3om v5enue v4erd
5vere. v4erel v3eren ver5enc v4eres ver3ie vermi4n 3verse ver3th v4e2s
4ves. ves4te ve4te vet3er ve4ty vi5ali 5vian 5vide. 5vided 4v3iden 5vides
5vidi v3if vi5gn vik4 2vil 5vilit v3i3liz v1in 4vi4na v2inc vin5d 4ving
vio3l v3io4r vi1ou vi4p vi5ro vis3it vi3so vi3su 4viti vit3r 4vity 3viv
5vo. voi4 3vok vo4la v5ole 5volt 3volv vom5i vor5ab vori4 vo4ry vo4ta
4votee 4vv4 v4y w5abl 2wac wa5ger wag5o wait5 w5al. wam4 war4t was4t wa1te
wa5ver w1b wea5rie weath3 wed4n weet3 wee5v wel4l w1er west3 w3ev whi4 wi2
wil2 will5in win4de win4g wir4 3wise with3 wiz5 w4k wl4es wl3in w4no 1wo2
wom1 wo5ven w5p wra4 wri4 writa4 w3sh ws4l ws4pe w5s4t 4wt wy4 x1a xac5e
x4ago xam3 x4ap xas5 x3c2 x1e xe4cuto x2ed xer4i xe5ro x1h xhi2 xhil5 xhu4
x3i xi5a xi5c xi5di x4ime xi5miz x3o x4ob x3p xpan4d xpecto5 xpe3d x1t2
x3ti x1u xu3a xx4 y5ac 3yar4 y5at y1b y1c y2ce yc5er y3ch ych4e ycom4 ycot4
y1d y5ee y1er y4erf yes4 ye4t y5gi 4y3h y1i y3la ylla5bl y3lo y5lu ymbol5
yme4 ympa3 yn3chr yn5d yn5g yn5ic 5ynx y1o4 yo5d y4o5g yom4 yo5net y4ons
y4os y4ped yper5 yp3i y3po y4poc yp2ta y5pu yra5m yr5ia y3ro yr4r ys4c
y3s2e ys3ica ys3io 3ysis y4so yss4 ys1t ys3ta ysur4 y3thin yt3ic y1w za1
z5a2b zar2 4zb 2ze ze4n ze4p z1er ze3ro zet4 2z1i z4il z4is 5zl 4zm 1zo
zo4m zo5ol zte4 4z1z2 z4zy
"""
# Extra patterns, from ushyphmax.tex, dated 2005-05-30.
# Copyright (C) 1990, 2004, 2005 Gerard D.C. Kuiken.
# Copying and distribution of this file, with or without modification,
# are permitted in any medium without royalty provided the copyright
# notice and this notice are preserved.
#
# These patterns are based on the Hyphenation Exception Log
# published in TUGboat, Volume 10 (1989), No. 3, pp. 337-341,
# and a large number of incorrectly hyphenated words not yet published.
"""
.con5gr .de5riva .dri5v4 .eth1y6l1 .eu4ler .ev2 .ever5si5b .ga4s1om1
.ge4ome .ge5ot1 .he3mo1 .he3p6a .he3roe .in5u2t .kil2n3i .ko6r1te1 .le6ices
.me4ga1l .met4ala .mim5i2c1 .mi1s4ers .ne6o3f .noe1th .non1e2m .poly1s
.post1am .pre1am .rav5en1o .semi5 .sem4ic .semid6 .semip4 .semir4 .sem6is4
.semiv4 .sph6in1 .spin1o .ta5pes1tr .te3legr .to6pog .to2q .un3at5t
.un5err5 .vi2c3ar .we2b1l .re1e4c a5bolic a2cabl af6fish am1en3ta5b anal6ys
ano5a2c ans5gr ans3v anti1d an3ti1n2 anti1re a4pe5able ar3che5t ar2range
as5ymptot ath3er1o1s at6tes. augh4tl au5li5f av3iou back2er. ba6r1onie
ba1thy bbi4t be2vie bi5d2if bil2lab bio5m bi1orb bio1rh b1i3tive blan2d1
blin2d1 blon2d2 bor1no5 bo2t1u1l brus4q bus6i2er bus6i2es buss4ing
but2ed. but4ted cad5e1m cat1a1s2 4chs. chs3hu chie5vo cig3a3r cin2q cle4ar
co6ph1o3n cous2ti cri3tie croc1o1d cro5e2co c2tro3me6c 1cu2r1ance 2d3alone
data1b dd5a5b d2d5ib de4als. de5clar1 de2c5lina de3fin3iti de2mos des3ic
de2tic dic1aid dif5fra 3di1methy di2ren di2rer 2d1lead 2d1li2e 3do5word
dren1a5l drif2t1a d1ri3pleg5 drom3e5d d3tab du2al. du1op1o1l ea4n3ies
e3chas edg1l ed1uling eli2t1is e1loa en1dix eo3grap 1e6p3i3neph1 e2r3i4an.
e3spac6i eth1y6l1ene 5eu2clid1 feb1rua fermi1o 3fich fit5ted. fla1g6el
flow2er. 3fluor gen2cy. ge3o1d ght1we g1lead get2ic. 4g1lish 5glo5bin
1g2nac gnet1ism gno5mo g2n1or. g2noresp 2g1o4n3i1za graph5er. griev1 g1utan
hair1s ha2p3ar5r hatch1 hex2a3 hite3sid h3i5pel1a4 hnau3z ho6r1ic. h2t1eou
hypo1tha id4ios ifac1et ign4it ignit1er i4jk im3ped3a infra1s2
i5nitely. irre6v3oc i1tesima ith5i2l itin5er5ar janu3a japan1e2s je1re1m
1ke6ling 1ki5netic 1kovian k3sha la4c3i5e lai6n3ess lar5ce1n l3chai
l3chil6d1 lead6er. lea4s1a 1lec3ta6b le3g6en2dre 1le1noid lith1o5g ll1fl
l2l3ish l5mo3nell lo1bot1o1 lo2ges. load4ed. load6er. l3tea lth5i2ly lue1p
1lunk3er 1lum5bia. 3lyg1a1mi ly5styr ma1la1p m2an. man3u1sc mar1gin1
medi2c med3i3cin medio6c1 me3gran3 m2en. 3mi3da5b 3milita mil2l1ag
mil5li5li mi6n3is. mi1n2ut1er mi1n2ut1est m3ma1b 5maph1ro1 5moc1ra1t
mo5e2las mol1e5c mon4ey1l mono3ch mo4no1en moro6n5is mono1s6 moth4et2
m1ou3sin m5shack2 mu2dro mul2ti5u n3ar4chs. n3ch2es1t ne3back 2ne1ski
n1dieck nd3thr nfi6n3ites 4n5i4an. nge5nes ng1ho ng1spr nk3rup n5less
5noc3er1os nom1a6l nom5e1no n1o1mist non1eq non1i4so 5nop1oly. no1vemb
ns5ceiv ns4moo ntre1p obli2g1 o3chas odel3li odit1ic oerst2 oke1st
o3les3ter oli3gop1o1 o1lo3n4om o3mecha6 onom1ic o3norma o3no2t1o3n o3nou
op1ism. or4tho3ni4t orth1ri or5tively o4s3pher o5test1er o5tes3tor
oth3e1o1s ou3ba3do o6v3i4an. oxi6d1ic pal6mat parag6ra4 par4a1le param4
para3me pee2v1 phi2l3ant phi5lat1e3l pi2c1a3d pli2c1ab pli5nar poin3ca
1pole. poly1e po3lyph1ono 1prema3c pre1neu pres2pli pro2cess
proc3i3ty. pro2g1e 3pseu2d pseu3d6o3d2 pseu3d6o3f2 pto3mat4 p5trol3
pu5bes5c quain2t1e qu6a3si3 quasir6 quasis6 quin5tes5s qui3v4ar r1abolic
3rab1o1loi ra3chu r3a3dig radi1o6g r2amen 3ra4m5e1triz ra3mou ra5n2has
ra1or r3bin1ge re2c3i1pr rec5t6ang re4t1ribu r3ial. riv1o1l 6rk. rk1ho
r1krau 6rks. r5le5qu ro1bot1 ro5e2las ro5epide1 ro3mesh ro1tron r3pau5li
rse1rad1i r1thou r1treu r1veil rz1sc sales3c sales5w 5sa3par5il sca6p1er
sca2t1ol s4chitz schro1ding1 1sci2utt scrap4er. scy4th1 sem1a1ph se3mes1t
se1mi6t5ic sep3temb shoe1st sid2ed. side5st side5sw si5resid sky1sc
3slova1kia 3s2og1a1my so2lute 3s2pace 1s2pacin spe3cio spher1o spi2c1il
spokes5w sports3c sports3w s3qui3to s2s1a3chu1 ss3hat s2s3i4an. s5sign5a3b
1s2tamp s2t1ant5shi star3tli sta1ti st5b 1stor1ab strat1a1g strib5ut st5scr
stu1pi4d1 styl1is su2per1e6 1sync 1syth3i2 swimm6 5tab1o1lism
ta3gon. talk1a5 t1a1min t6ap6ath 5tar2rh tch1c tch3i1er t1cr
teach4er. tele2g tele1r6o 3ter1gei ter2ic. t3ess2es tha4l1am tho3don
th1o5gen1i tho1k2er thy4l1an thy3sc 2t3i4an. ti2n3o1m t1li2er tolo2gy
tot3ic trai3tor1 tra1vers travers3a3b treach1e tr4ial. 3tro1le1um
trof4ic. tro3fit tro1p2is 3trop1o5les 3trop1o5lis t1ro1pol3it tsch3ie
ttrib1ut1 turn3ar t1wh ty2p5al ua3drati uad1ratu u5do3ny uea1m
u2r1al. uri4al. us2er. v1ativ v1oir5du1 va6guer vaude3v 1verely. v1er1eig
ves1tite vi1vip3a3r voice1p waste3w6a2 wave1g4 w3c week1n wide5sp wo4k1en
wrap3aro writ6er. x1q xquis3 y5che3d ym5e5try y1stro yes5ter1y
z3ian. z3o1phr z2z3w
"""
)
EXCEPTIONS = """
as-so-ciate as-so-ciates dec-li-na-tion oblig-a-tory phil-an-thropic present
presents project projects reci-procity re-cog-ni-zance ref-or-ma-tion
ret-ri-bu-tion ta-ble
"""
hyphenator = Hyphenator(PATTERNS, EXCEPTIONS)
hyphenate_word = hyphenator.hyphenate_word
import re
def split_paragraphs(text: str) -> list[tuple[str, int, int]]:
"""
Split the text into paragraphs.
Returns a list of paragraphs with their start and end indices of the original text.
"""
# Use a regex pattern to split on one or more blank lines
pattern = r"\n\s*\n"
# Find all splits in the text
splits = list(re.finditer(pattern, text))
paragraphs: list[tuple[str, int, int]] = []
start = 0
# Handle the case where there are no splits (i.e., single paragraph)
if not splits:
stripped = text.strip()
# skip empty
if not stripped:
return paragraphs
start_index = text.index(stripped)
return [(stripped, start_index, start_index + len(stripped))]
# Process each split
for split in splits:
end = split.start()
paragraph = text[start:end].strip()
if paragraph: # Only add non-empty paragraphs
para_start = start + text[start:end].index(paragraph)
para_end = para_start + len(paragraph)
paragraphs.append((paragraph, para_start, para_end))
start = split.end()
# Add the last paragraph
last_paragraph = text[start:].strip()
if last_paragraph:
para_start = start + text[start:].index(last_paragraph)
para_end = para_start + len(last_paragraph)
paragraphs.append((last_paragraph, para_start, para_end))
return paragraphs
import re
# rule based segmentation based on https://stackoverflow.com/a/31505798, works surprisingly well
def split_sentences(
text: str, min_sentence_len: int = 20, retain_format: bool = False
) -> list[tuple[str, int, int]]:
"""
the text may not contain substrings "<prd>" or "<stop>"
"""
alphabets = r"([A-Za-z])"
prefixes = r"(Mr|St|Mrs|Ms|Dr)[.]"
suffixes = r"(Inc|Ltd|Jr|Sr|Co)"
starters = r"(Mr|Mrs|Ms|Dr|Prof|Capt|Cpt|Lt|He\s|She\s|It\s|They\s|Their\s|Our\s|We\s|But\s|However\s|That\s|This\s|Wherever)" # noqa: E501
acronyms = r"([A-Z][.][A-Z][.](?:[A-Z][.])?)"
websites = r"[.](com|net|org|io|gov|edu|me)"
digits = r"([0-9])"
multiple_dots = r"\.{2,}"
# fmt: off
if retain_format:
text = text.replace("\n","<nel><stop>")
else:
text = text.replace("\n"," ")
text = re.sub(prefixes,"\\1<prd>", text)
text = re.sub(websites,"<prd>\\1", text)
text = re.sub(digits + "[.]" + digits,"\\1<prd>\\2",text)
# text = re.sub(multiple_dots, lambda match: "<prd>" * len(match.group(0)) + "<stop>", text)
# TODO(theomonnom): need improvement for ""..." dots", check capital + next sentence should not be # noqa: E501
# small
text = re.sub(multiple_dots, lambda match: "<prd>" * len(match.group(0)), text)
if "Ph.D" in text:
text = text.replace("Ph.D.","Ph<prd>D<prd>")
text = re.sub(r"\s" + alphabets + "[.] "," \\1<prd> ",text)
text = re.sub(acronyms+" "+starters,"\\1<stop> \\2",text)
text = re.sub(alphabets + "[.]" + alphabets + "[.]" + alphabets + "[.]","\\1<prd>\\2<prd>\\3<prd>",text) # noqa: E501
text = re.sub(alphabets + "[.]" + alphabets + "[.]","\\1<prd>\\2<prd>",text)
text = re.sub(r" "+suffixes+"[.] "+starters," \\1<stop> \\2",text)
text = re.sub(r" "+suffixes+"[.]"," \\1<prd>",text)
text = re.sub(r" " + alphabets + "[.]"," \\1<prd>",text)
if "”" in text:
text = text.replace(".”","”.")
if "\"" in text:
text = text.replace(".\"","\".")
if "!" in text:
text = text.replace("!\"","\"!")
if "?" in text:
text = text.replace("?\"","\"?")
text = text.replace(".",".<stop>")
text = text.replace("?","?<stop>")
text = text.replace("!","!<stop>")
text = text.replace("<prd>",".")
# fmt: on
if retain_format:
text = text.replace("<nel>", "\n")
splitted_sentences = text.split("<stop>")
text = text.replace("<stop>", "")
sentences: list[tuple[str, int, int]] = []
buff = ""
start_pos = 0
end_pos = 0
pre_pad = "" if retain_format else " "
for match in splitted_sentences:
if retain_format:
sentence = match
else:
sentence = match.strip()
if not sentence:
continue
buff += pre_pad + sentence
end_pos += len(match)
if len(buff) > min_sentence_len:
sentences.append((buff[len(pre_pad) :], start_pos, end_pos))
start_pos = end_pos
buff = ""
if buff:
sentences.append((buff[len(pre_pad) :], start_pos, len(text) - 1))
return sentences
import re
from . import tokenizer
def split_words(text: str, ignore_punctuation: bool = True) -> list[tuple[str, int, int]]:
"""
Split the text into words.
Returns a list of words with their start and end indices of the original text.
"""
matches = re.finditer(r"\S+", text)
words: list[tuple[str, int, int]] = []
for match in matches:
word = match.group(0)
start_pos = match.start()
end_pos = match.end()
if ignore_punctuation:
# TODO(theomonnom): acronyms passthrough
translation_table = str.maketrans("", "", "".join(tokenizer.PUNCTUATIONS))
word = word.translate(translation_table)
if not word:
continue
words.append((word, start_pos, end_pos))
return words
from __future__ import annotations
import functools
from dataclasses import dataclass
from . import (
_basic_hyphenator,
_basic_paragraph,
_basic_sent,
_basic_word,
token_stream,
tokenizer,
)
# Really naive implementation of SentenceTokenizer, WordTokenizer + hyphenate_word
# The basic tokenizer is rule-based and only English is really tested
__all__ = [
"SentenceTokenizer",
"WordTokenizer",
"hyphenate_word",
"tokenize_paragraphs",
]
@dataclass
class _TokenizerOptions:
language: str
min_sentence_len: int
stream_context_len: int
retain_format: bool
class SentenceTokenizer(tokenizer.SentenceTokenizer):
def __init__(
self,
*,
language: str = "english",
min_sentence_len: int = 20,
stream_context_len: int = 10,
retain_format: bool = False,
) -> None:
self._config = _TokenizerOptions(
language=language,
min_sentence_len=min_sentence_len,
stream_context_len=stream_context_len,
retain_format=retain_format,
)
def tokenize(self, text: str, *, language: str | None = None) -> list[str]:
return [
tok[0]
for tok in _basic_sent.split_sentences(
text,
min_sentence_len=self._config.min_sentence_len,
retain_format=self._config.retain_format,
)
]
def stream(self, *, language: str | None = None) -> tokenizer.SentenceStream:
return token_stream.BufferedSentenceStream(
tokenizer=functools.partial(
_basic_sent.split_sentences,
min_sentence_len=self._config.min_sentence_len,
retain_format=self._config.retain_format,
),
min_token_len=self._config.min_sentence_len,
min_ctx_len=self._config.stream_context_len,
)
class WordTokenizer(tokenizer.WordTokenizer):
def __init__(self, *, ignore_punctuation: bool = True) -> None:
self._ignore_punctuation = ignore_punctuation
def tokenize(self, text: str, *, language: str | None = None) -> list[str]:
return [
tok[0]
for tok in _basic_word.split_words(text, ignore_punctuation=self._ignore_punctuation)
]
def stream(self, *, language: str | None = None) -> tokenizer.WordStream:
return token_stream.BufferedWordStream(
tokenizer=functools.partial(
_basic_word.split_words, ignore_punctuation=self._ignore_punctuation
),
min_token_len=1,
min_ctx_len=1, # ignore
)
def hyphenate_word(word: str) -> list[str]:
return _basic_hyphenator.hyphenate_word(word)
def split_words(text: str, ignore_punctuation: bool = True) -> list[tuple[str, int, int]]:
return _basic_word.split_words(text, ignore_punctuation=ignore_punctuation)
def tokenize_paragraphs(text: str) -> list[str]:
return [tok[0] for tok in _basic_paragraph.split_paragraphs(text)]
from __future__ import annotations
import typing
from typing import Callable, Union
from ..utils import aio, shortuuid
from .tokenizer import SentenceStream, TokenData, WordStream
# Tokenizers can either provide us with a list of tokens or a list of tokens along with their start and end indices. # noqa: E501
# If the start and end indices are not available, we attempt to locate the token within the text using str.find. # noqa: E501
TokenizeCallable = Callable[[str], Union[list[str], list[tuple[str, int, int]]]]
class BufferedTokenStream:
def __init__(
self,
*,
tokenize_fnc: TokenizeCallable,
min_token_len: int,
min_ctx_len: int,
retain_format: bool = False,
) -> None:
self._event_ch = aio.Chan[TokenData]()
self._tokenize_fnc = tokenize_fnc
self._min_ctx_len = min_ctx_len
self._min_token_len = min_token_len
self._retain_format = retain_format
self._current_segment_id = shortuuid()
self._buf_tokens: list[str] = [] # <= min_token_len
self._in_buf = ""
self._out_buf = ""
@typing.no_type_check
def push_text(self, text: str) -> None:
self._check_not_closed()
self._in_buf += text
if len(self._in_buf) < self._min_ctx_len:
return
while True:
tokens = self._tokenize_fnc(self._in_buf)
if len(tokens) <= 1:
break
if self._out_buf:
self._out_buf += " "
tok = tokens.pop(0)
tok_text = tok
if isinstance(tok, tuple):
tok_text = tok[0]
self._out_buf += tok_text
if len(self._out_buf) >= self._min_token_len:
self._event_ch.send_nowait(
TokenData(token=self._out_buf, segment_id=self._current_segment_id)
)
self._out_buf = ""
if isinstance(tok, tuple):
self._in_buf = self._in_buf[tok[2] :]
else:
tok_i = max(self._in_buf.find(tok), 0)
self._in_buf = self._in_buf[tok_i + len(tok) :].lstrip()
@typing.no_type_check
def flush(self) -> None:
self._check_not_closed()
if self._in_buf or self._out_buf:
tokens = self._tokenize_fnc(self._in_buf)
if tokens:
if self._out_buf:
self._out_buf += " "
if isinstance(tokens[0], tuple):
self._out_buf += " ".join([tok[0] for tok in tokens])
else:
self._out_buf += " ".join(tokens)
if self._out_buf:
self._event_ch.send_nowait(
TokenData(token=self._out_buf, segment_id=self._current_segment_id)
)
self._current_segment_id = shortuuid()
self._in_buf = ""
self._out_buf = ""
def end_input(self) -> None:
self.flush()
self._event_ch.close()
async def aclose(self) -> None:
self._event_ch.close()
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
def __aiter__(self) -> BufferedTokenStream:
return self
async def __anext__(self) -> TokenData:
return await self._event_ch.__anext__()
class BufferedSentenceStream(BufferedTokenStream, SentenceStream):
def __init__(
self,
*,
tokenizer: TokenizeCallable,
min_token_len: int,
min_ctx_len: int,
) -> None:
super().__init__(
tokenize_fnc=tokenizer,
min_token_len=min_token_len,
min_ctx_len=min_ctx_len,
)
class BufferedWordStream(BufferedTokenStream, WordStream):
def __init__(
self,
*,
tokenizer: TokenizeCallable,
min_token_len: int,
min_ctx_len: int,
) -> None:
super().__init__(
tokenize_fnc=tokenizer,
min_token_len=min_token_len,
min_ctx_len=min_ctx_len,
)
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass
from ..utils import aio
# fmt: off
PUNCTUATIONS = ['!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', # noqa: E501
'?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '±', '—', '‘', '’', '“', '”', '…'] # noqa: E501
# fmt: on
@dataclass
class TokenData:
segment_id: str = ""
token: str = ""
class SentenceTokenizer(ABC):
@abstractmethod
def tokenize(self, text: str, *, language: str | None = None) -> list[str]:
pass
@abstractmethod
def stream(self, *, language: str | None = None) -> SentenceStream:
pass
class SentenceStream(ABC):
def __init__(self) -> None:
self._event_ch = aio.Chan[TokenData]()
@abstractmethod
def push_text(self, text: str) -> None: ...
@abstractmethod
def flush(self) -> None: ...
@abstractmethod
def end_input(self) -> None: ...
@abstractmethod
async def aclose(self) -> None: ...
async def __anext__(self) -> TokenData:
return await self._event_ch.__anext__()
def __aiter__(self) -> AsyncIterator[TokenData]:
return self
def _do_close(self) -> None:
self._event_ch.close()
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
class WordTokenizer(ABC):
@abstractmethod
def tokenize(self, text: str, *, language: str | None = None) -> list[str]:
pass
@abstractmethod
def stream(self, *, language: str | None = None) -> WordStream:
pass
def format_words(self, words: list[str]) -> str:
return " ".join(words)
class WordStream(ABC):
def __init__(self) -> None:
self._event_ch = aio.Chan[TokenData]()
@abstractmethod
def push_text(self, text: str) -> None: ...
@abstractmethod
def flush(self) -> None: ...
@abstractmethod
def end_input(self) -> None: ...
@abstractmethod
async def aclose(self) -> None: ...
async def __anext__(self) -> TokenData:
return await self._event_ch.__anext__()
def __aiter__(self) -> AsyncIterator[TokenData]:
return self
def _do_close(self) -> None:
self._event_ch.close()
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
from __future__ import annotations
from collections.abc import AsyncIterable
from typing import overload
from . import _basic_word, tokenizer
@overload
def replace_words(
*,
text: str,
replacements: dict[str, str],
) -> str: ...
@overload
def replace_words(
*,
text: AsyncIterable[str],
replacements: dict[str, str],
) -> AsyncIterable[str]: ...
def replace_words(
*,
text: str | AsyncIterable[str],
replacements: dict[str, str],
) -> str | AsyncIterable[str]:
"""
Replace words in the given (async) text. The replacements are case-insensitive and the
replacement will keep the case of the original word.
Args:
text: text to replace words in
words: dictionary of words to replace
"""
replacements = {k.lower(): v for k, v in replacements.items()}
def _process_words(text, words):
offset = 0
processed_index = 0
for word, start_index, end_index in words:
no_punctuation = word.rstrip("".join(tokenizer.PUNCTUATIONS))
punctuation_off = len(word) - len(no_punctuation)
replacement = replacements.get(no_punctuation.lower())
if replacement:
text = (
text[: start_index + offset]
+ replacement
+ text[end_index + offset - punctuation_off :]
)
offset += len(replacement) - len(word) + punctuation_off
processed_index = end_index + offset
return text, processed_index
if isinstance(text, str):
words = _basic_word.split_words(text, ignore_punctuation=False)
text, _ = _process_words(text, words)
return text
else:
async def _replace_words():
buffer = ""
async for chunk in text:
buffer += chunk
words = _basic_word.split_words(buffer, ignore_punctuation=False)
if len(words) <= 1:
continue
buffer, procesed_index = _process_words(buffer, words[:-1])
yield buffer[:procesed_index]
buffer = buffer[procesed_index:]
if buffer:
words = _basic_word.split_words(buffer, ignore_punctuation=False)
buffer, _ = _process_words(buffer, words)
yield buffer
return _replace_words()
from .fallback_adapter import (
AvailabilityChangedEvent,
FallbackAdapter,
FallbackChunkedStream,
FallbackSynthesizeStream,
)
from .stream_adapter import StreamAdapter, StreamAdapterWrapper
from .tts import (
TTS,
ChunkedStream,
SynthesizedAudio,
SynthesizedAudioEmitter,
SynthesizeStream,
TTSCapabilities,
TTSError,
)
__all__ = [
"TTS",
"SynthesizedAudio",
"SynthesizeStream",
"TTSCapabilities",
"StreamAdapterWrapper",
"StreamAdapter",
"ChunkedStream",
"AvailabilityChangedEvent",
"FallbackAdapter",
"FallbackChunkedStream",
"FallbackSynthesizeStream",
"SynthesizedAudioEmitter",
"TTSError",
]
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import time
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Literal, Union
from livekit import rtc
from .. import utils
from .._exceptions import APIConnectionError, APIError
from ..log import logger
from ..utils import aio
from .tts import (
DEFAULT_API_CONNECT_OPTIONS,
TTS,
APIConnectOptions,
ChunkedStream,
SynthesizedAudio,
SynthesizeStream,
TTSCapabilities,
)
# don't retry when using the fallback adapter
DEFAULT_FALLBACK_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
@dataclass
class _TTSStatus:
available: bool
recovering_task: asyncio.Task | None
resampler: rtc.AudioResampler | None
@dataclass
class AvailabilityChangedEvent:
tts: TTS
available: bool
class FallbackAdapter(
TTS[Literal["tts_availability_changed"]],
):
"""
Manages multiple TTS instances, providing a fallback mechanism to ensure continuous TTS service.
"""
def __init__(
self,
tts: list[TTS],
*,
attempt_timeout: float = 10.0,
max_retry_per_tts: int = 1, # only retry once by default
retry_interval: float = 5,
no_fallback_after_audio_duration: float | None = 3.0,
sample_rate: int | None = None,
) -> None:
"""
Initialize a FallbackAdapter that manages multiple TTS instances.
Args:
tts (list[TTS]): A list of TTS instances to use for fallback.
attempt_timeout (float, optional): Timeout for each synthesis attempt in seconds. Defaults to 10.0.
max_retry_per_tts (int, optional): Maximum number of retries per TTS instance. Defaults to 1.
no_fallback_after_audio_duration (float | None, optional): Disables fallback after this duration of audio is synthesized. Defaults to 3.0.
This is used to prevent unnaturally resaying the same text when the first TTS
instance fails.
sample_rate (int | None, optional): Desired sample rate for the synthesized audio. If None, uses the maximum sample rate among the TTS instances.
Raises:
ValueError: If less than one TTS instance is provided.
ValueError: If TTS instances have different numbers of channels.
""" # noqa: E501
if len(tts) < 1:
raise ValueError("at least one TTS instance must be provided.")
if len({t.num_channels for t in tts}) != 1:
raise ValueError("all TTS must have the same number of channels")
if sample_rate is None:
sample_rate = max(t.sample_rate for t in tts)
num_channels = tts[0].num_channels
super().__init__(
capabilities=TTSCapabilities(
streaming=all(t.capabilities.streaming for t in tts),
),
sample_rate=sample_rate,
num_channels=num_channels,
)
self._tts_instances = tts
self._attempt_timeout = attempt_timeout
self._max_retry_per_tts = max_retry_per_tts
self._retry_interval = retry_interval
self._no_fallback_after_audio_duration = no_fallback_after_audio_duration
self._status: list[_TTSStatus] = []
for t in tts:
resampler = None
if sample_rate != t.sample_rate:
logger.info(f"resampling {t.label} from {t.sample_rate}Hz to {sample_rate}Hz")
resampler = rtc.AudioResampler(input_rate=t.sample_rate, output_rate=sample_rate)
self._status.append(
_TTSStatus(available=True, recovering_task=None, resampler=resampler)
)
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions | None = None,
) -> FallbackChunkedStream:
return FallbackChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options or DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
)
def stream(
self,
*,
conn_options: APIConnectOptions | None = None,
) -> FallbackSynthesizeStream:
return FallbackSynthesizeStream(
tts=self,
conn_options=conn_options or DEFAULT_FALLBACK_API_CONNECT_OPTIONS,
)
def prewarm(self) -> None:
if self._tts_instances:
self._tts_instances[0].prewarm()
async def aclose(self) -> None:
for tts_status in self._status:
if tts_status.recovering_task is not None:
await aio.cancel_and_wait(tts_status.recovering_task)
class FallbackChunkedStream(ChunkedStream):
def __init__(
self,
*,
tts: FallbackAdapter,
input_text: str,
conn_options: APIConnectOptions | None,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._fallback_adapter = tts
async def _try_synthesize(
self, *, tts: TTS, recovering: bool = False
) -> AsyncGenerator[SynthesizedAudio, None]:
try:
audio_duration = 0.0
async with tts.synthesize(
self._input_text,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_tts,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
) as stream:
while True:
try:
audio = await asyncio.wait_for(
stream.__anext__(),
self._fallback_adapter._attempt_timeout
if audio_duration == 0.0
else None,
)
audio_duration += audio.frame.duration
yield audio
except StopAsyncIteration:
break
if audio_duration == 0.0:
raise APIConnectionError("no audio received")
except asyncio.TimeoutError:
if recovering:
logger.warning(f"{tts.label} recovery timed out", extra={"streamed": False})
raise
logger.warning(
f"{tts.label} timed out, switching to next TTS",
extra={"streamed": False},
)
raise
except APIError as e:
if recovering:
logger.warning(
f"{tts.label} recovery failed",
exc_info=e,
extra={"streamed": False},
)
raise
logger.warning(
f"{tts.label} failed, switching to next TTS",
exc_info=e,
extra={"streamed": False},
)
raise
except Exception:
if recovering:
logger.exception(
f"{tts.label} recovery unexpected error", extra={"streamed": False}
)
raise
logger.exception(
f"{tts.label} unexpected error, switching to next TTS",
extra={"streamed": False},
)
raise
def _try_recovery(self, tts: TTS) -> None:
assert isinstance(self._tts, FallbackAdapter)
tts_status = self._tts._status[self._tts._tts_instances.index(tts)]
if tts_status.recovering_task is None or tts_status.recovering_task.done():
async def _recover_tts_task(tts: TTS) -> None:
try:
async for _ in self._try_synthesize(tts=tts, recovering=True):
pass
tts_status.available = True
logger.info(f"tts.FallbackAdapter, {tts.label} recovered")
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=True),
)
except Exception:
return
tts_status.recovering_task = asyncio.create_task(_recover_tts_task(tts))
async def _run(self) -> None:
assert isinstance(self._tts, FallbackAdapter)
start_time = time.time()
all_failed = all(not tts_status.available for tts_status in self._tts._status)
if all_failed:
logger.error("all TTSs are unavailable, retrying..")
for i, tts in enumerate(self._tts._tts_instances):
tts_status = self._tts._status[i]
if tts_status.available or all_failed:
audio_duration = 0.0
try:
request_id: str | None = None
resampler = tts_status.resampler
async for synthesized_audio in self._try_synthesize(tts=tts, recovering=False):
audio_duration += synthesized_audio.frame.duration
request_id = synthesized_audio.request_id
if resampler is not None:
for rf in resampler.push(synthesized_audio.frame):
self._event_ch.send_nowait(
SynthesizedAudio(
frame=rf,
request_id=synthesized_audio.request_id,
)
)
continue
self._event_ch.send_nowait(synthesized_audio)
if resampler is not None and request_id is not None:
for rf in resampler.flush():
self._event_ch.send_nowait(
SynthesizedAudio(
frame=rf,
request_id=request_id,
)
)
return
except Exception: # exceptions already logged inside _try_synthesize
if tts_status.available:
tts_status.available = False
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=False),
)
if self._tts._no_fallback_after_audio_duration is not None:
if audio_duration >= self._tts._no_fallback_after_audio_duration:
logger.warning(
f"{tts.label} already synthesized {audio_duration}s of audio, ignoring fallback" # noqa: E501
)
return
self._try_recovery(tts)
raise APIConnectionError(
f"all TTSs failed ({[tts.label for tts in self._tts._tts_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
class FallbackSynthesizeStream(SynthesizeStream):
def __init__(
self,
*,
tts: FallbackAdapter,
conn_options: APIConnectOptions | None = None,
):
super().__init__(tts=tts, conn_options=conn_options or DEFAULT_FALLBACK_API_CONNECT_OPTIONS)
self._fallback_adapter = tts
self._total_segments: list[list[str]] = []
self._pending_segments_chunks: list[list[str]] = []
self._current_segment_text: list[str] = []
async def _try_synthesize(
self,
*,
tts: TTS,
input_ch: aio.ChanReceiver[str | SynthesizeStream._FlushSentinel],
conn_options: APIConnectOptions,
recovering: bool = False,
) -> AsyncGenerator[SynthesizedAudio, None]:
stream = tts.stream(conn_options=conn_options)
input_sent_fut = asyncio.Future() # type: ignore
@utils.log_exceptions(logger=logger)
async def _input_task() -> None:
try:
segment = ""
async for data in input_ch:
if isinstance(data, str):
segment += data
stream.push_text(data)
elif isinstance(data, self._FlushSentinel):
# start the timeout on flush
if segment:
segment = ""
with contextlib.suppress(asyncio.InvalidStateError):
input_sent_fut.set_result(True)
stream.flush()
finally:
with contextlib.suppress(RuntimeError):
stream.end_input()
with contextlib.suppress(asyncio.InvalidStateError):
input_sent_fut.set_result(False)
input_task = asyncio.create_task(_input_task())
next_audio_task: asyncio.Future[SynthesizedAudio] | None = None
try:
audio_duration = 0.0
async with stream:
while True:
if next_audio_task is None or next_audio_task.done():
next_audio_task = asyncio.ensure_future(stream.__anext__())
try:
if not input_sent_fut.done():
await asyncio.wait(
[input_sent_fut, next_audio_task],
return_when=asyncio.FIRST_COMPLETED,
)
if not next_audio_task.done():
continue
audio = next_audio_task.result()
else:
audio = await asyncio.wait_for(
next_audio_task, self._fallback_adapter._attempt_timeout
)
audio_duration += audio.frame.duration
if audio.is_final:
input_sent_fut = asyncio.Future()
audio_duration = 0.0
yield audio
except StopAsyncIteration:
break
if audio_duration == 0.0 and input_sent_fut.done() and input_sent_fut.result():
raise APIConnectionError("no audio received")
except asyncio.TimeoutError:
if recovering:
logger.warning(f"{tts.label} recovery timed out", extra={"streamed": True})
raise
logger.warning(
f"{tts.label} timed out, switching to next TTS",
extra={"streamed": True},
)
raise
except APIError as e:
if recovering:
logger.warning(f"{tts.label} recovery failed", exc_info=e, extra={"streamed": True})
raise
logger.warning(
f"{tts.label} failed, switching to next TTS",
exc_info=e,
extra={"streamed": True},
)
raise
except Exception:
if recovering:
logger.exception(
f"{tts.label} recovery unexpected error",
extra={"streamed": True},
)
raise
logger.exception(
f"{tts.label} unexpected error, switching to next TTS",
extra={"streamed": True},
)
raise
finally:
if next_audio_task is not None:
await utils.aio.cancel_and_wait(next_audio_task)
await utils.aio.cancel_and_wait(input_task)
async def _run(self) -> None:
start_time = time.time()
all_failed = all(not tts_status.available for tts_status in self._fallback_adapter._status)
if all_failed:
logger.error("all TTSs are unavailable, retrying..")
new_input_ch: aio.Chan[str | SynthesizeStream._FlushSentinel] | None = None
async def _forward_input_task():
nonlocal new_input_ch
async for data in self._input_ch:
if new_input_ch:
new_input_ch.send_nowait(data)
if isinstance(data, str) and data:
self._current_segment_text.append(data)
elif isinstance(data, self._FlushSentinel) and self._current_segment_text:
self._total_segments.append(self._current_segment_text)
self._pending_segments_chunks.append(self._current_segment_text)
self._current_segment_text = []
if new_input_ch:
new_input_ch.close()
input_task = asyncio.create_task(_forward_input_task())
try:
for i, tts in enumerate(self._fallback_adapter._tts_instances):
tts_status = self._fallback_adapter._status[i]
if tts_status.available or all_failed:
audio_duration = 0.0
try:
new_input_ch = aio.Chan[Union[str, SynthesizeStream._FlushSentinel]]()
for text in self._pending_segments_chunks:
for chunk in text:
new_input_ch.send_nowait(chunk)
new_input_ch.send_nowait(self._FlushSentinel())
for chunk in self._current_segment_text:
new_input_ch.send_nowait(chunk)
if input_task.done():
new_input_ch.close()
last_segment_id: str | None = None
resampler = tts_status.resampler
async for synthesized_audio in self._try_synthesize(
tts=tts,
input_ch=new_input_ch,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_tts,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
recovering=False,
):
audio_duration += synthesized_audio.frame.duration
if resampler is not None:
for resampled_frame in resampler.push(synthesized_audio.frame):
self._event_ch.send_nowait(
dataclasses.replace(
synthesized_audio, frame=resampled_frame
)
)
if synthesized_audio.is_final:
for resampled_frame in resampler.flush():
self._event_ch.send_nowait(
dataclasses.replace(
synthesized_audio, frame=resampled_frame
)
)
else:
self._event_ch.send_nowait(synthesized_audio)
if (
synthesized_audio.is_final
or (
last_segment_id is not None
and synthesized_audio.segment_id != last_segment_id
)
) and self._pending_segments_chunks:
audio_duration = 0.0
self._pending_segments_chunks.pop(0)
last_segment_id = synthesized_audio.segment_id
return
except Exception:
if tts_status.available:
tts_status.available = False
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=False),
)
if self._fallback_adapter._no_fallback_after_audio_duration is not None:
if (
audio_duration
>= self._fallback_adapter._no_fallback_after_audio_duration
and self._pending_segments_chunks
):
logger.warning(
f"{tts.label} already synthesized {audio_duration}s of audio, ignoring the current segment for the tts fallback" # noqa: E501
)
return
self._try_recovery(tts)
raise APIConnectionError(
f"all TTSs failed ({[tts.label for tts in self._fallback_adapter._tts_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
finally:
await utils.aio.cancel_and_wait(input_task)
def _try_recovery(self, tts: TTS) -> None:
assert isinstance(self._tts, FallbackAdapter)
retry_segments = [self._current_segment_text.copy()]
if self._total_segments:
retry_segments.insert(0, self._total_segments[-1])
tts_status = self._tts._status[self._tts._tts_instances.index(tts)]
if tts_status.recovering_task is None or tts_status.recovering_task.done():
async def _recover_tts_task(tts: TTS) -> None:
try:
input_ch = aio.Chan[Union[str, SynthesizeStream._FlushSentinel]]()
for segment in retry_segments:
for t in segment:
input_ch.send_nowait(t)
input_ch.send_nowait(self._FlushSentinel())
input_ch.close()
async for _ in self._try_synthesize(
tts=tts,
input_ch=input_ch,
recovering=True,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=0,
timeout=self._fallback_adapter._attempt_timeout,
retry_interval=self._fallback_adapter._retry_interval,
),
):
pass
tts_status.available = True
logger.info(f"tts.FallbackAdapter, {tts.label} recovered")
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=True),
)
except Exception:
return
tts_status.recovering_task = asyncio.create_task(_recover_tts_task(tts))
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable
from .. import tokenize, utils
from ..types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
from .tts import TTS, ChunkedStream, SynthesizedAudio, SynthesizeStream, TTSCapabilities
class StreamAdapter(TTS):
def __init__(
self,
*,
tts: TTS,
sentence_tokenizer: tokenize.SentenceTokenizer,
) -> None:
super().__init__(
capabilities=TTSCapabilities(
streaming=True,
),
sample_rate=tts.sample_rate,
num_channels=tts.num_channels,
)
self._tts = tts
self._sentence_tokenizer = sentence_tokenizer
@self._tts.on("metrics_collected")
def _forward_metrics(*args, **kwargs):
self.emit("metrics_collected", *args, **kwargs)
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return self._tts.synthesize(text=text, conn_options=conn_options)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> StreamAdapterWrapper:
return StreamAdapterWrapper(
tts=self,
conn_options=conn_options,
wrapped_tts=self._tts,
sentence_tokenizer=self._sentence_tokenizer,
)
def prewarm(self) -> None:
self._tts.prewarm()
class StreamAdapterWrapper(SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
conn_options: APIConnectOptions,
wrapped_tts: TTS,
sentence_tokenizer: tokenize.SentenceTokenizer,
) -> None:
super().__init__(tts=tts, conn_options=conn_options)
self._wrapped_tts = wrapped_tts
self._sent_stream = sentence_tokenizer.stream()
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SynthesizedAudio]) -> None:
pass # do nothing
async def _run(self) -> None:
async def _forward_input():
"""forward input to vad"""
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
self._sent_stream.flush()
continue
self._sent_stream.push_text(data)
self._sent_stream.end_input()
async def _synthesize():
async for ev in self._sent_stream:
last_audio: SynthesizedAudio | None = None
async for audio in self._wrapped_tts.synthesize(ev.token):
if last_audio is not None:
self._event_ch.send_nowait(last_audio)
last_audio = audio
if last_audio is not None:
last_audio.is_final = True
self._event_ch.send_nowait(last_audio)
tasks = [
asyncio.create_task(_forward_input()),
asyncio.create_task(_synthesize()),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
from __future__ import annotations
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass
from types import TracebackType
from typing import Generic, Literal, TypeVar, Union
from pydantic import BaseModel, ConfigDict, Field
from livekit import rtc
from .._exceptions import APIConnectionError, APIError
from ..log import logger
from ..metrics import TTSMetrics
from ..types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
from ..utils import aio
@dataclass
class SynthesizedAudio:
frame: rtc.AudioFrame
"""Synthesized audio frame"""
request_id: str
"""Request ID (one segment could be made up of multiple requests)"""
is_final: bool = False
"""Whether this is latest frame of the segment (streaming only)"""
segment_id: str = ""
"""Segment ID, each segment is separated by a flush (streaming only)"""
delta_text: str = ""
"""Current segment of the synthesized audio (streaming only)"""
@dataclass
class TTSCapabilities:
streaming: bool
"""Whether this TTS supports streaming (generally using websockets)"""
class TTSError(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["tts_error"] = "tts_error"
timestamp: float
label: str
error: APIError = Field(..., exclude=True)
recoverable: bool
TEvent = TypeVar("TEvent")
class TTS(
ABC,
rtc.EventEmitter[Union[Literal["metrics_collected", "error"], TEvent]],
Generic[TEvent],
):
def __init__(
self,
*,
capabilities: TTSCapabilities,
sample_rate: int,
num_channels: int,
conn_options: APIConnectOptions | None = None,
) -> None:
super().__init__()
self._capabilities = capabilities
self._sample_rate = sample_rate
self._num_channels = num_channels
self._label = f"{type(self).__module__}.{type(self).__name__}"
self._conn_options = conn_options or DEFAULT_API_CONNECT_OPTIONS
@property
def label(self) -> str:
return self._label
@property
def capabilities(self) -> TTSCapabilities:
return self._capabilities
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def num_channels(self) -> int:
return self._num_channels
@abstractmethod
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions | None = None,
) -> ChunkedStream: ...
def stream(self, *, conn_options: APIConnectOptions | None = None) -> SynthesizeStream:
raise NotImplementedError(
"streaming is not supported by this TTS, please use a different TTS or use a StreamAdapter" # noqa: E501
)
def prewarm(self) -> None:
"""Pre-warm connection to the TTS service"""
pass
async def aclose(self) -> None: ...
async def __aenter__(self) -> TTS:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
class ChunkedStream(ABC):
"""Used by the non-streamed synthesize API, some providers support chunked http responses"""
def __init__(
self,
*,
tts: TTS,
input_text: str,
conn_options: APIConnectOptions | None = None,
) -> None:
self._input_text = input_text
self._tts = tts
self._conn_options = conn_options or DEFAULT_API_CONNECT_OPTIONS
self._event_ch = aio.Chan[SynthesizedAudio]()
self._event_aiter, monitor_aiter = aio.itertools.tee(self._event_ch, 2)
self._current_attempt_has_error = False
self._metrics_task = asyncio.create_task(
self._metrics_monitor_task(monitor_aiter), name="TTS._metrics_task"
)
self._synthesize_task = asyncio.create_task(self._main_task(), name="TTS._synthesize_task")
self._synthesize_task.add_done_callback(lambda _: self._event_ch.close())
@property
def input_text(self) -> str:
return self._input_text
@property
def done(self) -> bool:
return self._synthesize_task.done()
@property
def exception(self) -> BaseException | None:
return self._synthesize_task.exception()
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SynthesizedAudio]) -> None:
"""Task used to collect metrics"""
start_time = time.perf_counter()
audio_duration = 0.0
ttfb = -1.0
request_id = ""
async for ev in event_aiter:
request_id = ev.request_id
if ttfb == -1.0:
ttfb = time.perf_counter() - start_time
audio_duration += ev.frame.duration
duration = time.perf_counter() - start_time
if self._current_attempt_has_error:
return
metrics = TTSMetrics(
timestamp=time.time(),
request_id=request_id,
ttfb=ttfb,
duration=duration,
characters_count=len(self._input_text),
audio_duration=audio_duration,
cancelled=self._synthesize_task.cancelled(),
label=self._tts._label,
streamed=False,
)
self._tts.emit("metrics_collected", metrics)
async def collect(self) -> rtc.AudioFrame:
"""Utility method to collect every frame in a single call"""
frames = []
async for ev in self:
frames.append(ev.frame)
return rtc.combine_audio_frames(frames)
@abstractmethod
async def _run(self) -> None: ...
async def _main_task(self) -> None:
for i in range(self._conn_options.max_retry + 1):
try:
return await self._run()
except APIError as e:
retry_interval = self._conn_options._interval_for_retry(i)
if self._conn_options.max_retry == 0:
self._emit_error(e, recoverable=False)
raise
elif i == self._conn_options.max_retry:
self._emit_error(e, recoverable=False)
raise APIConnectionError(
f"failed to synthesize speech after {self._conn_options.max_retry + 1} attempts", # noqa: E501
) from e
else:
self._emit_error(e, recoverable=True)
logger.warning(
f"failed to synthesize speech, retrying in {retry_interval}s",
exc_info=e,
extra={
"tts": self._tts._label,
"attempt": i + 1,
"streamed": False,
},
)
await asyncio.sleep(retry_interval)
# Reset the flag when retrying
self._current_attempt_has_error = False
def _emit_error(self, api_error: APIError, recoverable: bool):
self._current_attempt_has_error = True
self._tts.emit(
"error",
TTSError(
timestamp=time.time(),
label=self._tts._label,
error=api_error,
recoverable=recoverable,
),
)
async def aclose(self) -> None:
"""Close is automatically called if the stream is completely collected"""
await aio.cancel_and_wait(self._synthesize_task)
self._event_ch.close()
await self._metrics_task
async def __anext__(self) -> SynthesizedAudio:
try:
val = await self._event_aiter.__anext__()
except StopAsyncIteration:
if not self._synthesize_task.cancelled() and (exc := self._synthesize_task.exception()):
raise exc # noqa: B904
raise StopAsyncIteration from None
return val
def __aiter__(self) -> AsyncIterator[SynthesizedAudio]:
return self
async def __aenter__(self) -> ChunkedStream:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
class SynthesizeStream(ABC):
class _FlushSentinel: ...
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions | None = None) -> None:
super().__init__()
self._tts = tts
self._conn_options = conn_options or DEFAULT_API_CONNECT_OPTIONS
self._input_ch = aio.Chan[Union[str, SynthesizeStream._FlushSentinel]]()
self._event_ch = aio.Chan[SynthesizedAudio]()
self._event_aiter, self._monitor_aiter = aio.itertools.tee(self._event_ch, 2)
self._task = asyncio.create_task(self._main_task(), name="TTS._main_task")
self._task.add_done_callback(lambda _: self._event_ch.close())
self._metrics_task: asyncio.Task | None = None # started on first push
self._current_attempt_has_error = False
self._started_time: float = 0
# used to track metrics
self._mtc_pending_texts: list[str] = []
self._mtc_text = ""
@abstractmethod
async def _run(self) -> None: ...
async def _main_task(self) -> None:
for i in range(self._conn_options.max_retry + 1):
try:
return await self._run()
except APIError as e:
retry_interval = self._conn_options._interval_for_retry(i)
if self._conn_options.max_retry == 0:
self._emit_error(e, recoverable=False)
raise
elif i == self._conn_options.max_retry:
self._emit_error(e, recoverable=False)
raise APIConnectionError(
f"failed to synthesize speech after {self._conn_options.max_retry + 1} attempts", # noqa: E501
) from e
else:
self._emit_error(e, recoverable=True)
logger.warning(
f"failed to synthesize speech, retrying in {retry_interval}s",
exc_info=e,
extra={
"tts": self._tts._label,
"attempt": i + 1,
"streamed": True,
},
)
await asyncio.sleep(retry_interval)
# Reset the flag when retrying
self._current_attempt_has_error = False
def _emit_error(self, api_error: APIError, recoverable: bool):
self._current_attempt_has_error = True
self._tts.emit(
"error",
TTSError(
timestamp=time.time(),
label=self._tts._label,
error=api_error,
recoverable=recoverable,
),
)
def _mark_started(self) -> None:
# only set the started time once, it'll get reset after we emit metrics
if self._started_time == 0:
self._started_time = time.perf_counter()
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SynthesizedAudio]) -> None:
"""Task used to collect metrics"""
audio_duration = 0.0
ttfb = -1.0
request_id = ""
def _emit_metrics():
nonlocal audio_duration, ttfb, request_id
if not self._started_time or self._current_attempt_has_error:
return
duration = time.perf_counter() - self._started_time
if not self._mtc_pending_texts:
return
text = self._mtc_pending_texts.pop(0)
if not text:
return
metrics = TTSMetrics(
timestamp=time.time(),
request_id=request_id,
ttfb=ttfb,
duration=duration,
characters_count=len(text),
audio_duration=audio_duration,
cancelled=self._task.cancelled(),
label=self._tts._label,
streamed=True,
)
self._tts.emit("metrics_collected", metrics)
audio_duration = 0.0
ttfb = -1.0
request_id = ""
self._started_time = 0
async for ev in event_aiter:
if ttfb == -1.0:
ttfb = time.perf_counter() - self._started_time
audio_duration += ev.frame.duration
request_id = ev.request_id
if ev.is_final:
_emit_metrics()
if request_id:
_emit_metrics()
def push_text(self, token: str) -> None:
"""Push some text to be synthesized"""
if self._metrics_task is None:
self._metrics_task = asyncio.create_task(
self._metrics_monitor_task(self._monitor_aiter),
name="TTS._metrics_task",
)
self._mtc_text += token
self._check_input_not_ended()
self._check_not_closed()
self._input_ch.send_nowait(token)
def flush(self) -> None:
"""Mark the end of the current segment"""
if self._mtc_text:
self._mtc_pending_texts.append(self._mtc_text)
self._mtc_text = ""
self._check_input_not_ended()
self._check_not_closed()
self._input_ch.send_nowait(self._FlushSentinel())
def end_input(self) -> None:
"""Mark the end of input, no more text will be pushed"""
self.flush()
self._input_ch.close()
async def aclose(self) -> None:
"""Close ths stream immediately"""
self._input_ch.close()
await aio.cancel_and_wait(self._task)
if self._metrics_task is not None:
await self._metrics_task
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
def _check_input_not_ended(self) -> None:
if self._input_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} input ended")
async def __anext__(self) -> SynthesizedAudio:
try:
val = await self._event_aiter.__anext__()
except StopAsyncIteration:
if not self._task.cancelled() and (exc := self._task.exception()):
raise exc # noqa: B904
raise StopAsyncIteration from None
return val
def __aiter__(self) -> AsyncIterator[SynthesizedAudio]:
return self
async def __aenter__(self) -> SynthesizeStream:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()
class SynthesizedAudioEmitter:
"""Utility for buffering and emitting audio frames with metadata to a channel.
This class helps TTS implementers to correctly handle is_final logic when streaming responses.
"""
def __init__(
self,
*,
event_ch: aio.Chan[SynthesizedAudio],
request_id: str,
segment_id: str = "",
) -> None:
self._event_ch = event_ch
self._frame: rtc.AudioFrame | None = None
self._request_id = request_id
self._segment_id = segment_id
def push(self, frame: rtc.AudioFrame | None):
"""Emits any buffered frame and stores the new frame for later emission.
The buffered frame is emitted as not final.
"""
self._emit_frame(is_final=False)
self._frame = frame
def _emit_frame(self, is_final: bool = False):
"""Sends the buffered frame to the event channel if one exists."""
if self._frame is None:
return
self._event_ch.send_nowait(
SynthesizedAudio(
frame=self._frame,
request_id=self._request_id,
segment_id=self._segment_id,
is_final=is_final,
)
)
self._frame = None
def flush(self):
"""Emits any buffered frame as final."""
self._emit_frame(is_final=True)
from dataclasses import dataclass
from typing import Literal, TypeVar, Union
from typing_extensions import TypeAlias
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID = "lk.segment_id"
ATTRIBUTE_TRANSCRIPTION_TRACK_ID = "lk.transcribed_track_id"
ATTRIBUTE_TRANSCRIPTION_FINAL = "lk.transcription_final"
ATTRIBUTE_PUBLISH_ON_BEHALF = "lk.publish_on_behalf"
ATTRIBUTE_AGENT_STATE = "lk.agent.state"
"""
The state of the agent, stored in the agent's attributes.
This can be retrieved on the client side by using `RemoteParticipant.attributes`.
With components-js, this can be easily retrieved using:
```js
const { state, ... } = useVoiceAssistant();
”””
TOPIC_CHAT = “lk.chat” TOPIC_TRANSCRIPTION = “lk.transcription”
_T = TypeVar(“_T”)
class NotGiven: def bool(self) -> Literal[False]: return False
def __repr__(self) -> str:
return "NOT_GIVEN"
NotGivenOr: TypeAlias = Union[_T, NotGiven] NOT_GIVEN = NotGiven()
@dataclass(frozen=True) class APIConnectOptions: max_retry: int = 3 “”” Maximum number of retries to connect to the API. “””
retry_interval: float = 2.0
"""
Interval between retries to connect to the API in seconds.
"""
timeout: float = 10.0
"""
Timeout for connecting to the API in seconds.
"""
def __post_init__(self):
if self.max_retry < 0:
raise ValueError("max_retry must be greater than or equal to 0")
if self.retry_interval < 0:
raise ValueError("retry_interval must be greater than or equal to 0")
if self.timeout < 0:
raise ValueError("timeout must be greater than or equal to 0")
def _interval_for_retry(self, num_retries: int) -> float:
"""
Return the interval for the given number of retries.
The first retry is immediate, and then uses specified retry_interval
"""
if num_retries == 0:
return 0.1
return self.retry_interval
DEFAULT_API_CONNECT_OPTIONS = APIConnectOptions()
## livekit-agents/livekit/agents/utils/__init__.py
```py
from livekit import rtc
from . import aio, audio, codecs, http_context, hw, images
from .audio import AudioBuffer, combine_frames, merge_frames
from .connection_pool import ConnectionPool
from .exp_filter import ExpFilter
from .log import log_exceptions
from .misc import is_given, shortuuid, time_ms
from .moving_average import MovingAverage
from .participant import wait_for_participant
EventEmitter = rtc.EventEmitter
__all__ = [
"AudioBuffer",
"merge_frames",
"combine_frames",
"time_ms",
"shortuuid",
"http_context",
"ExpFilter",
"MovingAverage",
"EventEmitter",
"log_exceptions",
"codecs",
"images",
"audio",
"aio",
"hw",
"is_given",
"ConnectionPool",
"wait_for_participant",
]
from . import debug, duplex_unix, itertools
from .channel import Chan, ChanClosed, ChanReceiver, ChanSender
from .interval import Interval, interval
from .sleep import Sleep, SleepFinished, sleep
from .task_set import TaskSet
from .utils import cancel_and_wait, gracefully_cancel
from .wait_group import WaitGroup
__all__ = [
"ChanClosed",
"Chan",
"ChanSender",
"ChanReceiver",
"channel",
"Interval",
"interval",
"Sleep",
"SleepFinished",
"sleep",
"TaskSet",
"WaitGroup",
"debug",
"cancel_and_wait",
"duplex_unix",
"itertools",
"cancel_and_wait",
"gracefully_cancel",
]
from __future__ import annotations
import asyncio
import contextlib
from collections import deque
from collections.abc import AsyncIterator
from typing import Generic, Protocol, TypeVar
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
# Based on asyncio.Queue, see https://github.com/python/cpython/blob/main/Lib/asyncio/queues.py
class ChanClosed(Exception):
pass
class ChanFull(Exception):
pass
class ChanEmpty(Exception):
pass
class ChanSender(Protocol[T_contra]):
async def send(self, value: T_contra) -> None: ...
def send_nowait(self, value: T_contra) -> None: ...
def close(self) -> None: ...
class ChanReceiver(Protocol[T_co]):
async def recv(self) -> T_co: ...
def recv_nowait(self) -> T_co: ...
def close(self) -> None: ...
def __aiter__(self) -> AsyncIterator[T_co]: ...
async def __anext__(self) -> T_co: ...
class Chan(Generic[T]):
def __init__(
self,
maxsize: int = 0,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._loop = loop or asyncio.get_event_loop()
self._maxsize = max(maxsize, 0)
# self._finished_ev = asyncio.Event()
self._close_ev = asyncio.Event()
self._closed = False
self._gets: deque[asyncio.Future[T | None]] = deque()
self._puts: deque[asyncio.Future[T | None]] = deque()
self._queue: deque[T] = deque()
def _wakeup_next(self, waiters: deque[asyncio.Future[T | None]]):
while waiters:
waiter = waiters.popleft()
if not waiter.done():
waiter.set_result(None)
break
async def send(self, value: T) -> None:
while self.full() and not self._close_ev.is_set():
p = self._loop.create_future()
self._puts.append(p)
try:
await p
except ChanClosed:
raise
except:
p.cancel()
with contextlib.suppress(ValueError):
self._puts.remove(p)
if not self.full() and not p.cancelled():
self._wakeup_next(self._puts)
raise
self.send_nowait(value)
def send_nowait(self, value: T) -> None:
if self.full():
raise ChanFull
if self._close_ev.is_set():
raise ChanClosed
self._queue.append(value)
self._wakeup_next(self._gets)
async def recv(self) -> T:
while self.empty() and not self._close_ev.is_set():
g = self._loop.create_future()
self._gets.append(g)
try:
await g
except ChanClosed:
raise
except Exception:
g.cancel()
with contextlib.suppress(ValueError):
self._gets.remove(g)
if not self.empty() and not g.cancelled():
self._wakeup_next(self._gets)
raise
return self.recv_nowait()
def recv_nowait(self) -> T:
if self.empty():
if self._close_ev.is_set():
raise ChanClosed
else:
raise ChanEmpty
item = self._queue.popleft()
# if self.empty() and self._close_ev.is_set():
# self._finished_ev.set()
self._wakeup_next(self._puts)
return item
def close(self) -> None:
self._closed = True
self._close_ev.set()
for putter in self._puts:
if not putter.cancelled():
putter.set_exception(ChanClosed())
while len(self._gets) > self.qsize():
getter = self._gets.pop()
if not getter.cancelled():
getter.set_exception(ChanClosed())
while self._gets:
self._wakeup_next(self._gets)
# if self.empty():
# self._finished_ev.set()
@property
def closed(self) -> bool:
return self._closed
# async def join(self) -> None:
# await self._finished_ev.wait()
def qsize(self) -> int:
"""the number of elements queued (unread) in the channel buffer"""
return len(self._queue)
def full(self) -> bool:
if self._maxsize <= 0:
return False
else:
return self.qsize() >= self._maxsize
def empty(self) -> bool:
return not self._queue
def __aiter__(self) -> AsyncIterator[T]:
return self
async def __anext__(self) -> T:
try:
return await self.recv()
except ChanClosed:
raise StopAsyncIteration from None
from __future__ import annotations
import asyncio
import time
from asyncio.base_events import _format_handle # type: ignore
from typing import Any
from ...log import logger
def hook_slow_callbacks(slow_duration: float) -> None:
_run = asyncio.events.Handle._run
def instrumented(self: Any):
start = time.monotonic()
val = _run(self)
dt = time.monotonic() - start
if dt >= slow_duration:
logger.warning(
"Running %s took too long: %.2f seconds",
_format_handle(self), # type: ignore
dt,
)
return val
asyncio.events.Handle._run = instrumented # type: ignore
from __future__ import annotations
import asyncio
import socket
import struct
class DuplexClosed(Exception):
"""Exception raised when the duplex connection is closed."""
pass
class _AsyncDuplex:
def __init__(
self,
sock: socket.socket,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._loop = loop
self._sock = sock
self._reader = reader
self._writer = writer
@staticmethod
async def open(sock: socket.socket) -> _AsyncDuplex:
loop = asyncio.get_running_loop()
reader, writer = await asyncio.open_connection(sock=sock)
return _AsyncDuplex(sock, reader, writer, loop)
async def recv_bytes(self) -> bytes:
try:
len_bytes = await self._reader.readexactly(4)
len = struct.unpack("!I", len_bytes)[0]
return await self._reader.readexactly(len)
except (
OSError,
EOFError,
asyncio.IncompleteReadError,
) as e:
raise DuplexClosed() from e
async def send_bytes(self, data: bytes) -> None:
try:
len_bytes = struct.pack("!I", len(data))
self._writer.write(len_bytes)
self._writer.write(data)
await self._writer.drain()
except OSError as e:
raise DuplexClosed() from e
async def aclose(self) -> None:
try:
self._writer.close()
await self._writer.wait_closed()
self._sock.close()
except OSError as e:
raise DuplexClosed() from e
def _read_exactly(sock: socket.socket, num_bytes: int) -> bytes:
data = bytearray()
while len(data) < num_bytes:
packet = sock.recv(num_bytes - len(data))
if not packet:
raise EOFError()
data.extend(packet)
return bytes(data)
class _Duplex:
def __init__(self, sock: socket.socket) -> None:
self._sock: socket.socket | None = sock
@staticmethod
def open(sock: socket.socket) -> _Duplex:
return _Duplex(sock)
def recv_bytes(self) -> bytes:
if self._sock is None:
raise DuplexClosed()
try:
len_bytes = _read_exactly(self._sock, 4)
len = struct.unpack("!I", len_bytes)[0]
return _read_exactly(self._sock, len)
except (OSError, EOFError) as e:
raise DuplexClosed() from e
def send_bytes(self, data: bytes) -> None:
if self._sock is None:
raise DuplexClosed()
try:
len_bytes = struct.pack("!I", len(data))
self._sock.sendall(len_bytes)
self._sock.sendall(data)
except OSError as e:
raise DuplexClosed() from e
def detach(self) -> socket.socket:
if self._sock is None:
raise DuplexClosed()
sock = self._sock
self._sock = None
return sock
def close(self) -> None:
try:
if self._sock is not None:
self._sock.close()
self._sock = None
except OSError as e:
raise DuplexClosed() from e
from __future__ import annotations
import asyncio
from typing import Any
def _finish_fut(fut: asyncio.Future[Any]):
if fut.cancelled():
return
fut.set_result(None)
# MissedBehaviour is "Delay"
class Interval:
def __init__(self, interval: float) -> None:
self._interval = interval
self._last_sleep = 0.0
self._i = 0
self._handler: asyncio.TimerHandle | None = None
def reset(self) -> None:
if self._fut and self._handler and not self._handler.cancelled():
self._handler.cancel()
loop = asyncio.get_event_loop()
self._handler = loop.call_later(self._interval, _finish_fut, self._fut)
else:
self._last_sleep = 0
async def tick(self) -> int:
loop = asyncio.get_event_loop()
if self._last_sleep:
self._fut = loop.create_future()
delay = self._last_sleep - loop.time() + self._interval
self._handler = loop.call_later(delay, _finish_fut, self._fut)
try:
await self._fut
finally:
self._handler.cancel()
self._i += 1
self._last_sleep = loop.time()
return self._i
def __aiter__(self) -> Interval:
return self
async def __anext__(self):
return await self.tick()
def interval(interval: float) -> Interval:
return Interval(interval)
import asyncio
from collections import deque
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Iterator
from typing import Any, Generic, Protocol, TypeVar, Union, overload, runtime_checkable
from typing_extensions import AsyncContextManager
# based on https://github.com/maxfischer2781/asyncstdlib/blob/master/asyncstdlib/itertools.py
@runtime_checkable
class _ACloseable(Protocol):
async def aclose(self) -> None:
"""Asynchronously close this object"""
T = TypeVar("T")
async def tee_peer(
iterator: AsyncIterator[T],
buffer: deque[T],
peers: list[deque[T]],
lock: AsyncContextManager[Any],
) -> AsyncGenerator[T, None]:
try:
while True:
if not buffer:
async with lock:
if buffer:
continue
try:
item = await iterator.__anext__()
except StopAsyncIteration:
break
else:
for peer_buffer in peers:
peer_buffer.append(item)
yield buffer.popleft()
finally:
for idx, peer_buffer in enumerate(peers): # pragma: no branch
if peer_buffer is buffer:
peers.pop(idx)
break
if not peers and isinstance(iterator, _ACloseable):
await iterator.aclose()
class Tee(Generic[T]):
__slots__ = ("_iterator", "_buffers", "_children")
def __init__(
self,
iterator: AsyncIterable[T],
n: int = 2,
):
self._iterator = iterator.__aiter__()
self._buffers: list[deque[T]] = [deque() for _ in range(n)]
lock = asyncio.Lock()
self._children = tuple(
tee_peer(
iterator=self._iterator,
buffer=buffer,
peers=self._buffers,
lock=lock,
)
for buffer in self._buffers
)
def __len__(self) -> int:
return len(self._children)
@overload
def __getitem__(self, item: int) -> AsyncIterator[T]: ...
@overload
def __getitem__(self, item: slice) -> tuple[AsyncIterator[T], ...]: ...
def __getitem__(
self, item: Union[int, slice]
) -> Union[AsyncIterator[T], tuple[AsyncIterator[T], ...]]:
return self._children[item]
def __iter__(self) -> Iterator[AsyncIterator[T]]:
yield from self._children
async def __aenter__(self) -> "Tee[T]":
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
await self.aclose()
async def aclose(self) -> None:
for child in self._children:
await child.aclose()
tee = Tee
from __future__ import annotations
import asyncio
from typing import Any
def _finish_fut(fut: asyncio.Future[Any]):
if fut.cancelled():
return
fut.set_result(None)
class SleepFinished(Exception):
pass
class Sleep:
"""Same as asyncio.sleep except it is resettable"""
def __init__(self, delay: float) -> None:
self._delay = delay
self._handler: asyncio.TimerHandle | None = None
def reset(self, new_delay: float | None = None) -> None:
if new_delay is None:
new_delay = self._delay
self._delay = new_delay
if self._handler is None:
return
if self._handler.cancelled() or self._fut.done():
raise SleepFinished
self._handler.cancel()
loop = asyncio.get_event_loop()
self._handler = loop.call_later(new_delay, _finish_fut, self._fut)
def cancel(self) -> None:
if self._handler is None:
return
self._handler.cancel()
self._fut.cancel()
async def _sleep(self) -> None:
if self._delay <= 0:
self._fut = asyncio.Future[None]()
self._fut.set_result(None)
return
loop = asyncio.get_event_loop()
self._fut = loop.create_future()
self._handler = loop.call_later(self._delay, _finish_fut, self._fut)
try:
await self._fut
finally:
self._handler.cancel()
def __await__(self):
return self._sleep().__await__()
def sleep(delay: float) -> Sleep:
return Sleep(delay)
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from typing import Any, TypeVar
_T = TypeVar("_T")
class TaskSet:
"""Small utility to create tasks in a fire-and-forget fashion."""
def __init__(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
self._loop = loop or asyncio.get_event_loop()
self._set = set[asyncio.Task[Any]]()
self._closed = False
def create_task(
self, coro: Coroutine[Any, Any, _T], name: str | None = None
) -> asyncio.Task[_T]:
if self._closed:
raise RuntimeError("TaskSet is closed")
task = self._loop.create_task(coro, name=name)
self._set.add(task)
task.add_done_callback(self._set.remove)
return task
@property
def tasks(self) -> set[asyncio.Task[Any]]:
return self._set.copy()
import asyncio
import functools
async def cancel_and_wait(*futures: asyncio.Future):
loop = asyncio.get_running_loop()
waiters = []
for fut in futures:
waiter = loop.create_future()
cb = functools.partial(_release_waiter, waiter)
waiters.append((waiter, cb))
fut.add_done_callback(cb)
fut.cancel()
try:
for waiter, _ in waiters:
await waiter
finally:
for i, fut in enumerate(futures):
_, cb = waiters[i]
fut.remove_done_callback(cb)
def _release_waiter(waiter, *_):
if not waiter.done():
waiter.set_result(None)
gracefully_cancel = cancel_and_wait
import asyncio
class WaitGroup:
"""
asyncio wait group implementation (similar to sync.WaitGroup in go)
"""
def __init__(self):
self._counter = 0
self._zero_event = asyncio.Event()
self._zero_event.set()
def add(self, delta: int = 1):
new_value = self._counter + delta
if new_value < 0:
raise ValueError("WaitGroup counter cannot go negative.")
self._counter = new_value
if self._counter == 0:
self._zero_event.set()
else:
self._zero_event.clear()
def done(self):
self.add(-1)
async def wait(self):
await self._zero_event.wait()
from __future__ import annotations
import asyncio
import ctypes
from collections.abc import AsyncGenerator
from typing import Union
import aiofiles
from livekit import rtc
from ..log import logger
from .aio.utils import cancel_and_wait
# deprecated aliases
AudioBuffer = Union[list[rtc.AudioFrame], rtc.AudioFrame]
combine_frames = rtc.combine_audio_frames
merge_frames = rtc.combine_audio_frames
def calculate_audio_duration(frames: AudioBuffer) -> float:
"""
Calculate the total duration of audio frames.
This function computes the total duration of audio frames in seconds.
It accepts either a list of `rtc.AudioFrame` objects or a single `rtc.AudioFrame` object.
Parameters:
- frames (AudioBuffer): A list of `rtc.AudioFrame` instances or a single `rtc.AudioFrame` instance.
Returns:
- float: The total duration in seconds of all frames provided.
""" # noqa: E501
if isinstance(frames, list):
return sum(frame.duration for frame in frames)
else:
return frames.duration
class AudioByteStream:
"""
Buffer and chunk audio byte data into fixed-size frames.
This class is designed to handle incoming audio data in bytes,
buffering it and producing audio frames of a consistent size.
It is mainly used to easily chunk big or too small audio frames
into a fixed size, helping to avoid processing very small frames
(which can be inefficient) and very large frames (which can cause
latency or processing delays). By normalizing frame sizes, it
facilitates consistent and efficient audio data processing.
"""
def __init__(
self,
sample_rate: int,
num_channels: int,
samples_per_channel: int | None = None,
) -> None:
"""
Initialize an AudioByteStream instance.
Parameters:
sample_rate (int): The audio sample rate in Hz.
num_channels (int): The number of audio channels.
samples_per_channel (int, optional): The number of samples per channel in each frame.
If None, defaults to `sample_rate // 10` (i.e., 100ms of audio data).
The constructor sets up the internal buffer and calculates the size of each frame in bytes.
The frame size is determined by the number of channels, samples per channel, and the size
of each sample (assumed to be 16 bits or 2 bytes).
"""
self._sample_rate = sample_rate
self._num_channels = num_channels
if samples_per_channel is None:
samples_per_channel = sample_rate // 10 # 100ms by default
self._bytes_per_frame = num_channels * samples_per_channel * ctypes.sizeof(ctypes.c_int16)
self._buf = bytearray()
def push(self, data: bytes) -> list[rtc.AudioFrame]:
"""
Add audio data to the buffer and retrieve fixed-size frames.
Parameters:
data (bytes): The incoming audio data to buffer.
Returns:
list[rtc.AudioFrame]: A list of `AudioFrame` objects of fixed size.
The method appends the incoming data to the internal buffer.
While the buffer contains enough data to form complete frames,
it extracts the data for each frame, creates an `AudioFrame` object,
and appends it to the list of frames to return.
This allows you to feed in variable-sized chunks of audio data
(e.g., from a stream or file) and receive back a list of
fixed-size audio frames ready for processing or transmission.
"""
self._buf.extend(data)
frames = []
while len(self._buf) >= self._bytes_per_frame:
frame_data = self._buf[: self._bytes_per_frame]
self._buf = self._buf[self._bytes_per_frame :]
frames.append(
rtc.AudioFrame(
data=frame_data,
sample_rate=self._sample_rate,
num_channels=self._num_channels,
samples_per_channel=len(frame_data) // 2,
)
)
return frames
write = push # Alias for the push method.
def flush(self) -> list[rtc.AudioFrame]:
"""
Flush the buffer and retrieve any remaining audio data as a frame.
Returns:
list[rtc.AudioFrame]: A list containing any remaining `AudioFrame` objects.
This method processes any remaining data in the buffer that does not
fill a complete frame. If the remaining data forms a partial frame
(i.e., its size is not a multiple of the expected sample size), a warning is
logged and an empty list is returned. Otherwise, it returns the final
`AudioFrame` containing the remaining data.
Use this method when you have no more data to push and want to ensure
that all buffered audio data has been processed.
"""
if len(self._buf) == 0:
return []
if len(self._buf) % (2 * self._num_channels) != 0:
logger.warning("AudioByteStream: incomplete frame during flush, dropping")
return []
return [
rtc.AudioFrame(
data=self._buf,
sample_rate=self._sample_rate,
num_channels=self._num_channels,
samples_per_channel=len(self._buf) // 2,
)
]
async def audio_frames_from_file(
file_path: str, sample_rate: int = 48000, num_channels: int = 1
) -> AsyncGenerator[rtc.AudioFrame, None]:
"""
Decode the audio file into rtc.AudioFrame instances and yield them as an async iterable.
Args:
file_path (str): The path to the audio file.
sample_rate (int, optional): Desired sample rate. Defaults to 48000.
num_channels (int, optional): Number of channels (1 for mono, 2 for stereo). Defaults to 1.
Returns:
AsyncIterable[rtc.AudioFrame]: An async iterable that yields decoded AudioFrame
"""
from .codecs import AudioStreamDecoder
decoder = AudioStreamDecoder(sample_rate=sample_rate, num_channels=num_channels)
async def file_reader():
async with aiofiles.open(file_path, mode="rb") as f:
while True:
chunk = await f.read(4096)
if not chunk:
break
decoder.push(chunk)
decoder.end_input()
reader_task = asyncio.create_task(file_reader())
try:
async for frame in decoder:
yield frame
finally:
await cancel_and_wait(reader_task)
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .decoder import AudioStreamDecoder, StreamBuffer
__all__ = ["AudioStreamDecoder", "StreamBuffer"]
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import contextlib
import io
import struct
import threading
from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
import av
import av.container
from livekit import rtc
from livekit.agents.log import logger
from livekit.agents.utils import aio
class StreamBuffer:
"""
A thread-safe buffer that behaves like an IO stream.
Allows writing from one thread and reading from another.
"""
def __init__(self):
self._buffer = io.BytesIO()
self._lock = threading.Lock()
self._data_available = threading.Condition(self._lock)
self._eof = False
def write(self, data: bytes):
"""Write data to the buffer from a writer thread."""
with self._data_available:
self._buffer.seek(0, io.SEEK_END)
self._buffer.write(data)
self._data_available.notify_all()
def read(self, size: int = -1) -> bytes:
"""Read data from the buffer in a reader thread."""
if self._buffer.closed:
return b""
with self._data_available:
while True:
if self._buffer.closed:
return b""
# always read from beginning
self._buffer.seek(0)
data = self._buffer.read(size)
if data:
# shrink the buffer to remove already-read data
remaining = self._buffer.read()
self._buffer = io.BytesIO(remaining)
return data
if self._eof:
return b""
self._data_available.wait()
def end_input(self):
"""Signal that no more data will be written."""
with self._data_available:
self._eof = True
self._data_available.notify_all()
def close(self):
self._buffer.close()
class AudioStreamDecoder:
"""A class that can be used to decode audio stream into PCM AudioFrames.
Decoders are stateful, and it should not be reused across multiple streams. Each decoder
is designed to decode a single stream.
"""
_max_workers: int = 10
_executor: Optional[ThreadPoolExecutor] = None
def __init__(
self, *, sample_rate: int = 48000, num_channels: int = 1, format: Optional[str] = None
):
self._sample_rate = sample_rate
self._layout = "mono"
if num_channels == 2:
self._layout = "stereo"
elif num_channels != 1:
raise ValueError(f"Invalid number of channels: {num_channels}")
self._format = format.lower() if format else None
self._output_ch = aio.Chan[rtc.AudioFrame]()
self._closed = False
self._started = False
self._input_buf = StreamBuffer()
self._loop = asyncio.get_event_loop()
if self.__class__._executor is None:
# each decoder instance will submit jobs to the shared pool
self.__class__._executor = ThreadPoolExecutor(max_workers=self.__class__._max_workers)
def push(self, chunk: bytes):
self._input_buf.write(chunk)
if not self._started:
self._started = True
# choose decode loop based on format
if self._format == "wav":
target = self._decode_wav_loop
else:
target = self._decode_loop
self._loop.run_in_executor(self.__class__._executor, target)
def end_input(self):
self._input_buf.end_input()
if not self._started:
# if no data was pushed, close the output channel
self._output_ch.close()
def _decode_loop(self):
container: av.container.InputContainer | None = None
resampler: av.AudioResampler | None = None
try:
# open container in low-latency streaming mode
container = av.open(
self._input_buf,
mode="r",
buffer_size=1024,
options={
"fflags": "nobuffer+flush_packets",
"probesize": "32",
"analyzeduration": "0",
"max_delay": "0",
},
)
# explicitly disable internal buffering flags on the FFmpeg container
container.flags |= (
av.container.Flags.no_buffer.value | av.container.Flags.flush_packets.value
)
if len(container.streams.audio) == 0:
raise ValueError("no audio stream found")
audio_stream = container.streams.audio[0]
resampler = av.AudioResampler(format="s16", layout=self._layout, rate=self._sample_rate)
for frame in container.decode(audio_stream):
if self._closed:
return
for resampled_frame in resampler.resample(frame):
nchannels = len(resampled_frame.layout.channels)
self._loop.call_soon_threadsafe(
self._output_ch.send_nowait,
rtc.AudioFrame(
data=resampled_frame.to_ndarray().tobytes(),
num_channels=nchannels,
sample_rate=int(resampled_frame.sample_rate),
samples_per_channel=int(resampled_frame.samples / nchannels),
),
)
except Exception:
logger.exception("error decoding audio")
finally:
self._loop.call_soon_threadsafe(self._output_ch.close)
if container:
container.close()
def _decode_wav_loop(self):
"""Decode wav data from the buffer without ffmpeg, parse header and emit PCM frames.
This can be much faster than using ffmpeg, as we are emitting frames as quickly as possible.
"""
try:
from livekit.agents.utils.audio import AudioByteStream
# parse RIFF header
header = b""
while len(header) < 12:
chunk = self._input_buf.read(12 - len(header))
if not chunk:
raise ValueError("Invalid WAV file: incomplete header")
header += chunk
if header[:4] != b"RIFF" or header[8:12] != b"WAVE":
raise ValueError(f"Invalid WAV file: missing RIFF/WAVE: {header}")
# parse fmt chunk
while True:
sub_header = self._input_buf.read(8)
if len(sub_header) < 8:
raise ValueError("Invalid WAV file: incomplete fmt chunk header")
chunk_id, chunk_size = struct.unpack("<4sI", sub_header)
data = b""
remaining = chunk_size
while remaining > 0:
part = self._input_buf.read(min(1024, remaining))
if not part:
raise ValueError("Invalid WAV file: incomplete fmt chunk data")
data += part
remaining -= len(part)
if chunk_id == b"fmt ":
audio_format, wave_channels, wave_rate, _, _, bits_per_sample = struct.unpack(
"<HHIIHH", data[:16]
)
if audio_format != 1:
raise ValueError(f"Unsupported WAV audio format: {audio_format}")
break
# parse data chunk
while True:
sub_header = self._input_buf.read(8)
if len(sub_header) < 8:
raise ValueError("Invalid WAV file: incomplete data chunk header")
chunk_id, chunk_size = struct.unpack("<4sI", sub_header)
if chunk_id == b"data":
break
# skip chunk data
to_skip = chunk_size
while to_skip > 0:
skipped = self._input_buf.read(min(1024, to_skip))
if not skipped:
raise ValueError("Invalid WAV file: incomplete chunk while seeking data")
to_skip -= len(skipped)
# now ready to decode
bstream = AudioByteStream(sample_rate=wave_rate, num_channels=wave_channels)
resampler = rtc.AudioResampler(
input_rate=wave_rate, output_rate=self._sample_rate, num_channels=wave_channels
)
def resample_and_push(frame: rtc.AudioFrame):
for resampled_frame in resampler.push(frame):
self._loop.call_soon_threadsafe(
self._output_ch.send_nowait,
resampled_frame,
)
while True:
chunk = self._input_buf.read(1024)
if not chunk:
break
frames = bstream.push(chunk)
for rtc_frame in frames:
resample_and_push(rtc_frame)
for rtc_frame in bstream.flush():
resample_and_push(rtc_frame)
except Exception:
logger.exception("error decoding wav")
finally:
self._loop.call_soon_threadsafe(self._output_ch.close)
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame]:
return self
async def __anext__(self) -> rtc.AudioFrame:
return await self._output_ch.__anext__()
async def aclose(self):
if self._closed:
return
self.end_input()
self._closed = True
self._input_buf.close()
# wait for decode loop to finish, only if anything's been pushed
with contextlib.suppress(aio.ChanClosed):
if self._started:
await self._output_ch.recv()
import asyncio
import time
import weakref
from collections.abc import AsyncGenerator, Awaitable
from contextlib import asynccontextmanager
from typing import Callable, Generic, Optional, TypeVar
from . import aio
T = TypeVar("T")
class ConnectionPool(Generic[T]):
"""Helper class to manage persistent connections like websockets.
Handles connection pooling and reconnection after max duration.
Can be used as an async context manager to automatically return connections to the pool.
"""
def __init__(
self,
*,
max_session_duration: Optional[float] = None,
mark_refreshed_on_get: bool = False,
connect_cb: Optional[Callable[[], Awaitable[T]]] = None,
close_cb: Optional[Callable[[T], Awaitable[None]]] = None,
) -> None:
"""Initialize the connection wrapper.
Args:
max_session_duration: Maximum duration in seconds before forcing reconnection
mark_refreshed_on_get: If True, the session will be marked as fresh when get() is called. only used when max_session_duration is set.
connect_cb: Optional async callback to create new connections
close_cb: Optional async callback to close connections
""" # noqa: E501
self._max_session_duration = max_session_duration
self._mark_refreshed_on_get = mark_refreshed_on_get
self._connect_cb = connect_cb
self._close_cb = close_cb
self._connections: dict[T, float] = {} # conn -> connected_at timestamp
self._available: set[T] = set()
# store connections to be reaped (closed) later.
self._to_close: set[T] = set()
self._prewarm_task: Optional[weakref.ref[asyncio.Task]] = None
async def _connect(self) -> T:
"""Create a new connection.
Returns:
The new connection object
Raises:
NotImplementedError: If no connect callback was provided
"""
if self._connect_cb is None:
raise NotImplementedError("Must provide connect_cb or implement connect()")
connection = await self._connect_cb()
self._connections[connection] = time.time()
return connection
async def _drain_to_close(self) -> None:
"""Drain and close all the connections queued for closing."""
for conn in list(self._to_close):
await self._maybe_close_connection(conn)
self._to_close.clear()
@asynccontextmanager
async def connection(self) -> AsyncGenerator[T, None]:
"""Get a connection from the pool and automatically return it when done.
Yields:
An active connection object
"""
conn = await self.get()
try:
yield conn
except BaseException:
self.remove(conn)
raise
else:
self.put(conn)
async def get(self) -> T:
"""Get an available connection or create a new one if needed.
Returns:
An active connection object
"""
await self._drain_to_close()
now = time.time()
# try to reuse an available connection that hasn't expired
while self._available:
conn = self._available.pop()
if (
self._max_session_duration is None
or now - self._connections[conn] <= self._max_session_duration
):
if self._mark_refreshed_on_get:
self._connections[conn] = now
return conn
# connection expired; mark it for resetting.
self.remove(conn)
return await self._connect()
def put(self, conn: T) -> None:
"""Mark a connection as available for reuse.
If connection has been reset, it will not be added to the pool.
Args:
conn: The connection to make available
"""
if conn in self._connections:
self._available.add(conn)
async def _maybe_close_connection(self, conn: T) -> None:
"""Close a connection if close_cb is provided.
Args:
conn: The connection to close
"""
if self._close_cb is not None:
await self._close_cb(conn)
def remove(self, conn: T) -> None:
"""Remove a specific connection from the pool.
Marks the connection to be closed during the next drain cycle.
Args:
conn: The connection to reset
"""
self._available.discard(conn)
if conn in self._connections:
self._to_close.add(conn)
self._connections.pop(conn, None)
def invalidate(self) -> None:
"""Clear all existing connections.
Marks all current connections to be closed during the next drain cycle.
"""
for conn in list(self._connections.keys()):
self._to_close.add(conn)
self._connections.clear()
self._available.clear()
def prewarm(self) -> None:
"""Initiate prewarming of the connection pool without blocking.
This method starts a background task that creates a new connection if none exist.
The task automatically cleans itself up when the connection pool is closed.
"""
if self._prewarm_task is not None or self._connections:
return
async def _prewarm_impl():
if not self._connections:
conn = await self._connect()
self._available.add(conn)
task = asyncio.create_task(_prewarm_impl())
self._prewarm_task = weakref.ref(task)
async def aclose(self):
"""Close all connections, draining any pending connection closures."""
if self._prewarm_task is not None:
task = self._prewarm_task()
if task:
await aio.gracefully_cancel(task)
self.invalidate()
await self._drain_to_close()
class ExpFilter:
def __init__(self, alpha: float, max_val: float = -1.0) -> None:
self._alpha = alpha
self._filtered = -1.0
self._max_val = max_val
def reset(self, alpha: float = -1.0) -> None:
if alpha != -1.0:
self._alpha = alpha
self._filtered = -1.0
def apply(self, exp: float, sample: float) -> float:
if self._filtered == -1.0:
self._filtered = sample
else:
a = self._alpha**exp
self._filtered = a * self._filtered + (1 - a) * sample
if self._max_val != -1.0 and self._filtered > self._max_val:
self._filtered = self._max_val
return self._filtered
def filtered(self) -> float:
return self._filtered
def update_base(self, alpha: float) -> None:
self._alpha = alpha
from __future__ import annotations
import contextvars
from typing import Callable
import aiohttp
from ..log import logger
_ClientFactory = Callable[[], aiohttp.ClientSession]
_ContextVar = contextvars.ContextVar("agent_http_session") # type: ignore
def _new_session_ctx() -> _ClientFactory:
g_session: aiohttp.ClientSession | None = None
def _new_session() -> aiohttp.ClientSession:
nonlocal g_session
if g_session is None:
logger.debug("http_session(): creating a new httpclient ctx")
from ..job import get_job_context
try:
http_proxy = get_job_context().proc.http_proxy
except RuntimeError:
http_proxy = None
g_session = aiohttp.ClientSession(proxy=http_proxy)
return g_session
_ContextVar.set(_new_session) # type: ignore
return _new_session
def http_session() -> aiohttp.ClientSession:
"""Optional utility function to avoid having to manually manage an aiohttp.ClientSession lifetime.
On job processes, this http session will be bound to the main event loop.
""" # noqa: E501
val = _ContextVar.get(None) # type: ignore
if val is None:
raise RuntimeError(
"Attempted to use an http session outside of a job context. This is probably because you are trying to use a plugin without using the agent worker api. You may need to create your own aiohttp.ClientSession, pass it into the plugin constructor as a kwarg, and manage its lifecycle." # noqa: E501
)
return val() # type: ignore
async def _close_http_ctx():
val = _ContextVar.get(None) # type: ignore
if val is not None:
logger.debug("http_session(): closing the httpclient ctx")
await val().close() # type: ignore
_ContextVar.set(None) # type: ignore
from .cpu import CGroupV2CPUMonitor, CPUMonitor, DefaultCPUMonitor, get_cpu_monitor
__all__ = ["get_cpu_monitor", "CPUMonitor", "CGroupV2CPUMonitor", "DefaultCPUMonitor"]
import os
import time
from abc import ABC, abstractmethod
import psutil
class CPUMonitor(ABC):
@abstractmethod
def cpu_count(self) -> float:
"""Number of logical CPUs.
Returns a float to allow for fractional CPUs (in the case of cgroups)."""
pass
@abstractmethod
def cpu_percent(self, interval: float = 0.5) -> float:
"""CPU usage percentage between 0 and 1"""
pass
class DefaultCPUMonitor(CPUMonitor):
def cpu_count(self) -> float:
return psutil.cpu_count() or 1.0
def cpu_percent(self, interval: float = 0.5) -> float:
return psutil.cpu_percent(interval) / 100.0
class CGroupV2CPUMonitor(CPUMonitor):
def cpu_count(self) -> float:
# quota: The maximum CPU time in microseconds that the cgroup can use within a given period.
# period: The period of time in microseconds over which the quota applies.
# If the quota is set to "max", it means the cgroup is allowed to use all available CPUs without restriction. # noqa: E501
# Otherwise, the quota is a number that represents the maximum CPU time in microseconds that the cgroup can use within a given period. # noqa: E501
quota, period = self._read_cpu_max()
if quota == "max":
return os.cpu_count() or 1
return 1.0 * int(quota) / period
def cpu_percent(self, interval: float = 0.5) -> float:
cpu_usage_start = self._read_cpu_usage()
time.sleep(interval)
cpu_usage_end = self._read_cpu_usage()
cpu_usage_diff = cpu_usage_end - cpu_usage_start
# Convert microseconds to seconds
cpu_usage_seconds = cpu_usage_diff / 1_000_000
# Get the number of CPUs available to the container
num_cpus = self.cpu_count()
# Calculate the percentage
cpu_usage_percent = cpu_usage_seconds / (interval * num_cpus)
return min(cpu_usage_percent, 1)
def _read_cpu_max(self) -> tuple[str, int]:
try:
with open("/sys/fs/cgroup/cpu.max") as f:
data = f.read().strip().split()
quota = data[0]
period = int(data[1])
except FileNotFoundError:
quota = "max"
period = 100000
return quota, period
def _read_cpu_usage(self) -> int:
with open("/sys/fs/cgroup/cpu.stat") as f:
for line in f:
if line.startswith("usage_usec"):
return int(line.split()[1])
raise RuntimeError("Failed to read CPU usage")
def get_cpu_monitor() -> CPUMonitor:
if _is_cgroup_v2():
return CGroupV2CPUMonitor()
return DefaultCPUMonitor()
def _is_cgroup_v2() -> bool:
return os.path.exists("/sys/fs/cgroup/cpu.stat")
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .image import EncodeOptions, ResizeOptions, encode
__all__ = ["EncodeOptions", "ResizeOptions", "encode"]
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import io
from dataclasses import dataclass
from importlib import import_module
from typing import TYPE_CHECKING, Any, Literal, Optional
from livekit import rtc
if TYPE_CHECKING:
from PIL import Image
@dataclass
class EncodeOptions:
"""Options for encoding rtc.VideoFrame to portable image formats."""
format: Literal["JPEG", "PNG"] = "JPEG"
"""The format to encode the image."""
resize_options: Optional["ResizeOptions"] = None
"""Options for resizing the image."""
quality: Optional[int] = 75
"""Image compression quality, 0-100. Only applies to JPEG."""
@dataclass
class ResizeOptions:
"""Options for resizing rtc.VideoFrame as part of encoding to a portable image format."""
width: int
"""The desired resize width (in)"""
height: int
"""The desired height to resize the image to."""
strategy: Literal[
"center_aspect_fit",
"center_aspect_cover",
"scale_aspect_fit",
"scale_aspect_cover",
"skew",
]
"""The strategy to use when resizing the image:
- center_aspect_fit: Fit the image into the provided dimensions, with letterboxing
- center_aspect_cover: Fill the provided dimensions, with cropping
- scale_aspect_fit: Fit the image into the provided dimensions, preserving its original aspect ratio
- scale_aspect_cover: Fill the provided dimensions, preserving its original aspect ratio (image will be larger than the provided dimensions)
- skew: Precisely resize the image to the provided dimensions
""" # noqa: E501
def import_pil():
try:
if "Image" not in globals():
globals()["Image"] = import_module("PIL.Image")
except ImportError:
raise ImportError(
"You haven't included the 'images' optional dependencies. Please install the 'codecs' extra by running `pip install livekit-agents[images]`" # noqa: E501
) from None
def encode(frame: rtc.VideoFrame, options: EncodeOptions) -> bytes:
"""Encode a rtc.VideoFrame to a portable image format (JPEG or PNG).
See EncodeOptions for more details.
"""
import_pil()
img = _image_from_frame(frame)
resized = _resize_image(img, options)
buffer = io.BytesIO()
kwargs = {}
if options.format == "JPEG" and options.quality is not None:
kwargs["quality"] = options.quality
resized.save(buffer, options.format, **kwargs)
buffer.seek(0)
return buffer.read()
def _image_from_frame(frame: rtc.VideoFrame):
converted = frame
if frame.type != rtc.VideoBufferType.RGBA:
converted = frame.convert(rtc.VideoBufferType.RGBA)
rgb_image = Image.frombytes( # type: ignore
"RGBA", (frame.width, frame.height), converted.data
).convert("RGB")
return rgb_image
def _resize_image(image: Any, options: EncodeOptions):
if options.resize_options is None:
return image
resize_opts = options.resize_options
if resize_opts.strategy == "skew":
return image.resize((resize_opts.width, resize_opts.height))
elif resize_opts.strategy == "center_aspect_fit":
result = Image.new("RGB", (resize_opts.width, resize_opts.height)) # noqa
# Start with assuming the new image is narrower than the original
new_width = resize_opts.width
new_height = int(image.height * (resize_opts.width / image.width))
# If the new image is wider than the original
if resize_opts.width / resize_opts.height > image.width / image.height:
new_height = resize_opts.height
new_width = int(image.width * (resize_opts.height / image.height))
resized = image.resize((new_width, new_height))
Image.Image.paste(
result,
resized,
(
(resize_opts.width - new_width) // 2,
(resize_opts.height - new_height) // 2,
),
)
return result
elif resize_opts.strategy == "center_aspect_cover":
result = Image.new("RGB", (resize_opts.width, resize_opts.height)) # noqa
# Start with assuming the new image is shorter than the original
new_height = int(image.height * (resize_opts.width / image.width))
new_width = resize_opts.width
# If the new image is taller than the original
if resize_opts.height / resize_opts.width > image.height / image.width:
new_width = int(image.width * (resize_opts.height / image.height))
new_height = resize_opts.height
resized = image.resize((new_width, new_height))
Image.Image.paste( # noqa
result,
resized,
(
(resize_opts.width - new_width) // 2,
(resize_opts.height - new_height) // 2,
),
)
return result
elif resize_opts.strategy == "scale_aspect_fill":
# Start with assuming width is the limiting dimension
new_width = resize_opts.width
new_height = int(image.height * (resize_opts.width / image.width))
# If height is under the limit, scale based on height instead
if new_height < resize_opts.height:
new_height = resize_opts.height
new_width = int(image.width * (resize_opts.height / image.height))
return image.resize((new_width, new_height))
elif resize_opts.strategy == "scale_aspect_fit":
# Start with assuming width is the limiting dimension
new_width = resize_opts.width
new_height = int(image.height * (resize_opts.width / image.width))
# If height would exceed the limit, scale based on height instead
if new_height > resize_opts.height:
new_height = resize_opts.height
new_width = int(image.width * (resize_opts.height / image.height))
return image.resize((new_width, new_height))
raise ValueError(f"Unknown resize strategy: {resize_opts.strategy}")
import asyncio
import functools
import logging
from typing import Any, Callable, TypeVar, cast
F = TypeVar("F", bound=Callable[..., Any])
def log_exceptions(msg: str = "", logger: logging.Logger = logging.getLogger()) -> Callable[[F], F]: # noqa: B008
def deco(fn: F) -> F:
if asyncio.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_fn_logs(*args: Any, **kwargs: Any) -> Any:
try:
return await fn(*args, **kwargs)
except Exception:
err = f"Error in {fn.__name__}"
if msg:
err += f" – {msg}"
logger.exception(err)
raise
return cast(F, async_fn_logs)
else:
@functools.wraps(fn)
def fn_logs(*args: Any, **kwargs: Any) -> Any:
try:
return fn(*args, **kwargs)
except Exception:
err = f"Error in {fn.__name__}"
if msg:
err += f" – {msg}"
logger.exception(err)
raise
return cast(F, fn_logs)
return deco
from __future__ import annotations
import time
import uuid
from typing import TypeVar
from typing_extensions import TypeGuard
from ..types import NotGiven, NotGivenOr
_T = TypeVar("_T")
def time_ms() -> int:
return int(time.time() * 1000 + 0.5)
def shortuuid(prefix: str = "") -> str:
return prefix + str(uuid.uuid4().hex)[:12]
def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]:
return not isinstance(obj, NotGiven)
from __future__ import annotations
class MovingAverage:
def __init__(self, window_size: int) -> None:
self._hist: list[float] = [0] * window_size
self._sum: float = 0
self._count: int = 0
def add_sample(self, sample: float) -> None:
self._count += 1
index = self._count % len(self._hist)
if self._count > len(self._hist):
self._sum -= self._hist[index]
self._sum += sample
self._hist[index] = sample
def get_avg(self) -> float:
if self._count == 0:
return 0
return self._sum / self.size()
def reset(self):
self._count = 0
self._sum = 0
def size(self) -> int:
return min(self._count, len(self._hist))
from __future__ import annotations
import asyncio
from livekit import rtc
async def wait_for_participant(
room: rtc.Room,
*,
identity: str | None = None,
kind: list[rtc.ParticipantKind.ValueType] | rtc.ParticipantKind.ValueType | None = None,
) -> rtc.RemoteParticipant:
"""
Returns a participant that matches the given identity. If identity is None, the first
participant that joins the room will be returned.
If the participant has already joined, the function will return immediately.
"""
if not room.isconnected():
raise RuntimeError("room is not connected")
fut = asyncio.Future[rtc.RemoteParticipant]()
def kind_match(p: rtc.RemoteParticipant) -> bool:
if kind is None:
return True
if isinstance(kind, list):
return p.kind in kind
return p.kind == kind
def _on_participant_connected(p: rtc.RemoteParticipant):
if (identity is None or p.identity == identity) and kind_match(p):
room.off("participant_connected", _on_participant_connected)
if not fut.done():
fut.set_result(p)
room.on("participant_connected", _on_participant_connected)
for p in room.remote_participants.values():
_on_participant_connected(p)
if fut.done():
break
return await fut
from __future__ import annotations
import asyncio
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass, field
from enum import Enum, unique
from typing import Literal, Union
from livekit import rtc
from .metrics import VADMetrics
from .utils import aio
@unique
class VADEventType(str, Enum):
START_OF_SPEECH = "start_of_speech"
INFERENCE_DONE = "inference_done"
END_OF_SPEECH = "end_of_speech"
@dataclass
class VADEvent:
"""
Represents an event detected by the Voice Activity Detector (VAD).
"""
type: VADEventType
"""Type of the VAD event (e.g., start of speech, end of speech, inference done)."""
samples_index: int
"""Index of the audio sample where the event occurred, relative to the inference sample rate."""
timestamp: float
"""Timestamp (in seconds) when the event was fired."""
speech_duration: float
"""Duration of the speech segment in seconds."""
silence_duration: float
"""Duration of the silence segment in seconds."""
frames: list[rtc.AudioFrame] = field(default_factory=list)
"""
List of audio frames associated with the speech.
- For `start_of_speech` events, this contains the audio chunks that triggered the detection.
- For `inference_done` events, this contains the audio chunks that were processed.
- For `end_of_speech` events, this contains the complete user speech.
"""
probability: float = 0.0
"""Probability that speech is present (only for `INFERENCE_DONE` events)."""
inference_duration: float = 0.0
"""Time taken to perform the inference, in seconds (only for `INFERENCE_DONE` events)."""
speaking: bool = False
"""Indicates whether speech was detected in the frames."""
raw_accumulated_silence: float = 0.0
"""Threshold used to detect silence."""
raw_accumulated_speech: float = 0.0
"""Threshold used to detect speech."""
@dataclass
class VADCapabilities:
update_interval: float
class VAD(ABC, rtc.EventEmitter[Literal["metrics_collected"]]):
def __init__(self, *, capabilities: VADCapabilities) -> None:
super().__init__()
self._capabilities = capabilities
self._label = f"{type(self).__module__}.{type(self).__name__}"
@property
def capabilities(self) -> VADCapabilities:
return self._capabilities
@abstractmethod
def stream(self) -> VADStream: ...
class VADStream(ABC):
class _FlushSentinel:
pass
def __init__(self, vad: VAD) -> None:
self._vad = vad
self._last_activity_time = time.perf_counter()
self._input_ch = aio.Chan[Union[rtc.AudioFrame, VADStream._FlushSentinel]]()
self._event_ch = aio.Chan[VADEvent]()
self._event_aiter, monitor_aiter = aio.itertools.tee(self._event_ch, 2)
self._metrics_task = asyncio.create_task(
self._metrics_monitor_task(monitor_aiter), name="TTS._metrics_task"
)
self._task = asyncio.create_task(self._main_task())
self._task.add_done_callback(lambda _: self._event_ch.close())
@abstractmethod
async def _main_task(self) -> None: ...
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[VADEvent]) -> None:
"""Task used to collect metrics"""
inference_duration_total = 0.0
inference_count = 0
async for ev in event_aiter:
if ev.type == VADEventType.INFERENCE_DONE:
inference_duration_total += ev.inference_duration
inference_count += 1
if inference_count >= 1 / self._vad.capabilities.update_interval:
vad_metrics = VADMetrics(
timestamp=time.time(),
idle_time=time.perf_counter() - self._last_activity_time,
inference_duration_total=inference_duration_total,
inference_count=inference_count,
label=self._vad._label,
)
self._vad.emit("metrics_collected", vad_metrics)
inference_duration_total = 0.0
inference_count = 0
elif ev.type in [VADEventType.START_OF_SPEECH, VADEventType.END_OF_SPEECH]:
self._last_activity_time = time.perf_counter()
def push_frame(self, frame: rtc.AudioFrame) -> None:
"""Push some text to be synthesized"""
self._check_input_not_ended()
self._check_not_closed()
self._input_ch.send_nowait(frame)
def flush(self) -> None:
"""Mark the end of the current segment"""
self._check_input_not_ended()
self._check_not_closed()
self._input_ch.send_nowait(self._FlushSentinel())
def end_input(self) -> None:
"""Mark the end of input, no more text will be pushed"""
self.flush()
self._input_ch.close()
async def aclose(self) -> None:
"""Close the stream immediately"""
self._input_ch.close()
await aio.cancel_and_wait(self._task)
self._event_ch.close()
await self._metrics_task
async def __anext__(self) -> VADEvent:
try:
val = await self._event_aiter.__anext__()
except StopAsyncIteration:
if not self._task.cancelled() and (exc := self._task.exception()):
raise exc # noqa: B904
raise StopAsyncIteration from None
return val
def __aiter__(self) -> AsyncIterator[VADEvent]:
return self
def _check_not_closed(self) -> None:
if self._event_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} is closed")
def _check_input_not_ended(self) -> None:
if self._input_ch.closed:
cls = type(self)
raise RuntimeError(f"{cls.__module__}.{cls.__name__} input ended")
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
from .agent import Agent, InlineTask, ModelSettings
from .agent_session import AgentSession, VoiceActivityVideoSampler
from .chat_cli import ChatCLI
from .events import (
AgentEvent,
AgentStateChangedEvent,
CloseEvent,
ConversationItemAddedEvent,
ErrorEvent,
FunctionToolsExecutedEvent,
MetricsCollectedEvent,
RunContext,
SpeechCreatedEvent,
UserInputTranscribedEvent,
UserStateChangedEvent,
)
from .speech_handle import SpeechHandle
__all__ = [
"ChatCLI",
"AgentSession",
"VoiceActivityVideoSampler",
"Agent",
"ModelSettings",
"InlineTask",
"SpeechHandle",
"RunContext",
"UserInputTranscribedEvent",
"AgentEvent",
"MetricsCollectedEvent",
"ConversationItemAddedEvent",
"SpeechCreatedEvent",
"ErrorEvent",
"CloseEvent",
"UserStateChangedEvent",
"AgentStateChangedEvent",
"FunctionToolsExecutedEvent",
]
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, AsyncIterable, Coroutine
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from livekit import rtc
from .. import llm, stt, tokenize, tts, utils, vad
from ..llm import ChatContext, FunctionTool, ToolError, find_function_tools
from ..llm.chat_context import _ReadOnlyChatContext
from ..log import logger
from ..types import NOT_GIVEN, NotGivenOr
if TYPE_CHECKING:
from .agent_activity import AgentActivity
from .agent_session import AgentSession, TurnDetectionMode
@dataclass
class ModelSettings:
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN
"""The tool choice to use when calling the LLM."""
class Agent:
def __init__(
self,
*,
instructions: str,
chat_ctx: NotGivenOr[llm.ChatContext | None] = NOT_GIVEN,
tools: list[llm.FunctionTool] | None = None,
turn_detection: NotGivenOr[TurnDetectionMode | None] = NOT_GIVEN,
stt: NotGivenOr[stt.STT | None] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | None] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS | None] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
tools = tools or []
self._instructions = instructions
self._tools = tools.copy() + find_function_tools(self)
self._chat_ctx = chat_ctx.copy(tools=self._tools) if chat_ctx else ChatContext.empty()
self._turn_detection = turn_detection
self._stt = stt
self._llm = llm
self._tts = tts
self._vad = vad
self._allow_interruptions = allow_interruptions
self._activity: AgentActivity | None = None
@property
def instructions(self) -> str:
"""
Returns:
str: The core instructions that guide the agent's behavior.
"""
return self._instructions
@property
def tools(self) -> list[llm.FunctionTool]:
"""
Returns:
list[llm.FunctionTool]: A list of function tools available to the agent.
"""
return self._tools.copy()
@property
def chat_ctx(self) -> llm.ChatContext:
"""
Provides a read-only view of the agent's current chat context.
Returns:
llm.ChatContext: A read-only version of the agent's conversation history.
See Also:
update_chat_ctx: Method to update the internal chat context.
"""
return _ReadOnlyChatContext(self._chat_ctx.items)
async def update_instructions(self, instructions: str) -> None:
"""
Updates the agent's instructions.
If the agent is running in realtime mode, this method also updates
the instructions for the ongoing realtime session.
Args:
instructions (str):
The new instructions to set for the agent.
Raises:
llm.RealtimeError: If updating the realtime session instructions fails.
"""
if self._activity is None:
self._instructions = instructions
return
await self._activity.update_instructions(instructions)
async def update_tools(self, tools: list[llm.FunctionTool]) -> None:
"""
Updates the agent's available function tools.
If the agent is running in realtime mode, this method also updates
the tools for the ongoing realtime session.
Args:
tools (list[llm.FunctionTool]):
The new list of function tools available to the agent.
Raises:
llm.RealtimeError: If updating the realtime session tools fails.
"""
if self._activity is None:
self._tools = list(set(tools))
self._chat_ctx = self._chat_ctx.copy(tools=self._tools)
return
await self._activity.update_tools(tools)
async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None:
"""
Updates the agent's chat context.
If the agent is running in realtime mode, this method also updates
the chat context for the ongoing realtime session.
Args:
chat_ctx (llm.ChatContext):
The new or updated chat context for the agent.
Raises:
llm.RealtimeError: If updating the realtime session chat context fails.
"""
if self._activity is None:
self._chat_ctx = chat_ctx.copy(tools=self._tools)
return
await self._activity.update_chat_ctx(chat_ctx)
@property
def turn_detection(self) -> NotGivenOr[TurnDetectionMode | None]:
"""
Retrieves the turn detection mode for identifying conversational turns.
If this property was not set at Agent creation, but an ``AgentSession`` provides a turn detection,
the session's turn detection mode will be used at runtime instead.
Returns:
NotGivenOr[TurnDetectionMode | None]: An optional turn detection mode for managing conversation flow.
""" # noqa: E501
return self._turn_detection
@property
def stt(self) -> NotGivenOr[stt.STT | None]:
"""
Retrieves the Speech-To-Text component for the agent.
If this property was not set at Agent creation, but an ``AgentSession`` provides an STT component,
the session's STT will be used at runtime instead.
Returns:
NotGivenOr[stt.STT | None]: An optional STT component.
""" # noqa: E501
return self._stt
@property
def llm(self) -> NotGivenOr[llm.LLM | llm.RealtimeModel | None]:
"""
Retrieves the Language Model or RealtimeModel used for text generation.
If this property was not set at Agent creation, but an ``AgentSession`` provides an LLM or RealtimeModel,
the session's model will be used at runtime instead.
Returns:
NotGivenOr[llm.LLM | llm.RealtimeModel | None]: The language model for text generation.
""" # noqa: E501
return self._llm
@property
def tts(self) -> NotGivenOr[tts.TTS | None]:
"""
Retrieves the Text-To-Speech component for the agent.
If this property was not set at Agent creation, but an ``AgentSession`` provides a TTS component,
the session's TTS will be used at runtime instead.
Returns:
NotGivenOr[tts.TTS | None]: An optional TTS component for generating audio output.
""" # noqa: E501
return self._tts
@property
def vad(self) -> NotGivenOr[vad.VAD | None]:
"""
Retrieves the Voice Activity Detection component for the agent.
If this property was not set at Agent creation, but an ``AgentSession`` provides a VAD component,
the session's VAD will be used at runtime instead.
Returns:
NotGivenOr[vad.VAD | None]: An optional VAD component for detecting voice activity.
""" # noqa: E501
return self._vad
@property
def allow_interruptions(self) -> NotGivenOr[bool]:
"""
Indicates whether interruptions (e.g., stopping TTS playback) are allowed.
If this property was not set at Agent creation, but an ``AgentSession`` provides a value for
allowing interruptions, the session's value will be used at runtime instead.
Returns:
NotGivenOr[bool]: Whether interruptions are permitted.
"""
return self._allow_interruptions
@property
def realtime_llm_session(self) -> llm.RealtimeSession:
"""
Retrieve the realtime LLM session associated with the current agent.
Raises:
RuntimeError: If the agent is not running or the realtime LLM session is not available
"""
if (rt_session := self._get_activity_or_raise().realtime_llm_session) is None:
raise RuntimeError("no realtime LLM session")
return rt_session
@property
def session(self) -> AgentSession:
"""
Retrieve the VoiceAgent associated with the current agent.
Raises:
RuntimeError: If the agent is not running
"""
return self._get_activity_or_raise().session
# -- Pipeline nodes --
# They can all be overriden by subclasses, by default they use the STT/LLM/TTS specified in the
# constructor of the VoiceAgent
async def on_enter(self) -> None:
"""Called when the task is entered"""
pass
async def on_exit(self) -> None:
"""Called when the task is exited"""
pass
async def on_user_turn_completed(
self, turn_ctx: llm.ChatContext, new_message: llm.ChatMessage
) -> None:
"""Called when the user has finished speaking, and the LLM is about to respond
This is a good opportunity to update the chat context or edit the new message before it is
sent to the LLM.
"""
pass
def stt_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> (
AsyncIterable[stt.SpeechEvent | str]
| Coroutine[Any, Any, AsyncIterable[stt.SpeechEvent | str]]
| Coroutine[Any, Any, None]
):
"""
A node in the processing pipeline that transcribes audio frames into speech events.
By default, this node uses a Speech-To-Text (STT) capability from the current agent.
If the STT implementation does not support streaming natively, a VAD (Voice Activity
Detection) mechanism is required to wrap the STT.
You can override this node with your own implementation for more flexibility (e.g.,
custom pre-processing of audio, additional buffering, or alternative STT strategies).
Args:
audio (AsyncIterable[rtc.AudioFrame]): An asynchronous stream of audio frames.
model_settings (ModelSettings): Configuration and parameters for model execution.
Yields:
stt.SpeechEvent: An event containing transcribed text or other STT-related data.
"""
return Agent.default.stt_node(self, audio, model_settings)
def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool],
model_settings: ModelSettings,
) -> (
AsyncIterable[llm.ChatChunk | str]
| Coroutine[Any, Any, AsyncIterable[llm.ChatChunk | str]]
| Coroutine[Any, Any, str]
| Coroutine[Any, Any, llm.ChatChunk]
| Coroutine[Any, Any, None]
):
"""
A node in the processing pipeline that processes text generation with an LLM.
By default, this node uses the agent's LLM to process the provided context. It may yield
plain text (as `str`) for straightforward text generation, or `llm.ChatChunk` objects that
can include text and optional tool calls. `ChatChunk` is helpful for capturing more complex
outputs such as function calls, usage statistics, or other metadata.
You can override this node to customize how the LLM is used or how tool invocations
and responses are handled.
Args:
chat_ctx (llm.ChatContext): The context for the LLM (the conversation history).
tools (list[FunctionTool]): A list of callable tools that the LLM may invoke.
model_settings (ModelSettings): Configuration and parameters for model execution.
Yields/Returns:
str: Plain text output from the LLM.
llm.ChatChunk: An object that can contain both text and optional tool calls.
"""
return Agent.default.llm_node(self, chat_ctx, tools, model_settings)
def transcription_node(
self, text: AsyncIterable[str], model_settings: ModelSettings
) -> AsyncIterable[str] | Coroutine[Any, Any, AsyncIterable[str]] | Coroutine[Any, Any, None]:
"""
A node in the processing pipeline that finalizes transcriptions from text segments.
This node can be used to adjust or post-process text coming from an LLM (or any other
source) into a final transcribed form. For instance, you might clean up formatting, fix
punctuation, or perform any other text transformations here.
You can override this node to customize post-processing logic according to your needs.
Args:
text (AsyncIterable[str]): An asynchronous stream of text segments.
model_settings (ModelSettings): Configuration and parameters for model execution.
Yields:
str: Finalized or post-processed text segments.
"""
return Agent.default.transcription_node(self, text, model_settings)
def tts_node(
self, text: AsyncIterable[str], model_settings: ModelSettings
) -> (
AsyncGenerator[rtc.AudioFrame, None]
| Coroutine[Any, Any, AsyncIterable[rtc.AudioFrame]]
| Coroutine[Any, Any, None]
):
"""
A node in the processing pipeline that synthesizes audio from text segments.
By default, this node converts incoming text into audio frames using the Text-To-Speech
from the agent.
If the TTS implementation does not support streaming natively, it uses a sentence tokenizer
to split text for incremental synthesis.
You can override this node to provide different text chunking behavior, a custom TTS engine,
or any other specialized processing.
Args:
text (AsyncIterable[str]): An asynchronous stream of text segments to be synthesized.
model_settings (ModelSettings): Configuration and parameters for model execution.
Yields:
rtc.AudioFrame: Audio frames synthesized from the provided text.
"""
return Agent.default.tts_node(self, text, model_settings)
def realtime_audio_output_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> (
AsyncIterable[rtc.AudioFrame]
| Coroutine[Any, Any, AsyncIterable[rtc.AudioFrame]]
| Coroutine[Any, Any, None]
):
"""A node processing the audio from the realtime LLM session before it is played out."""
return Agent.default.realtime_audio_output_node(self, audio, model_settings)
def _get_activity_or_raise(self) -> AgentActivity:
"""Get the current activity context for this task (internal)"""
if self._activity is None:
raise RuntimeError("no activity context found, this task is not running")
return self._activity
class default:
@staticmethod
async def stt_node(
agent: Agent, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> AsyncGenerator[stt.SpeechEvent, None]:
"""Default implementation for `Agent.stt_node`"""
activity = agent._get_activity_or_raise()
assert activity.stt is not None, "stt_node called but no STT node is available"
wrapped_stt = activity.stt
if not activity.stt.capabilities.streaming:
if not activity.vad:
raise RuntimeError(
f"The STT ({activity.stt.label}) does not support streaming, add a VAD to the AgentTask/VoiceAgent to enable streaming" # noqa: E501
"Or manually wrap your STT in a stt.StreamAdapter"
)
wrapped_stt = stt.StreamAdapter(stt=wrapped_stt, vad=activity.vad)
async with wrapped_stt.stream() as stream:
@utils.log_exceptions(logger=logger)
async def _forward_input():
async for frame in audio:
stream.push_frame(frame)
forward_task = asyncio.create_task(_forward_input())
try:
async for event in stream:
yield event
finally:
await utils.aio.cancel_and_wait(forward_task)
@staticmethod
async def llm_node(
agent: Agent,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool],
model_settings: ModelSettings,
) -> AsyncGenerator[llm.ChatChunk | str, None]:
"""Default implementation for `Agent.llm_node`"""
activity = agent._get_activity_or_raise()
assert activity.llm is not None, "llm_node called but no LLM node is available"
assert isinstance(activity.llm, llm.LLM), (
"llm_node should only be used with LLM (non-multimodal/realtime APIs) nodes"
)
tool_choice = model_settings.tool_choice if model_settings else NOT_GIVEN
activity_llm = activity.llm
async with activity_llm.chat(
chat_ctx=chat_ctx, tools=tools, tool_choice=tool_choice
) as stream:
async for chunk in stream:
yield chunk
@staticmethod
async def tts_node(
agent: Agent, text: AsyncIterable[str], model_settings: ModelSettings
) -> AsyncGenerator[rtc.AudioFrame, None]:
"""Default implementation for `Agent.tts_node`"""
activity = agent._get_activity_or_raise()
assert activity.tts is not None, "tts_node called but no TTS node is available"
wrapped_tts = activity.tts
if not activity.tts.capabilities.streaming:
wrapped_tts = tts.StreamAdapter(
tts=wrapped_tts, sentence_tokenizer=tokenize.basic.SentenceTokenizer()
)
async with wrapped_tts.stream() as stream:
async def _forward_input():
async for chunk in text:
stream.push_text(chunk)
stream.end_input()
forward_task = asyncio.create_task(_forward_input())
try:
async for ev in stream:
yield ev.frame
finally:
await utils.aio.cancel_and_wait(forward_task)
@staticmethod
async def transcription_node(
agent: Agent, text: AsyncIterable[str], model_settings: ModelSettings
) -> AsyncGenerator[str, None]:
"""Default implementation for `Agent.transcription_node`"""
async for delta in text:
yield delta
@staticmethod
async def realtime_audio_output_node(
agent: Agent, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> AsyncGenerator[rtc.AudioFrame, None]:
"""Default implementation for `Agent.realtime_audio_output_node`"""
activity = agent._get_activity_or_raise()
assert activity.realtime_llm_session is not None, (
"realtime_audio_output_node called but no realtime LLM session is available"
)
async for frame in audio:
yield frame
TaskResult_T = TypeVar("TaskResult_T")
# TODO: rename to InlineAgent?
class InlineTask(Agent, Generic[TaskResult_T]):
def __init__(
self,
*,
instructions: str,
chat_ctx: NotGivenOr[llm.ChatContext] = NOT_GIVEN,
tools: list[llm.FunctionTool] | None = None,
turn_detection: NotGivenOr[TurnDetectionMode | None] = NOT_GIVEN,
stt: NotGivenOr[stt.STT | None] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | None] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS | None] = NOT_GIVEN,
) -> None:
tools = tools or []
super().__init__(
instructions=instructions,
chat_ctx=chat_ctx,
tools=tools,
turn_detection=turn_detection,
stt=stt,
vad=vad,
llm=llm,
tts=tts,
)
self.__started = False
self.__fut = asyncio.Future[TaskResult_T]()
def complete(self, result: TaskResult_T | ToolError) -> None:
if self.__fut.done():
raise RuntimeError(f"{self.__class__.__name__} is already done")
if isinstance(result, ToolError):
self.__fut.set_exception(result)
else:
self.__fut.set_result(result)
async def __await_impl(self):
if self.__started:
raise RuntimeError(f"{self.__class__.__name__} is not re-entrant, await only once")
self.__started = True
task = asyncio.current_task()
if task is None or not _is_inline_task_authorized(task):
raise RuntimeError(
f"{self.__class__.__name__} should only be awaited inside an async ai_function or the on_enter/on_exit methods of an AgentTask" # noqa: E501
)
def _handle_task_done(_) -> None:
if self.__fut.done():
return
# if the asyncio.Task running the InlineTask completes before the InlineTask itself, log
# an error and attempt to recover by terminating the InlineTask.
self.__fut.set_exception(
RuntimeError(
f"{self.__class__.__name__} was not completed by the time the asyncio.Task running it was done" # noqa: E501
)
)
logger.error(
f"{self.__class__.__name__} was not completed by the time the asyncio.Task running it was done" # noqa: E501
)
# TODO(theomonnom): recover somehow
task.add_done_callback(_handle_task_done)
# enter task
await asyncio.shield(self.__fut)
# exit task
def __await__(self):
return self.__await_impl().__await__()
@dataclass
class _InlineTaskInfo:
function_call: llm.FunctionCall | None
def _authorize_inline_task(
task: asyncio.Task, *, function_call: llm.FunctionCall | None = None
) -> None:
setattr(task, "__livekit_agents_inline_task", _InlineTaskInfo(function_call=function_call))
def _get_inline_task_info(task: asyncio.Task) -> _InlineTaskInfo | None:
return getattr(task, "__livekit_agents_inline_task", None)
def _is_inline_task_authorized(task: asyncio.Task) -> bool:
return getattr(task, "__livekit_agents_inline_task", None) is not None
from __future__ import annotations
import asyncio
import contextvars
import heapq
import time
from collections.abc import AsyncIterable, Coroutine
from typing import TYPE_CHECKING, Any
from livekit import rtc
from .. import debug, llm, stt, tts, utils, vad
from ..llm.tool_context import StopResponse
from ..log import logger
from ..metrics import EOUMetrics, LLMMetrics, STTMetrics, TTSMetrics, VADMetrics
from ..types import NOT_GIVEN, NotGivenOr
from ..utils.misc import is_given
from .agent import Agent, ModelSettings
from .audio_recognition import AudioRecognition, RecognitionHooks, _EndOfTurnInfo
from .events import (
ErrorEvent,
FunctionToolsExecutedEvent,
MetricsCollectedEvent,
SpeechCreatedEvent,
UserInputTranscribedEvent,
)
from .generation import (
_AudioOutput,
_TextOutput,
_TTSGenerationData,
perform_audio_forwarding,
perform_llm_inference,
perform_text_forwarding,
perform_tool_executions,
perform_tts_inference,
remove_instructions,
update_instructions,
)
from .speech_handle import SpeechHandle
try:
from livekit.plugins.google.beta.realtime.realtime_api import (
RealtimeModel as GoogleRealtimeModel,
)
except ImportError:
GoogleRealtimeModel = None
def log_event(event: str, **kwargs) -> None:
debug.Tracing.log_event(event, kwargs)
if TYPE_CHECKING:
from .agent_session import AgentSession, TurnDetectionMode
_AgentActivityContextVar = contextvars.ContextVar["AgentActivity"]("agents_activity")
_SpeechHandleContextVar = contextvars.ContextVar["SpeechHandle"]("agents_speech_handle")
# NOTE: AgentActivity isn't exposed to the public API
class AgentActivity(RecognitionHooks):
def __init__(self, agent: Agent, sess: AgentSession) -> None:
self._agent, self._session = agent, sess
self._rt_session: llm.RealtimeSession | None = None
self._audio_recognition: AudioRecognition | None = None
self._lock = asyncio.Lock()
self._tool_choice: llm.ToolChoice | None = None
self._started = False
self._draining = False
self._current_speech: SpeechHandle | None = None
self._speech_q: list[tuple[int, float, SpeechHandle]] = []
# fired when a speech_task finishes or when a new speech_handle is scheduled
# this is used to wake up the main task when the scheduling state changes
self._q_updated = asyncio.Event()
self._main_atask: asyncio.Task | None = None
self._user_turn_completed_atask: asyncio.Task | None = None
self._speech_tasks: list[asyncio.Task] = []
from .. import llm as large_language_model
self._turn_detection_mode = (
self.turn_detection if isinstance(self.turn_detection, str) else None
)
if self._turn_detection_mode == "vad" and not self.vad:
logger.warning("turn_detection is set to 'vad', but no VAD model is provided")
self._turn_detection_mode = None
if self._turn_detection_mode == "stt" and not self.stt:
logger.warning(
"turn_detection is set to 'stt', but no STT model is provided, "
"ignoring the turn_detection setting"
)
self._turn_detection_mode = None
if isinstance(self.llm, large_language_model.RealtimeModel):
if self.llm.capabilities.turn_detection and not self.allow_interruptions:
raise ValueError(
"the RealtimeModel uses a server-side turn detection, "
"allow_interruptions cannot be False, disable turn_detection in "
"the RealtimeModel and use VAD on the AgentSession instead"
)
if (
self._turn_detection_mode == "realtime_llm"
and not self.llm.capabilities.turn_detection
):
logger.warning(
"turn_detection is set to 'realtime_llm', but the LLM is not a RealtimeModel "
"or the server-side turn detection is not supported/enabled, "
"ignoring the turn_detection setting"
)
self._turn_detection_mode = None
if self._turn_detection_mode == "stt":
logger.warning(
"turn_detection is set to 'stt', but the LLM is a RealtimeModel, "
"ignoring the turn_detection setting"
)
self._turn_detection_mode = None
elif (
self._turn_detection_mode
and self._turn_detection_mode != "realtime_llm"
and self.llm.capabilities.turn_detection
):
logger.warning(
f"turn_detection is set to '{self._turn_detection_mode}', but the LLM "
"is a RealtimeModel and server-side turn detection enabled, "
"ignoring the turn_detection setting"
)
self._turn_detection_mode = None
# fallback to VAD if server side turn detection is disabled and VAD is available
if (
not self.llm.capabilities.turn_detection
and self.vad
and self._turn_detection_mode is None
):
self._turn_detection_mode = "vad"
elif self._turn_detection_mode == "realtime_llm":
logger.warning(
"turn_detection is set to 'realtime_llm', but the LLM is not a RealtimeModel"
)
self._turn_detection_mode = None
if (
not self.vad
and self.stt
and isinstance(self.llm, llm.LLM)
and self.allow_interruptions
and self._turn_detection_mode is None
):
logger.warning(
"VAD is not set. Enabling VAD is recommended when using LLM and STT "
"for more responsive interruption handling."
)
@property
def draining(self) -> bool:
return self._draining
@property
def session(self) -> AgentSession:
return self._session
@property
def turn_detection(self) -> TurnDetectionMode | None:
return self._agent._turn_detection or self._session._turn_detection
@property
def agent(self) -> Agent:
return self._agent
@property
def stt(self) -> stt.STT | None:
return self._agent.stt or self._session.stt
@property
def llm(self) -> llm.LLM | llm.RealtimeModel | None:
return self._agent.llm or self._session.llm
@property
def tts(self) -> tts.TTS | None:
return self._agent.tts or self._session.tts
@property
def vad(self) -> vad.VAD | None:
return self._agent.vad or self._session.vad
@property
def allow_interruptions(self) -> bool:
return (
self._agent.allow_interruptions
if is_given(self._agent.allow_interruptions)
else self._session.options.allow_interruptions
)
@property
def realtime_llm_session(self) -> llm.RealtimeSession | None:
return self._rt_session
@property
def current_speech(self) -> SpeechHandle | None:
return self._current_speech
async def update_instructions(self, instructions: str) -> None:
self._agent._instructions = instructions
if self._rt_session is not None:
await self._rt_session.update_instructions(instructions)
else:
update_instructions(
self._agent._chat_ctx, instructions=instructions, add_if_missing=True
)
async def update_tools(self, tools: list[llm.FunctionTool]) -> None:
tools = list(set(tools))
self._agent._tools = tools
if self._rt_session is not None:
await self._rt_session.update_tools(tools)
if isinstance(self.llm, llm.LLM):
# for realtime LLM, we assume the server will remove unvalid tool messages
await self.update_chat_ctx(self._agent._chat_ctx.copy(tools=tools))
async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None:
chat_ctx = chat_ctx.copy(tools=self._agent.tools)
self._agent._chat_ctx = chat_ctx
if self._rt_session is not None:
remove_instructions(chat_ctx)
await self._rt_session.update_chat_ctx(chat_ctx)
else:
update_instructions(
chat_ctx, instructions=self._agent.instructions, add_if_missing=True
)
def update_options(self, *, tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN) -> None:
if utils.is_given(tool_choice):
self._tool_choice = tool_choice
if self._rt_session is not None:
self._rt_session.update_options(tool_choice=self._tool_choice)
def _create_speech_task(
self,
coro: Coroutine[Any, Any, Any],
*,
owned_speech_handle: SpeechHandle | None = None,
name: str | None = None,
) -> asyncio.Task:
"""
This method must only be used for tasks that "could" create a new SpeechHandle.
When draining, every task created with this method will be awaited.
"""
# https://github.com/python/cpython/pull/31837 alternative impl
tk = _AgentActivityContextVar.set(self)
task = asyncio.create_task(coro, name=name)
self._speech_tasks.append(task)
task.add_done_callback(lambda _: self._speech_tasks.remove(task))
if owned_speech_handle is not None:
# make sure to finish playout in case something goes wrong
# the tasks should normally do this before their function calls
task.add_done_callback(lambda _: owned_speech_handle._mark_playout_done())
task.add_done_callback(lambda _: self._wake_up_main_task())
_AgentActivityContextVar.reset(tk)
return task
def _wake_up_main_task(self) -> None:
self._q_updated.set()
# TODO(theomonnom): Shoukd pause and resume call on_enter and on_exit? probably not
async def pause(self) -> None:
pass
async def resume(self) -> None:
pass
async def start(self) -> None:
from .agent import _authorize_inline_task
async with self._lock:
self._agent._activity = self
if isinstance(self.llm, llm.RealtimeModel):
self._rt_session = self.llm.session()
self._rt_session.on("generation_created", self._on_generation_created)
self._rt_session.on("input_speech_started", self._on_input_speech_started)
self._rt_session.on("input_speech_stopped", self._on_input_speech_stopped)
self._rt_session.on(
"input_audio_transcription_completed",
self._on_input_audio_transcription_completed,
)
self._rt_session.on("error", self._on_error)
remove_instructions(self._agent._chat_ctx)
try:
await self._rt_session.update_instructions(self._agent.instructions)
except llm.RealtimeError:
logger.exception("failed to update the instructions")
try:
await self._rt_session.update_chat_ctx(self._agent.chat_ctx)
except llm.RealtimeError:
logger.exception("failed to update the chat_ctx")
try:
await self._rt_session.update_tools(self._agent.tools)
except llm.RealtimeError:
logger.exception("failed to update the tools")
elif isinstance(self.llm, llm.LLM):
try:
update_instructions(
self._agent._chat_ctx,
instructions=self._agent.instructions,
add_if_missing=True,
)
except ValueError:
logger.exception("failed to update the instructions")
# metrics and error handling
if isinstance(self.llm, llm.LLM):
self.llm.on("metrics_collected", self._on_metrics_collected)
self.llm.on("error", self._on_error)
if isinstance(self.stt, stt.STT):
self.stt.on("metrics_collected", self._on_metrics_collected)
self.stt.on("error", self._on_error)
if isinstance(self.tts, tts.TTS):
self.tts.on("metrics_collected", self._on_metrics_collected)
self.tts.on("error", self._on_error)
if isinstance(self.vad, vad.VAD):
self.vad.on("metrics_collected", self._on_metrics_collected)
self._main_atask = asyncio.create_task(self._main_task(), name="_main_task")
self._audio_recognition = AudioRecognition(
hooks=self,
stt=self._agent.stt_node if self.stt else None,
vad=self.vad,
turn_detector=(
self.turn_detection if not isinstance(self.turn_detection, str) else None
),
min_endpointing_delay=self._session.options.min_endpointing_delay,
max_endpointing_delay=self._session.options.max_endpointing_delay,
manual_turn_detection=self._turn_detection_mode == "manual",
)
self._audio_recognition.start()
self._started = True
task = self._create_speech_task(self._agent.on_enter(), name="AgentTask_on_enter")
_authorize_inline_task(task)
async def drain(self) -> None:
from .agent import _authorize_inline_task
async with self._lock:
if self._draining:
return
task = self._create_speech_task(self._agent.on_exit(), name="AgentTask_on_exit")
_authorize_inline_task(task)
self._wake_up_main_task()
self._draining = True
if self._main_atask is not None:
await asyncio.shield(self._main_atask)
async def aclose(self) -> None:
async with self._lock:
if not self._draining:
logger.warning("task closing without draining")
# Unregister event handlers to prevent duplicate metrics
if isinstance(self.llm, llm.LLM):
self.llm.off("metrics_collected", self._on_metrics_collected)
self.llm.off("error", self._on_error)
if isinstance(self.llm, llm.RealtimeModel) and self._rt_session is not None:
self._rt_session.off("generation_created", self._on_generation_created)
self._rt_session.off("input_speech_started", self._on_input_speech_started)
self._rt_session.off("input_speech_stopped", self._on_input_speech_stopped)
self._rt_session.off(
"input_audio_transcription_completed",
self._on_input_audio_transcription_completed,
)
self._rt_session.off("error", self._on_error)
if isinstance(self.stt, stt.STT):
self.stt.off("metrics_collected", self._on_metrics_collected)
self.stt.off("error", self._on_error)
if isinstance(self.tts, tts.TTS):
self.tts.off("metrics_collected", self._on_metrics_collected)
self.tts.off("error", self._on_error)
if isinstance(self.vad, vad.VAD):
self.vad.off("metrics_collected", self._on_metrics_collected)
if self._rt_session is not None:
await self._rt_session.aclose()
if self._audio_recognition is not None:
await self._audio_recognition.aclose()
if self._main_atask is not None:
await utils.aio.cancel_and_wait(self._main_atask)
self._agent._activity = None
def push_audio(self, frame: rtc.AudioFrame) -> None:
if not self._started:
return
if (
self._current_speech
and not self._current_speech.allow_interruptions
and self._session.options.discard_audio_if_uninterruptible
):
# discard the audio if the current speech is not interruptable
return
if self._rt_session is not None:
self._rt_session.push_audio(frame)
if self._audio_recognition is not None:
self._audio_recognition.push_audio(frame)
def push_video(self, frame: rtc.VideoFrame) -> None:
if not self._started:
return
if self._rt_session is not None:
self._rt_session.push_video(frame)
def say(
self,
text: str | AsyncIterable[str],
*,
audio: NotGivenOr[AsyncIterable[rtc.AudioFrame]] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
add_to_chat_ctx: bool = True,
) -> SpeechHandle:
if not is_given(audio) and not self.tts:
raise RuntimeError("trying to generate speech from text without a TTS model")
if (
isinstance(self.llm, llm.RealtimeModel)
and self.llm.capabilities.turn_detection
and allow_interruptions is False
):
logger.warning(
"the RealtimeModel uses a server-side turn detection, allow_interruptions cannot be False when using VoiceAgent.say(), " # noqa: E501
"disable turn_detection in the RealtimeModel and use VAD on the AgentTask/VoiceAgent instead" # noqa: E501
)
allow_interruptions = NOT_GIVEN
handle = SpeechHandle.create(
allow_interruptions=allow_interruptions
if is_given(allow_interruptions)
else self.allow_interruptions
)
self._session.emit(
"speech_created",
SpeechCreatedEvent(speech_handle=handle, user_initiated=True, source="say"),
)
self._create_speech_task(
self._tts_task(
speech_handle=handle,
text=text,
audio=audio or None,
add_to_chat_ctx=add_to_chat_ctx,
model_settings=ModelSettings(),
),
owned_speech_handle=handle,
name="AgentActivity.tts_say",
)
self._schedule_speech(handle, SpeechHandle.SPEECH_PRIORITY_NORMAL)
return handle
def _generate_reply(
self,
*,
user_message: NotGivenOr[llm.ChatMessage | None] = NOT_GIVEN,
chat_ctx: NotGivenOr[llm.ChatContext | None] = NOT_GIVEN,
instructions: NotGivenOr[str] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
) -> SpeechHandle:
if (
isinstance(self.llm, llm.RealtimeModel)
and self.llm.capabilities.turn_detection
and allow_interruptions is False
):
logger.warning(
"the RealtimeModel uses a server-side turn detection, allow_interruptions cannot be False when using VoiceAgent.generate_reply(), " # noqa: E501
"disable turn_detection in the RealtimeModel and use VAD on the AgentTask/VoiceAgent instead" # noqa: E501
)
allow_interruptions = NOT_GIVEN
log_event(
"generate_reply",
new_message=user_message.text_content if user_message else None,
instructions=instructions or None,
)
from .agent import _get_inline_task_info
task = asyncio.current_task()
if not is_given(tool_choice) and task is not None:
if task_info := _get_inline_task_info(task):
if task_info.function_call is not None:
# when generete_reply is called inside a function_tool, set tool_choice to None by default # noqa: E501
tool_choice = "none"
handle = SpeechHandle.create(
allow_interruptions=allow_interruptions
if is_given(allow_interruptions)
else self.allow_interruptions
)
self._session.emit(
"speech_created",
SpeechCreatedEvent(speech_handle=handle, user_initiated=True, source="generate_reply"),
)
if isinstance(self.llm, llm.RealtimeModel):
self._create_speech_task(
self._realtime_reply_task(
speech_handle=handle,
# TODO(theomonnom): support llm.ChatMessage for the realtime model
user_input=user_message.text_content if user_message else None,
instructions=instructions or None,
model_settings=ModelSettings(tool_choice=tool_choice),
),
owned_speech_handle=handle,
name="AgentActivity.realtime_reply",
)
elif isinstance(self.llm, llm.LLM):
# instructions used inside generate_reply are "extra" instructions.
# this matches the behavior of the Realtime API:
# https://platform.openai.com/docs/api-reference/realtime-client-events/response/create
if instructions:
instructions = "\n".join([self._agent.instructions, instructions])
self._create_speech_task(
self._pipeline_reply_task(
speech_handle=handle,
chat_ctx=chat_ctx or self._agent._chat_ctx,
tools=self._agent.tools,
new_message=user_message.model_copy() if user_message else None,
instructions=instructions or None,
model_settings=ModelSettings(
tool_choice=tool_choice
if utils.is_given(tool_choice) or self._tool_choice is None
else self._tool_choice
),
),
owned_speech_handle=handle,
name="AgentActivity.pipeline_reply",
)
self._schedule_speech(handle, SpeechHandle.SPEECH_PRIORITY_NORMAL)
return handle
def interrupt(self) -> asyncio.Future:
"""Interrupt the current speech generation and any queued speeches.
Returns:
An asyncio.Future that completes when the interruption is fully processed
and chat context has been updated
"""
future = asyncio.Future()
current_speech = self._current_speech
if current_speech is not None:
current_speech = current_speech.interrupt()
for speech in self._speech_q:
_, _, speech = speech
speech.interrupt()
if self._rt_session is not None:
self._rt_session.interrupt()
if current_speech is None:
future.set_result(None)
else:
def on_playout_done(sh: SpeechHandle) -> None:
if future.done():
return
future.set_result(None)
current_speech.add_done_callback(on_playout_done)
if current_speech.done():
future.set_result(None)
return future
def clear_user_turn(self) -> None:
if self._audio_recognition:
self._audio_recognition.clear_user_turn()
if self._rt_session is not None:
self._rt_session.clear_audio()
def commit_user_turn(self) -> None:
assert self._audio_recognition is not None
self._audio_recognition.commit_user_turn()
def _schedule_speech(
self, speech: SpeechHandle, priority: int, bypass_draining: bool = False
) -> None:
"""
This method is used to schedule a new speech.
Args:
bypass_draining: bypass_draining should only be used to allow the last tool response to be scheduled
Raises RuntimeError if the agent is draining
""" # noqa: E501
if self.draining and not bypass_draining:
raise RuntimeError("cannot schedule new speech, the agent is draining")
heapq.heappush(self._speech_q, (priority, time.time(), speech))
self._wake_up_main_task()
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
while True:
await self._q_updated.wait()
while self._speech_q:
_, _, speech = heapq.heappop(self._speech_q)
self._current_speech = speech
speech._authorize_playout()
await speech.wait_for_playout()
self._current_speech = None
# If we're draining and there are no more speech tasks, we can exit.
# Only speech tasks can bypass draining to create a tool response
if self._draining and len(self._speech_tasks) == 0:
break
self._q_updated.clear()
# -- Realtime Session events --
def _on_metrics_collected(self, ev: STTMetrics | TTSMetrics | VADMetrics | LLMMetrics) -> None:
if (speech_handle := _SpeechHandleContextVar.get(None)) and (
isinstance(ev, LLMMetrics) or isinstance(ev, TTSMetrics)
):
ev.speech_id = speech_handle.id
self._session.emit("metrics_collected", MetricsCollectedEvent(metrics=ev))
def _on_error(
self, error: llm.LLMError | stt.STTError | tts.TTSError | llm.RealtimeModelError
) -> None:
if isinstance(error, llm.LLMError):
error_event = ErrorEvent(error=error, source=self.llm)
self._session.emit("error", error_event)
elif isinstance(error, llm.RealtimeModelError):
error_event = ErrorEvent(error=error, source=self.llm)
self._session.emit("error", error_event)
elif isinstance(error, stt.STTError):
error_event = ErrorEvent(error=error, source=self.stt)
self._session.emit("error", error_event)
elif isinstance(error, tts.TTSError):
error_event = ErrorEvent(error=error, source=self.tts)
self._session.emit("error", error_event)
self._session._on_error(error)
def _on_input_speech_started(self, _: llm.InputSpeechStartedEvent) -> None:
log_event("input_speech_started")
if self.vad is None:
self._session._update_user_state("speaking")
# self.interrupt() isn't going to raise when allow_interruptions is False, llm.InputSpeechStartedEvent is only fired by the server when the turn_detection is enabled. # noqa: E501
# When using the server-side turn_detection, we don't allow allow_interruptions to be False.
try:
self.interrupt() # input_speech_started is also interrupting on the serverside realtime session # noqa: E501
except RuntimeError:
logger.exception(
"RealtimeAPI input_speech_started, but current speech is not interruptable, this should never happen!" # noqa: E501
)
def _on_input_speech_stopped(self, ev: llm.InputSpeechStoppedEvent) -> None:
log_event("input_speech_stopped")
if self.vad is None:
self._session._update_user_state("listening")
if ev.user_transcription_enabled:
self._session.emit(
"user_input_transcribed",
UserInputTranscribedEvent(transcript="", is_final=False),
)
def _on_input_audio_transcription_completed(self, ev: llm.InputTranscriptionCompleted) -> None:
log_event("input_audio_transcription_completed")
self._session.emit(
"user_input_transcribed",
UserInputTranscribedEvent(transcript=ev.transcript, is_final=True),
)
msg = llm.ChatMessage(role="user", content=[ev.transcript], id=ev.item_id)
self._agent._chat_ctx.items.append(msg)
self._session._conversation_item_added(msg)
def _on_generation_created(self, ev: llm.GenerationCreatedEvent) -> None:
if ev.user_initiated:
# user_initiated generations are directly handled inside _realtime_reply_task
return
if self.draining:
# TODO(theomonnom): should we "forward" this new turn to the next agent?
logger.warning("skipping new realtime generation, the agent is draining")
return
handle = SpeechHandle.create(allow_interruptions=self.allow_interruptions)
self._session.emit(
"speech_created",
SpeechCreatedEvent(speech_handle=handle, user_initiated=False, source="generate_reply"),
)
self._create_speech_task(
self._realtime_generation_task(
speech_handle=handle, generation_ev=ev, model_settings=ModelSettings()
),
owned_speech_handle=handle,
name="AgentActivity.realtime_generation",
)
self._schedule_speech(handle, SpeechHandle.SPEECH_PRIORITY_NORMAL)
# region recognition hooks
def on_start_of_speech(self, ev: vad.VADEvent) -> None:
self._session._update_user_state("speaking")
def on_end_of_speech(self, ev: vad.VADEvent) -> None:
self._session._update_user_state("listening")
def on_vad_inference_done(self, ev: vad.VADEvent) -> None:
if self._turn_detection_mode not in ("vad", None):
# ignore vad inference done event if turn_detection is not set to vad or default
return
if isinstance(self.llm, llm.RealtimeModel) and self.llm.capabilities.turn_detection:
# ignore if turn_detection is enabled on the realtime model
return
if ev.speech_duration > self._session.options.min_interruption_duration:
if (
self._current_speech is not None
and not self._current_speech.interrupted
and self._current_speech.allow_interruptions
):
log_event(
"speech interrupted by vad",
speech_id=self._current_speech.id,
)
if self._rt_session is not None:
self._rt_session.interrupt()
self._current_speech.interrupt()
def on_interim_transcript(self, ev: stt.SpeechEvent) -> None:
if isinstance(self.llm, llm.RealtimeModel) and self.llm.capabilities.user_transcription:
# skip stt transcription if user_transcription is enabled on the realtime model
return
self._session.emit(
"user_input_transcribed",
UserInputTranscribedEvent(transcript=ev.alternatives[0].text, is_final=False),
)
def on_final_transcript(self, ev: stt.SpeechEvent) -> None:
if isinstance(self.llm, llm.RealtimeModel) and self.llm.capabilities.user_transcription:
# skip stt transcription if user_transcription is enabled on the realtime model
return
self._session.emit(
"user_input_transcribed",
UserInputTranscribedEvent(transcript=ev.alternatives[0].text, is_final=True),
)
async def on_end_of_turn(self, info: _EndOfTurnInfo) -> None:
# IMPORTANT: This method can be cancelled by the AudioRecognition
# We explicitly create a new task to avoid cancelling user code.
if self.draining:
logger.warning(
"skipping user input, task is draining",
extra={"user_input": info.new_transcript},
)
log_event(
"skipping user input, task is draining",
user_input=info.new_transcript,
)
return
if self.draining:
# TODO(theomonnom): should we "forward" this new turn to the next agent/activity?
logger.warning("ignoring new user turn, the agent is draining")
return
old_task = self._user_turn_completed_atask
self._user_turn_completed_atask = self._create_speech_task(
self._user_turn_completed_task(old_task, info),
name="AgentActivity._user_turn_completed_task",
)
@utils.log_exceptions(logger=logger)
async def _user_turn_completed_task(
self, old_task: asyncio.Task | None, info: _EndOfTurnInfo
) -> None:
if old_task is not None:
# We never cancel user code as this is very confusing.
# So we wait for the old execution of on_user_turn_completed to finish.
# In practice this is OK because most speeches will be interrupted if a new turn
# is detected. So the previous execution should complete quickly.
await old_task
# When the audio recognition detects the end of a user turn:
# - check if realtime model server-side turn detection is enabled
# - check if there is no current generation happening
# - cancel the current generation if it allows interruptions (otherwise skip this current
# turn)
# - generate a reply to the user input
if isinstance(self.llm, llm.RealtimeModel):
if self.llm.capabilities.turn_detection:
return
if self._rt_session is not None:
self._rt_session.commit_audio()
if self._current_speech is not None:
if not self._current_speech.allow_interruptions:
logger.warning(
"skipping reply to user input, current speech generation cannot be interrupted",
extra={"user_input": info.new_transcript},
)
return
log_event(
"speech interrupted, new user turn detected",
speech_id=self._current_speech.id,
)
self._current_speech.interrupt()
if self._rt_session is not None:
self._rt_session.interrupt()
# id is generated
user_message = llm.ChatMessage(role="user", content=[info.new_transcript])
# create a temporary mutable chat context to pass to on_user_turn_completed
# the user can edit it for the current generation, but changes will not be kept inside the
# Agent.chat_ctx
temp_mutable_chat_ctx = self._agent.chat_ctx.copy()
start_time = time.time()
try:
await self._agent.on_user_turn_completed(
temp_mutable_chat_ctx, new_message=user_message
)
except StopResponse:
return # ignore this turn
except Exception:
logger.exception("error occured during on_user_turn_completed")
return
callback_duration = time.time() - start_time
if isinstance(self.llm, llm.RealtimeModel):
# ignore stt transcription for realtime model
user_message = None
# Ensure the new message is passed to generate_reply
# This preserves the original message_id, making it easier for users to track responses
speech_handle = self._generate_reply(
user_message=user_message, chat_ctx=temp_mutable_chat_ctx
)
if self._user_turn_completed_atask != asyncio.current_task():
# If a new user turn has already started, interrupt this one since it's now outdated
# (We still create the SpeechHandle and the generate_reply coroutine, otherwise we may
# lose data like the beginning of a user speech).
speech_handle.interrupt()
eou_metrics = EOUMetrics(
timestamp=time.time(),
end_of_utterance_delay=info.end_of_utterance_delay,
transcription_delay=info.transcription_delay,
on_user_turn_completed_delay=callback_duration,
speech_id=speech_handle.id,
)
self._session.emit("metrics_collected", MetricsCollectedEvent(metrics=eou_metrics))
# AudioRecognition is calling this method to retrieve the chat context before running the TurnDetector model # noqa: E501
def retrieve_chat_ctx(self) -> llm.ChatContext:
return self._agent.chat_ctx
# endregion
@utils.log_exceptions(logger=logger)
async def _tts_task(
self,
speech_handle: SpeechHandle,
text: str | AsyncIterable[str],
audio: AsyncIterable[rtc.AudioFrame] | None,
add_to_chat_ctx: bool,
model_settings: ModelSettings,
) -> None:
_SpeechHandleContextVar.set(speech_handle)
tr_output = (
self._session.output.transcription
if self._session.output.transcription_enabled
else None
)
audio_output = self._session.output.audio if self._session.output.audio_enabled else None
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(speech_handle._wait_for_authorization())]
)
if speech_handle.interrupted:
return
text_source: AsyncIterable[str] | None = None
audio_source: AsyncIterable[str] | None = None
if isinstance(text, AsyncIterable):
text_source, audio_source = utils.aio.itertools.tee(text, 2)
elif isinstance(text, str):
async def _read_text() -> AsyncIterable[str]:
yield text
text_source = _read_text()
audio_source = _read_text()
tasks = []
tr_node = self._agent.transcription_node(text_source, model_settings)
if asyncio.iscoroutine(tr_node):
tr_node = await tr_node
forward_text, text_out = perform_text_forwarding(
text_output=tr_output,
source=tr_node,
)
tasks.append(forward_text)
def _on_first_frame(_: asyncio.Future) -> None:
self._session._update_agent_state("speaking")
if audio_output is None:
# update the agent state based on text if no audio output
text_out.first_text_fut.add_done_callback(_on_first_frame)
else:
if audio is None:
# generate audio using TTS
tts_task, tts_gen_data = perform_tts_inference(
node=self._agent.tts_node,
input=audio_source,
model_settings=model_settings,
)
tasks.append(tts_task)
forward_task, audio_out = perform_audio_forwarding(
audio_output=audio_output, tts_output=tts_gen_data.audio_ch
)
tasks.append(forward_task)
else:
# use the provided audio
forward_task, audio_out = perform_audio_forwarding(
audio_output=audio_output, tts_output=audio
)
tasks.append(forward_task)
audio_out.first_frame_fut.add_done_callback(_on_first_frame)
await speech_handle.wait_if_not_interrupted([*tasks])
if audio_output is not None:
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(audio_output.wait_for_playout())]
)
if speech_handle.interrupted:
await utils.aio.cancel_and_wait(*tasks)
if audio_output is not None:
audio_output.clear_buffer()
await audio_output.wait_for_playout()
if add_to_chat_ctx:
msg = self._agent._chat_ctx.add_message(
role="assistant", content=text_out.text, interrupted=speech_handle.interrupted
)
speech_handle._set_chat_message(msg)
self._session._conversation_item_added(msg)
self._session._update_agent_state("listening")
@utils.log_exceptions(logger=logger)
async def _pipeline_reply_task(
self,
*,
speech_handle: SpeechHandle,
chat_ctx: llm.ChatContext,
tools: list[llm.FunctionTool],
model_settings: ModelSettings,
new_message: llm.ChatMessage | None = None,
instructions: str | None = None,
_tools_messages: list[llm.ChatItem] | None = None,
) -> None:
from .agent import ModelSettings
_SpeechHandleContextVar.set(speech_handle)
log_event(
"generation started",
speech_id=speech_handle.id,
step_index=speech_handle.step_index,
)
audio_output = self._session.output.audio if self._session.output.audio_enabled else None
text_output = (
self._session.output.transcription
if self._session.output.transcription_enabled
else None
)
chat_ctx = chat_ctx.copy()
tool_ctx = llm.ToolContext(tools)
if new_message is not None:
idx = chat_ctx.find_insertion_index(created_at=new_message.created_at)
chat_ctx.items.insert(idx, new_message)
idx = self._agent._chat_ctx.find_insertion_index(created_at=new_message.created_at)
self._agent._chat_ctx.items.insert(idx, new_message)
self._session._conversation_item_added(new_message)
if instructions is not None:
try:
update_instructions(chat_ctx, instructions=instructions, add_if_missing=True)
except ValueError:
logger.exception("failed to update the instructions")
self._session._update_agent_state("thinking")
tasks = []
llm_task, llm_gen_data = perform_llm_inference(
node=self._agent.llm_node,
chat_ctx=chat_ctx,
tool_ctx=tool_ctx,
model_settings=model_settings,
)
tasks.append(llm_task)
tts_text_input, llm_output = utils.aio.itertools.tee(llm_gen_data.text_ch)
tts_task: asyncio.Task | None = None
tts_gen_data: _TTSGenerationData | None = None
if audio_output is not None:
tts_task, tts_gen_data = perform_tts_inference(
node=self._agent.tts_node,
input=tts_text_input,
model_settings=model_settings,
)
tasks.append(tts_task)
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(speech_handle._wait_for_authorization())]
)
if speech_handle.interrupted:
await utils.aio.cancel_and_wait(*tasks)
return
tr_node = self._agent.transcription_node(llm_output, model_settings)
if asyncio.iscoroutine(tr_node):
tr_node = await tr_node
forward_task, text_out = perform_text_forwarding(text_output=text_output, source=tr_node)
tasks.append(forward_task)
def _on_first_frame(_: asyncio.Future) -> None:
self._session._update_agent_state("speaking")
audio_out: _AudioOutput | None = None
if audio_output is not None:
assert tts_gen_data is not None
# TODO(theomonnom): should the audio be added to the chat_context too?
forward_task, audio_out = perform_audio_forwarding(
audio_output=audio_output, tts_output=tts_gen_data.audio_ch
)
tasks.append(forward_task)
audio_out.first_frame_fut.add_done_callback(_on_first_frame)
else:
text_out.first_text_fut.add_done_callback(_on_first_frame)
# start to execute tools (only after play())
exe_task, tool_output = perform_tool_executions(
session=self._session,
speech_handle=speech_handle,
tool_ctx=tool_ctx,
tool_choice=model_settings.tool_choice,
function_stream=llm_gen_data.function_ch,
)
await speech_handle.wait_if_not_interrupted([*tasks])
# wait for the end of the playout if the audio is enabled
if audio_output is not None:
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(audio_output.wait_for_playout())]
)
if not speech_handle.interrupted:
self._session._update_agent_state("listening")
# add the tools messages that triggers this reply to the chat context
if _tools_messages:
self._agent._chat_ctx.items.extend(_tools_messages)
if speech_handle.interrupted:
await utils.aio.cancel_and_wait(*tasks)
forwarded_text = text_out.text
# if the audio playout was enabled, clear the buffer
if audio_output is not None:
audio_output.clear_buffer()
playback_ev = await audio_output.wait_for_playout()
if audio_out is not None and audio_out.first_frame_fut.done():
# playback_ev is valid only if the first frame was already played
log_event(
"playout interrupted",
playback_position=playback_ev.playback_position,
speech_id=speech_handle.id,
)
if playback_ev.synchronized_transcript is not None:
forwarded_text = playback_ev.synchronized_transcript
else:
forwarded_text = ""
msg = chat_ctx.add_message(
role="assistant",
content=forwarded_text,
id=llm_gen_data.id,
interrupted=True,
)
self._agent._chat_ctx.items.append(msg)
self._session._update_agent_state("listening")
self._session._conversation_item_added(msg)
speech_handle._set_chat_message(msg)
speech_handle._mark_playout_done()
await utils.aio.cancel_and_wait(exe_task)
return
if text_out.text:
msg = chat_ctx.add_message(
role="assistant", content=text_out.text, id=llm_gen_data.id, interrupted=False
)
self._agent._chat_ctx.items.append(msg)
self._session._conversation_item_added(msg)
speech_handle._set_chat_message(msg)
self._session._update_agent_state("listening")
log_event("playout completed", speech_id=speech_handle.id)
speech_handle._mark_playout_done() # mark the playout done before waiting for the tool execution # noqa: E501
tool_output.first_tool_fut.add_done_callback(
lambda _: self._session._update_agent_state("thinking")
)
await exe_task
# important: no agent output should be used after this point
if len(tool_output.output) > 0:
if speech_handle.step_index >= self._session.options.max_tool_steps:
logger.warning(
"maximum number of function calls steps reached",
extra={"speech_id": speech_handle.id},
)
log_event(
"maximum number of function calls steps reached",
speech_id=speech_handle.id,
)
return
new_calls: list[llm.FunctionCall] = []
new_fnc_outputs: list[llm.FunctionCallOutput] = []
generate_tool_reply: bool = False
new_agent_task: Agent | None = None
ignore_task_switch = False
fnc_executed_ev = FunctionToolsExecutedEvent(
function_calls=[],
function_call_outputs=[],
)
for py_out in tool_output.output:
sanitized_out = py_out.sanitize()
if sanitized_out.fnc_call_out is not None:
new_calls.append(sanitized_out.fnc_call)
new_fnc_outputs.append(sanitized_out.fnc_call_out)
if sanitized_out.reply_required:
generate_tool_reply = True
# add the function call and output to the event, including the None outputs
fnc_executed_ev.function_calls.append(sanitized_out.fnc_call)
fnc_executed_ev.function_call_outputs.append(sanitized_out.fnc_call_out)
if new_agent_task is not None and sanitized_out.agent_task is not None:
logger.error(
"expected to receive only one AgentTask from the tool executions",
)
ignore_task_switch = True
# TODO(long): should we mark the function call as failed to notify the LLM?
new_agent_task = sanitized_out.agent_task
self._session.emit("function_tools_executed", fnc_executed_ev)
draining = self.draining
if not ignore_task_switch and new_agent_task is not None:
self._session.update_agent(new_agent_task)
draining = True
if generate_tool_reply:
chat_ctx.items.extend(new_calls)
chat_ctx.items.extend(new_fnc_outputs)
handle = SpeechHandle.create(
allow_interruptions=speech_handle.allow_interruptions,
step_index=speech_handle.step_index + 1,
parent=speech_handle,
)
self._session.emit(
"speech_created",
SpeechCreatedEvent(
speech_handle=handle, user_initiated=False, source="tool_response"
),
)
self._create_speech_task(
self._pipeline_reply_task(
speech_handle=handle,
chat_ctx=chat_ctx,
tools=tools,
model_settings=ModelSettings(
tool_choice=model_settings.tool_choice if not draining else "none",
),
_tools_messages=[*new_calls, *new_fnc_outputs],
),
owned_speech_handle=handle,
name="AgentActivity.pipeline_reply",
)
self._schedule_speech(
handle, SpeechHandle.SPEECH_PRIORITY_NORMAL, bypass_draining=True
)
elif len(new_fnc_outputs) > 0:
# add the tool calls and outputs to the chat context even no reply is generated
self._agent._chat_ctx.items.extend(new_calls)
self._agent._chat_ctx.items.extend(new_fnc_outputs)
@utils.log_exceptions(logger=logger)
async def _realtime_reply_task(
self,
*,
speech_handle: SpeechHandle,
model_settings: ModelSettings,
user_input: str | None = None,
instructions: str | None = None,
) -> None:
_SpeechHandleContextVar.set(speech_handle) # not needed, but here for completeness
assert self._rt_session is not None, "rt_session is not available"
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(speech_handle._wait_for_authorization())]
)
if user_input is not None:
chat_ctx = self._rt_session.chat_ctx.copy()
msg = chat_ctx.add_message(role="user", content=user_input)
await self._rt_session.update_chat_ctx(chat_ctx)
self._agent._chat_ctx.items.append(msg)
self._session._conversation_item_added(msg)
ori_tool_choice = self._tool_choice
if utils.is_given(model_settings.tool_choice):
self._rt_session.update_options(tool_choice=model_settings.tool_choice)
try:
generation_ev = await self._rt_session.generate_reply(
instructions=instructions or NOT_GIVEN
)
await self._realtime_generation_task(
speech_handle=speech_handle,
generation_ev=generation_ev,
model_settings=model_settings,
)
finally:
# reset tool_choice value
if (
utils.is_given(model_settings.tool_choice)
and model_settings.tool_choice != ori_tool_choice
):
self._rt_session.update_options(tool_choice=ori_tool_choice)
@utils.log_exceptions(logger=logger)
async def _realtime_generation_task(
self,
*,
speech_handle: SpeechHandle,
generation_ev: llm.GenerationCreatedEvent,
model_settings: ModelSettings,
) -> None:
_SpeechHandleContextVar.set(speech_handle)
assert self._rt_session is not None, "rt_session is not available"
log_event(
"generation started",
speech_id=speech_handle.id,
step_index=speech_handle.step_index,
realtime=True,
)
audio_output = self._session.output.audio if self._session.output.audio_enabled else None
text_output = (
self._session.output.transcription
if self._session.output.transcription_enabled
else None
)
tool_ctx = llm.ToolContext(self._agent.tools)
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(speech_handle._wait_for_authorization())]
)
if speech_handle.interrupted:
return # TODO(theomonnom): remove the message from the serverside history
def _on_first_frame(_: asyncio.Future) -> None:
self._session._update_agent_state("speaking")
@utils.log_exceptions(logger=logger)
async def _read_messages(
outputs: list[tuple[str, _TextOutput, _AudioOutput | None]],
) -> None:
forward_tasks: list[asyncio.Task] = []
try:
async for msg in generation_ev.message_stream:
if len(forward_tasks) > 0:
logger.warning(
"expected to receive only one message generation from the realtime API"
)
break
tr_node = self._agent.transcription_node(msg.text_stream, model_settings)
if asyncio.iscoroutine(tr_node):
tr_node = await tr_node
forward_task, text_out = perform_text_forwarding(
text_output=text_output,
source=tr_node,
)
forward_tasks.append(forward_task)
audio_out = None
if audio_output is not None:
realtime_audio = self._agent.realtime_audio_output_node(
msg.audio_stream, model_settings
)
if asyncio.iscoroutine(realtime_audio):
realtime_audio = await realtime_audio
if realtime_audio is not None:
forward_task, audio_out = perform_audio_forwarding(
audio_output=audio_output, tts_output=realtime_audio
)
forward_tasks.append(forward_task)
audio_out.first_frame_fut.add_done_callback(_on_first_frame)
else:
text_out.first_text_fut.add_done_callback(_on_first_frame)
outputs.append((msg.message_id, text_out, audio_out))
await asyncio.gather(*forward_tasks)
finally:
await utils.aio.cancel_and_wait(*forward_tasks)
message_outputs: list[tuple[str, _TextOutput, _AudioOutput | None]] = []
tasks = [
asyncio.create_task(
_read_messages(message_outputs),
name="AgentActivity.realtime_generation.read_messages",
)
]
exe_task, tool_output = perform_tool_executions(
session=self._session,
speech_handle=speech_handle,
tool_ctx=tool_ctx,
tool_choice=model_settings.tool_choice,
function_stream=generation_ev.function_stream,
)
await speech_handle.wait_if_not_interrupted([*tasks])
if audio_output is not None:
await speech_handle.wait_if_not_interrupted(
[asyncio.ensure_future(audio_output.wait_for_playout())]
)
self._session._update_agent_state("listening")
if speech_handle.interrupted:
await utils.aio.cancel_and_wait(*tasks)
if len(message_outputs) > 0:
# there should be only one message
msg_id, text_out, audio_out = message_outputs[0]
forwarded_text = text_out.text
if audio_output is not None:
audio_output.clear_buffer()
playback_ev = await audio_output.wait_for_playout()
playback_position = playback_ev.playback_position
if audio_out is not None and audio_out.first_frame_fut.done():
# playback_ev is valid only if the first frame was already played
log_event(
"playout interrupted",
playback_position=playback_ev.playback_position,
speech_id=speech_handle.id,
)
if playback_ev.synchronized_transcript is not None:
forwarded_text = playback_ev.synchronized_transcript
else:
forwarded_text = ""
playback_position = 0
# truncate server-side message
self._rt_session.truncate(
message_id=msg_id, audio_end_ms=int(playback_position * 1000)
)
msg = llm.ChatMessage(
role="assistant", content=[forwarded_text], id=msg_id, interrupted=True
)
self._agent._chat_ctx.items.append(msg)
speech_handle._set_chat_message(msg)
self._session._conversation_item_added(msg)
speech_handle._mark_playout_done()
await utils.aio.cancel_and_wait(exe_task)
return
if len(message_outputs) > 0:
# there should be only one message
msg_id, text_out, _ = message_outputs[0]
msg = llm.ChatMessage(
role="assistant", content=[text_out.text], id=msg_id, interrupted=False
)
self._agent._chat_ctx.items.append(msg)
speech_handle._set_chat_message(msg)
self._session._conversation_item_added(msg)
# mark playout must be done before _set_chat_message
speech_handle._mark_playout_done() # mark the playout done before waiting for the tool execution # noqa: E501
tool_output.first_tool_fut.add_done_callback(
lambda _: self._session._update_agent_state("thinking")
)
await exe_task
# important: no agent ouput should be used after this point
if len(tool_output.output) > 0:
new_fnc_outputs: list[llm.FunctionCallOutput] = []
generate_tool_reply: bool = False
fnc_executed_ev = FunctionToolsExecutedEvent(
function_calls=[],
function_call_outputs=[],
)
new_agent_task: Agent | None = None
ignore_task_switch = False
for py_out in tool_output.output:
sanitized_out = py_out.sanitize()
# add the function call and output to the event, including the None outputs
fnc_executed_ev.function_calls.append(sanitized_out.fnc_call)
fnc_executed_ev.function_call_outputs.append(sanitized_out.fnc_call_out)
if sanitized_out.fnc_call_out is not None:
new_fnc_outputs.append(sanitized_out.fnc_call_out)
if sanitized_out.reply_required:
generate_tool_reply = True
if new_agent_task is not None and sanitized_out.agent_task is not None:
logger.error(
"expected to receive only one AgentTask from the tool executions",
)
ignore_task_switch = True
new_agent_task = sanitized_out.agent_task
self._session.emit("function_tools_executed", fnc_executed_ev)
draining = self.draining
if not ignore_task_switch and new_agent_task is not None:
self._session.update_agent(new_agent_task)
draining = True
if len(new_fnc_outputs) > 0:
chat_ctx = self._rt_session.chat_ctx.copy()
chat_ctx.items.extend(new_fnc_outputs)
try:
await self._rt_session.update_chat_ctx(chat_ctx)
except llm.RealtimeError as e:
logger.warning(
"failed to update chat context before generating the function calls results", # noqa: E501
extra={"error": str(e)},
)
if generate_tool_reply and (
# no direct cancellation in Gemini
GoogleRealtimeModel is None or not isinstance(self.llm, GoogleRealtimeModel)
):
self._rt_session.interrupt()
handle = SpeechHandle.create(
allow_interruptions=speech_handle.allow_interruptions,
step_index=speech_handle.step_index + 1,
parent=speech_handle,
)
self._session.emit(
"speech_created",
SpeechCreatedEvent(
speech_handle=handle,
user_initiated=False,
source="tool_response",
),
)
self._create_speech_task(
self._realtime_reply_task(
speech_handle=handle,
model_settings=ModelSettings(
tool_choice=model_settings.tool_choice if not draining else "none",
),
),
owned_speech_handle=handle,
name="AgentActivity.realtime_reply",
)
self._schedule_speech(
handle,
SpeechHandle.SPEECH_PRIORITY_NORMAL,
bypass_draining=True,
)
from __future__ import annotations
import asyncio
import copy
import time
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Generic, Literal, Protocol, TypeVar, Union, runtime_checkable
from livekit import rtc
from .. import debug, llm, stt, tts, utils, vad
from ..cli import cli
from ..job import get_job_context
from ..llm import ChatContext
from ..log import logger
from ..types import NOT_GIVEN, NotGivenOr
from ..utils.misc import is_given
from . import io, room_io
from .agent import Agent
from .agent_activity import AgentActivity
from .audio_recognition import _TurnDetector
from .events import (
AgentEvent,
AgentState,
AgentStateChangedEvent,
CloseEvent,
ConversationItemAddedEvent,
EventTypes,
UserState,
UserStateChangedEvent,
)
from .speech_handle import SpeechHandle
@dataclass
class VoiceOptions:
allow_interruptions: bool
discard_audio_if_uninterruptible: bool
min_interruption_duration: float
min_endpointing_delay: float
max_endpointing_delay: float
max_tool_steps: int
Userdata_T = TypeVar("Userdata_T")
TurnDetectionMode = Union[Literal["stt", "vad", "realtime_llm", "manual"], _TurnDetector]
"""
The mode of turn detection to use.
- "stt": use speech-to-text result to detect the end of the user's turn
- "vad": use VAD to detect the start and end of the user's turn
- "realtime_llm": use server-side turn detection provided by the realtime LLM
- "manual": manually manage the turn detection
- _TurnDetector: use the default mode with the provided turn detector
(default) If not provided, automatically choose the best mode based on
available models (realtime_llm -> vad -> stt -> manual)
If the needed model (VAD, STT, or RealtimeModel) is not provided, fallback to the default mode.
"""
@runtime_checkable
class _VideoSampler(Protocol):
def __call__(self, frame: rtc.VideoFrame, session: AgentSession) -> bool: ...
# TODO(theomonnom): Should this be moved to another file?
class VoiceActivityVideoSampler:
def __init__(self, *, speaking_fps: float = 1.0, silent_fps: float = 0.3):
if speaking_fps <= 0 or silent_fps <= 0:
raise ValueError("FPS values must be greater than zero")
self.speaking_fps = speaking_fps
self.silent_fps = silent_fps
self._last_sampled_time: float | None = None
def __call__(self, frame: rtc.VideoFrame, session: AgentSession) -> bool:
now = time.time()
is_speaking = session.user_state == "speaking"
target_fps = self.speaking_fps if is_speaking else self.silent_fps
min_frame_interval = 1.0 / target_fps
if self._last_sampled_time is None:
self._last_sampled_time = now
return True
if (now - self._last_sampled_time) >= min_frame_interval:
self._last_sampled_time = now
return True
return False
class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
def __init__(
self,
*,
turn_detection: NotGivenOr[TurnDetectionMode] = NOT_GIVEN,
stt: NotGivenOr[stt.STT] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD] = NOT_GIVEN,
llm: NotGivenOr[llm.LLM | llm.RealtimeModel] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS] = NOT_GIVEN,
userdata: NotGivenOr[Userdata_T] = NOT_GIVEN,
allow_interruptions: bool = True,
discard_audio_if_uninterruptible: bool = True,
min_interruption_duration: float = 0.5,
min_endpointing_delay: float = 0.5,
max_endpointing_delay: float = 6.0,
max_tool_steps: int = 3,
video_sampler: NotGivenOr[_VideoSampler | None] = NOT_GIVEN,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
"""`AgentSession` is the LiveKit Agents runtime that glues together
media streams, speech/LLM components, and tool orchestration into a
single real-time voice agent.
It links audio, video, and text I/O with STT, VAD, TTS, and the LLM;
handles turn detection, endpointing, interruptions, and multi-step
tool calls; and exposes everything through event callbacks so you can
focus on writing function tools and simple hand-offs rather than
low-level streaming logic.
Args:
turn_detection (TurnDetectionMode, optional): Strategy for deciding
when the user has finished speaking.
* ``"stt"`` – rely on speech-to-text end-of-utterance cues
* ``"vad"`` – rely on Voice Activity Detection start/stop cues
* ``"realtime_llm"`` – use server-side detection from a
realtime LLM
* ``"manual"`` – caller controls turn boundaries explicitly
* ``_TurnDetector`` instance – plug-in custom detector
If *NOT_GIVEN*, the session chooses the best available mode in
priority order ``realtime_llm → vad → stt → manual``; it
automatically falls back if the necessary model is missing.
stt (stt.STT, optional): Speech-to-text backend.
vad (vad.VAD, optional): Voice-activity detector
llm (llm.LLM | llm.RealtimeModel, optional): LLM or RealtimeModel
tts (tts.TTS, optional): Text-to-speech engine.
userdata (Userdata_T, optional): Arbitrary per-session user data.
allow_interruptions (bool): Whether the user can interrupt the
agent mid-utterance. Default ``True``.
discard_audio_if_uninterruptible (bool): When ``True``, buffered
audio is dropped while the agent is speaking and cannot be
interrupted. Default ``True``.
min_interruption_duration (float): Minimum speech length (s) to
register as an interruption. Default ``0.5`` s.
min_endpointing_delay (float): Minimum time-in-seconds the agent
must wait after a potential end-of-utterance signal (from VAD
or an EOU model) before it declares the user’s turn complete.
Default ``0.5`` s.
max_endpointing_delay (float): Maximum time-in-seconds the agent
will wait before terminating the turn. Default ``6.0`` s.
max_tool_steps (int): Maximum consecutive tool calls per LLM turn.
Default ``3``.
video_sampler (_VideoSampler, optional): Uses
:class:`VoiceActivityVideoSampler` when *NOT_GIVEN*; that sampler
captures video at ~1 fps while the user is speaking and ~0.3 fps
when silent by default.
loop (asyncio.AbstractEventLoop, optional): Event loop to bind the
session to. Falls back to :pyfunc:`asyncio.get_event_loop()`.
"""
super().__init__()
self._loop = loop or asyncio.get_event_loop()
if not is_given(video_sampler):
video_sampler = VoiceActivityVideoSampler(speaking_fps=1.0, silent_fps=0.3)
self._video_sampler = video_sampler
# This is the "global" chat_context, it holds the entire conversation history
self._chat_ctx = ChatContext.empty()
self._opts = VoiceOptions(
allow_interruptions=allow_interruptions,
discard_audio_if_uninterruptible=discard_audio_if_uninterruptible,
min_interruption_duration=min_interruption_duration,
min_endpointing_delay=min_endpointing_delay,
max_endpointing_delay=max_endpointing_delay,
max_tool_steps=max_tool_steps,
)
self._started = False
self._turn_detection = turn_detection or None
self._stt = stt or None
self._vad = vad or None
self._llm = llm or None
self._tts = tts or None
# configurable IO
self._input = io.AgentInput(self._on_video_input_changed, self._on_audio_input_changed)
self._output = io.AgentOutput(
self._on_video_output_changed,
self._on_audio_output_changed,
self._on_text_output_changed,
)
self._forward_audio_atask: asyncio.Task | None = None
self._update_activity_atask: asyncio.Task | None = None
self._activity_lock = asyncio.Lock()
self._lock = asyncio.Lock()
# used to keep a reference to the room io
# this is not exposed, if users want access to it, they can create their own RoomIO
self._room_io: room_io.RoomIO | None = None
self._agent: Agent | None = None
self._activity: AgentActivity | None = None
self._user_state: UserState = "listening"
self._agent_state: AgentState = "initializing"
self._userdata: Userdata_T | None = userdata if is_given(userdata) else None
self._closing_task: asyncio.Task | None = None
@property
def userdata(self) -> Userdata_T:
if self._userdata is None:
raise ValueError("VoiceAgent userdata is not set")
return self._userdata
@userdata.setter
def userdata(self, value: Userdata_T) -> None:
self._userdata = value
@property
def turn_detection(self) -> TurnDetectionMode | None:
return self._turn_detection
@property
def stt(self) -> stt.STT | None:
return self._stt
@property
def llm(self) -> llm.LLM | llm.RealtimeModel | None:
return self._llm
@property
def tts(self) -> tts.TTS | None:
return self._tts
@property
def vad(self) -> vad.VAD | None:
return self._vad
@property
def input(self) -> io.AgentInput:
return self._input
@property
def output(self) -> io.AgentOutput:
return self._output
@property
def options(self) -> VoiceOptions:
return self._opts
@property
def history(self) -> llm.ChatContext:
return self._chat_ctx
@property
def current_speech(self) -> SpeechHandle | None:
return self._activity.current_speech if self._activity is not None else None
@property
def user_state(self) -> UserState:
return self._user_state
@property
def agent_state(self) -> AgentState:
return self._agent_state
@property
def current_agent(self) -> Agent:
if self._agent is None:
raise RuntimeError("VoiceAgent isn't running")
return self._agent
async def start(
self,
agent: Agent,
*,
room: NotGivenOr[rtc.Room] = NOT_GIVEN,
room_input_options: NotGivenOr[room_io.RoomInputOptions] = NOT_GIVEN,
room_output_options: NotGivenOr[room_io.RoomOutputOptions] = NOT_GIVEN,
) -> None:
"""Start the voice agent.
Create a default RoomIO if the input or output audio is not already set.
If the console flag is provided, start a ChatCLI.
Args:
room: The room to use for input and output
room_input_options: Options for the room input
room_output_options: Options for the room output
"""
async with self._lock:
if self._started:
return
self._agent = agent
self._update_agent_state("initializing")
if cli.CLI_ARGUMENTS is not None and cli.CLI_ARGUMENTS.console:
from .chat_cli import ChatCLI
if (
self.input.audio is not None
or self.output.audio is not None
or self.output.transcription is not None
):
logger.warning(
"agent started with the console subcommand, but input.audio or output.audio " # noqa: E501
"or output.transcription is already set, overriding.."
)
chat_cli = ChatCLI(self)
await chat_cli.start()
elif is_given(room) and not self._room_io:
room_input_options = copy.deepcopy(room_input_options)
room_output_options = copy.deepcopy(room_output_options)
if (
self.input.audio is not None
and is_given(room_input_options)
and room_input_options.audio_enabled
):
logger.warning(
"RoomIO audio input is enabled but input.audio is already set, ignoring.."
)
room_input_options.audio_enabled = False
if (
self.output.audio is not None
and is_given(room_output_options)
and room_output_options.audio_enabled
):
logger.warning(
"RoomIO audio output is enabled but output.audio is already set, ignoring.."
)
room_output_options.audio_enabled = False
if (
self.output.transcription is not None
and is_given(room_output_options)
and room_output_options.transcription_enabled
):
logger.warning(
"RoomIO transcription output is enabled but output.transcription is already set, ignoring.." # noqa: E501
)
room_output_options.transcription_enabled = False
self._room_io = room_io.RoomIO(
room=room,
agent_session=self,
input_options=(room_input_options or room_io.DEFAULT_ROOM_INPUT_OPTIONS),
output_options=(room_output_options or room_io.DEFAULT_ROOM_OUTPUT_OPTIONS),
)
await self._room_io.start()
else:
if not self._room_io and not self.output.audio and not self.output.transcription:
logger.warning(
"session starts without output, forgetting to pass `room` to `AgentSession.start()`?" # noqa: E501
)
try:
job_ctx = get_job_context()
job_ctx.add_tracing_callback(self._trace_chat_ctx)
except RuntimeError:
pass # ignore
# it is ok to await it directly, there is no previous task to drain
await self._update_activity_task(self._agent)
# important: no await should be done after this!
if self.input.audio is not None:
self._forward_audio_atask = asyncio.create_task(
self._forward_audio_task(), name="_forward_audio_task"
)
if self.input.video is not None:
self._forward_video_atask = asyncio.create_task(
self._forward_video_task(), name="_forward_video_task"
)
self._started = True
self._update_agent_state("listening")
async def _trace_chat_ctx(self) -> None:
if self._activity is None:
return # can happen at startup
chat_ctx = self._activity.agent.chat_ctx
debug.Tracing.store_kv("chat_ctx", chat_ctx.to_dict(exclude_function_call=False))
debug.Tracing.store_kv("history", self.history.to_dict(exclude_function_call=False))
async def drain(self) -> None:
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
await self._activity.drain()
async def _aclose_impl(
self,
*,
error: llm.LLMError | stt.STTError | tts.TTSError | None = None,
) -> None:
async with self._lock:
if not self._started:
return
self.emit("close", CloseEvent(error=error))
if self._activity is not None:
await self._activity.aclose()
if self._forward_audio_atask is not None:
await utils.aio.cancel_and_wait(self._forward_audio_atask)
if self._room_io:
await self._room_io.aclose()
async def aclose(self) -> None:
await self._aclose_impl()
def emit(self, event: EventTypes, ev: AgentEvent) -> None: # type: ignore
# don't log VAD metrics as they are too verbose
if ev.type != "metrics_collected" or ev.metrics.type != "vad_metrics":
debug.Tracing.log_event(f'agent.on("{event}")', ev.model_dump())
return super().emit(event, ev)
def update_options(self) -> None:
pass
def say(
self,
text: str | AsyncIterable[str],
*,
audio: NotGivenOr[AsyncIterable[rtc.AudioFrame]] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
add_to_chat_ctx: bool = True,
) -> SpeechHandle:
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
if self._activity.draining:
if self._next_activity is None:
raise RuntimeError("AgentSession is closing, cannot use say()")
return self._next_activity.say(
text,
audio=audio,
allow_interruptions=allow_interruptions,
add_to_chat_ctx=add_to_chat_ctx,
)
return self._activity.say(
text,
audio=audio,
allow_interruptions=allow_interruptions,
add_to_chat_ctx=add_to_chat_ctx,
)
def generate_reply(
self,
*,
user_input: NotGivenOr[str] = NOT_GIVEN,
instructions: NotGivenOr[str] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
) -> SpeechHandle:
"""Generate a reply for the agent to speak to the user.
Args:
user_input (NotGivenOr[str], optional): The user's input that may influence the reply,
such as answering a question.
instructions (NotGivenOr[str], optional): Additional instructions for generating the reply.
tool_choice (NotGivenOr[llm.ToolChoice], optional): Specifies the external tool to use when
generating the reply. If generate_reply is invoked within a function_tool, defaults to "none".
allow_interruptions (NotGivenOr[bool], optional): Indicates whether the user can interrupt this speech.
Returns:
SpeechHandle: A handle to the generated reply.
""" # noqa: E501
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
user_message = (
llm.ChatMessage(role="user", content=[user_input])
if is_given(user_input)
else NOT_GIVEN
)
if self._activity.draining:
if self._next_activity is None:
raise RuntimeError("AgentSession is closing, cannot use generate_reply()")
return self._next_activity._generate_reply(
user_message=user_message,
instructions=instructions,
tool_choice=tool_choice,
allow_interruptions=allow_interruptions,
)
return self._activity._generate_reply(
user_message=user_message,
instructions=instructions,
tool_choice=tool_choice,
allow_interruptions=allow_interruptions,
)
def interrupt(self) -> asyncio.Future:
"""Interrupt the current speech generation.
Returns:
An asyncio.Future that completes when the interruption is fully processed
and chat context has been updated.
Example:
```python
await session.interrupt()
```
"""
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
return self._activity.interrupt()
def clear_user_turn(self) -> None:
# clear the transcription or input audio buffer of the user turn
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
self._activity.clear_user_turn()
def commit_user_turn(self) -> None:
# commit the user turn and generate a reply
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
self._activity.commit_user_turn()
def update_agent(self, agent: Agent) -> None:
self._agent = agent
if self._started:
self._update_activity_atask = asyncio.create_task(
self._update_activity_task(self._agent), name="_update_activity_task"
)
def _on_error(
self,
error: llm.LLMError | stt.STTError | tts.TTSError | llm.RealtimeModelError,
) -> None:
if self._closing_task or error.recoverable:
return
async def drain_and_close() -> None:
await self.drain()
await self._aclose_impl(error=error)
def on_close_done(_: asyncio.Task) -> None:
self._closing_task = None
self._closing_task = asyncio.create_task(drain_and_close())
self._closing_task.add_done_callback(on_close_done)
@utils.log_exceptions(logger=logger)
async def _update_activity_task(self, task: Agent) -> None:
async with self._activity_lock:
self._next_activity = AgentActivity(task, self)
if self._activity is not None:
await self._activity.drain()
await self._activity.aclose()
self._activity = self._next_activity
self._next_activity = None
await self._activity.start()
@utils.log_exceptions(logger=logger)
async def _forward_audio_task(self) -> None:
audio_input = self.input.audio
if audio_input is None:
return
async for frame in audio_input:
if self._activity is not None:
self._activity.push_audio(frame)
@utils.log_exceptions(logger=logger)
async def _forward_video_task(self) -> None:
video_input = self.input.video
if video_input is None:
return
async for frame in video_input:
if self._activity is not None:
if self._video_sampler is not None and not self._video_sampler(frame, self):
continue # ignore this frame
self._activity.push_video(frame)
def _update_agent_state(self, state: AgentState) -> None:
if self._agent_state == state:
return
old_state = self._agent_state
self._agent_state = state
self.emit(
"agent_state_changed", AgentStateChangedEvent(old_state=old_state, new_state=state)
)
def _update_user_state(self, state: UserState) -> None:
if self._user_state == state:
return
old_state = self._user_state
self._user_state = state
self.emit("user_state_changed", UserStateChangedEvent(old_state=old_state, new_state=state))
def _conversation_item_added(self, message: llm.ChatMessage) -> None:
self._chat_ctx.items.append(message)
self.emit("conversation_item_added", ConversationItemAddedEvent(item=message))
# -- User changed input/output streams/sinks --
def _on_video_input_changed(self) -> None:
if not self._started:
return
if self._forward_video_atask is not None:
self._forward_video_atask.cancel()
self._forward_video_atask = asyncio.create_task(
self._forward_video_task(), name="_forward_video_task"
)
def _on_audio_input_changed(self) -> None:
if not self._started:
return
if self._forward_audio_atask is not None:
self._forward_audio_atask.cancel()
self._forward_audio_atask = asyncio.create_task(
self._forward_audio_task(), name="_forward_audio_task"
)
def _on_video_output_changed(self) -> None:
pass
def _on_audio_output_changed(self) -> None:
pass
def _on_text_output_changed(self) -> None:
pass
# ---
from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Protocol
from livekit import rtc
from .. import llm, stt, utils, vad
from ..debug import tracing
from ..log import logger
from ..utils import aio
from . import io
from .agent import ModelSettings
@dataclass
class _EndOfTurnInfo:
new_transcript: str
transcription_delay: float
end_of_utterance_delay: float
class _TurnDetector(Protocol):
# TODO: Move those two functions to EOU ctor (capabilities dataclass)
def unlikely_threshold(self, language: str | None) -> float | None: ...
def supports_language(self, language: str | None) -> bool: ...
async def predict_end_of_turn(self, chat_ctx: llm.ChatContext) -> float: ...
class RecognitionHooks(Protocol):
def on_start_of_speech(self, ev: vad.VADEvent) -> None: ...
def on_vad_inference_done(self, ev: vad.VADEvent) -> None: ...
def on_end_of_speech(self, ev: vad.VADEvent) -> None: ...
def on_interim_transcript(self, ev: stt.SpeechEvent) -> None: ...
def on_final_transcript(self, ev: stt.SpeechEvent) -> None: ...
async def on_end_of_turn(self, info: _EndOfTurnInfo) -> None: ...
def retrieve_chat_ctx(self) -> llm.ChatContext: ...
class AudioRecognition:
def __init__(
self,
*,
hooks: RecognitionHooks,
stt: io.STTNode | None,
vad: vad.VAD | None,
turn_detector: _TurnDetector | None,
min_endpointing_delay: float,
max_endpointing_delay: float,
manual_turn_detection: bool,
) -> None:
self._hooks = hooks
self._audio_input_atask: asyncio.Task[None] | None = None
self._stt_atask: asyncio.Task[None] | None = None
self._vad_atask: asyncio.Task[None] | None = None
self._end_of_turn_task: asyncio.Task[None] | None = None
self._min_endpointing_delay = min_endpointing_delay
self._max_endpointing_delay = max_endpointing_delay
self._turn_detector = turn_detector
self._stt = stt
self._vad = vad
self._manual_turn_detection = manual_turn_detection
self._speaking = False
self._last_speaking_time: float = 0
self._last_final_transcript_time: float = 0
self._audio_transcript = ""
self._audio_interim_transcript = ""
self._last_language: str | None = None
self._vad_graph = tracing.Tracing.add_graph(
title="vad",
x_label="time",
y_label="speech_probability",
x_type="time",
y_range=(0, 1),
max_data_points=int(30 * 30),
)
self._stt_ch: aio.Chan[rtc.AudioFrame] | None = None
self._vad_ch: aio.Chan[rtc.AudioFrame] | None = None
self._tasks: set[asyncio.Task] = set()
def start(self) -> None:
self.update_stt(self._stt)
self.update_vad(self._vad)
def stop(self) -> None:
self.update_stt(None)
self.update_vad(None)
def push_audio(self, frame: rtc.AudioFrame) -> None:
if self._stt_ch is not None:
self._stt_ch.send_nowait(frame)
if self._vad_ch is not None:
self._vad_ch.send_nowait(frame)
async def aclose(self) -> None:
await aio.cancel_and_wait(*self._tasks)
if self._stt_atask is not None:
await aio.cancel_and_wait(self._stt_atask)
if self._vad_atask is not None:
await aio.cancel_and_wait(self._vad_atask)
if self._end_of_turn_task is not None:
await aio.cancel_and_wait(self._end_of_turn_task)
def update_stt(self, stt: io.STTNode | None) -> None:
self._stt = stt
if stt:
self._stt_ch = aio.Chan[rtc.AudioFrame]()
self._stt_atask = asyncio.create_task(
self._stt_task(stt, self._stt_ch, self._stt_atask)
)
elif self._stt_atask is not None:
task = asyncio.create_task(aio.cancel_and_wait(self._stt_atask))
task.add_done_callback(lambda _: self._tasks.discard(task))
self._tasks.add(task)
self._stt_atask = None
self._stt_ch = None
def update_vad(self, vad: vad.VAD | None) -> None:
self._vad = vad
if vad:
self._vad_ch = aio.Chan[rtc.AudioFrame]()
self._vad_atask = asyncio.create_task(
self._vad_task(vad, self._vad_ch, self._vad_atask)
)
elif self._vad_atask is not None:
task = asyncio.create_task(aio.cancel_and_wait(self._vad_atask))
task.add_done_callback(lambda _: self._tasks.discard(task))
self._tasks.add(task)
self._vad_atask = None
self._vad_ch = None
def clear_user_turn(self) -> None:
self._audio_transcript = ""
self._audio_interim_transcript = ""
# reset stt to clear the buffer from previous user turn
stt = self._stt
self.update_stt(None)
self.update_stt(stt)
def commit_user_turn(self) -> None:
if self._audio_interim_transcript:
# append interim transcript in case the final transcript is not ready
self._audio_transcript = (
f"{self._audio_transcript} {self._audio_interim_transcript}".strip()
)
self._audio_interim_transcript = ""
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
async def _on_stt_event(self, ev: stt.SpeechEvent) -> None:
if ev.type == stt.SpeechEventType.FINAL_TRANSCRIPT:
self._hooks.on_final_transcript(ev)
transcript = ev.alternatives[0].text
self._last_language = ev.alternatives[0].language
if not transcript:
return
logger.debug(
"received user transcript",
extra={"user_transcript": transcript, "language": self._last_language},
)
tracing.Tracing.log_event(
"user transcript",
{
"transcript": transcript,
"buffered_transcript": self._audio_transcript,
},
)
self._last_final_transcript_time = time.time()
self._audio_transcript += f" {transcript}"
self._audio_transcript = self._audio_transcript.lstrip()
self._audio_interim_transcript = ""
if not self._speaking:
if not self._vad:
# vad disabled, use stt timestamp
# TODO: this would screw up transcription latency metrics
# but we'll live with it for now.
# the correct way is to ensure STT fires SpeechEventType.END_OF_SPEECH
# and using that timestamp for _last_speaking_time
self._last_speaking_time = time.time()
if not self._manual_turn_detection:
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
elif ev.type == stt.SpeechEventType.INTERIM_TRANSCRIPT:
self._hooks.on_interim_transcript(ev)
self._audio_interim_transcript = ev.alternatives[0].text
async def _on_vad_event(self, ev: vad.VADEvent) -> None:
if ev.type == vad.VADEventType.START_OF_SPEECH:
self._hooks.on_start_of_speech(ev)
self._speaking = True
if self._end_of_turn_task is not None:
self._end_of_turn_task.cancel()
elif ev.type == vad.VADEventType.INFERENCE_DONE:
self._vad_graph.plot(ev.timestamp, ev.probability)
self._hooks.on_vad_inference_done(ev)
elif ev.type == vad.VADEventType.END_OF_SPEECH:
self._hooks.on_end_of_speech(ev)
self._speaking = False
# when VAD fires END_OF_SPEECH, it already waited for the silence_duration
self._last_speaking_time = time.time() - ev.silence_duration
if not self._manual_turn_detection:
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
def _run_eou_detection(self, chat_ctx: llm.ChatContext) -> None:
if self._stt and not self._audio_transcript and not self._manual_turn_detection:
# stt enabled but no transcript yet
return
chat_ctx = chat_ctx.copy()
chat_ctx.add_message(role="user", content=self._audio_transcript)
turn_detector = (
self._turn_detector
if self._audio_transcript and not self._manual_turn_detection
else None # disable EOU model if manual turn detection enabled
)
@utils.log_exceptions(logger=logger)
async def _bounce_eou_task(last_speaking_time: float) -> None:
endpointing_delay = self._min_endpointing_delay
if turn_detector is not None:
if not turn_detector.supports_language(self._last_language):
logger.debug("Turn detector does not support language %s", self._last_language)
else:
end_of_turn_probability = await turn_detector.predict_end_of_turn(chat_ctx)
tracing.Tracing.log_event(
"end of user turn probability",
{"probability": end_of_turn_probability},
)
unlikely_threshold = turn_detector.unlikely_threshold(self._last_language)
if (
unlikely_threshold is not None
and end_of_turn_probability < unlikely_threshold
):
endpointing_delay = self._max_endpointing_delay
extra_sleep = last_speaking_time + endpointing_delay - time.time()
await asyncio.sleep(max(extra_sleep, 0))
tracing.Tracing.log_event("end of user turn", {"transcript": self._audio_transcript})
await self._hooks.on_end_of_turn(
_EndOfTurnInfo(
new_transcript=self._audio_transcript,
transcription_delay=max(
self._last_final_transcript_time - last_speaking_time, 0
),
end_of_utterance_delay=time.time() - last_speaking_time,
)
)
self._audio_transcript = ""
if self._end_of_turn_task is not None:
# TODO(theomonnom): disallow cancel if the extra sleep is done
self._end_of_turn_task.cancel()
# copy the last_speaking_time before awaiting (the value can change)
self._end_of_turn_task = asyncio.create_task(_bounce_eou_task(self._last_speaking_time))
@utils.log_exceptions(logger=logger)
async def _stt_task(
self,
stt_node: io.STTNode,
audio_input: io.AudioInput,
task: asyncio.Task[None] | None,
) -> None:
if task is not None:
await aio.cancel_and_wait(task)
node = stt_node(audio_input, ModelSettings())
if asyncio.iscoroutine(node):
node = await node
if node is None:
return
if isinstance(node, AsyncIterable):
async for ev in node:
assert isinstance(ev, stt.SpeechEvent), "STT node must yield SpeechEvent"
await self._on_stt_event(ev)
@utils.log_exceptions(logger=logger)
async def _vad_task(
self, vad: vad.VAD, audio_input: io.AudioInput, task: asyncio.Task[None] | None
) -> None:
if task is not None:
await aio.cancel_and_wait(task)
stream = vad.stream()
@utils.log_exceptions(logger=logger)
async def _forward() -> None:
async for frame in audio_input:
stream.push_frame(frame)
forward_task = asyncio.create_task(_forward())
try:
async for ev in stream:
await self._on_vad_event(ev)
finally:
await aio.cancel_and_wait(forward_task)
await stream.aclose()
from ._datastream_io import DataStreamAudioOutput, DataStreamAudioReceiver
from ._queue_io import QueueAudioOutput
from ._runner import (
AudioReceiver,
AudioSegmentEnd,
AvatarOptions,
AvatarRunner,
VideoGenerator,
)
__all__ = [
"AvatarRunner",
"AvatarOptions",
"VideoGenerator",
"AudioReceiver",
"AudioSegmentEnd",
"QueueAudioOutput",
"DataStreamAudioReceiver",
"DataStreamAudioOutput",
]
from __future__ import annotations
import asyncio
import ctypes
import json
import logging
from collections.abc import AsyncGenerator, AsyncIterator
from dataclasses import asdict
from livekit import rtc
from ... import utils
from ..io import AudioOutput, PlaybackFinishedEvent
from ._types import AudioReceiver, AudioSegmentEnd
logger = logging.getLogger(__name__)
RPC_CLEAR_BUFFER = "lk.clear_buffer"
RPC_PLAYBACK_FINISHED = "lk.playback_finished"
AUDIO_STREAM_TOPIC = "lk.audio_stream"
class DataStreamAudioOutput(AudioOutput):
"""
AudioOutput implementation that streams audio to a remote avatar worker using LiveKit DataStream.
""" # noqa: E501
def __init__(
self, room: rtc.Room, *, destination_identity: str, sample_rate: int | None = None
):
super().__init__(next_in_chain=None, sample_rate=sample_rate)
self._room = room
self._destination_identity = destination_identity
self._stream_writer: rtc.ByteStreamWriter | None = None
self._pushed_duration: float = 0.0
self._tasks: set[asyncio.Task] = set()
# playback finished handler
def _handle_playback_finished(data: rtc.RpcInvocationData) -> str:
if data.caller_identity != self._destination_identity:
logger.warning(
"playback finished event received from unexpected participant",
extra={
"caller_identity": data.caller_identity,
"expected_identity": self._destination_identity,
},
)
return "reject"
event = PlaybackFinishedEvent(**json.loads(data.payload))
self.on_playback_finished(
playback_position=event.playback_position,
interrupted=event.interrupted,
)
return "ok"
self._room.local_participant.register_rpc_method(
RPC_PLAYBACK_FINISHED, _handle_playback_finished
)
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
"""Capture and stream audio frame to remote worker"""
await super().capture_frame(frame)
if not self._stream_writer:
self._stream_writer = await self._room.local_participant.stream_bytes(
name=utils.shortuuid("AUDIO_"),
topic=AUDIO_STREAM_TOPIC,
destination_identities=[self._destination_identity],
attributes={
"sample_rate": str(frame.sample_rate),
"num_channels": str(frame.num_channels),
},
)
self._pushed_duration = 0.0
await self._stream_writer.write(bytes(frame.data))
self._pushed_duration += frame.duration
def flush(self) -> None:
"""Mark end of current audio segment"""
super().flush()
if self._stream_writer is None:
return
# close the stream marking the end of the segment
task = asyncio.create_task(self._stream_writer.aclose())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self._stream_writer = None
def clear_buffer(self) -> None:
task = asyncio.create_task(
self._room.local_participant.perform_rpc(
destination_identity=self._destination_identity,
method=RPC_CLEAR_BUFFER,
payload="",
)
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
class DataStreamAudioReceiver(AudioReceiver):
"""
Audio receiver that receives streamed audio from a sender participant using LiveKit DataStream.
If the sender_identity is provided, subscribe to the specified participant. If not provided,
subscribe to the first agent participant in the room.
"""
def __init__(self, room: rtc.Room, *, sender_identity: str | None = None):
super().__init__()
self._room = room
self._sender_identity = sender_identity
self._remote_participant: rtc.RemoteParticipant | None = None
self._stream_readers: list[rtc.ByteStreamReader] = []
self._stream_reader_changed: asyncio.Event = asyncio.Event()
self._current_reader: rtc.ByteStreamReader | None = None
self._current_reader_cleared: bool = False
async def start(self) -> None:
# wait for the target participant or first agent participant to join
self._remote_participant = await utils.wait_for_participant(
room=self._room,
identity=self._sender_identity,
kind=rtc.ParticipantKind.PARTICIPANT_KIND_AGENT if not self._sender_identity else None,
)
def _handle_clear_buffer(data: rtc.RpcInvocationData) -> str:
assert self._remote_participant is not None
if data.caller_identity != self._remote_participant.identity:
logger.warning(
"clear buffer event received from unexpected participant",
extra={
"caller_identity": data.caller_identity,
"expected_identity": self._remote_participant.identity,
},
)
return "reject"
if self._current_reader:
self._current_reader_cleared = True
self.emit("clear_buffer")
return "ok"
self._room.local_participant.register_rpc_method(RPC_CLEAR_BUFFER, _handle_clear_buffer)
def _handle_stream_received(
reader: rtc.ByteStreamReader, remote_participant_id: str
) -> None:
if remote_participant_id != self._remote_participant.identity:
return
self._stream_readers.append(reader)
self._stream_reader_changed.set()
self._room.register_byte_stream_handler(AUDIO_STREAM_TOPIC, _handle_stream_received)
async def notify_playback_finished(self, playback_position: int, interrupted: bool) -> None:
"""Notify the sender that playback has finished"""
assert self._remote_participant is not None
event = PlaybackFinishedEvent(playback_position=playback_position, interrupted=interrupted)
try:
logger.debug(
f"notifying playback finished: {event.playback_position:.3f}s, "
f"interrupted: {event.interrupted}"
)
await self._room.local_participant.perform_rpc(
destination_identity=self._remote_participant.identity,
method=RPC_PLAYBACK_FINISHED,
payload=json.dumps(asdict(event)),
)
except Exception as e:
logger.exception(f"error notifying playback finished: {e}")
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame | AudioSegmentEnd]:
return self._stream_impl()
@utils.log_exceptions(logger=logger)
async def _stream_impl(
self,
) -> AsyncGenerator[rtc.AudioFrame | AudioSegmentEnd, None]:
while True:
await self._stream_reader_changed.wait()
while self._stream_readers:
self._current_reader = self._stream_readers.pop(0)
sample_rate = int(self._current_reader.info.attributes["sample_rate"])
num_channels = int(self._current_reader.info.attributes["num_channels"])
async for data in self._current_reader:
if self._current_reader_cleared:
# ignore the rest data of the current reader if clear_buffer was called
continue
samples_per_channel = len(data) // num_channels // ctypes.sizeof(ctypes.c_int16)
frame = rtc.AudioFrame(
data=data,
sample_rate=sample_rate,
num_channels=num_channels,
samples_per_channel=samples_per_channel,
)
yield frame
self._current_reader = None
self._current_reader_cleared = False
yield AudioSegmentEnd()
self._stream_reader_changed.clear()
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Literal, Union
from livekit import rtc
from ... import utils
from ..io import AudioOutput
from ._types import AudioReceiver, AudioSegmentEnd
logger = logging.getLogger(__name__)
class QueueAudioOutput(
AudioOutput,
AudioReceiver,
rtc.EventEmitter[Literal["playback_finished", "clear_buffer"]],
):
"""
AudioOutput implementation that sends audio frames through a queue.
"""
def __init__(self, *, sample_rate: int | None = None):
super().__init__(next_in_chain=None, sample_rate=sample_rate)
self._data_ch = utils.aio.Chan[Union[rtc.AudioFrame, AudioSegmentEnd]]()
self._capturing = False
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
"""Capture and queue audio frame"""
await super().capture_frame(frame)
if not self._capturing:
self._capturing = True
await self._data_ch.send(frame)
def flush(self) -> None:
"""Mark end of current audio segment"""
super().flush()
if not self._capturing:
return
self._capturing = False
self._data_ch.send_nowait(AudioSegmentEnd())
# as AudioReceiver for AvatarRunner
def clear_buffer(self) -> None:
"""Clear the audio buffer"""
while True:
try:
self._data_ch.recv_nowait()
except utils.aio.channel.ChanEmpty:
break
self.emit("clear_buffer")
def notify_playback_finished(self, playback_position: float, interrupted: bool) -> None:
self.on_playback_finished(playback_position=playback_position, interrupted=interrupted)
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame | AudioSegmentEnd]:
return self._data_ch
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from livekit import rtc
from livekit.agents import utils
from ._types import AudioReceiver, AudioSegmentEnd, VideoGenerator
logger = logging.getLogger(__name__)
@dataclass
class AvatarOptions:
video_width: int
video_height: int
video_fps: float
audio_sample_rate: int
audio_channels: int
class AvatarRunner:
"""Worker that generates synchronized avatar video based on received audio"""
def __init__(
self,
room: rtc.Room,
*,
audio_recv: AudioReceiver,
video_gen: VideoGenerator,
options: AvatarOptions,
_queue_size_ms: int = 100,
) -> None:
self._room = room
self._video_gen = video_gen
self._options = options
self._queue_size_ms = _queue_size_ms
self._audio_recv = audio_recv
self._playback_position = 0.0
self._audio_playing = False
self._tasks: set[asyncio.Task] = set()
# Audio/video sources
self._audio_source = rtc.AudioSource(
sample_rate=options.audio_sample_rate,
num_channels=options.audio_channels,
queue_size_ms=self._queue_size_ms,
)
self._video_source = rtc.VideoSource(width=options.video_width, height=options.video_height)
# AV synchronizer
self._av_sync = rtc.AVSynchronizer(
audio_source=self._audio_source,
video_source=self._video_source,
video_fps=options.video_fps,
video_queue_size_ms=self._queue_size_ms,
)
self._publish_video_atask: asyncio.Task[None] | None = None
@property
def av_sync(self) -> rtc.AVSynchronizer:
return self._av_sync
async def start(self) -> None:
"""Start the worker"""
# Start audio receiver
await self._audio_recv.start()
def _on_clear_buffer():
task = asyncio.create_task(self._handle_clear_buffer())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self._audio_recv.on("clear_buffer", _on_clear_buffer)
# Publish tracks
audio_track = rtc.LocalAudioTrack.create_audio_track("avatar_audio", self._audio_source)
video_track = rtc.LocalVideoTrack.create_video_track("avatar_video", self._video_source)
audio_options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
video_options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA)
self._avatar_audio_publication = await self._room.local_participant.publish_track(
audio_track, audio_options
)
self._avatar_video_publication = await self._room.local_participant.publish_track(
video_track, video_options
)
# Start processing
self._read_audio_atask = asyncio.create_task(self._read_audio())
self._publish_video_atask = asyncio.create_task(self._publish_video())
async def wait_for_subscription(self) -> None:
await asyncio.gather(
self._avatar_audio_publication.wait_for_subscription(),
self._avatar_video_publication.wait_for_subscription(),
)
async def _read_audio(self) -> None:
async for frame in self._audio_recv:
if not self._audio_playing and isinstance(frame, rtc.AudioFrame):
self._audio_playing = True
await self._video_gen.push_audio(frame)
@utils.log_exceptions(logger=logger)
async def _publish_video(self) -> None:
"""Process audio frames and generate synchronized video"""
async for frame in self._video_gen:
if isinstance(frame, AudioSegmentEnd):
# notify the agent that the audio has finished playing
if self._audio_playing:
notify_task = self._audio_recv.notify_playback_finished(
playback_position=self._playback_position,
interrupted=False,
)
if asyncio.iscoroutine(notify_task):
await notify_task
self._playback_position = 0.0
self._audio_playing = False
continue
await self._av_sync.push(frame)
if isinstance(frame, rtc.AudioFrame):
self._playback_position += frame.duration
async def _handle_clear_buffer(self) -> None:
"""Handle clearing the buffer and notify about interrupted playback"""
tasks = []
clear_result = self._video_gen.clear_buffer()
if asyncio.iscoroutine(clear_result):
tasks.append(clear_result)
if self._audio_playing:
notify_task = self._audio_recv.notify_playback_finished(
playback_position=self._playback_position,
interrupted=True,
)
if asyncio.iscoroutine(notify_task):
tasks.append(notify_task)
self._playback_position = 0.0
self._audio_playing = False
await asyncio.gather(*tasks)
async def aclose(self) -> None:
if self._publish_video_atask:
await utils.aio.cancel_and_wait(self._publish_video_atask)
if self._read_audio_atask:
await utils.aio.cancel_and_wait(self._read_audio_atask)
await utils.aio.cancel_and_wait(*self._tasks)
await self._av_sync.aclose()
await self._audio_source.aclose()
await self._video_source.aclose()
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Coroutine
from typing import Literal
from livekit import rtc
class AudioSegmentEnd:
pass
class AudioReceiver(ABC, rtc.EventEmitter[Literal["clear_buffer"]]):
async def start(self) -> None:
pass
@abstractmethod
def notify_playback_finished(
self, playback_position: float, interrupted: bool
) -> None | Coroutine[None, None, None]:
"""Notify the sender that playback has finished"""
@abstractmethod
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame | AudioSegmentEnd]:
"""Continuously stream out audio frames or AudioSegmentEnd when the stream ends"""
class VideoGenerator(ABC):
@abstractmethod
async def push_audio(self, frame: rtc.AudioFrame | AudioSegmentEnd) -> None:
"""Push an audio frame to the video generator"""
@abstractmethod
def clear_buffer(self) -> None | Coroutine[None, None, None]:
"""Clear the audio buffer, stopping audio playback immediately"""
@abstractmethod
def __aiter__(
self,
) -> AsyncIterator[rtc.VideoFrame | rtc.AudioFrame | AudioSegmentEnd]:
"""Continuously stream out video and audio frames, or AudioSegmentEnd when the audio segment ends""" # noqa: E501
from __future__ import annotations
import asyncio
import atexit
import contextlib
import enum
import random
from collections.abc import AsyncGenerator, AsyncIterator
from importlib.resources import as_file, files
from typing import NamedTuple, Union, cast
import numpy as np
from livekit import rtc
from ..cli import cli
from ..log import logger
from ..types import NOT_GIVEN, NotGivenOr
from ..utils import is_given, log_exceptions
from ..utils.aio import cancel_and_wait
from ..utils.audio import audio_frames_from_file
from .agent_session import AgentSession
from .events import AgentStateChangedEvent
_resource_stack = contextlib.ExitStack()
atexit.register(_resource_stack.close)
class BuiltinAudioClip(enum.Enum):
OFFICE_AMBIENCE = "office-ambience.ogg"
KEYBOARD_TYPING = "keyboard-typing.ogg"
KEYBOARD_TYPING2 = "keyboard-typing2.ogg"
def path(self) -> str:
file_path = files("livekit.agents.resources") / self.value
return str(_resource_stack.enter_context(as_file(file_path)))
AudioSource = Union[AsyncIterator[rtc.AudioFrame], str, BuiltinAudioClip]
class AudioConfig(NamedTuple):
"""
Definition for the audio to be played in the background
Args:
volume: The volume of the audio (0.0-1.0)
probability: The probability of the audio being played, when multiple
AudioConfigs are provided (0.0-1.0)
"""
source: AudioSource
volume: float = 1.0
probability: float = 1.0
# The queue size is set to 400ms, which determines how much audio Rust will buffer.
# We intentionally keep this small within BackgroundAudio because calling
# AudioSource.clear_queue() would abruptly cut off ambient sounds.
# Instead, we remove the sound from the mixer, and it will get removed 400ms later.
_AUDIO_SOURCE_BUFFER_MS = 400
class BackgroundAudioPlayer:
def __init__(
self,
*,
ambient_sound: NotGivenOr[AudioSource | AudioConfig | list[AudioConfig] | None] = NOT_GIVEN,
thinking_sound: NotGivenOr[
AudioSource | AudioConfig | list[AudioConfig] | None
] = NOT_GIVEN,
) -> None:
"""
Initializes the BackgroundAudio component with optional ambient and thinking sounds.
This component creates and publishes a continuous audio track to a LiveKit room while managing
the playback of ambient and agent “thinking” sounds. It supports three types of audio sources:
- A BuiltinAudioClip enum value, which will use a pre-defined sound from the package resources
- A file path (string) pointing to an audio file, which can be looped.
- An AsyncIterator that yields rtc.AudioFrame
When a list (or AudioConfig) is supplied, the component considers each sound’s volume and probability:
- The probability value determines the chance that a particular sound is selected for playback.
- A total probability below 1.0 means there is a chance no sound will be selected (resulting in silence).
Args:
ambient_sound (NotGivenOr[Union[AudioSource, AudioConfig, List[AudioConfig], None]], optional):
The ambient sound to be played continuously. For file paths, the sound will be looped.
For AsyncIterator sources, ensure the iterator is infinite or looped.
thinking_sound (NotGivenOr[Union[AudioSource, AudioConfig, List[AudioConfig], None]], optional):
The sound to be played when the associated agent enters a “thinking” state. This can be a single
sound source or a list of AudioConfig objects (with volume and probability settings).
""" # noqa: E501
self._ambient_sound = ambient_sound if is_given(ambient_sound) else None
self._thinking_sound = thinking_sound if is_given(thinking_sound) else None
self._audio_source = rtc.AudioSource(48000, 1, queue_size_ms=_AUDIO_SOURCE_BUFFER_MS)
self._audio_mixer = rtc.AudioMixer(48000, 1, blocksize=4800, capacity=1)
self._publication: rtc.LocalTrackPublication | None = None
self._lock = asyncio.Lock()
self._republish_task: asyncio.Task | None = None # republish the task on reconnect
self._mixer_atask: asyncio.Task | None = None
self._play_tasks: list[asyncio.Task] = []
self._ambient_handle: PlayHandle | None = None
self._thinking_handle: PlayHandle | None = None
def _select_sound_from_list(self, sounds: list[AudioConfig]) -> AudioConfig | None:
"""
Selects a sound from a list of BackgroundSound based on their probabilities.
Returns None if no sound is selected (when sum of probabilities < 1.0).
"""
total_probability = sum(sound.probability for sound in sounds)
if total_probability <= 0:
return None
if total_probability < 1.0 and random.random() > total_probability:
return None
normalize_factor = 1.0 if total_probability <= 1.0 else total_probability
r = random.random() * min(total_probability, 1.0)
cumulative = 0.0
for sound in sounds:
if sound.probability <= 0:
continue
norm_prob = sound.probability / normalize_factor
cumulative += norm_prob
if r <= cumulative:
return sound
return sounds[-1]
def _normalize_sound_source(
self, source: AudioSource | AudioConfig | list[AudioConfig] | None
) -> tuple[AudioSource, float] | None:
if source is None:
return None
if isinstance(source, BuiltinAudioClip):
return self._normalize_builtin_audio(source), 1.0
elif isinstance(source, list):
selected = self._select_sound_from_list(cast(list[AudioConfig], source))
if selected is None:
return None
return selected.source, selected.volume
elif isinstance(source, AudioConfig):
return self._normalize_builtin_audio(source.source), source.volume
return source, 1.0
def _normalize_builtin_audio(self, source: AudioSource) -> AsyncIterator[rtc.AudioFrame] | str:
if isinstance(source, BuiltinAudioClip):
return source.path()
else:
return source
def play(
self,
audio: AudioSource | AudioConfig | list[AudioConfig],
*,
loop: bool = False,
) -> PlayHandle:
"""
Plays an audio once or in a loop.
Args:
audio (Union[AudioSource, AudioConfig, List[AudioConfig]]):
The audio to play. Can be:
- A string pointing to a file path
- An AsyncIterator that yields `rtc.AudioFrame`
- An AudioConfig object with volume and probability
- A list of AudioConfig objects, where one will be selected based on probability
If a string is provided and `loop` is True, the sound will be looped.
If an AsyncIterator is provided, it is played until exhaustion (and cannot be looped
automatically).
loop (bool, optional):
Whether to loop the audio. Only applicable if `audio` is a string or contains strings.
Defaults to False.
Returns:
PlayHandle: An object representing the playback handle. This can be
awaited or stopped manually.
""" # noqa: E501
if not self._mixer_atask:
raise RuntimeError("BackgroundAudio is not started")
normalized = self._normalize_sound_source(audio)
if normalized is None:
play_handle = PlayHandle()
play_handle._mark_playout_done()
return play_handle
sound_source, volume = normalized
if loop and isinstance(sound_source, AsyncIterator):
raise ValueError(
"Looping sound via AsyncIterator is not supported. Use a string file path or your own 'infinite' AsyncIterator with loop=False" # noqa: E501
)
play_handle = PlayHandle()
task = asyncio.create_task(self._play_task(play_handle, sound_source, volume, loop))
task.add_done_callback(lambda _: self._play_tasks.remove(task))
task.add_done_callback(lambda _: play_handle._mark_playout_done())
self._play_tasks.append(task)
return play_handle
async def start(
self,
*,
room: rtc.Room,
agent_session: NotGivenOr[AgentSession] = NOT_GIVEN,
track_publish_options: NotGivenOr[rtc.TrackPublishOptions] = NOT_GIVEN,
) -> None:
"""
Starts the background audio system, publishing the audio track
and beginning playback of any configured ambient sound.
If `ambient_sound` is provided (and contains file paths), they will loop
automatically. If `ambient_sound` contains AsyncIterators, they are assumed
to be already infinite or looped.
Args:
room (rtc.Room):
The LiveKit Room object where the audio track will be published.
agent_session (NotGivenOr[AgentSession], optional):
The session object used to track the agent's state (e.g., "thinking").
Required if `thinking_sound` is provided.
track_publish_options (NotGivenOr[rtc.TrackPublishOptions], optional):
Options used when publishing the audio track. If not given, defaults will
be used.
"""
async with self._lock:
self._room = room
self._agent_session = agent_session or None
self._track_publish_options = track_publish_options or None
if cli.CLI_ARGUMENTS is not None and cli.CLI_ARGUMENTS.console:
logger.warning(
"Background audio is not supported in console mode. Audio will not be played."
)
await self._publish_track()
self._mixer_atask = asyncio.create_task(self._run_mixer_task())
self._room.on("reconnected", self._on_reconnected)
if self._agent_session:
self._agent_session.on("agent_state_changed", self._agent_state_changed)
if self._ambient_sound:
normalized = self._normalize_sound_source(self._ambient_sound)
if normalized:
sound_source, volume = normalized
selected_sound = AudioConfig(sound_source, volume)
if isinstance(sound_source, str):
self._ambient_handle = self.play(selected_sound, loop=True)
else:
self._ambient_handle = self.play(selected_sound)
async def aclose(self) -> None:
"""
Gracefully closes the background audio system, canceling all ongoing
playback tasks and unpublishing the audio track.
"""
async with self._lock:
if not self._mixer_atask:
return # not started
await cancel_and_wait(*self._play_tasks)
if self._republish_task:
await cancel_and_wait(self._republish_task)
await cancel_and_wait(self._mixer_atask)
await self._audio_source.aclose()
await self._audio_mixer.aclose()
if self._agent_session:
self._agent_session.off("agent_state_changed", self._agent_state_changed)
self._room.off("reconnected", self._on_reconnected)
with contextlib.suppress(Exception):
if self._publication is not None:
await self._room.local_participant.unpublish_track(self._publication.sid)
def _on_reconnected(self) -> None:
if self._republish_task:
self._republish_task.cancel()
self._publication = None
self._republish_task = asyncio.create_task(self._republish_track_task())
def _agent_state_changed(self, ev: AgentStateChangedEvent) -> None:
if not self._thinking_sound:
return
if ev.new_state == "thinking":
if self._thinking_handle and not self._thinking_handle.done():
return
self._thinking_handle = self.play(self._thinking_sound)
elif self._thinking_handle:
self._thinking_handle.stop()
@log_exceptions(logger=logger)
async def _play_task(
self, play_handle: PlayHandle, sound: AudioSource, volume: float, loop: bool
) -> None:
if isinstance(sound, BuiltinAudioClip):
sound = sound.path()
if isinstance(sound, str):
if loop:
sound = _loop_audio_frames(sound)
else:
sound = audio_frames_from_file(sound)
async def _gen_wrapper() -> AsyncGenerator[rtc.AudioFrame, None]:
async for frame in sound:
if volume != 1.0:
data = np.frombuffer(frame.data, dtype=np.int16).astype(np.float32)
data *= 10 ** (np.log10(volume))
np.clip(data, -32768, 32767, out=data)
yield rtc.AudioFrame(
data=data.astype(np.int16).tobytes(),
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
samples_per_channel=frame.samples_per_channel,
)
else:
yield frame
# TODO(theomonnom): the wait_for_playout() may be innaccurate by 400ms
play_handle._mark_playout_done()
gen = _gen_wrapper()
try:
self._audio_mixer.add_stream(gen)
await play_handle.wait_for_playout() # wait for playout or interruption
finally:
if play_handle._stop_fut.done():
self._audio_mixer.remove_stream(gen)
await gen.aclose()
play_handle._mark_playout_done() # the task could be cancelled
@log_exceptions(logger=logger)
async def _run_mixer_task(self) -> None:
async for frame in self._audio_mixer:
await self._audio_source.capture_frame(frame)
async def _publish_track(self) -> None:
if self._publication is not None:
return
track = rtc.LocalAudioTrack.create_audio_track("background_audio", self._audio_source)
self._publication = await self._room.local_participant.publish_track(
track, self._track_publish_options or rtc.TrackPublishOptions()
)
@log_exceptions(logger=logger)
async def _republish_track_task(self) -> None:
# used to republish the track on agent reconnect
async with self._lock:
await self._publish_track()
class PlayHandle:
def __init__(self) -> None:
self._done_fut = asyncio.Future()
self._stop_fut = asyncio.Future()
def done(self) -> bool:
"""
Returns True if the sound has finished playing.
"""
return self._done_fut.done()
def stop(self) -> None:
"""
Stops the sound from playing.
"""
if self.done():
return
with contextlib.suppress(asyncio.InvalidStateError):
self._stop_fut.set_result(None)
self._mark_playout_done() # TODO(theomonnom): move this to _play_task
async def wait_for_playout(self) -> None:
"""
Waits for the sound to finish playing.
"""
await asyncio.shield(self._done_fut)
def __await__(self):
async def _await_impl() -> PlayHandle:
await self.wait_for_playout()
return self
return _await_impl().__await__()
def _mark_playout_done(self) -> None:
with contextlib.suppress(asyncio.InvalidStateError):
self._done_fut.set_result(None)
async def _loop_audio_frames(file_path: str) -> AsyncGenerator[rtc.AudioFrame, None]:
while True:
async for frame in audio_frames_from_file(file_path):
yield frame
from __future__ import annotations
import asyncio
import sys
import threading
import time
from typing import TYPE_CHECKING, Literal
import click
import numpy as np
from livekit import rtc
from ..log import logger
from ..utils import aio, log_exceptions
from . import io
from .agent_session import AgentSession
if TYPE_CHECKING:
import sounddevice as sd
MAX_AUDIO_BAR = 30
INPUT_DB_MIN = -70.0
INPUT_DB_MAX = 0.0
FPS = 16
AEC_RING_BUFFER_SIZE = 24000 * 4
def _esc(*codes: int) -> str:
return "\033[" + ";".join(str(c) for c in codes) + "m"
def _normalize_db(amplitude_db: float, db_min: float, db_max: float) -> float:
amplitude_db = max(db_min, min(amplitude_db, db_max))
return (amplitude_db - db_min) / (db_max - db_min)
class _AudioInput(io.AudioInput):
def __init__(self, cli: ChatCLI) -> None:
self._cli = cli
async def __anext__(self) -> rtc.AudioFrame:
return await self._cli._audio_input_ch.__anext__()
class _TextOutput(io.TextOutput):
def __init__(self, cli: ChatCLI) -> None:
super().__init__(next_in_chain=None)
self._cli = cli
self._capturing = False
async def capture_text(self, text: str) -> None:
if not self._capturing:
self._capturing = True
sys.stdout.write("\r")
sys.stdout.flush()
click.echo(_esc(36), nl=False)
click.echo(text, nl=False)
def flush(self) -> None:
if self._capturing:
click.echo(_esc(0))
self._capturing = False
class _AudioOutput(io.AudioOutput):
def __init__(self, cli: ChatCLI) -> None:
super().__init__(next_in_chain=None, sample_rate=24000)
self._cli = cli
self._capturing = False
self._pushed_duration: float = 0.0
self._capture_start: float = 0.0
self._dispatch_handle: asyncio.TimerHandle | None = None
self._flush_complete = asyncio.Event()
self._flush_complete.set()
self._output_buf = bytearray()
self._output_lock = threading.Lock()
@property
def lock(self) -> threading.Lock:
return self._output_lock
@property
def audio_buffer(self) -> bytearray:
return self._output_buf
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
await super().capture_frame(frame)
await self._flush_complete.wait()
if not self._capturing:
self._capturing = True
self._pushed_duration = 0.0
self._capture_start = time.monotonic()
self._pushed_duration += frame.duration
with self._output_lock:
self._output_buf += frame.data
def flush(self) -> None:
super().flush()
if self._capturing:
self._flush_complete.clear()
self._capturing = False
to_wait = max(0.0, self._pushed_duration - (time.monotonic() - self._capture_start))
self._dispatch_handle = self._cli._loop.call_later(
to_wait, self._dispatch_playback_finished
)
def clear_buffer(self) -> None:
self._capturing = False
with self._output_lock:
self._output_buf.clear()
if self._pushed_duration > 0.0:
if self._dispatch_handle is not None:
self._dispatch_handle.cancel()
self._flush_complete.set()
self._pushed_duration = 0.0
played_duration = min(time.monotonic() - self._capture_start, self._pushed_duration)
self.on_playback_finished(
playback_position=played_duration,
interrupted=played_duration + 1.0 < self._pushed_duration,
)
def _dispatch_playback_finished(self) -> None:
self.on_playback_finished(playback_position=self._pushed_duration, interrupted=False)
self._flush_complete.set()
self._pushed_duration = 0.0
class ChatCLI:
def __init__(
self,
agent: AgentSession,
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._loop = loop or asyncio.get_event_loop()
self._agent = agent
self._done_fut = asyncio.Future()
self._micro_db = INPUT_DB_MIN
self._audio_input_ch = aio.Chan[rtc.AudioFrame](loop=self._loop)
self._input_stream: sd.InputStream | None = None
self._output_stream: sd.OutputStream | None = None
self._cli_mode: Literal["text", "audio"] = "audio"
self._text_input_buf = []
self._text_sink = _TextOutput(self)
self._audio_sink = _AudioOutput(self)
self._apm = rtc.AudioProcessingModule(
echo_cancellation=True,
noise_suppression=True,
high_pass_filter=True,
auto_gain_control=True,
)
self._output_delay = 0.0
self._input_delay = 0.0
self._main_atask: asyncio.Task | None = None
async def start(self) -> None:
self._main_atask = asyncio.create_task(self._main_task(), name="_main_task")
@log_exceptions(logger=logger)
async def _main_task(self) -> None:
stdin_ch = aio.Chan[str](loop=self._loop)
if sys.platform == "win32":
import msvcrt
async def win_reader():
while True:
ch = await self._loop.run_in_executor(None, msvcrt.getch)
if ch == b"\x03": # Ctrl+C on Windows
break
try:
ch = ch.decode("utf-8")
except Exception:
pass
await stdin_ch.send(ch)
self._win_read_task = asyncio.create_task(win_reader())
else:
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
tty.setcbreak(fd)
def on_input():
try:
ch = sys.stdin.read(1)
stdin_ch.send_nowait(ch)
except Exception:
stdin_ch.close()
self._loop.add_reader(fd, on_input)
self._update_microphone(enable=True)
self._update_speaker(enable=True)
try:
input_cli_task = asyncio.create_task(self._input_cli_task(stdin_ch))
input_cli_task.add_done_callback(lambda _: self._done_fut.set_result(None))
render_cli_task = asyncio.create_task(self._render_cli_task())
await self._done_fut
await aio.cancel_and_wait(render_cli_task)
self._update_microphone(enable=False)
self._update_speaker(enable=False)
finally:
if sys.platform != "win32":
import termios
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
self._loop.remove_reader(fd)
def _update_microphone(self, *, enable: bool) -> None:
import sounddevice as sd
input_device, _ = sd.default.device
if input_device is not None and enable:
device_info = sd.query_devices(input_device)
assert isinstance(device_info, dict)
self._input_device_name: str = device_info.get("name", "Microphone")
self._input_stream = sd.InputStream(
callback=self._sd_input_callback,
dtype="int16",
channels=1,
device=input_device,
samplerate=24000,
blocksize=2400,
)
self._input_stream.start()
self._agent.input.audio = _AudioInput(self)
elif self._input_stream is not None:
self._input_stream.stop()
self._input_stream.close()
self._input_stream = None
self._agent.input.audio = None
def _update_speaker(self, *, enable: bool) -> None:
import sounddevice as sd
_, output_device = sd.default.device
if output_device is not None and enable:
self._output_stream = sd.OutputStream(
callback=self._sd_output_callback,
dtype="int16",
channels=1,
device=output_device,
samplerate=24000,
blocksize=2400, # 100ms
)
self._output_stream.start()
self._agent.output.audio = self._audio_sink
elif self._output_stream is not None:
self._output_stream.close()
self._output_stream = None
self._agent.output.audio = None
def _update_text_output(self, *, enable: bool) -> None:
if enable:
self._agent.output.transcription = self._text_sink
else:
self._agent.output.transcription = None
self._text_input_buf = []
def _sd_output_callback(self, outdata: np.ndarray, frames: int, time, *_) -> None:
self._output_delay = time.outputBufferDacTime - time.currentTime
FRAME_SAMPLES = 240
with self._audio_sink.lock:
bytes_needed = frames * 2
if len(self._audio_sink.audio_buffer) < bytes_needed:
available_bytes = len(self._audio_sink.audio_buffer)
outdata[: available_bytes // 2, 0] = np.frombuffer(
self._audio_sink.audio_buffer,
dtype=np.int16,
count=available_bytes // 2,
)
outdata[available_bytes // 2 :, 0] = 0
del self._audio_sink.audio_buffer[:available_bytes]
else:
chunk = self._audio_sink.audio_buffer[:bytes_needed]
outdata[:, 0] = np.frombuffer(chunk, dtype=np.int16, count=frames)
del self._audio_sink.audio_buffer[:bytes_needed]
num_chunks = frames // FRAME_SAMPLES
for i in range(num_chunks):
start = i * FRAME_SAMPLES
end = start + FRAME_SAMPLES
render_chunk = outdata[start:end, 0]
render_frame_for_aec = rtc.AudioFrame(
data=render_chunk.tobytes(),
samples_per_channel=FRAME_SAMPLES,
sample_rate=24000,
num_channels=1,
)
self._apm.process_reverse_stream(render_frame_for_aec)
def _sd_input_callback(self, indata: np.ndarray, frame_count: int, time, *_) -> None:
self._input_delay = time.currentTime - time.inputBufferAdcTime
total_delay = self._output_delay + self._input_delay
self._apm.set_stream_delay_ms(int(total_delay * 1000))
FRAME_SAMPLES = 240 # 10ms at 24000 Hz
num_frames = frame_count // FRAME_SAMPLES
for i in range(num_frames):
start = i * FRAME_SAMPLES
end = start + FRAME_SAMPLES
capture_chunk = indata[start:end]
capture_frame_for_aec = rtc.AudioFrame(
data=capture_chunk.tobytes(),
samples_per_channel=FRAME_SAMPLES,
sample_rate=24000,
num_channels=1,
)
self._apm.process_stream(capture_frame_for_aec)
in_data_aec = np.frombuffer(capture_frame_for_aec.data, dtype=np.int16)
rms = np.sqrt(np.mean(in_data_aec.astype(np.float32) ** 2))
max_int16 = np.iinfo(np.int16).max
self._micro_db = 20.0 * np.log10(rms / max_int16 + 1e-6)
self._loop.call_soon_threadsafe(self._audio_input_ch.send_nowait, capture_frame_for_aec)
@log_exceptions(logger=logger)
async def _input_cli_task(self, in_ch: aio.Chan[str]) -> None:
while True:
char = await in_ch.recv()
if char is None:
break
if char == "\x02": # Ctrl+B
if self._cli_mode == "audio":
self._cli_mode = "text"
self._update_text_output(enable=True)
self._update_microphone(enable=False)
self._update_speaker(enable=False)
click.echo("\nSwitched to Text Input Mode.", nl=False)
else:
self._cli_mode = "audio"
self._update_text_output(enable=False)
self._update_microphone(enable=True)
self._update_speaker(enable=True)
self._text_input_buf = []
click.echo("\nSwitched to Audio Input Mode.", nl=False)
if self._cli_mode == "text": # Read input
if char in ("\r", "\n"):
text = "".join(self._text_input_buf)
if text:
self._text_input_buf = []
self._agent.interrupt()
self._agent.generate_reply(user_input=text)
click.echo("\n", nl=False)
elif char == "\x7f": # Backspace
if self._text_input_buf:
self._text_input_buf.pop()
sys.stdout.write("\b \b")
sys.stdout.flush()
elif char.isprintable():
self._text_input_buf.append(char)
click.echo(char, nl=False)
sys.stdout.flush()
async def _render_cli_task(self) -> None:
next_frame = time.perf_counter()
while True:
next_frame += 1 / FPS
if self._cli_mode == "audio":
self._print_audio_mode()
elif self._cli_mode == "text" and not self._text_sink._capturing:
self._print_text_mode()
await asyncio.sleep(max(0, next_frame - time.perf_counter()))
def _print_audio_mode(self):
amplitude_db = _normalize_db(self._micro_db, db_min=INPUT_DB_MIN, db_max=INPUT_DB_MAX)
nb_bar = round(amplitude_db * MAX_AUDIO_BAR)
color_code = 31 if amplitude_db > 0.75 else 33 if amplitude_db > 0.5 else 32
bar = "#" * nb_bar + "-" * (MAX_AUDIO_BAR - nb_bar)
sys.stdout.write(
f"\r[Audio] {self._input_device_name[-20:]} [{self._micro_db:6.2f} dBFS] {_esc(color_code)}[{bar}]{_esc(0)}" # noqa: E501
)
sys.stdout.flush()
def _print_text_mode(self):
sys.stdout.write("\r")
sys.stdout.flush()
prompt = "Enter your message: "
sys.stdout.write(f"[Text {prompt}{''.join(self._text_input_buf)}")
sys.stdout.flush()
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing_extensions import Self
from ..llm import (
LLM,
ChatMessage,
FunctionCall,
FunctionCallOutput,
LLMError,
RealtimeModel,
RealtimeModelError,
)
from ..metrics import AgentMetrics
from ..stt import STT, STTError
from ..tts import TTS, TTSError
from .speech_handle import SpeechHandle
if TYPE_CHECKING:
from .agent_session import AgentSession
Userdata_T = TypeVar("Userdata_T")
class RunContext(Generic[Userdata_T]):
# private ctor
def __init__(
self,
*,
session: AgentSession,
speech_handle: SpeechHandle,
function_call: FunctionCall,
) -> None:
self._session = session
self._speech_handle = speech_handle
self._function_call = function_call
@property
def session(self) -> AgentSession[Userdata_T]:
return self._session
@property
def speech_handle(self) -> SpeechHandle:
return self._speech_handle
@property
def function_call(self) -> FunctionCall:
return self._function_call
@property
def userdata(self) -> Userdata_T:
return self.session.userdata
EventTypes = Literal[
"user_state_changed",
"agent_state_changed",
"user_input_transcribed",
"conversation_item_added",
"function_tools_executed",
"metrics_collected",
"speech_created",
"error",
"close",
]
UserState = Literal["speaking", "listening", "away"]
AgentState = Literal["initializing", "idle", "listening", "thinking", "speaking"]
class UserStateChangedEvent(BaseModel):
type: Literal["user_state_changed"] = "user_state_changed"
old_state: UserState
new_state: UserState
class AgentStateChangedEvent(BaseModel):
type: Literal["agent_state_changed"] = "agent_state_changed"
old_state: AgentState
new_state: AgentState
class UserInputTranscribedEvent(BaseModel):
type: Literal["user_input_transcribed"] = "user_input_transcribed"
transcript: str
is_final: bool
class MetricsCollectedEvent(BaseModel):
type: Literal["metrics_collected"] = "metrics_collected"
metrics: AgentMetrics
class _TypeDiscriminator(BaseModel):
type: Literal["unknown"] = "unknown" # force user to use the type discriminator
class ConversationItemAddedEvent(BaseModel):
type: Literal["conversation_item_added"] = "conversation_item_added"
item: ChatMessage | _TypeDiscriminator
class FunctionToolsExecutedEvent(BaseModel):
type: Literal["function_tools_executed"] = "function_tools_executed"
function_calls: list[FunctionCall]
function_call_outputs: list[FunctionCallOutput]
def zipped(self) -> list[tuple[FunctionCall, FunctionCallOutput]]:
return list(zip(self.function_calls, self.function_call_outputs))
@model_validator(mode="after")
def verify_lists_length(self) -> Self:
if len(self.function_calls) != len(self.function_call_outputs):
raise ValueError("The number of function_calls and function_call_outputs must match.")
return self
class SpeechCreatedEvent(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["speech_created"] = "speech_created"
user_initiated: bool
"""True if the speech was created using public methods like `say` or `generate_reply`"""
source: Literal["say", "generate_reply", "tool_response"]
"""Source indicating how the speech handle was created"""
speech_handle: SpeechHandle = Field(..., exclude=True)
"""The speech handle that was created"""
class ErrorEvent(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal["error"] = "error"
error: LLMError | STTError | TTSError | RealtimeModelError | Any
source: LLM | STT | TTS | RealtimeModel | Any
class CloseEvent(BaseModel):
type: Literal["close"] = "close"
error: LLMError | STTError | TTSError | RealtimeModelError | None = None
AgentEvent = Annotated[
Union[
UserInputTranscribedEvent,
UserStateChangedEvent,
AgentStateChangedEvent,
MetricsCollectedEvent,
ConversationItemAddedEvent,
FunctionToolsExecutedEvent,
SpeechCreatedEvent,
ErrorEvent,
CloseEvent,
],
Field(discriminator="type"),
]
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from pydantic import ValidationError
from livekit import rtc
from .. import debug, llm, utils
from ..llm import (
ChatChunk,
ChatContext,
StopResponse,
ToolContext,
ToolError,
utils as llm_utils,
)
from ..llm.tool_context import (
is_function_tool,
is_raw_function_tool,
)
from ..log import logger
from ..types import NotGivenOr
from ..utils import aio
from . import io
from .speech_handle import SpeechHandle
if TYPE_CHECKING:
from .agent import Agent, ModelSettings
from .agent_session import AgentSession
@runtime_checkable
class _ACloseable(Protocol):
async def aclose(self): ...
@dataclass
class _LLMGenerationData:
text_ch: aio.Chan[str]
function_ch: aio.Chan[llm.FunctionCall]
generated_text: str = ""
generated_functions: list[llm.FunctionCall] = field(default_factory=list)
id: str = field(default_factory=lambda: utils.shortuuid("item_"))
def perform_llm_inference(
*,
node: io.LLMNode,
chat_ctx: ChatContext,
tool_ctx: ToolContext,
model_settings: ModelSettings,
) -> tuple[asyncio.Task, _LLMGenerationData]:
text_ch = aio.Chan()
function_ch = aio.Chan()
data = _LLMGenerationData(text_ch=text_ch, function_ch=function_ch)
@utils.log_exceptions(logger=logger)
async def _inference_task():
tools = list(tool_ctx.function_tools.values())
llm_node = node(
chat_ctx,
tools,
model_settings,
)
if asyncio.iscoroutine(llm_node):
llm_node = await llm_node
# update the tool context after llm node
tool_ctx.update_tools(tools)
if isinstance(llm_node, str):
data.generated_text = llm_node
text_ch.send_nowait(llm_node)
return True
if isinstance(llm_node, AsyncIterable):
# forward llm stream to output channels
try:
async for chunk in llm_node:
# io.LLMNode can either return a string or a ChatChunk
if isinstance(chunk, str):
data.generated_text += chunk
text_ch.send_nowait(chunk)
elif isinstance(chunk, ChatChunk):
if not chunk.delta:
continue
if chunk.delta.tool_calls:
for tool in chunk.delta.tool_calls:
if tool.type != "function":
continue
fnc_call = llm.FunctionCall(
id=f"{data.id}/fnc_{len(data.generated_functions)}",
call_id=tool.call_id,
name=tool.name,
arguments=tool.arguments,
)
data.generated_functions.append(fnc_call)
function_ch.send_nowait(fnc_call)
if chunk.delta.content:
data.generated_text += chunk.delta.content
text_ch.send_nowait(chunk.delta.content)
else:
logger.warning(
f"LLM node returned an unexpected type: {type(chunk)}",
)
finally:
if isinstance(llm_node, _ACloseable):
await llm_node.aclose()
return True
return False
llm_task = asyncio.create_task(_inference_task())
llm_task.add_done_callback(lambda _: text_ch.close())
llm_task.add_done_callback(lambda _: function_ch.close())
return llm_task, data
@dataclass
class _TTSGenerationData:
audio_ch: aio.Chan[rtc.AudioFrame]
def perform_tts_inference(
*, node: io.TTSNode, input: AsyncIterable[str], model_settings: ModelSettings
) -> tuple[asyncio.Task, _TTSGenerationData]:
audio_ch = aio.Chan[rtc.AudioFrame]()
@utils.log_exceptions(logger=logger)
async def _inference_task():
tts_node = node(input, model_settings)
if asyncio.iscoroutine(tts_node):
tts_node = await tts_node
if isinstance(tts_node, AsyncIterable):
async for audio_frame in tts_node:
audio_ch.send_nowait(audio_frame)
return True
return False
tts_task = asyncio.create_task(_inference_task())
tts_task.add_done_callback(lambda _: audio_ch.close())
return tts_task, _TTSGenerationData(audio_ch=audio_ch)
@dataclass
class _TextOutput:
text: str
first_text_fut: asyncio.Future
def perform_text_forwarding(
*, text_output: io.TextOutput | None, source: AsyncIterable[str]
) -> tuple[asyncio.Task, _TextOutput]:
out = _TextOutput(text="", first_text_fut=asyncio.Future())
task = asyncio.create_task(_text_forwarding_task(text_output, source, out))
return task, out
@utils.log_exceptions(logger=logger)
async def _text_forwarding_task(
text_output: io.TextOutput | None,
source: AsyncIterable[str],
out: _TextOutput,
) -> None:
try:
async for delta in source:
out.text += delta
if text_output is not None:
await text_output.capture_text(delta)
if not out.first_text_fut.done():
out.first_text_fut.set_result(None)
finally:
if isinstance(source, _ACloseable):
await source.aclose()
if text_output is not None:
text_output.flush()
@dataclass
class _AudioOutput:
audio: list[rtc.AudioFrame]
first_frame_fut: asyncio.Future
def perform_audio_forwarding(
*,
audio_output: io.AudioOutput,
tts_output: AsyncIterable[rtc.AudioFrame],
) -> tuple[asyncio.Task, _AudioOutput]:
out = _AudioOutput(audio=[], first_frame_fut=asyncio.Future())
task = asyncio.create_task(_audio_forwarding_task(audio_output, tts_output, out))
return task, out
@utils.log_exceptions(logger=logger)
async def _audio_forwarding_task(
audio_output: io.AudioOutput,
tts_output: AsyncIterable[rtc.AudioFrame],
out: _AudioOutput,
) -> None:
resampler: rtc.AudioResampler | None = None
try:
async for frame in tts_output:
out.audio.append(frame)
if (
not out.first_frame_fut.done()
and audio_output.sample_rate is not None
and frame.sample_rate != audio_output.sample_rate
and resampler is None
):
resampler = rtc.AudioResampler(
input_rate=frame.sample_rate,
output_rate=audio_output.sample_rate,
num_channels=frame.num_channels,
)
if resampler:
for f in resampler.push(frame):
await audio_output.capture_frame(f)
else:
await audio_output.capture_frame(frame)
# set the first frame future if not already set
# (after completing the first frame)
if not out.first_frame_fut.done():
out.first_frame_fut.set_result(None)
finally:
if isinstance(tts_output, _ACloseable):
await tts_output.aclose()
if resampler:
for frame in resampler.flush():
await audio_output.capture_frame(frame)
audio_output.flush()
@dataclass
class _ToolOutput:
output: list[_PythonOutput]
first_tool_fut: asyncio.Future
def perform_tool_executions(
*,
session: AgentSession,
speech_handle: SpeechHandle,
tool_ctx: ToolContext,
tool_choice: NotGivenOr[llm.ToolChoice],
function_stream: AsyncIterable[llm.FunctionCall],
) -> tuple[asyncio.Task, _ToolOutput]:
tool_output = _ToolOutput(output=[], first_tool_fut=asyncio.Future())
task = asyncio.create_task(
_execute_tools_task(
session=session,
speech_handle=speech_handle,
tool_ctx=tool_ctx,
tool_choice=tool_choice,
function_stream=function_stream,
tool_output=tool_output,
),
name="execute_tools_task",
)
return task, tool_output
@utils.log_exceptions(logger=logger)
async def _execute_tools_task(
*,
session: AgentSession,
speech_handle: SpeechHandle,
tool_ctx: ToolContext,
tool_choice: NotGivenOr[llm.ToolChoice],
function_stream: AsyncIterable[llm.FunctionCall],
tool_output: _ToolOutput,
) -> None:
"""execute tools, when cancelled, stop executing new tools but wait for the pending ones"""
from .agent import _authorize_inline_task
from .events import RunContext
tasks: list[asyncio.Task] = []
try:
async for fnc_call in function_stream:
if tool_choice == "none":
logger.error(
"received a tool call with tool_choice set to 'none', ignoring",
extra={
"function": fnc_call.name,
"speech_id": speech_handle.id,
},
)
continue
# TODO(theomonnom): assert other tool_choice values
if (function_tool := tool_ctx.function_tools.get(fnc_call.name)) is None:
logger.warning(
f"unknown AI function `{fnc_call.name}`",
extra={
"function": fnc_call.name,
"speech_id": speech_handle.id,
},
)
continue
if not is_function_tool(function_tool) and not is_raw_function_tool(function_tool):
logger.error(
f"unknown tool type: {type(function_tool)}",
extra={
"function": fnc_call.name,
"speech_id": speech_handle.id,
},
)
continue
try:
json_args = fnc_call.arguments or "{}"
fnc_args, fnc_kwargs = llm_utils.prepare_function_arguments(
fnc=function_tool,
json_arguments=json_args,
call_ctx=RunContext(
session=session, speech_handle=speech_handle, function_call=fnc_call
),
)
except (ValidationError, ValueError):
logger.exception(
f"tried to call AI function `{fnc_call.name}` with invalid arguments",
extra={
"function": fnc_call.name,
"arguments": fnc_call.arguments,
"speech_id": speech_handle.id,
},
)
continue
if not tool_output.first_tool_fut.done():
tool_output.first_tool_fut.set_result(None)
logger.debug(
"executing tool",
extra={
"function": fnc_call.name,
"arguments": fnc_call.arguments,
"speech_id": speech_handle.id,
},
)
py_out = _PythonOutput(fnc_call=fnc_call, output=None, exception=None)
try:
task = asyncio.create_task(
function_tool(*fnc_args, **fnc_kwargs),
name=f"function_tool_{fnc_call.name}",
)
tasks.append(task)
_authorize_inline_task(task, function_call=fnc_call)
except Exception:
# catching exceptions here because even though the function is asynchronous,
# errors such as missing or incompatible arguments can still occur at
# invocation time.
logger.exception(
"exception occurred while executing tool",
extra={
"function": fnc_call.name,
"speech_id": speech_handle.id,
},
)
continue
def _log_exceptions(
task: asyncio.Task,
*,
py_out: _PythonOutput,
fnc_call: llm.FunctionCall,
) -> None:
if task.exception() is not None:
logger.error(
"exception occurred while executing tool",
extra={
"function": fnc_call.name,
"speech_id": speech_handle.id,
},
exc_info=task.exception(),
)
py_out.exception = task.exception()
tool_output.output.append(py_out)
return
py_out.output = task.result()
tool_output.output.append(py_out)
tasks.remove(task)
task.add_done_callback(
lambda task, py_out=py_out, fnc_call=fnc_call: _log_exceptions(
task, py_out=py_out, fnc_call=fnc_call
)
)
await asyncio.shield(asyncio.gather(*tasks, return_exceptions=True))
except asyncio.CancelledError:
if len(tasks) > 0:
names = [task.get_name() for task in tasks]
logger.debug(
"waiting for function call to finish before fully cancelling",
extra={
"functions": names,
"speech_id": speech_handle.id,
},
)
debug.Tracing.log_event(
"waiting for function call to finish before fully cancelling",
{
"functions": names,
"speech_id": speech_handle.id,
},
)
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
if len(tool_output.output) > 0:
logger.debug(
"tools execution completed",
extra={"speech_id": speech_handle.id},
)
debug.Tracing.log_event(
"tools execution completed",
{"speech_id": speech_handle.id},
)
def _is_valid_function_output(value: Any) -> bool:
VALID_TYPES = (str, int, float, bool, complex, type(None))
if isinstance(value, VALID_TYPES):
return True
elif (
isinstance(value, list)
or isinstance(value, set)
or isinstance(value, frozenset)
or isinstance(value, tuple)
):
return all(_is_valid_function_output(item) for item in value)
elif isinstance(value, dict):
return all(
isinstance(key, VALID_TYPES) and _is_valid_function_output(val)
for key, val in value.items()
)
return False
@dataclass
class _SanitizedOutput:
fnc_call: llm.FunctionCall
fnc_call_out: llm.FunctionCallOutput | None
agent_task: Agent | None
reply_required: bool = field(default=True)
@dataclass
class _PythonOutput:
fnc_call: llm.FunctionCall
output: Any
exception: BaseException | None
def sanitize(self) -> _SanitizedOutput:
from .agent import Agent
if isinstance(self.exception, ToolError):
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=llm.FunctionCallOutput(
name=self.fnc_call.name,
call_id=self.fnc_call.call_id,
output=self.exception.message,
is_error=True,
),
agent_task=None,
)
if isinstance(self.exception, StopResponse):
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=None,
agent_task=None,
)
if self.exception is not None:
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=llm.FunctionCallOutput(
name=self.fnc_call.name,
call_id=self.fnc_call.call_id,
output="An internal error occurred", # Don't send the actual error message, as it may contain sensitive information # noqa: E501
is_error=True,
),
agent_task=None,
)
task: Agent | None = None
fnc_out: Any = self.output
if (
isinstance(self.output, list)
or isinstance(self.output, set)
or isinstance(self.output, frozenset)
or isinstance(self.output, tuple)
):
agent_tasks = [item for item in self.output if isinstance(item, Agent)]
other_outputs = [item for item in self.output if not isinstance(item, Agent)]
if len(agent_tasks) > 1:
logger.error(
f"AI function `{self.fnc_call.name}` returned multiple AgentTask instances, ignoring the output", # noqa: E501
extra={
"call_id": self.fnc_call.call_id,
"output": self.output,
},
)
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=None,
agent_task=None,
)
task = next(iter(agent_tasks), None)
# fmt: off
fnc_out = (
other_outputs if task is None
else None if not other_outputs
else other_outputs[0] if len(other_outputs) == 1
else other_outputs
)
# fmt: on
elif isinstance(fnc_out, Agent):
task = fnc_out
fnc_out = None
if not _is_valid_function_output(fnc_out):
logger.error(
f"AI function `{self.fnc_call.name}` returned an invalid output",
extra={
"call_id": self.fnc_call.call_id,
"output": self.output,
},
)
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=None,
agent_task=None,
)
return _SanitizedOutput(
fnc_call=self.fnc_call.model_copy(),
fnc_call_out=(
llm.FunctionCallOutput(
name=self.fnc_call.name,
call_id=self.fnc_call.call_id,
output=str(fnc_out or ""), # take the string representation of the output
is_error=False,
)
),
reply_required=fnc_out is not None, # require a reply if the tool returned an output
agent_task=task,
)
INSTRUCTIONS_MESSAGE_ID = "lk.agent_task.instructions" # value must not change
"""
The ID of the instructions message in the chat context. (only for stateless LLMs)
"""
def update_instructions(chat_ctx: ChatContext, *, instructions: str, add_if_missing: bool) -> None:
"""
Update the instruction message in the chat context or insert a new one if missing.
This function looks for an existing instruction message in the chat context using the identifier
'INSTRUCTIONS_MESSAGE_ID'.
Raises:
ValueError: If an existing instruction message is not of type "message".
"""
idx = chat_ctx.index_by_id(INSTRUCTIONS_MESSAGE_ID)
if idx is not None:
if chat_ctx.items[idx].type == "message":
# create a new instance to avoid mutating the original
chat_ctx.items[idx] = llm.ChatMessage(
id=INSTRUCTIONS_MESSAGE_ID, role="system", content=[instructions]
)
else:
raise ValueError(
"expected the instructions inside the chat_ctx to be of type 'message'"
)
elif add_if_missing:
# insert the instructions at the beginning of the chat context
chat_ctx.items.insert(
0, llm.ChatMessage(id=INSTRUCTIONS_MESSAGE_ID, role="system", content=[instructions])
)
def remove_instructions(chat_ctx: ChatContext) -> None:
# loop in case there are items with the same id (shouldn't happen!)
while True:
if msg := chat_ctx.get_by_id(INSTRUCTIONS_MESSAGE_ID):
chat_ctx.items.remove(msg)
else:
break
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, AsyncIterator, Awaitable
from dataclasses import dataclass
from typing import Callable, Literal, Optional, Union
from livekit import rtc
from .. import llm, stt
from ..log import logger
from ..types import NOT_GIVEN, NotGivenOr
from .agent import ModelSettings
# TODO(theomonnom): can those types be simplified?
STTNode = Callable[
[AsyncIterable[rtc.AudioFrame], ModelSettings],
Union[
Optional[Union[AsyncIterable[stt.SpeechEvent], AsyncIterable[str]]],
Awaitable[Optional[Union[AsyncIterable[stt.SpeechEvent], AsyncIterable[str]]]],
],
]
LLMNode = Callable[
[llm.ChatContext, list[llm.FunctionTool], ModelSettings],
Union[
Optional[Union[AsyncIterable[llm.ChatChunk], AsyncIterable[str], str]],
Awaitable[Optional[Union[AsyncIterable[llm.ChatChunk], AsyncIterable[str], str]]],
],
]
TTSNode = Callable[
[AsyncIterable[str], ModelSettings],
Union[
Optional[AsyncIterable[rtc.AudioFrame]],
Awaitable[Optional[AsyncIterable[rtc.AudioFrame]]],
],
]
class TimedString(str):
start_time: NotGivenOr[float]
end_time: NotGivenOr[float]
def __new__(
cls,
text: str,
start_time: NotGivenOr[float] = NOT_GIVEN,
end_time: NotGivenOr[float] = NOT_GIVEN,
) -> TimedString:
obj = super().__new__(cls, text)
obj.start_time = start_time
obj.end_time = end_time
return obj
class AudioInput:
def __aiter__(self) -> AsyncIterator[rtc.AudioFrame]:
return self
async def __anext__(self) -> rtc.AudioFrame: ...
def on_attached(self) -> None: ...
def on_detached(self) -> None: ...
class VideoInput:
def __aiter__(self) -> AsyncIterator[rtc.VideoFrame]:
return self
async def __anext__(self) -> rtc.VideoFrame: ...
def on_attached(self) -> None: ...
def on_detached(self) -> None: ...
@dataclass
class PlaybackFinishedEvent:
playback_position: float
"""How much of the audio was played back"""
interrupted: bool
"""Interrupted is True if playback was interrupted (clear_buffer() was called)"""
synchronized_transcript: str | None = None
"""Transcript synced with playback; may be partial if the audio was interrupted
When None, the transcript is not synchronized with the playback"""
class AudioOutput(ABC, rtc.EventEmitter[Literal["playback_finished"]]):
def __init__(
self,
*,
next_in_chain: AudioOutput | None = None,
sample_rate: int | None = None,
) -> None:
"""
Args:
sample_rate: The sample rate required by the audio sink, if None, any sample rate is accepted
""" # noqa: E501
super().__init__()
self._next_in_chain = next_in_chain
self._sample_rate = sample_rate
self.__capturing = False
self.__playback_finished_event = asyncio.Event()
self.__playback_segments_count = 0
self.__playback_finished_count = 0
self.__last_playback_ev: PlaybackFinishedEvent = PlaybackFinishedEvent(
playback_position=0, interrupted=False
)
if self._next_in_chain:
self._next_in_chain.on(
"playback_finished",
lambda ev: self.on_playback_finished(
interrupted=ev.interrupted,
playback_position=ev.playback_position,
synchronized_transcript=ev.synchronized_transcript,
),
)
def on_playback_finished(
self,
*,
playback_position: float,
interrupted: bool,
synchronized_transcript: str | None = None,
) -> None:
"""
Developers building audio sinks must call this method when a playback/segment is finished.
Segments are segmented by calls to flush() or clear_buffer()
"""
if self.__playback_finished_count >= self.__playback_segments_count:
logger.warning(
"playback_finished called more times than playback segments were captured"
)
return
self.__playback_finished_count += 1
self.__playback_finished_event.set()
ev = PlaybackFinishedEvent(
playback_position=playback_position,
interrupted=interrupted,
synchronized_transcript=synchronized_transcript,
)
self.__last_playback_ev = ev
self.emit("playback_finished", ev)
async def wait_for_playout(self) -> PlaybackFinishedEvent:
"""
Wait for the past audio segments to finish playing out.
Returns:
PlaybackFinishedEvent: The event that was emitted when the audio finished playing out
(only the last segment information)
"""
target = self.__playback_segments_count
while self.__playback_finished_count < target:
await self.__playback_finished_event.wait()
self.__playback_finished_event.clear()
return self.__last_playback_ev
@property
def sample_rate(self) -> int | None:
"""The sample rate required by the audio sink, if None, any sample rate is accepted"""
return self._sample_rate
@abstractmethod
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
"""Capture an audio frame for playback, frames can be pushed faster than real-time"""
if not self.__capturing:
self.__capturing = True
self.__playback_segments_count += 1
@abstractmethod
def flush(self) -> None:
"""Flush any buffered audio, marking the current playback/segment as complete"""
self.__capturing = False
@abstractmethod
def clear_buffer(self) -> None:
"""Clear the buffer, stopping playback immediately"""
def on_attached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_attached()
def on_detached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_detached()
class TextOutput(ABC):
def __init__(self, *, next_in_chain: TextOutput | None) -> None:
self._next_in_chain = next_in_chain
@abstractmethod
async def capture_text(self, text: str) -> None:
"""Capture a text segment (Used by the output of LLM nodes)"""
@abstractmethod
def flush(self) -> None:
"""Mark the current text segment as complete (e.g LLM generation is complete)"""
def on_attached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_attached()
def on_detached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_detached()
# TODO(theomonnom): Add documentation to VideoSink
class VideoOutput(ABC):
def __init__(self, *, next_in_chain: VideoOutput | None) -> None:
self._next_in_chain = next_in_chain
@abstractmethod
async def capture_frame(self, text: rtc.VideoFrame) -> None: ...
@abstractmethod
def flush(self) -> None: ...
def on_attached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_attached()
def on_detached(self) -> None:
if self._next_in_chain:
self._next_in_chain.on_detached()
class AgentInput:
def __init__(self, video_changed: Callable, audio_changed: Callable) -> None:
self._video_stream: VideoInput | None = None
self._audio_stream: AudioInput | None = None
self._video_changed = video_changed
self._audio_changed = audio_changed
# enabled by default
self._audio_enabled = True
self._video_enabled = True
def set_audio_enabled(self, enable: bool):
if enable == self._audio_enabled:
return
self._audio_enabled = enable
if not self._audio_stream:
return
if enable:
self._audio_stream.on_attached()
else:
self._audio_stream.on_detached()
def set_video_enabled(self, enable: bool):
if enable == self._video_enabled:
return
self._video_enabled = enable
if not self._video_stream:
return
if enable:
self._video_stream.on_attached()
else:
self._video_stream.on_detached()
@property
def audio_enabled(self) -> bool:
return self._audio_enabled
@property
def video_enabled(self) -> bool:
return self._video_enabled
@property
def video(self) -> VideoInput | None:
return self._video_stream
@video.setter
def video(self, stream: VideoInput | None) -> None:
self._video_stream = stream
self._video_changed()
@property
def audio(self) -> AudioInput | None:
return self._audio_stream
@audio.setter
def audio(self, stream: AudioInput | None) -> None:
self._audio_stream = stream
self._audio_changed()
class AgentOutput:
def __init__(
self,
video_changed: Callable,
audio_changed: Callable,
transcription_changed: Callable,
) -> None:
self._video_sink: VideoOutput | None = None
self._audio_sink: AudioOutput | None = None
self._transcription_sink: TextOutput | None = None
self._video_changed = video_changed
self._audio_changed = audio_changed
self._transcription_changed = transcription_changed
self._audio_enabled = True
self._video_enabled = True
self._transcription_enabled = True
def set_video_enabled(self, enabled: bool):
if enabled == self._video_enabled:
return
self._video_enabled = enabled
if not self._video_sink:
return
if enabled:
self._video_sink.on_attached()
else:
self._video_sink.on_detached()
def set_audio_enabled(self, enabled: bool):
if enabled == self._audio_enabled:
return
self._audio_enabled = enabled
if not self._audio_sink:
return
if enabled:
self._audio_sink.on_attached()
else:
self._audio_sink.on_detached()
def set_transcription_enabled(self, enabled: bool):
if enabled == self._transcription_enabled:
return
self._transcription_enabled = enabled
if not self._transcription_sink:
return
if enabled:
self._transcription_sink.on_attached()
else:
self._transcription_sink.on_detached()
@property
def audio_enabled(self) -> bool:
return self._audio_enabled
@property
def video_enabled(self) -> bool:
return self._video_enabled
@property
def transcription_enabled(self) -> bool:
return self._transcription_enabled
@property
def video(self) -> VideoOutput | None:
return self._video_sink
@video.setter
def video(self, sink: VideoOutput | None) -> None:
self._video_sink = sink
self._video_changed()
@property
def audio(self) -> AudioOutput | None:
return self._audio_sink
@audio.setter
def audio(self, sink: AudioOutput | None) -> None:
if sink is self._audio_sink:
return
if self._audio_sink:
self._audio_sink.on_detached()
self._audio_sink = sink
self._audio_changed()
if self._audio_sink:
self._audio_sink.on_attached()
@property
def transcription(self) -> TextOutput | None:
return self._transcription_sink
@transcription.setter
def transcription(self, sink: TextOutput | None) -> None:
if sink is self._transcription_sink:
return
if self._transcription_sink:
self._transcription_sink.on_detached()
self._transcription_sink = sink
self._transcription_changed()
if self._transcription_sink:
self._transcription_sink.on_attached()
from .room_io import (
ATTRIBUTE_PUBLISH_ON_BEHALF,
DEFAULT_ROOM_INPUT_OPTIONS,
DEFAULT_ROOM_OUTPUT_OPTIONS,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
)
__all__ = [
"RoomIO",
"DEFAULT_ROOM_INPUT_OPTIONS",
"DEFAULT_ROOM_OUTPUT_OPTIONS",
"RoomInputOptions",
"RoomOutputOptions",
"ATTRIBUTE_PUBLISH_ON_BEHALF",
]
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from typing import Generic, TypeVar, Union
from typing_extensions import override
import livekit.rtc as rtc
from livekit.agents import utils
from ...log import logger
from ..io import AudioInput, VideoInput
T = TypeVar("T", bound=Union[rtc.AudioFrame, rtc.VideoFrame])
class _ParticipantInputStream(Generic[T], ABC):
"""
A stream that dynamically transitions between new audio and video feeds from a connected
participant, seamlessly switching to a different stream when the linked participant changes.
"""
def __init__(
self,
room: rtc.Room,
*,
track_source: rtc.TrackSource.ValueType,
) -> None:
self._room, self._track_source = room, track_source
self._data_ch = utils.aio.Chan[T]()
self._stream: rtc.VideoStream | rtc.AudioStream | None = None
self._participant_identity: str | None = None
self._attached = True
self._forward_atask: asyncio.Task | None = None
self._tasks: set[asyncio.Task] = set()
self._room.on("track_subscribed", self._on_track_available)
async def __anext__(self) -> T:
return await self._data_ch.__anext__()
def __aiter__(self) -> AsyncIterator[T]:
return self
def on_attached(self) -> None:
logger.debug(
"input stream attached",
extra={
"participant": self._participant_identity,
"source": rtc.TrackSource.Name(self._track_source),
},
)
self._attached = True
def on_detached(self) -> None:
logger.debug(
"input stream detached",
extra={
"participant": self._participant_identity,
"source": rtc.TrackSource.Name(self._track_source),
},
)
self._attached = False
def set_participant(self, participant: rtc.Participant | str | None) -> None:
# set_participant can be called before the participant is connected
participant_identity = (
participant.identity if isinstance(participant, rtc.Participant) else participant
)
if self._participant_identity == participant_identity:
return
self._participant_identity = participant_identity
if participant_identity is None:
self._close_stream()
return
participant = (
participant
if isinstance(participant, rtc.Participant)
else self._room.remote_participants.get(participant_identity)
)
if participant:
for publication in participant.track_publications.values():
if not publication.track:
continue
self._on_track_available(publication.track, publication, participant)
async def aclose(self) -> None:
if self._stream:
await self._stream.aclose()
self._stream = None
if self._forward_atask:
await utils.aio.cancel_and_wait(self._forward_atask)
self._room.off("track_subscribed", self._on_track_available)
self._data_ch.close()
@utils.log_exceptions(logger=logger)
async def _forward_task(
self, old_task: asyncio.Task | None, stream: rtc.VideoStream | rtc.AudioStream
) -> None:
if old_task:
await utils.aio.cancel_and_wait(old_task)
extra = {
"participant": self._participant_identity,
"source": rtc.TrackSource.Name(self._track_source),
}
logger.debug("start reading stream", extra=extra)
async for event in stream:
if not self._attached:
# drop frames if the stream is detached
continue
await self._data_ch.send(event.frame)
logger.debug("stream closed", extra=extra)
@abstractmethod
def _create_stream(self, track: rtc.RemoteTrack) -> rtc.VideoStream | rtc.AudioStream: ...
def _close_stream(self) -> None:
if self._stream is not None:
task = asyncio.create_task(self._stream.aclose())
task.add_done_callback(self._tasks.discard)
self._tasks.add(task)
self._stream = None
def _on_track_available(
self,
track: rtc.RemoteTrack,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
) -> None:
if (
self._participant_identity != participant.identity
or publication.source != self._track_source
):
return
self._close_stream()
self._stream = self._create_stream(track)
self._forward_atask = asyncio.create_task(
self._forward_task(self._forward_atask, self._stream)
)
class _ParticipantAudioInputStream(_ParticipantInputStream[rtc.AudioFrame], AudioInput):
def __init__(
self,
room: rtc.Room,
*,
sample_rate: int,
num_channels: int,
noise_cancellation: rtc.NoiseCancellationOptions | None,
) -> None:
_ParticipantInputStream.__init__(
self, room=room, track_source=rtc.TrackSource.SOURCE_MICROPHONE
)
self._sample_rate = sample_rate
self._num_channels = num_channels
self._noise_cancellation = noise_cancellation
@override
def _create_stream(self, track: rtc.Track) -> rtc.AudioStream:
return rtc.AudioStream.from_track(
track=track,
sample_rate=self._sample_rate,
num_channels=self._num_channels,
noise_cancellation=self._noise_cancellation,
)
class _ParticipantVideoInputStream(_ParticipantInputStream[rtc.VideoFrame], VideoInput):
def __init__(self, room: rtc.Room) -> None:
_ParticipantInputStream.__init__(
self, room=room, track_source=rtc.TrackSource.SOURCE_CAMERA
)
@override
def _create_stream(self, track: rtc.Track) -> rtc.VideoStream:
return rtc.VideoStream.from_track(track=track)
from __future__ import annotations
import asyncio
from livekit import rtc
from ... import utils
from ...log import logger
from ...types import (
ATTRIBUTE_PUBLISH_ON_BEHALF,
ATTRIBUTE_TRANSCRIPTION_FINAL,
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID,
ATTRIBUTE_TRANSCRIPTION_TRACK_ID,
TOPIC_TRANSCRIPTION,
)
from .. import io
from ..transcription import find_micro_track_id
class _ParticipantAudioOutput(io.AudioOutput):
def __init__(
self,
room: rtc.Room,
*,
sample_rate: int,
num_channels: int,
track_publish_options: rtc.TrackPublishOptions,
queue_size_ms: int = 100_000, # TODO(long): move buffer to python
) -> None:
super().__init__(next_in_chain=None, sample_rate=sample_rate)
self._room = room
self._lock = asyncio.Lock()
self._audio_source = rtc.AudioSource(sample_rate, num_channels, queue_size_ms)
self._publish_options = track_publish_options
self._publication: rtc.LocalTrackPublication | None = None
self._republish_task: asyncio.Task | None = None # used to republish track on reconnection
self._flush_task: asyncio.Task | None = None
self._interrupted_event = asyncio.Event()
self._pushed_duration: float = 0.0
self._interrupted: bool = False
async def _publish_track(self) -> None:
async with self._lock:
track = rtc.LocalAudioTrack.create_audio_track("roomio_audio", self._audio_source)
self._publication = await self._room.local_participant.publish_track(
track, self._publish_options
)
await self._publication.wait_for_subscription()
async def start(self) -> None:
await self._publish_track()
def _on_reconnected() -> None:
if self._republish_task:
self._republish_task.cancel()
self._republish_task = asyncio.create_task(self._publish_track())
self._room.on("reconnected", _on_reconnected)
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
await super().capture_frame(frame)
if self._flush_task and not self._flush_task.done():
logger.error("capture_frame called while flush is in progress")
await self._flush_task
self._pushed_duration += frame.duration
await self._audio_source.capture_frame(frame)
def flush(self) -> None:
super().flush()
if not self._pushed_duration:
return
if self._flush_task and not self._flush_task.done():
# shouldn't happen if only one active speech handle at a time
logger.error("flush called while playback is in progress")
self._flush_task.cancel()
self._flush_task = asyncio.create_task(self._wait_for_playout())
def clear_buffer(self) -> None:
super().clear_buffer()
if not self._pushed_duration:
return
self._interrupted_event.set()
async def _wait_for_playout(self) -> None:
wait_for_interruption = asyncio.create_task(self._interrupted_event.wait())
wait_for_playout = asyncio.create_task(self._audio_source.wait_for_playout())
await asyncio.wait(
[wait_for_playout, wait_for_interruption],
return_when=asyncio.FIRST_COMPLETED,
)
interrupted = wait_for_interruption.done()
pushed_duration = self._pushed_duration
if interrupted:
pushed_duration = max(pushed_duration - self._audio_source.queued_duration, 0)
self._audio_source.clear_queue()
wait_for_playout.cancel()
else:
wait_for_interruption.cancel()
self._pushed_duration = 0
self._interrupted_event.clear()
self.on_playback_finished(playback_position=pushed_duration, interrupted=interrupted)
class _ParticipantLegacyTranscriptionOutput(io.TextOutput):
def __init__(
self,
room: rtc.Room,
*,
is_delta_stream: bool = True,
participant: rtc.Participant | str | None = None,
):
super().__init__(next_in_chain=None)
self._room, self._is_delta_stream = room, is_delta_stream
self._track_id: str | None = None
self._participant_identity: str | None = None
# identity of the participant that on behalf of the current participant
self._represented_by: str | None = None
self._room.on("track_published", self._on_track_published)
self._room.on("local_track_published", self._on_local_track_published)
self._flush_task: asyncio.Task | None = None
self._reset_state()
self.set_participant(participant)
def set_participant(
self,
participant: rtc.Participant | str | None,
) -> None:
self._participant_identity = (
participant.identity if isinstance(participant, rtc.Participant) else participant
)
self._represented_by = self._participant_identity
if self._participant_identity is None:
return
# find track id from existing participants
if self._participant_identity == self._room.local_participant.identity:
for track in self._room.local_participant.track_publications.values():
self._on_local_track_published(track, track.track)
if self._track_id is not None:
break
if not self._track_id:
for p in self._room.remote_participants.values():
if not self._is_local_proxy_participant(p):
continue
for track in p.track_publications.values():
self._on_track_published(track, p)
if self._track_id is not None:
break
self.flush()
self._reset_state()
def _reset_state(self) -> None:
self._current_id = utils.shortuuid("SG_")
self._capturing = False
self._pushed_text = ""
@utils.log_exceptions(logger=logger)
async def capture_text(self, text: str) -> None:
if self._participant_identity is None or self._track_id is None:
return
if self._flush_task and not self._flush_task.done():
await self._flush_task
if not self._capturing:
self._reset_state()
self._capturing = True
if self._is_delta_stream:
self._pushed_text += text
else:
self._pushed_text = text
await self._publish_transcription(self._current_id, self._pushed_text, final=False)
@utils.log_exceptions(logger=logger)
def flush(self) -> None:
if self._participant_identity is None or self._track_id is None or not self._capturing:
return
self._flush_task = asyncio.create_task(
self._publish_transcription(self._current_id, self._pushed_text, final=True)
)
self._reset_state()
async def _publish_transcription(self, id: str, text: str, final: bool) -> None:
if self._participant_identity is None or self._track_id is None:
return
transcription = rtc.Transcription(
participant_identity=self._represented_by or self._participant_identity,
track_sid=self._track_id,
segments=[
rtc.TranscriptionSegment(
id=id,
text=text,
start_time=0,
end_time=0,
final=final,
language="",
)
],
)
try:
if self._room.isconnected():
await self._room.local_participant.publish_transcription(transcription)
except Exception as e:
logger.warning("failed to publish transcription", exc_info=e)
def _on_track_published(
self, track: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant
) -> None:
if (
not self._is_local_proxy_participant(participant)
or track.source != rtc.TrackSource.SOURCE_MICROPHONE
):
return
self._track_id = track.sid
self._represented_by = participant.identity
def _on_local_track_published(self, track: rtc.LocalTrackPublication, _: rtc.Track) -> None:
if (
self._participant_identity is None
or self._participant_identity != self._room.local_participant.identity
or track.source != rtc.TrackSource.SOURCE_MICROPHONE
):
return
self._track_id = track.sid
def _is_local_proxy_participant(self, participant: rtc.Participant) -> bool:
if not self._participant_identity:
return False
if participant.identity == self._participant_identity or (
(on_behalf := participant.attributes.get(ATTRIBUTE_PUBLISH_ON_BEHALF)) is not None
and on_behalf == self._participant_identity
):
return True
return False
class _ParticipantTranscriptionOutput(io.TextOutput):
def __init__(
self,
room: rtc.Room,
*,
is_delta_stream: bool = True,
participant: rtc.Participant | str | None = None,
):
super().__init__(next_in_chain=None)
self._room, self._is_delta_stream = room, is_delta_stream
self._track_id: str | None = None
self._participant_identity: str | None = None
self._writer: rtc.TextStreamWriter | None = None
self._room.on("track_published", self._on_track_published)
self._room.on("local_track_published", self._on_local_track_published)
self._flush_atask: asyncio.Task | None = None
self._reset_state()
self.set_participant(participant)
def set_participant(
self,
participant: rtc.Participant | str | None,
) -> None:
self._participant_identity = (
participant.identity if isinstance(participant, rtc.Participant) else participant
)
if self._participant_identity is None:
return
try:
self._track_id = find_micro_track_id(self._room, self._participant_identity)
except ValueError:
# track id is optional for TextStream when audio is not published
self._track_id = None
self.flush()
self._reset_state()
def _reset_state(self) -> None:
self._current_id = utils.shortuuid("SG_")
self._capturing = False
self._latest_text = ""
async def _create_text_writer(
self, attributes: dict[str, str] | None = None
) -> rtc.TextStreamWriter:
assert self._participant_identity is not None, "participant_identity is not set"
if not attributes:
attributes = {
ATTRIBUTE_TRANSCRIPTION_FINAL: "false",
}
if self._track_id:
attributes[ATTRIBUTE_TRANSCRIPTION_TRACK_ID] = self._track_id
attributes[ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID] = self._current_id
return await self._room.local_participant.stream_text(
topic=TOPIC_TRANSCRIPTION,
sender_identity=self._participant_identity,
attributes=attributes,
)
@utils.log_exceptions(logger=logger)
async def capture_text(self, text: str) -> None:
if self._participant_identity is None:
return
if self._flush_atask and not self._flush_atask.done():
await self._flush_atask
if not self._capturing:
self._reset_state()
self._capturing = True
self._latest_text = text
try:
if self._room.isconnected():
if self._is_delta_stream: # reuse the existing writer
if self._writer is None:
self._writer = await self._create_text_writer()
await self._writer.write(text)
else: # always create a new writer
tmp_writer = await self._create_text_writer()
await tmp_writer.write(text)
await tmp_writer.aclose()
except Exception as e:
logger.warning("failed to publish transcription", exc_info=e)
async def _flush_task(self, writer: rtc.TextStreamWriter | None):
attributes = {
ATTRIBUTE_TRANSCRIPTION_FINAL: "true",
}
if self._track_id:
attributes[ATTRIBUTE_TRANSCRIPTION_TRACK_ID] = self._track_id
try:
if self._room.isconnected():
if self._is_delta_stream:
if writer:
await writer.aclose(attributes=attributes)
else:
tmp_writer = await self._create_text_writer(attributes=attributes)
await tmp_writer.write(self._latest_text)
await tmp_writer.aclose()
except Exception as e:
logger.warning("failed to publish transcription", exc_info=e)
def flush(self) -> None:
if self._participant_identity is None or not self._capturing:
return
self._capturing = False
curr_writer = self._writer
self._writer = None
self._flush_atask = asyncio.create_task(self._flush_task(curr_writer))
def _on_track_published(
self, track: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant
) -> None:
if (
self._participant_identity is None
or participant.identity != self._participant_identity
or track.source != rtc.TrackSource.SOURCE_MICROPHONE
):
return
self._track_id = track.sid
def _on_local_track_published(self, track: rtc.LocalTrackPublication, _: rtc.Track) -> None:
if (
self._participant_identity is None
or self._participant_identity != self._room.local_participant.identity
or track.source != rtc.TrackSource.SOURCE_MICROPHONE
):
return
self._track_id = track.sid
# Keep this utility private for now
class _ParallelTextOutput(io.TextOutput):
def __init__(
self, sinks: list[io.TextOutput], *, next_in_chain: io.TextOutput | None = None
) -> None:
super().__init__(next_in_chain=next_in_chain)
self._sinks = sinks
async def capture_text(self, text: str) -> None:
await asyncio.gather(*[sink.capture_text(text) for sink in self._sinks])
def flush(self) -> None:
for sink in self._sinks:
sink.flush()
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Optional
from livekit import rtc
from ... import utils
from ...log import logger
from ...types import (
ATTRIBUTE_AGENT_STATE,
ATTRIBUTE_PUBLISH_ON_BEHALF,
NOT_GIVEN,
TOPIC_CHAT,
NotGivenOr,
)
from ..events import AgentStateChangedEvent, UserInputTranscribedEvent
from ..io import AudioInput, AudioOutput, TextOutput, VideoInput
from ..transcription import TranscriptSynchronizer
if TYPE_CHECKING:
from ..agent_session import AgentSession
from ._input import _ParticipantAudioInputStream, _ParticipantVideoInputStream
from ._output import (
_ParallelTextOutput,
_ParticipantAudioOutput,
_ParticipantLegacyTranscriptionOutput,
_ParticipantTranscriptionOutput,
)
DEFAULT_PARTICIPANT_KINDS: list[rtc.ParticipantKind.ValueType] = [
rtc.ParticipantKind.PARTICIPANT_KIND_SIP,
rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD,
]
@dataclass
class TextInputEvent:
text: str
info: rtc.TextStreamInfo
participant: rtc.RemoteParticipant
TextInputCallback = Callable[
["AgentSession", TextInputEvent], Optional[Coroutine[None, None, None]]
]
def _default_text_input_cb(sess: AgentSession, ev: TextInputEvent) -> None:
sess.interrupt()
sess.generate_reply(user_input=ev.text)
@dataclass
class RoomInputOptions:
text_enabled: bool = True
audio_enabled: bool = True
video_enabled: bool = False
audio_sample_rate: int = 24000
audio_num_channels: int = 1
noise_cancellation: rtc.NoiseCancellationOptions | None = None
text_input_cb: TextInputCallback = _default_text_input_cb
participant_kinds: NotGivenOr[list[rtc.ParticipantKind.ValueType]] = NOT_GIVEN
"""Participant kinds accepted for auto subscription. If not provided,
accept `DEFAULT_PARTICIPANT_KINDS`."""
participant_identity: NotGivenOr[str] = NOT_GIVEN
"""The participant to link to. If not provided, link to the first participant.
Can be overridden by the `participant` argument of RoomIO constructor or `set_participant`."""
@dataclass
class RoomOutputOptions:
transcription_enabled: bool = True
audio_enabled: bool = True
audio_sample_rate: int = 24000
audio_num_channels: int = 1
audio_publish_options: rtc.TrackPublishOptions = field(
default_factory=lambda: rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
)
sync_transcription: NotGivenOr[bool] = NOT_GIVEN
"""False to disable transcription synchronization with audio output.
Otherwise, transcription is emitted as quickly as available."""
DEFAULT_ROOM_INPUT_OPTIONS = RoomInputOptions()
DEFAULT_ROOM_OUTPUT_OPTIONS = RoomOutputOptions()
class RoomIO:
def __init__(
self,
agent_session: AgentSession,
room: rtc.Room,
*,
participant: rtc.RemoteParticipant | str | None = None,
input_options: RoomInputOptions = DEFAULT_ROOM_INPUT_OPTIONS,
output_options: RoomOutputOptions = DEFAULT_ROOM_OUTPUT_OPTIONS,
) -> None:
self._agent_session, self._room = agent_session, room
self._input_options = input_options
self._output_options = output_options
self._participant_identity = (
participant.identity if isinstance(participant, rtc.RemoteParticipant) else participant
)
if self._participant_identity is None and utils.is_given(
input_options.participant_identity
):
self._participant_identity = input_options.participant_identity
self._audio_input: _ParticipantAudioInputStream | None = None
self._video_input: _ParticipantVideoInputStream | None = None
self._audio_output: _ParticipantAudioOutput | None = None
self._user_tr_output: _ParallelTextOutput | None = None
self._agent_tr_output: _ParallelTextOutput | None = None
self._tr_synchronizer: TranscriptSynchronizer | None = None
self._participant_available_fut = asyncio.Future[rtc.RemoteParticipant]()
self._tasks: set[asyncio.Task] = set()
self._update_state_task: asyncio.Task | None = None
async def start(self) -> None:
self._room.on("participant_connected", self._on_participant_connected)
self._room.on("participant_disconnected", self._on_participant_disconnected)
for participant in self._room.remote_participants.values():
self._on_participant_connected(participant)
if self._input_options.text_enabled:
try:
self._room.register_text_stream_handler(TOPIC_CHAT, self._on_user_text_input)
except ValueError:
logger.warning(
f"text stream handler for topic '{TOPIC_CHAT}' already set, ignoring"
)
if self._input_options.video_enabled:
self._video_input = _ParticipantVideoInputStream(self._room)
if self._input_options.audio_enabled:
self._audio_input = _ParticipantAudioInputStream(
self._room,
sample_rate=self._input_options.audio_sample_rate,
num_channels=self._input_options.audio_num_channels,
noise_cancellation=self._input_options.noise_cancellation,
)
def _create_transcription_output(
is_delta_stream: bool, participant: rtc.Participant | str | None = None
) -> _ParallelTextOutput:
return _ParallelTextOutput(
[
_ParticipantLegacyTranscriptionOutput(
room=self._room, is_delta_stream=is_delta_stream, participant=participant
),
_ParticipantTranscriptionOutput(
room=self._room, is_delta_stream=is_delta_stream, participant=participant
),
],
next_in_chain=None,
)
if self._output_options.audio_enabled:
self._audio_output = _ParticipantAudioOutput(
self._room,
sample_rate=self._output_options.audio_sample_rate,
num_channels=self._output_options.audio_num_channels,
track_publish_options=self._output_options.audio_publish_options,
)
if self._output_options.transcription_enabled:
self._user_tr_output = _create_transcription_output(
is_delta_stream=False, participant=self._participant_identity
)
self._agent_tr_output = _create_transcription_output(
is_delta_stream=True, participant=self._room.local_participant
)
# use the RoomIO's audio output if available, otherwise use the agent's audio output
# (e.g the audio output isn't using RoomIO with our avatar datastream impl)
sync_transcription = True
if utils.is_given(self._output_options.sync_transcription):
sync_transcription = self._output_options.sync_transcription
if sync_transcription and (
audio_output := self._audio_output or self._agent_session.output.audio
):
self._tr_synchronizer = TranscriptSynchronizer(
next_in_chain_audio=audio_output, next_in_chain_text=self._agent_tr_output
)
# TODO(theomonnom): ideally we're consistent and every input/output has a start method
if self._audio_output:
await self._audio_output.start()
# wait for the specified participant or the first participant joined
input_participant = await self._participant_available_fut
self.set_participant(input_participant.identity)
if self.audio_input:
self._agent_session.input.audio = self.audio_input
if self.video_input:
self._agent_session.input.video = self.video_input
if self.audio_output:
self._agent_session.output.audio = self.audio_output
if self.transcription_output:
self._agent_session.output.transcription = self.transcription_output
self._agent_session.on("agent_state_changed", self._on_agent_state_changed)
self._agent_session.on("user_input_transcribed", self._on_user_input_transcribed)
self._agent_session._room_io = self
async def aclose(self) -> None:
self._room.off("participant_connected", self._on_participant_connected)
self._room.off("participant_disconnected", self._on_participant_disconnected)
if self._audio_input:
await self._audio_input.aclose()
if self._video_input:
await self._video_input.aclose()
if self._tr_synchronizer:
await self._tr_synchronizer.aclose()
# cancel and wait for all pending tasks
await utils.aio.cancel_and_wait(*self._tasks)
self._tasks.clear()
@property
def audio_output(self) -> AudioOutput | None:
if self._tr_synchronizer:
return self._tr_synchronizer.audio_output
return self._audio_output
@property
def transcription_output(self) -> TextOutput | None:
if self._tr_synchronizer:
return self._tr_synchronizer.text_output
return self._agent_tr_output
@property
def audio_input(self) -> AudioInput | None:
return self._audio_input
@property
def video_input(self) -> VideoInput | None:
return self._video_input
@property
def linked_participant(self) -> rtc.RemoteParticipant | None:
if not self._participant_available_fut.done():
return None
return self._participant_available_fut.result()
def set_participant(self, participant_identity: str | None) -> None:
"""Switch audio and video streams to specified participant"""
if participant_identity is None:
self.unset_participant()
return
if (
self._participant_identity is not None
and self._participant_identity != participant_identity
):
# reset future if switching to a different participant
self._participant_available_fut = asyncio.Future[rtc.RemoteParticipant]()
# check if new participant is already connected
for participant in self._room.remote_participants.values():
if participant.identity == participant_identity:
self._participant_available_fut.set_result(participant)
break
# update participant identity and handlers
self._participant_identity = participant_identity
if self._audio_input:
self._audio_input.set_participant(participant_identity)
if self._video_input:
self._video_input.set_participant(participant_identity)
self._update_user_transcription(participant_identity)
def unset_participant(self) -> None:
self._participant_identity = None
self._participant_available_fut = asyncio.Future[rtc.RemoteParticipant]()
if self._audio_input:
self._audio_input.set_participant(None)
if self._video_input:
self._video_input.set_participant(None)
self._update_user_transcription(None)
def _on_participant_connected(self, participant: rtc.RemoteParticipant) -> None:
if self._participant_available_fut.done():
return
if self._participant_identity is not None:
if participant.identity != self._participant_identity:
return
# otherwise, skip participants that are marked as publishing for this agent
elif (
participant.attributes.get(ATTRIBUTE_PUBLISH_ON_BEHALF)
== self._room.local_participant.identity
):
return
accepted_kinds = self._input_options.participant_kinds or DEFAULT_PARTICIPANT_KINDS
if participant.kind not in accepted_kinds:
# not an accepted participant kind, skip
return
self._participant_available_fut.set_result(participant)
def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
if self._participant_identity is None or self._participant_identity != participant.identity:
return
def _on_user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None:
async def _capture_text():
if self._user_tr_output is None:
return
await self._user_tr_output.capture_text(ev.transcript)
if ev.is_final:
# TODO(theomonnom): should we wait for the end of turn before sending the final transcript? # noqa: E501
self._user_tr_output.flush()
task = asyncio.create_task(_capture_text())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def _on_user_text_input(self, reader: rtc.TextStreamReader, participant_identity: str) -> None:
if participant_identity != self._participant_identity:
return
participant = self._room.remote_participants.get(participant_identity)
if not participant:
logger.warning("participant not found, ignoring text input")
return
async def _read_text():
text = await reader.read_all()
if self._input_options.text_input_cb:
text_input_result = self._input_options.text_input_cb(
self._agent_session,
TextInputEvent(text=text, info=reader.info, participant=participant),
)
if asyncio.iscoroutine(text_input_result):
await text_input_result
task = asyncio.create_task(_read_text())
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
def _on_agent_state_changed(self, ev: AgentStateChangedEvent):
@utils.log_exceptions(logger=logger)
async def _set_state() -> None:
if self._room.isconnected():
await self._room.local_participant.set_attributes(
{ATTRIBUTE_AGENT_STATE: ev.new_state}
)
if self._update_state_task is not None:
self._update_state_task.cancel()
self._update_state_task = asyncio.create_task(_set_state())
def _update_user_transcription(self, participant_identity: str | None) -> None:
if not self._user_tr_output:
return
for sink in self._user_tr_output._sinks:
assert isinstance(
sink,
(
_ParticipantLegacyTranscriptionOutput,
_ParticipantTranscriptionOutput,
),
)
sink.set_participant(participant_identity)
from __future__ import annotations
import asyncio
import contextlib
from typing import Callable
from .. import llm, utils
class SpeechHandle:
SPEECH_PRIORITY_LOW = 0
"""Priority for messages that should be played after all other messages in the queue"""
SPEECH_PRIORITY_NORMAL = 5
"""Every speech generates by the VoiceAgent defaults to this priority."""
SPEECH_PRIORITY_HIGH = 10
"""Priority for important messages that should be played before others."""
def __init__(
self,
*,
speech_id: str,
allow_interruptions: bool,
step_index: int,
parent: SpeechHandle | None,
) -> None:
self._id = speech_id
self._step_index = step_index
self._allow_interruptions = allow_interruptions
self._interrupt_fut = asyncio.Future()
self._authorize_fut = asyncio.Future()
self._playout_done_fut = asyncio.Future()
self._parent = parent
self._chat_message: llm.ChatMessage | None = None
@staticmethod
def create(
allow_interruptions: bool = True,
step_index: int = 0,
parent: SpeechHandle | None = None,
) -> SpeechHandle:
return SpeechHandle(
speech_id=utils.shortuuid("speech_"),
allow_interruptions=allow_interruptions,
step_index=step_index,
parent=parent,
)
@property
def id(self) -> str:
return self._id
@property
def step_index(self) -> int:
return self._step_index
@property
def interrupted(self) -> bool:
return self._interrupt_fut.done()
@property
def allow_interruptions(self) -> bool:
return self._allow_interruptions
@property
def chat_message(self) -> llm.ChatMessage | None:
"""
Returns the assistant's generated chat message associated with this speech handle.
Only available once the speech playout is complete.
"""
return self._chat_message
# TODO(theomonnom): should we introduce chat_items property as well for generated tools?
@property
def parent(self) -> SpeechHandle | None:
"""
The parent handle that initiated the creation of the current speech handle.
This happens when a tool call is made, a new SpeechHandle will be created for the tool response.
""" # noqa: E501
return self._parent
def done(self) -> bool:
return self._playout_done_fut.done()
def interrupt(self) -> SpeechHandle:
"""Interrupt the current speech generation.
Raises:
RuntimeError: If this speech handle does not allow interruptions.
Returns:
SpeechHandle: The same speech handle that was interrupted.
"""
if not self._allow_interruptions:
raise RuntimeError("This generation handle does not allow interruptions")
if self.done():
return self
with contextlib.suppress(asyncio.InvalidStateError):
self._interrupt_fut.set_result(None)
return self
async def wait_for_playout(self) -> None:
await asyncio.shield(self._playout_done_fut)
def __await__(self):
async def _await_impl() -> SpeechHandle:
await self.wait_for_playout()
return self
return _await_impl().__await__()
def add_done_callback(self, callback: Callable[[SpeechHandle], None]) -> None:
self._playout_done_fut.add_done_callback(lambda _: callback(self))
async def wait_if_not_interrupted(self, aw: list[asyncio.futures.Future]) -> None:
await asyncio.wait(
[asyncio.gather(*aw, return_exceptions=True), self._interrupt_fut],
return_when=asyncio.FIRST_COMPLETED,
)
def _authorize_playout(self) -> None:
self._authorize_fut.set_result(None)
async def _wait_for_authorization(self) -> None:
await asyncio.shield(self._authorize_fut)
def _mark_playout_done(self) -> None:
with contextlib.suppress(asyncio.InvalidStateError):
# will raise InvalidStateError if the future is already done (interrupted)
self._playout_done_fut.set_result(None)
def _set_chat_message(self, chat_message: llm.ChatMessage) -> None:
if self.done():
raise RuntimeError("Cannot set chat message after speech has been played")
if self._chat_message is not None:
raise RuntimeError("Chat message already set")
self._chat_message = chat_message
from ._utils import find_micro_track_id
from .synchronizer import TranscriptSynchronizer
__all__ = [
"TranscriptSynchronizer",
"find_micro_track_id",
]
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Union
import numpy as np
from livekit import rtc
from livekit.agents import utils
from livekit.agents.log import logger
@dataclass
class _SpeakingRateDetectionOptions:
window_duration: float
"window size in seconds"
step_size: float
"step size in seconds"
sample_rate: int | None
"inference sample rate, if None, use the sample rate of the input frame"
_silence_threshold: float = 0.005
"silence threshold for silence detection on audio RMS"
@dataclass
class SpeakingRateEvent:
timestamp: float
speaking: bool
speaking_rate: float
class SpeakingRateDetector:
def __init__(
self,
*,
window_size: float = 1.0,
step_size: float = 0.1,
sample_rate: int | None = None,
) -> None:
super().__init__()
self._opts = _SpeakingRateDetectionOptions(
window_duration=window_size,
step_size=step_size,
sample_rate=sample_rate,
)
def stream(self) -> SpeakingRateStream:
return SpeakingRateStream(self, self._opts)
class SpeakingRateStream:
class _FlushSentinel:
pass
def __init__(self, detector: SpeakingRateDetector, opts: _SpeakingRateDetectionOptions) -> None:
self._detector = detector
self._opts = opts
self._input_ch = utils.aio.Chan[Union[rtc.AudioFrame, SpeakingRateStream._FlushSentinel]]()
self._event_ch = utils.aio.Chan[SpeakingRateEvent]()
self._task = asyncio.create_task(self._main_task())
self._task.add_done_callback(lambda _: self._event_ch.close())
self._input_sample_rate = 0
self._window_size_samples = 0
self._step_size_samples = 0
@utils.log_exceptions(logger=logger)
async def _main_task(self):
_inference_sample_rate = 0
inference_f32_data = np.empty(0, dtype=np.float32)
pub_timestamp = self._opts.window_duration / 2
inference_frames = []
resampler = None
async for input_frame in self._input_ch:
if not isinstance(input_frame, rtc.AudioFrame):
# estimate the speech rate for the last frame
available_samples = sum(frame.samples_per_channel for frame in inference_frames)
if available_samples > self._window_size_samples * 0.5:
frame = utils.combine_frames(inference_frames)
frame_f32_data = np.divide(frame.data, np.iinfo(np.int16).max, dtype=np.float32)
sr = self._compute_speaking_rate(frame_f32_data, _inference_sample_rate)
pub_timestamp += frame.duration
self._event_ch.send_nowait(
SpeakingRateEvent(
timestamp=pub_timestamp,
speaking=sr > 0,
speaking_rate=sr,
)
)
inference_frames = []
continue
# resample the input frame if necessary
if not self._input_sample_rate:
self._input_sample_rate = input_frame.sample_rate
_inference_sample_rate = self._opts.sample_rate or self._input_sample_rate
self._window_size_samples = int(self._opts.window_duration * _inference_sample_rate)
self._step_size_samples = int(self._opts.step_size * _inference_sample_rate)
inference_f32_data = np.empty(self._window_size_samples, dtype=np.float32)
if self._input_sample_rate != _inference_sample_rate:
resampler = rtc.AudioResampler(
input_rate=self._input_sample_rate,
output_rate=_inference_sample_rate,
num_channels=1,
quality=rtc.AudioResamplerQuality.MEDIUM,
)
elif self._input_sample_rate != input_frame.sample_rate:
logger.error(
"a frame with different sample rate was pushed",
extra={
"sample_rate": input_frame.sample_rate,
"expected_sample_rate": self._input_sample_rate,
},
)
continue
if resampler is not None:
inference_frames.extend(resampler.push(input_frame))
else:
inference_frames.append(input_frame)
while True:
available_samples = sum(frame.samples_per_channel for frame in inference_frames)
if available_samples < self._window_size_samples:
break
inference_frame = utils.combine_frames(inference_frames)
np.divide(
inference_frame.data[: self._window_size_samples],
np.iinfo(np.int16).max,
out=inference_f32_data,
dtype=np.float32,
)
# run the inference
sr = self._compute_speaking_rate(inference_f32_data, _inference_sample_rate)
self._event_ch.send_nowait(
SpeakingRateEvent(
timestamp=pub_timestamp,
speaking=sr > 0,
speaking_rate=sr,
)
)
# move the window forward by the hop size
pub_timestamp += self._opts.step_size
if len(inference_frame.data) - self._step_size_samples > 0:
data = inference_frame.data[self._step_size_samples :]
inference_frames = [
rtc.AudioFrame(
data=data,
sample_rate=inference_frame.sample_rate,
num_channels=1,
samples_per_channel=len(data) // 2,
)
]
def _compute_speaking_rate(self, audio: np.ndarray, sample_rate: int) -> float:
"""
Compute the speaking rate of the audio using the selected method
"""
silence_threshold = self._opts._silence_threshold
audio_sq = audio**2
# check if the audio is silent
overall_rms = np.sqrt(np.mean(audio_sq))
if overall_rms < silence_threshold:
return 0.0
# or if the tail of the audio is silent
tail_audio_sq = audio_sq[int(len(audio_sq) * 0.7) :]
if len(tail_audio_sq) > 0 and np.sqrt(np.mean(tail_audio_sq)) < silence_threshold * 0.5:
return 0.0
return self._spectral_flux(audio, sample_rate)
def _stft(self, audio: np.ndarray, frame_length: int, hop_length: int) -> np.ndarray:
num_frames = (len(audio) - frame_length) // hop_length + 1
result = np.zeros((frame_length // 2 + 1, num_frames), dtype=complex)
window = np.hanning(frame_length)
scale_factor = 1.0 / np.sqrt(np.sum(window**2))
for i in range(num_frames):
start = i * hop_length
end = start + frame_length
frame = audio[start:end]
windowed = frame * window
# perform fft and scale
fft_result = np.fft.rfft(windowed)
result[:, i] = fft_result * scale_factor
return result
def _spectral_flux(self, audio: np.ndarray, sample_rate: int) -> float:
"""
Calculate speaking rate based on spectral flux.
Higher spectral flux correlates with more rapid speech articulation.
"""
# Parameters
frame_length = int(sample_rate * 0.025) # 25ms
hop_length = frame_length // 2 # 50% overlap
Zxx = self._stft(audio, frame_length, hop_length)
# calculate spectral flux (sum of spectral magnitude changes between frames)
spectral_magnitudes = np.abs(Zxx)
spectral_flux_values = []
for i in range(1, spectral_magnitudes.shape[1]):
# l1 norm of difference between consecutive spectral frames
flux = np.sum(np.abs(spectral_magnitudes[:, i] - spectral_magnitudes[:, i - 1]))
spectral_flux_values.append(flux)
if not spectral_flux_values:
return 0.0
avg_flux = np.mean(spectral_flux_values)
return avg_flux
def push_frame(self, frame: rtc.AudioFrame) -> None:
"""Push audio frame for syllable rate detection"""
self._input_ch.send_nowait(frame)
def flush(self) -> None:
"""Mark the end of the current segment"""
self._input_ch.send_nowait(self._FlushSentinel())
def end_input(self) -> None:
"""Mark the end of input, no more audio will be pushed"""
self.flush()
self._input_ch.close()
async def aclose(self) -> None:
"""Close this stream immediately"""
self._input_ch.close()
await utils.aio.cancel_and_wait(self._task)
self._event_ch.close()
def __aiter__(self):
return self._event_ch
from __future__ import annotations
from livekit import rtc
from ...utils import shortuuid
def find_micro_track_id(room: rtc.Room, identity: str) -> str:
p: rtc.RemoteParticipant | rtc.LocalParticipant | None = room.remote_participants.get(identity)
if identity == room.local_participant.identity:
p = room.local_participant
if p is None:
raise ValueError(f"participant {identity} not found")
# find first micro track
track_id = None
for track in p.track_publications.values():
if track.source == rtc.TrackSource.SOURCE_MICROPHONE:
track_id = track.sid
break
if track_id is None:
raise ValueError(f"participant {identity} does not have a microphone track")
return track_id
def segment_uuid() -> str:
return shortuuid("SG_")
def speech_uuid() -> str:
return shortuuid("SP_")
from __future__ import annotations
import asyncio
import contextlib
import functools
import time
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
from livekit import rtc
from ... import tokenize, utils
from ...log import logger
from ...types import NOT_GIVEN, NotGivenOr
from ...utils import is_given
from .. import io
from ._speaking_rate import SpeakingRateDetector, SpeakingRateStream
STANDARD_SPEECH_RATE = 3.83 # hyphens (syllables) per second
@dataclass
class _TextSyncOptions:
speed: float
hyphenate_word: Callable[[str], list[str]]
split_words: Callable[[str], list[tuple[str, int, int]]]
sentence_tokenizer: tokenize.SentenceTokenizer
speaking_rate_detector: SpeakingRateDetector
@dataclass
class _SpeakingRateData:
timestamps: list[float] = field(default_factory=list)
"""timestamps of the speaking rate"""
speaking_rate: list[float] = field(default_factory=list)
"""speed at the timestamp"""
speak_integrals: list[float] = field(default_factory=list)
"""accumulated speaking units up to the timestamp"""
_text_buffer: list[str] = field(default_factory=list)
def add_by_rate(self, *, timestamp: float, speaking_rate: float) -> None:
integral = self.speak_integrals[-1] if self.timestamps else 0
dt = timestamp - self.pushed_duration
integral += speaking_rate * dt
self.timestamps.append(timestamp)
self.speaking_rate.append(speaking_rate)
self.speak_integrals.append(integral)
def add_by_annotation(
self,
*,
text: str,
start_time: float | None,
end_time: float | None,
text_to_hyphens: Callable[[str], list[str]],
) -> None:
if start_time is not None:
# calculate the integral of the speaking rate up to the start time
integral = self.speak_integrals[-1] if self.timestamps else 0
dt = start_time - self.pushed_duration
full_text = "".join(self._text_buffer)
d_hyphens = len(text_to_hyphens(full_text))
integral += d_hyphens
rate = d_hyphens / dt if dt > 0 else 0
self.timestamps.append(start_time)
self.speaking_rate.append(rate)
self.speak_integrals.append(integral)
self._text_buffer.clear()
self._text_buffer.append(text)
if end_time is not None:
self.add_by_annotation(
"", start_time=end_time, end_time=None, text_to_hyphens=text_to_hyphens
)
def accumulate_to(self, timestamp: float) -> float:
"""Get accumulated speaking units up to the given timestamp."""
if not self.timestamps:
return 0
idx = np.searchsorted(self.timestamps, timestamp, side="right")
if idx == 0:
return 0
integral_t = self.speak_integrals[idx - 1]
# fill the tail assuming the speaking rate is constant
dt = timestamp - self.timestamps[idx - 1]
rate = (
self.speaking_rate[idx]
if idx < len(self.speaking_rate)
else self.speaking_rate[idx - 1]
)
integral_t += rate * dt
if idx < len(self.timestamps):
# if there is a next timestamp, make sure the integral does not exceed the next
integral_t = min(integral_t, self.speak_integrals[idx])
return integral_t
@property
def pushed_duration(self) -> float:
return self.timestamps[-1] if self.timestamps else 0
@dataclass
class _AudioData:
sr_stream: SpeakingRateStream # speaking rate estimation
pushed_duration: float = 0.0
done: bool = False
sr_data_est: _SpeakingRateData = field(default_factory=_SpeakingRateData)
sr_data_annotated: _SpeakingRateData | None = None # speaking rate from `start_time`
@dataclass
class _TextData:
sentence_stream: tokenize.SentenceStream
pushed_text: str = ""
done: bool = False
forwarded_hyphens: int = 0
forwarded_text: str = ""
class _SegmentSynchronizerImpl:
"""Synchronizes one text segment with one audio segment"""
def __init__(self, options: _TextSyncOptions, *, next_in_chain: io.TextOutput) -> None:
self._opts = options
self._text_data = _TextData(sentence_stream=self._opts.sentence_tokenizer.stream())
self._audio_data = _AudioData(sr_stream=self._opts.speaking_rate_detector.stream())
self._next_in_chain = next_in_chain
self._start_wall_time: float | None = None
self._start_fut: asyncio.Event = asyncio.Event()
self._speed = STANDARD_SPEECH_RATE * self._opts.speed # hyphens per second
self._speed_on_speaking_unit: float | None = None # hyphens per speaking unit
# a speaking unit is defined by the speaking rate estimation method, it's a relative unit
self._out_ch = utils.aio.Chan[str]()
self._close_future = asyncio.Future[None]()
self._main_atask = asyncio.create_task(self._main_task())
self._main_atask.add_done_callback(lambda _: self._out_ch.close())
self._capture_atask = asyncio.create_task(self._capture_task())
self._speaking_rate_atask = asyncio.create_task(self._speaking_rate_task())
self._playback_completed = False
@property
def closed(self) -> bool:
return self._close_future.done()
def push_audio(self, frame: rtc.AudioFrame) -> None:
if self.closed:
logger.warning("_SegmentSynchronizerImpl.push_audio called after close")
return
# the first audio frame we receive marks the start of the sync
# see `TranscriptSynchronizer` docstring
if self._start_wall_time is None and frame.duration > 0:
self._start_wall_time = time.time()
self._start_fut.set()
self._audio_data.sr_stream.push_frame(frame)
self._audio_data.pushed_duration += frame.duration
def end_audio_input(self) -> None:
if self.closed:
logger.warning("_SegmentSynchronizerImpl.end_audio_input called after close")
return
self._audio_data.done = True
self._audio_data.sr_stream.end_input()
self._reestimate_speed()
def push_text(self, text: str) -> None:
if self.closed:
logger.warning("_SegmentSynchronizerImpl.push_text called after close")
return
start_time, end_time = None, None
if isinstance(text, io.TimedString):
start_time = text.start_time or None
end_time = text.end_time or None
if not self._audio_data.sr_data_annotated:
self._audio_data.sr_data_annotated = _SpeakingRateData()
if start_time is not None or end_time is not None:
# flush if we have time annotations
self._text_data.sentence_stream.flush()
# accumulate the actual hyphens if time annotations are present
self._audio_data.sr_data_annotated.add_by_annotation(
text=text,
start_time=start_time,
end_time=end_time,
text_to_hyphens=self._calc_hyphens,
)
self._text_data.sentence_stream.push_text(text)
self._text_data.pushed_text += text
if start_time is not None or end_time is not None:
self._text_data.sentence_stream.flush()
def end_text_input(self) -> None:
if self.closed:
logger.warning("_SegmentSynchronizerImpl.end_text_input called after close")
return
self._text_data.done = True
self._text_data.sentence_stream.end_input()
self._reestimate_speed()
def _reestimate_speed(self) -> None:
if not self._text_data.done or not self._audio_data.done:
return
pushed_hyphens = len(self._calc_hyphens(self._text_data.pushed_text))
# hyphens per second
if self._audio_data.pushed_duration > 0:
self._speed = pushed_hyphens / self._audio_data.pushed_duration
# hyphens per speaking unit
pushed_speaking_units = self._audio_data.sr_data_est.accumulate_to(
self._audio_data.pushed_duration
)
if pushed_speaking_units > 0:
self._speed_on_speaking_unit = pushed_hyphens / pushed_speaking_units
def mark_playback_finished(self, *, playback_position: float, interrupted: bool) -> None:
if self.closed:
logger.warning("_SegmentSynchronizerImpl.playback_finished called after close")
return
if not self._text_data.done or not self._audio_data.done:
logger.warning(
"_SegmentSynchronizerImpl.playback_finished called before text/audio input is done"
)
return
# if the playback of the segment is done and were not interrupted, make sure the whole
# transcript is sent. (In case we're late)
if not interrupted:
self._playback_completed = True
@property
def synchronized_transcript(self) -> str:
if self._playback_completed:
return self._text_data.pushed_text
return self._text_data.forwarded_text
@utils.log_exceptions(logger=logger)
async def _capture_task(self) -> None:
try:
async for text in self._out_ch:
self._text_data.forwarded_text += text
await self._next_in_chain.capture_text(text)
finally:
self._next_in_chain.flush()
@utils.log_exceptions(logger=logger)
async def _speaking_rate_task(self) -> None:
async for ev in self._audio_data.sr_stream:
self._audio_data.sr_data_est.add_by_rate(
timestamp=ev.timestamp, speaking_rate=ev.speaking_rate
)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
await self._start_fut.wait()
if self.closed and not self._playback_completed:
return
assert self._start_wall_time is not None
async for text_seg in self._text_data.sentence_stream:
sentence = text_seg.token
text_cursor = 0
for word, _, end_pos in self._opts.split_words(sentence):
if self.closed and not self._playback_completed:
return
if self._playback_completed:
self._out_ch.send_nowait(sentence[text_cursor:end_pos])
text_cursor = end_pos
continue
word_hyphens = len(self._opts.hyphenate_word(word))
elapsed = time.time() - self._start_wall_time
target_hyphens: float | None = None
if self._audio_data.sr_data_annotated:
# use the actual speaking rate
target_hyphens = self._audio_data.sr_data_annotated.accumulate_to(elapsed)
elif self._speed_on_speaking_unit:
# use the estimated speed from speaking rate
target_speaking_units = self._audio_data.sr_data_est.accumulate_to(elapsed)
target_hyphens = target_speaking_units * self._speed_on_speaking_unit
if target_hyphens is not None:
dt = np.ceil(target_hyphens) - self._text_data.forwarded_hyphens
delay = max(0.0, word_hyphens - dt) / self._speed
else:
delay = word_hyphens / self._speed
# if playback completed, flush the word as soon as possible
if self._playback_completed:
delay = 0
await self._sleep_if_not_closed(delay / 2.0)
self._out_ch.send_nowait(sentence[text_cursor:end_pos])
await self._sleep_if_not_closed(delay / 2.0)
self._text_data.forwarded_hyphens += word_hyphens
text_cursor = end_pos
if text_cursor < len(sentence):
# send the remaining text (e.g. new line or spaces)
self._out_ch.send_nowait(sentence[text_cursor:])
def _calc_hyphens(self, text: str) -> list[str]:
"""Calculate hyphens for text."""
hyphens: list[str] = []
words: list[tuple[str, int, int]] = self._opts.split_words(text=text)
for word, _, _ in words:
new = self._opts.hyphenate_word(word)
hyphens.extend(new)
return hyphens
async def _sleep_if_not_closed(self, delay: float) -> None:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait([self._close_future], timeout=delay)
async def aclose(self) -> None:
if self.closed:
return
self._close_future.set_result(None)
self._start_fut.set() # avoid deadlock of main_task in case it never started
await self._text_data.sentence_stream.aclose()
await self._audio_data.sr_stream.aclose()
await self._capture_atask
await self._speaking_rate_atask
class TranscriptSynchronizer:
"""
Synchronizes text with audio playback timing.
This class is responsible for synchronizing text with audio playback timing.
It currently assumes that the first push_audio is starting the audio playback of a segment.
"""
def __init__(
self,
*,
next_in_chain_audio: io.AudioOutput,
next_in_chain_text: io.TextOutput,
speed: float = 1.0,
hyphenate_word: Callable[[str], list[str]] = tokenize.basic.hyphenate_word,
split_words: Callable[[str], list[tuple[str, int, int]]] = functools.partial(
tokenize.basic.split_words, ignore_punctuation=False
),
sentence_tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
) -> None:
super().__init__()
self._text_output = _SyncedTextOutput(self, next_in_chain=next_in_chain_text)
self._audio_output = _SyncedAudioOutput(self, next_in_chain=next_in_chain_audio)
self._text_attached, self._audio_attached = True, True
self._opts = _TextSyncOptions(
speed=speed,
hyphenate_word=hyphenate_word,
split_words=split_words,
sentence_tokenizer=(
sentence_tokenizer or tokenize.basic.SentenceTokenizer(retain_format=True)
),
speaking_rate_detector=SpeakingRateDetector(),
)
self._enabled = True
self._closed = False
# initial segment/first segment, recreated for each new segment
self._impl = _SegmentSynchronizerImpl(options=self._opts, next_in_chain=next_in_chain_text)
self._rotate_segment_atask = asyncio.create_task(self._rotate_segment_task())
@property
def audio_output(self) -> _SyncedAudioOutput:
return self._audio_output
@property
def text_output(self) -> _SyncedTextOutput:
return self._text_output
@property
def enabled(self) -> bool:
return self._enabled
async def aclose(self) -> None:
self._closed = True
await self.barrier()
await self._impl.aclose()
def set_enabled(self, enabled: bool) -> None:
if self._enabled == enabled:
return
self._enabled = enabled
self.rotate_segment()
def _on_attachment_changed(
self,
*,
audio_attached: NotGivenOr[bool] = NOT_GIVEN,
text_attached: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
if is_given(audio_attached):
self._audio_attached = audio_attached
if is_given(text_attached):
self._text_attached = text_attached
self.set_enabled(self._audio_attached and self._text_attached)
async def _rotate_segment_task(self) -> None:
await self._impl.aclose()
self._impl = _SegmentSynchronizerImpl(
options=self._opts, next_in_chain=self._text_output._next_in_chain
)
def rotate_segment(self) -> None:
if self._closed:
return
if not self._rotate_segment_atask.done():
logger.warning("rotate_segment called while previous segment is still being rotated")
self._rotate_segment_atask = asyncio.create_task(self._rotate_segment_task())
async def barrier(self) -> None:
if self._rotate_segment_atask is None:
return
# using a while loop in case rotate_segment is called twice (this should not happen, but
# just in case, we do log a warning if it does)
while not self._rotate_segment_atask.done():
await self._rotate_segment_atask
class _SyncedAudioOutput(io.AudioOutput):
def __init__(
self, synchronizer: TranscriptSynchronizer, *, next_in_chain: io.AudioOutput
) -> None:
super().__init__(next_in_chain=next_in_chain, sample_rate=next_in_chain.sample_rate)
self._next_in_chain = next_in_chain # redefined for better typing
self._synchronizer = synchronizer
self._capturing = False
self._pushed_duration: float = 0.0
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
# using barrier() on capture should be sufficient, flush() must not be called if
# capture_frame isn't completed
await self._synchronizer.barrier()
self._capturing = True
await super().capture_frame(frame)
await self._next_in_chain.capture_frame(frame) # passthrough audio
self._pushed_duration += frame.duration
if not self._synchronizer.enabled:
return
self._synchronizer._impl.push_audio(frame)
def flush(self) -> None:
super().flush()
self._next_in_chain.flush()
if not self._synchronizer.enabled:
return
if not self._pushed_duration:
# in case there is no audio after text was pushed, rotate the segment
self._synchronizer.rotate_segment()
return
self._capturing = False
self._synchronizer._impl.end_audio_input()
def clear_buffer(self) -> None:
super().clear_buffer()
self._next_in_chain.clear_buffer()
self._capturing = False
# this is going to be automatically called by the next_in_chain
def on_playback_finished(
self,
*,
playback_position: float,
interrupted: bool,
synchronized_transcript: str | None = None,
) -> None:
if not self._synchronizer.enabled:
super().on_playback_finished(
playback_position=playback_position,
interrupted=interrupted,
synchronized_transcript=synchronized_transcript,
)
return
self._synchronizer._impl.mark_playback_finished(
playback_position=playback_position, interrupted=interrupted
)
super().on_playback_finished(
playback_position=playback_position,
interrupted=interrupted,
synchronized_transcript=self._synchronizer._impl.synchronized_transcript,
)
self._synchronizer.rotate_segment()
self._pushed_duration = 0.0
def on_attached(self) -> None:
super().on_attached()
self._synchronizer._on_attachment_changed(audio_attached=True)
def on_detached(self) -> None:
super().on_detached()
self._synchronizer._on_attachment_changed(audio_attached=False)
class _SyncedTextOutput(io.TextOutput):
def __init__(
self, synchronizer: TranscriptSynchronizer, *, next_in_chain: io.TextOutput
) -> None:
super().__init__(next_in_chain=next_in_chain)
self._next_in_chain = next_in_chain # redefined for better typing
self._synchronizer = synchronizer
self._capturing = False
async def capture_text(self, text: str) -> None:
await self._synchronizer.barrier()
await super().capture_text(text)
if not self._synchronizer.enabled: # passthrough text if the synchronizer is disabled
await self._next_in_chain.capture_text(text)
return
self._capturing = True
self._synchronizer._impl.push_text(text)
def flush(self) -> None:
super().flush()
if not self._synchronizer.enabled: # passthrough text if the synchronizer is disabled
self._next_in_chain.flush()
return
if not self._capturing:
return
self._capturing = False
self._synchronizer._impl.end_text_input()
def on_attached(self) -> None:
super().on_attached()
self._synchronizer._on_attachment_changed(text_attached=True)
def on_detached(self) -> None:
super().on_detached()
self._synchronizer._on_attachment_changed(text_attached=False)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextlib
import datetime
import inspect
import json
import math
import multiprocessing as mp
import os
import sys
import threading
import time
from collections.abc import Awaitable
from dataclasses import dataclass, field
from enum import Enum
from functools import reduce
from typing import Any, Callable, Generic, Literal, TypeVar
from urllib.parse import urljoin, urlparse
import aiohttp
import jwt
from aiohttp import web
from livekit import api, rtc
from livekit.protocol import agent, models
from . import http_server, ipc, utils
from ._exceptions import AssignmentTimeoutError
from .debug import tracing
from .inference_runner import _InferenceRunner
from .job import (
JobAcceptArguments,
JobContext,
JobExecutorType,
JobProcess,
JobRequest,
RunningJobInfo,
)
from .log import DEV_LEVEL, logger
from .types import NOT_GIVEN, NotGivenOr
from .utils import is_given
from .utils.hw import get_cpu_monitor
from .version import __version__
ASSIGNMENT_TIMEOUT = 7.5
UPDATE_STATUS_INTERVAL = 2.5
UPDATE_LOAD_INTERVAL = 0.5
def _default_initialize_process_fnc(proc: JobProcess) -> Any:
return
async def _default_request_fnc(ctx: JobRequest) -> None:
await ctx.accept()
class WorkerType(Enum):
ROOM = agent.JobType.JT_ROOM
PUBLISHER = agent.JobType.JT_PUBLISHER
@dataclass
class SimulateJobInfo:
room: str
participant_identity: str | None = None
class _DefaultLoadCalc:
_instance = None
def __init__(self) -> None:
self._m_avg = utils.MovingAverage(5) # avg over 2.5
self._cpu_monitor = get_cpu_monitor()
self._thread = threading.Thread(
target=self._calc_load, daemon=True, name="worker_cpu_load_monitor"
)
self._lock = threading.Lock()
self._thread.start()
def _calc_load(self) -> None:
while True:
cpu_p = self._cpu_monitor.cpu_percent(interval=0.5)
with self._lock:
self._m_avg.add_sample(cpu_p)
def _get_avg(self) -> float:
with self._lock:
return self._m_avg.get_avg()
@classmethod
def get_load(cls, worker: Worker) -> float:
if cls._instance is None:
cls._instance = _DefaultLoadCalc()
return cls._instance._m_avg.get_avg()
@dataclass
class WorkerPermissions:
can_publish: bool = True
can_subscribe: bool = True
can_publish_data: bool = True
can_update_metadata: bool = True
can_publish_sources: list[models.TrackSource] = field(default_factory=list)
hidden: bool = False
if sys.platform.startswith("win"):
# Some python versions on Windows gets a BrokenPipeError when creating a new process
_default_job_executor_type = JobExecutorType.THREAD
else:
_default_job_executor_type = JobExecutorType.PROCESS
T = TypeVar("T")
@dataclass(frozen=True)
class _WorkerEnvOption(Generic[T]):
dev_default: T
prod_default: T
@staticmethod
def getvalue(opt: T | _WorkerEnvOption[T], devmode: bool) -> T:
if isinstance(opt, _WorkerEnvOption):
return opt.dev_default if devmode else opt.prod_default
return opt
# NOTE: this object must be pickle-able
@dataclass
class WorkerOptions:
entrypoint_fnc: Callable[[JobContext], Awaitable[None]]
"""Entrypoint function that will be called when a job is assigned to this worker."""
request_fnc: Callable[[JobRequest], Awaitable[None]] = _default_request_fnc
"""Inspect the request and decide if the current worker should handle it.
When left empty, all jobs are accepted."""
prewarm_fnc: Callable[[JobProcess], Any] = _default_initialize_process_fnc
"""A function to perform any necessary initialization before the job starts."""
load_fnc: Callable[[Worker], float] | Callable[[], float] = _DefaultLoadCalc.get_load
"""Called to determine the current load of the worker. Should return a value between 0 and 1."""
job_executor_type: JobExecutorType = _default_job_executor_type
"""Which executor to use to run jobs. (currently thread or process are supported)"""
load_threshold: float | _WorkerEnvOption[float] = _WorkerEnvOption(
dev_default=math.inf, prod_default=0.75
)
"""When the load exceeds this threshold, the worker will be marked as unavailable.
Defaults to 0.75 on "production" mode, and is disabled in "development" mode.
"""
job_memory_warn_mb: float = 500
"""Memory warning threshold in MB. If the job process exceeds this limit, a warning will be logged.""" # noqa: E501
job_memory_limit_mb: float = 0
"""Maximum memory usage for a job in MB, the job process will be killed if it exceeds this limit.
Defaults to 0 (disabled).
""" # noqa: E501
"""Number of idle processes to keep warm."""
num_idle_processes: int | _WorkerEnvOption[int] = _WorkerEnvOption(
dev_default=0, prod_default=math.ceil(get_cpu_monitor().cpu_count())
)
"""Number of idle processes to keep warm."""
shutdown_process_timeout: float = 60.0
"""Maximum amount of time to wait for a job to shut down gracefully"""
initialize_process_timeout: float = 10.0
"""Maximum amount of time to wait for a process to initialize/prewarm"""
permissions: WorkerPermissions = field(default_factory=WorkerPermissions)
"""Permissions that the agent should join the room with."""
agent_name: str = ""
"""Set agent_name to enable explicit dispatch. When explicit dispatch is enabled, jobs will not be dispatched to rooms automatically. Instead, you can either specify the agent(s) to be dispatched in the end-user's token, or use the AgentDispatch.createDispatch API""" # noqa: E501
worker_type: WorkerType = WorkerType.ROOM
"""Whether to spin up an agent for each room or publisher."""
max_retry: int = 16
"""Maximum number of times to retry connecting to LiveKit."""
ws_url: str = "ws://localhost:7880"
"""URL to connect to the LiveKit server.
By default it uses ``LIVEKIT_URL`` from environment"""
api_key: str | None = None
"""API key to authenticate with LiveKit.
By default it uses ``LIVEKIT_API_KEY`` from environment"""
api_secret: str | None = None
"""API secret to authenticate with LiveKit.
By default it uses ``LIVEKIT_API_SECRET`` from environment"""
host: str = "" # default to all interfaces
port: int | _WorkerEnvOption[int] = _WorkerEnvOption(dev_default=0, prod_default=8081)
"""Port for local HTTP server to listen on.
The HTTP server is used as a health check endpoint.
"""
http_proxy: NotGivenOr[str | None] = NOT_GIVEN
"""HTTP proxy used to connect to the LiveKit server.
By default it uses ``HTTP_PROXY`` or ``HTTPS_PROXY`` from environment
"""
def validate_config(self, devmode: bool):
load_threshold = _WorkerEnvOption.getvalue(self.load_threshold, devmode)
if load_threshold > 1 and not devmode:
logger.warning(
f"load_threshold in prod env must be less than 1, current value: {load_threshold}"
)
@dataclass
class WorkerInfo:
http_port: int
EventTypes = Literal["worker_started", "worker_registered"]
class Worker(utils.EventEmitter[EventTypes]):
def __init__(
self,
opts: WorkerOptions,
*,
devmode: bool = True,
register: bool = True,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
super().__init__()
opts.ws_url = opts.ws_url or os.environ.get("LIVEKIT_URL") or ""
opts.api_key = opts.api_key or os.environ.get("LIVEKIT_API_KEY") or ""
opts.api_secret = opts.api_secret or os.environ.get("LIVEKIT_API_SECRET") or ""
if not opts.ws_url:
raise ValueError("ws_url is required, or add LIVEKIT_URL in your environment")
if not opts.api_key:
raise ValueError("api_key is required, or add LIVEKIT_API_KEY in your environment")
if not opts.api_secret:
raise ValueError(
"api_secret is required, or add LIVEKIT_API_SECRET in your environment"
)
if opts.job_memory_limit_mb > 0 and opts.job_executor_type != JobExecutorType.PROCESS:
logger.warning(
"max_job_memory_usage is only supported for process-based job executors, "
"ignoring max_job_memory_usage"
)
if not is_given(opts.http_proxy):
opts.http_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
self._opts = opts
self._loop = loop or asyncio.get_event_loop()
self._id = "unregistered"
self._closed, self._draining, self._connecting = True, False, False
self._tasks = set[asyncio.Task[Any]]()
self._pending_assignments: dict[str, asyncio.Future[agent.JobAssignment]] = {}
self._close_future: asyncio.Future[None] | None = None
self._msg_chan = utils.aio.Chan[agent.WorkerMessage](128, loop=self._loop)
self._devmode = devmode
self._register = register
# using spawn context for all platforms. We may have further optimizations for
# Linux with forkserver, but for now, this is the safest option
mp_ctx = mp.get_context("spawn")
self._inference_executor: ipc.inference_proc_executor.InferenceProcExecutor | None = None
if len(_InferenceRunner.registered_runners) > 0:
self._inference_executor = ipc.inference_proc_executor.InferenceProcExecutor(
runners=_InferenceRunner.registered_runners,
initialize_timeout=30,
close_timeout=5,
memory_warn_mb=2000,
memory_limit_mb=0, # no limit
ping_interval=5,
ping_timeout=60,
high_ping_threshold=2.5,
mp_ctx=mp_ctx,
loop=self._loop,
http_proxy=opts.http_proxy or None,
)
self._proc_pool = ipc.proc_pool.ProcPool(
initialize_process_fnc=opts.prewarm_fnc,
job_entrypoint_fnc=opts.entrypoint_fnc,
num_idle_processes=_WorkerEnvOption.getvalue(opts.num_idle_processes, self._devmode),
loop=self._loop,
job_executor_type=opts.job_executor_type,
inference_executor=self._inference_executor,
mp_ctx=mp_ctx,
initialize_timeout=opts.initialize_process_timeout,
close_timeout=opts.shutdown_process_timeout,
memory_warn_mb=opts.job_memory_warn_mb,
memory_limit_mb=opts.job_memory_limit_mb,
http_proxy=opts.http_proxy or None,
)
self._previous_status = agent.WorkerStatus.WS_AVAILABLE
self._api: api.LiveKitAPI | None = None
self._http_session: aiohttp.ClientSession | None = None
self._http_server = http_server.HttpServer(
opts.host,
_WorkerEnvOption.getvalue(opts.port, self._devmode),
loop=self._loop,
)
async def health_check(_: Any):
return web.Response(text="OK")
async def worker(_: Any):
body = json.dumps(
{
"agent_name": self._opts.agent_name,
"worker_type": agent.JobType.Name(self._opts.worker_type.value),
"active_jobs": len(self.active_jobs),
}
)
return web.Response(body=body, content_type="application/json")
self._http_server.app.add_routes([web.get("/", health_check)])
self._http_server.app.add_routes([web.get("/worker", worker)])
self._http_server.app.add_subapp("/debug", tracing._create_tracing_app(self))
self._conn_task: asyncio.Task[None] | None = None
self._load_task: asyncio.Task[None] | None = None
self._worker_load: float = 0.0
self._worker_load_graph = tracing.Tracing.add_graph(
title="worker_load",
x_label="time",
y_label="load",
x_type="time",
y_range=(0, 1),
max_data_points=int(1 / UPDATE_LOAD_INTERVAL * 30),
)
default_num_idle_processes = _WorkerEnvOption.getvalue(
self._opts.num_idle_processes, self._devmode
)
self._num_idle_target_graph = tracing.Tracing.add_graph(
title="num_idle_processes_target",
x_label="time",
y_label="target",
x_type="time",
y_range=(0, default_num_idle_processes),
max_data_points=int(1 / UPDATE_LOAD_INTERVAL * 30),
)
self._num_idle_process_graph = tracing.Tracing.add_graph(
title="num_idle_processes",
x_label="time",
y_label="idle",
x_type="time",
y_range=(0, default_num_idle_processes),
max_data_points=int(1 / UPDATE_LOAD_INTERVAL * 30),
)
@property
def worker_info(self) -> WorkerInfo:
return WorkerInfo(http_port=self._http_server.port)
async def run(self):
if not self._closed:
raise Exception("worker is already running")
logger.info(
"starting worker",
extra={"version": __version__, "rtc-version": rtc.__version__},
)
if self._inference_executor is not None:
logger.info("starting inference executor")
await self._inference_executor.start()
await self._inference_executor.initialize()
self._closed = False
def _update_job_status(proc: ipc.job_executor.JobExecutor) -> None:
t = self._loop.create_task(self._update_job_status(proc))
self._tasks.add(t)
t.add_done_callback(self._tasks.discard)
await self._http_server.start()
self._proc_pool.on("process_started", _update_job_status)
self._proc_pool.on("process_closed", _update_job_status)
self._proc_pool.on("process_job_launched", _update_job_status)
await self._proc_pool.start()
self._http_session = aiohttp.ClientSession(proxy=self._opts.http_proxy or None)
self._api = api.LiveKitAPI(
self._opts.ws_url, self._opts.api_key, self._opts.api_secret, session=self._http_session
)
self._close_future = asyncio.Future(loop=self._loop)
@utils.log_exceptions(logger=logger)
async def _load_task():
"""periodically check load"""
interval = utils.aio.interval(UPDATE_LOAD_INTERVAL)
while True:
await interval.tick()
def load_fnc():
signature = inspect.signature(self._opts.load_fnc)
parameters = list(signature.parameters.values())
if len(parameters) == 0:
return self._opts.load_fnc() # type: ignore
return self._opts.load_fnc(self) # type: ignore
self._worker_load = await asyncio.get_event_loop().run_in_executor(None, load_fnc)
load_threshold = _WorkerEnvOption.getvalue(self._opts.load_threshold, self._devmode)
default_num_idle_processes = _WorkerEnvOption.getvalue(
self._opts.num_idle_processes, self._devmode
)
if not math.isinf(load_threshold):
active_jobs = len(self.active_jobs)
if active_jobs > 0:
job_load = self._worker_load / len(self.active_jobs)
if job_load > 0.0:
available_load = max(load_threshold - self._worker_load, 0.0)
available_job = min(
math.ceil(available_load / job_load), default_num_idle_processes
)
self._proc_pool.set_target_idle_processes(available_job)
else:
self._proc_pool.set_target_idle_processes(default_num_idle_processes)
self._num_idle_target_graph.plot(time.time(), self._proc_pool.target_idle_processes)
self._num_idle_process_graph.plot(
time.time(), self._proc_pool._warmed_proc_queue.qsize()
)
self._worker_load_graph.plot(time.time(), self._worker_load)
tasks = []
self._load_task = asyncio.create_task(_load_task(), name="load_task")
tasks.append(self._load_task)
if self._register:
self._conn_task = asyncio.create_task(self._connection_task(), name="worker_conn_task")
tasks.append(self._conn_task)
self.emit("worker_started")
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
if not self._close_future.done():
self._close_future.set_result(None)
@property
def id(self) -> str:
return self._id
@property
def active_jobs(self) -> list[RunningJobInfo]:
return [proc.running_job for proc in self._proc_pool.processes if proc.running_job]
async def drain(self, timeout: int | None = None) -> None:
"""When timeout isn't None, it will raise asyncio.TimeoutError if the processes didn't finish in time.""" # noqa: E501
if self._draining:
return
logger.info("draining worker", extra={"id": self.id, "timeout": timeout})
self._draining = True
await self._update_worker_status()
async def _join_jobs():
for proc in self._proc_pool.processes:
if proc.running_job:
await proc.join()
if timeout:
await asyncio.wait_for(_join_jobs(), timeout) # raises asyncio.TimeoutError on timeout
else:
await _join_jobs()
async def simulate_job(
self,
info: SimulateJobInfo | str,
) -> None:
"""
Simulate a job by creating a room and participant.
Args:
info: SimulateJobInfo or a join token for an existing room
"""
assert self._api is not None
# TODO(theomonnom): some fake information can still be found in the token
from livekit.protocol.models import Room
room = info.room if isinstance(info, SimulateJobInfo) else "unknown-room"
participant_identity = (
info.participant_identity
if isinstance(info, SimulateJobInfo)
else "unknown-participant"
)
agent_id = utils.shortuuid("simulated-agent-")
room_info = Room(sid=utils.shortuuid("RM_"), name=room)
participant_info = None
if isinstance(info, SimulateJobInfo):
from .cli import cli
if cli.CLI_ARGUMENTS is None or not cli.CLI_ARGUMENTS.console:
room_info = await self._api.room.create_room(api.CreateRoomRequest(name=room))
if participant_identity:
participant_info = await self._api.room.get_participant(
api.RoomParticipantIdentity(room=room, identity=participant_identity)
)
token = (
api.AccessToken(self._opts.api_key, self._opts.api_secret)
.with_identity(agent_id)
.with_kind("agent")
.with_grants(api.VideoGrants(room_join=True, room=room, agent=True))
.to_jwt()
)
else:
token = info
job = agent.Job(
id=utils.shortuuid("simulated-job-"),
room=room_info,
type=agent.JobType.JT_ROOM,
participant=participant_info,
)
running_info = RunningJobInfo(
worker_id=self._id,
accept_arguments=JobAcceptArguments(identity=agent_id, name="", metadata=""),
job=job,
url=self._opts.ws_url,
token=token,
)
await self._proc_pool.launch_job(running_info)
async def aclose(self) -> None:
if self._closed:
if self._close_future is not None:
await self._close_future
return
logger.info("shutting down worker", extra={"id": self.id})
assert self._close_future is not None
assert self._http_session is not None
assert self._api is not None
self._closed = True
if self._conn_task is not None:
await utils.aio.cancel_and_wait(self._conn_task)
if self._load_task is not None:
await utils.aio.cancel_and_wait(self._load_task)
await self._proc_pool.aclose()
if self._inference_executor is not None:
await self._inference_executor.aclose()
await self._http_session.close()
await self._http_server.aclose()
await self._api.aclose()
await asyncio.gather(*self._tasks, return_exceptions=True)
# await asyncio.sleep(0.25) # see https://github.com/aio-libs/aiohttp/issues/1925
self._msg_chan.close()
await self._close_future
async def _queue_msg(self, msg: agent.WorkerMessage) -> None:
"""_queue_msg raises aio.ChanClosed when the worker is closing/closed"""
if self._connecting:
which = msg.WhichOneof("message")
if which == "update_worker":
return
elif which == "ping":
return
await self._msg_chan.send(msg)
@utils.log_exceptions(logger=logger)
async def _connection_task(self) -> None:
assert self._http_session is not None
retry_count = 0
ws: aiohttp.ClientWebSocketResponse | None = None
while not self._closed:
try:
self._connecting = True
join_jwt = (
api.AccessToken(self._opts.api_key, self._opts.api_secret)
.with_grants(api.VideoGrants(agent=True))
.to_jwt()
)
headers = {"Authorization": f"Bearer {join_jwt}"}
parse = urlparse(self._opts.ws_url)
scheme = parse.scheme
if scheme.startswith("http"):
scheme = scheme.replace("http", "ws")
path_parts = [f"{scheme}://{parse.netloc}", parse.path, "/agent"]
agent_url = reduce(urljoin, path_parts)
ws = await self._http_session.ws_connect(
agent_url, headers=headers, autoping=True, proxy=self._opts.http_proxy or None
)
retry_count = 0
# register the worker
req = agent.WorkerMessage()
req.register.type = self._opts.worker_type.value
req.register.allowed_permissions.CopyFrom(
models.ParticipantPermission(
can_publish=self._opts.permissions.can_publish,
can_subscribe=self._opts.permissions.can_subscribe,
can_publish_data=self._opts.permissions.can_publish_data,
can_update_metadata=self._opts.permissions.can_update_metadata,
can_publish_sources=self._opts.permissions.can_publish_sources,
hidden=self._opts.permissions.hidden,
agent=True,
)
)
req.register.agent_name = self._opts.agent_name
req.register.version = __version__
await ws.send_bytes(req.SerializeToString())
# wait for the register response before running this connection
first_msg_b = await ws.receive_bytes()
msg = agent.ServerMessage()
msg.ParseFromString(first_msg_b)
if not msg.HasField("register"):
raise Exception("expected register response as first message")
self._handle_register(msg.register)
self._connecting = False
await self._run_ws(ws)
except Exception as e:
if self._closed:
break
if retry_count >= self._opts.max_retry:
raise RuntimeError(
f"failed to connect to livekit after {retry_count} attempts",
) from None
retry_delay = min(retry_count * 2, 10)
retry_count += 1
logger.warning(
f"failed to connect to livekit, retrying in {retry_delay}s", exc_info=e
)
await asyncio.sleep(retry_delay)
finally:
if ws is not None:
await ws.close()
async def _run_ws(self, ws: aiohttp.ClientWebSocketResponse):
closing_ws = False
async def _load_task():
"""periodically update worker status"""
interval = utils.aio.interval(UPDATE_STATUS_INTERVAL)
while True:
await interval.tick()
await self._update_worker_status()
async def _send_task():
nonlocal closing_ws
while True:
try:
msg = await self._msg_chan.recv()
await ws.send_bytes(msg.SerializeToString())
except utils.aio.ChanClosed:
closing_ws = True
return
async def _recv_task():
nonlocal closing_ws
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws:
return
raise Exception("worker connection closed unexpectedly")
if msg.type != aiohttp.WSMsgType.BINARY:
logger.warning("unexpected message type: %s", msg.type)
continue
data = msg.data
msg = agent.ServerMessage()
msg.ParseFromString(data)
which = msg.WhichOneof("message")
if which == "availability":
self._handle_availability(msg.availability)
elif which == "assignment":
self._handle_assignment(msg.assignment)
elif which == "termination":
user_task = self._loop.create_task(
self._handle_termination(msg.termination),
name="agent_job_termination",
)
self._tasks.add(user_task)
user_task.add_done_callback(self._tasks.discard)
tasks = [
asyncio.create_task(_load_task()),
asyncio.create_task(_send_task()),
asyncio.create_task(_recv_task()),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
async def _reload_jobs(self, jobs: list[RunningJobInfo]) -> None:
if not self._opts.api_secret:
raise RuntimeError("api_secret is required to reload jobs")
for aj in jobs:
logger.log(
DEV_LEVEL,
"reloading job",
extra={"job_id": aj.job.id, "agent_name": aj.job.agent_name},
)
# take the original jwt token and extend it while keeping all the same data that was generated # noqa: E501
# by the SFU for the original join token.
original_token = aj.token
decoded = jwt.decode(original_token, self._opts.api_secret, algorithms=["HS256"])
decoded["exp"] = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + 3600
running_info = RunningJobInfo(
accept_arguments=aj.accept_arguments,
job=aj.job,
url=self._opts.ws_url,
token=jwt.encode(decoded, self._opts.api_secret, algorithm="HS256"),
worker_id=aj.worker_id,
)
await self._proc_pool.launch_job(running_info)
def _handle_register(self, reg: agent.RegisterWorkerResponse):
self._id = reg.worker_id
logger.info(
"registered worker",
extra={
"id": reg.worker_id,
"url": self._opts.ws_url,
"region": reg.server_info.region,
"protocol": reg.server_info.protocol,
},
)
self.emit("worker_registered", reg.worker_id, reg.server_info)
def _handle_availability(self, msg: agent.AvailabilityRequest):
task = self._loop.create_task(self._answer_availability(msg))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _answer_availability(self, msg: agent.AvailabilityRequest):
"""Ask the user if they want to accept this job and forward the answer to the server.
If we get the job assigned, we start a new process."""
answered = False
async def _on_reject() -> None:
nonlocal answered
answered = True
availability_resp = agent.WorkerMessage()
availability_resp.availability.job_id = msg.job.id
availability_resp.availability.available = False
await self._queue_msg(availability_resp)
async def _on_accept(args: JobAcceptArguments) -> None:
nonlocal answered
answered = True
availability_resp = agent.WorkerMessage()
availability_resp.availability.job_id = msg.job.id
availability_resp.availability.available = True
availability_resp.availability.participant_identity = args.identity
availability_resp.availability.participant_name = args.name
availability_resp.availability.participant_metadata = args.metadata
if args.attributes:
availability_resp.availability.participant_attributes.update(args.attributes)
await self._queue_msg(availability_resp)
wait_assignment = asyncio.Future[agent.JobAssignment]()
self._pending_assignments[job_req.id] = wait_assignment
# the job was accepted by the user, wait for the server assignment
try:
await asyncio.wait_for(wait_assignment, ASSIGNMENT_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(
f"assignment for job {job_req.id} timed out",
extra={"job_request": job_req, "agent_name": self._opts.agent_name},
)
raise AssignmentTimeoutError() from None
job_assign = wait_assignment.result()
running_info = RunningJobInfo(
accept_arguments=args,
job=msg.job,
url=job_assign.url or self._opts.ws_url,
token=job_assign.token,
worker_id=self._id,
)
await self._proc_pool.launch_job(running_info)
job_req = JobRequest(job=msg.job, on_reject=_on_reject, on_accept=_on_accept)
logger.info(
"received job request",
extra={
"job_id": msg.job.id,
"dispatch_id": msg.job.dispatch_id,
"room_name": msg.job.room.name,
"agent_name": self._opts.agent_name,
"resuming": msg.resuming,
},
)
@utils.log_exceptions(logger=logger)
async def _job_request_task():
try:
await self._opts.request_fnc(job_req)
except Exception:
logger.exception(
"job_request_fnc failed",
extra={"job_request": job_req, "agent_name": self._opts.agent_name},
)
if not answered:
logger.warning(
"no answer was given inside the job_request_fnc, automatically rejecting the job", # noqa: E501
extra={"job_request": job_req, "agent_name": self._opts.agent_name},
)
await _on_reject()
user_task = self._loop.create_task(_job_request_task(), name="job_request")
self._tasks.add(user_task)
user_task.add_done_callback(self._tasks.discard)
def _handle_assignment(self, assignment: agent.JobAssignment):
if assignment.job.id in self._pending_assignments:
with contextlib.suppress(asyncio.InvalidStateError):
fut = self._pending_assignments.pop(assignment.job.id)
fut.set_result(assignment)
else:
logger.warning(
"received assignment for an unknown job",
extra={"job": assignment.job, "agent_name": self._opts.agent_name},
)
async def _handle_termination(self, msg: agent.JobTermination):
proc = self._proc_pool.get_by_job_id(msg.job_id)
if not proc:
# safe to ignore
return
await proc.aclose()
async def _update_worker_status(self):
job_cnt = len(self.active_jobs)
if self._draining:
update = agent.UpdateWorkerStatus(status=agent.WorkerStatus.WS_FULL, job_count=job_cnt)
msg = agent.WorkerMessage(update_worker=update)
await self._queue_msg(msg)
return
load_threshold = _WorkerEnvOption.getvalue(self._opts.load_threshold, self._devmode)
is_full = self._worker_load >= load_threshold
currently_available = not is_full and not self._draining
status = (
agent.WorkerStatus.WS_AVAILABLE if currently_available else agent.WorkerStatus.WS_FULL
)
update = agent.UpdateWorkerStatus(load=self._worker_load, status=status, job_count=job_cnt)
# only log if status has changed
if self._previous_status != status and not self._draining:
self._previous_status = status
extra = {
"load": self._worker_load,
"threshold": self._opts.load_threshold,
}
if is_full:
logger.info(
"worker is at full capacity, marking as unavailable",
extra=extra,
)
else:
logger.info(
"worker is below capacity, marking as available",
extra=extra,
)
msg = agent.WorkerMessage(update_worker=update)
with contextlib.suppress(utils.aio.ChanClosed):
await self._queue_msg(msg)
async def _update_job_status(self, proc: ipc.job_executor.JobExecutor) -> None:
job_info = proc.running_job
if job_info is None:
return
status: agent.JobStatus = agent.JobStatus.JS_RUNNING
if proc.status == ipc.job_executor.JobStatus.FAILED:
status = agent.JobStatus.JS_FAILED
elif proc.status == ipc.job_executor.JobStatus.SUCCESS:
status = agent.JobStatus.JS_SUCCESS
elif proc.status == ipc.job_executor.JobStatus.RUNNING:
status = agent.JobStatus.JS_RUNNING
update = agent.UpdateJobStatus(job_id=job_info.job.id, status=status, error="")
msg = agent.WorkerMessage(update_job=update)
await self._queue_msg(msg)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-agents"
dynamic = ["version"]
description = "A powerful framework for building realtime voice AI agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit", "agents", "AI"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"click~=8.1",
"livekit>=1.0.6,<2",
"livekit-api>=1.0.2,<2",
"livekit-protocol~=1.0",
"protobuf>=3",
"pyjwt>=2.0",
"types-protobuf>=4,<5",
"watchfiles>=1.0",
"psutil>=7.0",
"aiohttp~=3.10",
"typing-extensions>=4.12",
"sounddevice>=0.5",
"docstring_parser>=0.16",
"eval-type-backport",
"colorama>=0.4.6",
"av>=12.0.0",
"numpy>=1.26.0",
"pydantic>=2.0,<3",
"nest-asyncio>=1.6.0",
]
[project.optional-dependencies]
codecs = ["av>=12.0.0", "numpy>=1.26.0"]
images = ["pillow>=10.3.0"]
aws = ["livekit-plugins-aws>=1.0.17"]
neuphonic = ["livekit-plugins-neuphonic>=1.0.17"]
playai = ["livekit-plugins-playai>=1.0.17"]
turn-detector = ["livekit-plugins-turn-detector>=1.0.17"]
assemblyai = ["livekit-plugins-assemblyai>=1.0.17"]
rime = ["livekit-plugins-rime>=1.0.17"]
nltk = ["livekit-plugins-nltk>=1.0.17"]
anthropic = ["livekit-plugins-anthropic>=1.0.17"]
openai = ["livekit-plugins-openai>=1.0.17"]
groq = ["livekit-plugins-groq>=1.0.17"]
elevenlabs = ["livekit-plugins-elevenlabs>=1.0.17"]
azure = ["livekit-plugins-azure>=1.0.17"]
fal = ["livekit-plugins-fal>=1.0.17"]
clova = ["livekit-plugins-clova>=1.0.17"]
deepgram = ["livekit-plugins-deepgram>=1.0.17"]
silero = ["livekit-plugins-silero>=1.0.17"]
cartesia = ["livekit-plugins-cartesia>=1.0.17"]
speechmatics = ["livekit-plugins-speechmatics>=1.0.17"]
google = ["livekit-plugins-google>=1.0.17"]
gladia = ["livekit-plugins-gladia>=1.0.17"]
resemble = ["livekit-plugins-resemble>=1.0.17"]
bey = ["livekit-plugins-bey>=1.0.17"]
bithuman = ["livekit-plugins-bithuman>=1.0.17"]
speechify = ["livekit-plugins-speechify>=1.0.17"]
tavus = ["livekit-plugins-tavus>=1.0.17"]
hume = ["livekit-plugins-hume>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/agents/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
include = ["livekit/agents/resources/*", "livekit/agents/debug/index.html"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Anthropic
Agent Framework plugin for services from Anthropic.
## Installation
```bash
pip install livekit-plugins-anthropic
You’ll need an API key from Anthropic. It can be set as an environment variable: ANTHROPIC_API_KEY
## livekit-plugins/livekit-plugins-anthropic/livekit/plugins/anthropic/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .llm import LLM, LLMStream
from .log import logger
from .models import ChatModels
from .version import __version__
__all__ = [
"LLM",
"LLMStream",
"ChatModels",
"logger",
"__version__",
]
from livekit.agents import Plugin
class AnthropicPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(AnthropicPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os
from collections.abc import Awaitable
from dataclasses import dataclass
from typing import Any, Literal
import httpx
import anthropic
from livekit.agents import APIConnectionError, APIStatusError, APITimeoutError, llm
from livekit.agents.llm import ToolChoice
from livekit.agents.llm.chat_context import ChatContext
from livekit.agents.llm.tool_context import FunctionTool
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .models import ChatModels
from .utils import to_chat_ctx, to_fnc_ctx
@dataclass
class _LLMOptions:
model: str | ChatModels
user: NotGivenOr[str]
temperature: NotGivenOr[float]
parallel_tool_calls: NotGivenOr[bool]
tool_choice: NotGivenOr[ToolChoice]
caching: NotGivenOr[Literal["ephemeral"]]
top_k: NotGivenOr[int]
max_tokens: NotGivenOr[int]
"""If set to "ephemeral", the system prompt, tools, and chat history will be cached."""
class LLM(llm.LLM):
def __init__(
self,
*,
model: str | ChatModels = "claude-3-5-sonnet-20241022",
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
user: NotGivenOr[str] = NOT_GIVEN,
client: anthropic.AsyncClient | None = None,
top_k: NotGivenOr[int] = NOT_GIVEN,
max_tokens: NotGivenOr[int] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
caching: NotGivenOr[Literal["ephemeral"]] = NOT_GIVEN,
) -> None:
"""
Create a new instance of Anthropic LLM.
``api_key`` must be set to your Anthropic API key, either using the argument or by setting
the ``ANTHROPIC_API_KEY`` environmental variable.
model (str | ChatModels): The model to use. Defaults to "claude-3-5-sonnet-20241022".
api_key (str, optional): The Anthropic API key. Defaults to the ANTHROPIC_API_KEY environment variable.
base_url (str, optional): The base URL for the Anthropic API. Defaults to None.
user (str, optional): The user for the Anthropic API. Defaults to None.
client (anthropic.AsyncClient | None): The Anthropic client to use. Defaults to None.
temperature (float, optional): The temperature for the Anthropic API. Defaults to None.
parallel_tool_calls (bool, optional): Whether to parallelize tool calls. Defaults to None.
tool_choice (ToolChoice, optional): The tool choice for the Anthropic API. Defaults to "auto".
caching (Literal["ephemeral"], optional): If set to "ephemeral", caching will be enabled for the system prompt, tools, and chat history.
""" # noqa: E501
super().__init__()
self._opts = _LLMOptions(
model=model,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
caching=caching,
top_k=top_k,
max_tokens=max_tokens,
)
anthropic_api_key = api_key if is_given(api_key) else os.environ.get("ANTHROPIC_API_KEY")
if not anthropic_api_key:
raise ValueError("Anthropic API key is required")
self._client = anthropic.AsyncClient(
api_key=anthropic_api_key,
base_url=base_url if is_given(base_url) else None,
http_client=httpx.AsyncClient(
timeout=5.0,
follow_redirects=True,
limits=httpx.Limits(
max_connections=1000,
max_keepalive_connections=100,
keepalive_expiry=120,
),
),
)
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
extra = {}
if is_given(extra_kwargs):
extra.update(extra_kwargs)
if is_given(self._opts.user):
extra["user"] = self._opts.user
if is_given(self._opts.temperature):
extra["temperature"] = self._opts.temperature
if is_given(self._opts.top_k):
extra["top_k"] = self._opts.top_k
extra["max_tokens"] = self._opts.max_tokens if is_given(self._opts.max_tokens) else 1024
if tools:
extra["tools"] = to_fnc_ctx(tools, self._opts.caching)
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice
if is_given(tool_choice):
anthropic_tool_choice: dict[str, Any] | None = {"type": "auto"}
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
anthropic_tool_choice = {
"type": "tool",
"name": tool_choice["function"]["name"],
}
elif isinstance(tool_choice, str):
if tool_choice == "required":
anthropic_tool_choice = {"type": "any"}
elif tool_choice == "none":
extra["tools"] = []
anthropic_tool_choice = None
if anthropic_tool_choice is not None:
parallel_tool_calls = (
parallel_tool_calls
if is_given(parallel_tool_calls)
else self._opts.parallel_tool_calls
)
if is_given(parallel_tool_calls):
anthropic_tool_choice["disable_parallel_tool_use"] = not parallel_tool_calls
extra["tool_choice"] = anthropic_tool_choice
anthropic_ctx, system_message = to_chat_ctx(chat_ctx, id(self), caching=self._opts.caching)
if system_message:
extra["system"] = [system_message]
stream = self._client.messages.create(
messages=anthropic_ctx,
model=self._opts.model,
stream=True,
**extra,
)
return LLMStream(
self,
anthropic_stream=stream,
chat_ctx=chat_ctx,
tools=tools,
conn_options=conn_options,
)
class LLMStream(llm.LLMStream):
def __init__(
self,
llm: LLM,
*,
anthropic_stream: Awaitable[anthropic.AsyncStream[anthropic.types.RawMessageStreamEvent]],
chat_ctx: llm.ChatContext,
tools: list[FunctionTool] | None,
conn_options: APIConnectOptions,
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._awaitable_anthropic_stream = anthropic_stream
self._anthropic_stream: (
anthropic.AsyncStream[anthropic.types.RawMessageStreamEvent] | None
) = None
# current function call that we're waiting for full completion (args are streamed)
self._tool_call_id: str | None = None
self._fnc_name: str | None = None
self._fnc_raw_arguments: str | None = None
self._request_id: str = ""
self._ignoring_cot = False # ignore chain of thought
self._input_tokens = 0
self._cache_creation_tokens = 0
self._cache_read_tokens = 0
self._output_tokens = 0
async def _run(self) -> None:
retryable = True
try:
if not self._anthropic_stream:
self._anthropic_stream = await self._awaitable_anthropic_stream
async with self._anthropic_stream as stream:
async for event in stream:
chat_chunk = self._parse_event(event)
if chat_chunk is not None:
self._event_ch.send_nowait(chat_chunk)
retryable = False
self._event_ch.send_nowait(
llm.ChatChunk(
id=self._request_id,
usage=llm.CompletionUsage(
completion_tokens=self._output_tokens,
prompt_tokens=self._input_tokens,
total_tokens=self._input_tokens
+ self._output_tokens
+ self._cache_creation_tokens
+ self._cache_read_tokens,
cache_creation_input_tokens=self._cache_creation_tokens,
cache_read_input_tokens=self._cache_read_tokens,
),
)
)
except anthropic.APITimeoutError as e:
raise APITimeoutError(retryable=retryable) from e
except anthropic.APIStatusError as e:
raise APIStatusError(
e.message,
status_code=e.status_code,
request_id=e.request_id,
body=e.body,
) from e
except Exception as e:
raise APIConnectionError(retryable=retryable) from e
def _parse_event(self, event: anthropic.types.RawMessageStreamEvent) -> llm.ChatChunk | None:
if event.type == "message_start":
self._request_id = event.message.id
self._input_tokens = event.message.usage.input_tokens
self._output_tokens = event.message.usage.output_tokens
if event.message.usage.cache_creation_input_tokens:
self._cache_creation_tokens = event.message.usage.cache_creation_input_tokens
if event.message.usage.cache_read_input_tokens:
self._cache_read_tokens = event.message.usage.cache_read_input_tokens
elif event.type == "message_delta":
self._output_tokens += event.usage.output_tokens
elif event.type == "content_block_start":
if event.content_block.type == "tool_use":
self._tool_call_id = event.content_block.id
self._fnc_name = event.content_block.name
self._fnc_raw_arguments = ""
elif event.type == "content_block_delta":
delta = event.delta
if delta.type == "text_delta":
text = delta.text
if self._tools is not None:
# anthropic may inject COC when using functions
if text.startswith("<thinking>"):
self._ignoring_cot = True
elif self._ignoring_cot and "</thinking>" in text:
text = text.split("</thinking>")[-1]
self._ignoring_cot = False
if self._ignoring_cot:
return None
return llm.ChatChunk(
id=self._request_id,
delta=llm.ChoiceDelta(content=text, role="assistant"),
)
elif delta.type == "input_json_delta":
assert self._fnc_raw_arguments is not None
self._fnc_raw_arguments += delta.partial_json
elif event.type == "content_block_stop":
if self._tool_call_id is not None:
assert self._fnc_name is not None
assert self._fnc_raw_arguments is not None
chat_chunk = llm.ChatChunk(
id=self._request_id,
delta=llm.ChoiceDelta(
role="assistant",
tool_calls=[
llm.FunctionToolCall(
arguments=self._fnc_raw_arguments or "",
name=self._fnc_name or "",
call_id=self._tool_call_id or "",
)
],
),
)
self._tool_call_id = self._fnc_raw_arguments = self._fnc_name = None
return chat_chunk
return None
import logging
logger = logging.getLogger("livekit.plugins.anthropic")
from typing import Literal
ChatModels = Literal[
"claude-3-5-sonnet-20240620",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-5-sonnet-20241022",
"claude-3-haiku-20240307",
]
import base64
import json
from typing import Any, Literal
import anthropic
from livekit.agents import llm
from livekit.agents.llm import FunctionTool
CACHE_CONTROL_EPHEMERAL = anthropic.types.CacheControlEphemeralParam(type="ephemeral")
__all__ = ["to_fnc_ctx", "to_chat_ctx"]
def to_fnc_ctx(
fncs: list[FunctionTool], caching: Literal["ephemeral"] | None
) -> list[anthropic.types.ToolParam]:
tools: list[anthropic.types.ToolParam] = []
for i, fnc in enumerate(fncs):
cache_ctrl = (
CACHE_CONTROL_EPHEMERAL if (i == len(fncs) - 1) and caching == "ephemeral" else None
)
tools.append(_build_anthropic_schema(fnc, cache_ctrl=cache_ctrl))
return tools
def to_chat_ctx(
chat_ctx: llm.ChatContext,
cache_key: Any,
caching: Literal["ephemeral"] | None,
) -> list[anthropic.types.MessageParam]:
messages: list[anthropic.types.MessageParam] = []
system_message: anthropic.types.TextBlockParam | None = None
current_role: str | None = None
content: list[anthropic.types.TextBlockParam] = []
for i, msg in enumerate(chat_ctx.items):
if msg.type == "message" and msg.role == "system":
for content in msg.content:
if content and isinstance(content, str):
system_message = anthropic.types.TextBlockParam(
text=content,
type="text",
cache_control=CACHE_CONTROL_EPHEMERAL if caching == "ephemeral" else None,
)
continue
cache_ctrl = (
CACHE_CONTROL_EPHEMERAL
if (i == len(chat_ctx.items) - 1) and caching == "ephemeral"
else None
)
if msg.type == "message":
role = "assistant" if msg.role == "assistant" else "user"
elif msg.type == "function_call":
role = "assistant"
elif msg.type == "function_call_output":
role = "user"
if role != current_role:
if current_role is not None and content:
messages.append(anthropic.types.MessageParam(role=current_role, content=content))
content = []
current_role = role
if msg.type == "message":
for c in msg.content:
if c and isinstance(c, str):
content.append(
anthropic.types.TextBlockParam(
text=c, type="text", cache_control=cache_ctrl
)
)
elif isinstance(c, llm.ImageContent):
content.append(_to_image_content(c, cache_key, cache_ctrl=cache_ctrl))
elif msg.type == "function_call":
content.append(
anthropic.types.ToolUseBlockParam(
id=msg.call_id,
type="tool_use",
name=msg.name,
input=json.loads(msg.arguments or "{}"),
cache_control=cache_ctrl,
)
)
elif msg.type == "function_call_output":
content.append(
anthropic.types.ToolResultBlockParam(
tool_use_id=msg.call_id,
type="tool_result",
content=msg.output,
cache_control=cache_ctrl,
)
)
if current_role is not None and content:
messages.append(anthropic.types.MessageParam(role=current_role, content=content))
# ensure the messages starts with a "user" message
if not messages or messages[0]["role"] != "user":
messages.insert(
0,
anthropic.types.MessageParam(
role="user",
content=[anthropic.types.TextBlockParam(text="(empty)", type="text")],
),
)
return messages, system_message
def _to_image_content(
image: llm.ImageContent,
cache_key: Any,
cache_ctrl: anthropic.types.CacheControlEphemeralParam | None,
) -> anthropic.types.ImageBlockParam:
img = llm.utils.serialize_image(image)
if img.external_url:
return {
"type": "image",
"source": {"type": "url", "url": img.external_url},
"cache_control": cache_ctrl,
}
if cache_key not in image._cache:
image._cache[cache_key] = img.data_bytes
b64_data = base64.b64encode(image._cache[cache_key]).decode("utf-8")
return {
"type": "image",
"source": {
"type": "base64",
"data": f"data:{img.mime_type};base64,{b64_data}",
"media_type": img.mime_type,
},
"cache_control": cache_ctrl,
}
def _build_anthropic_schema(
function_tool: FunctionTool,
cache_ctrl: anthropic.types.CacheControlEphemeralParam | None = None,
) -> anthropic.types.ToolParam:
fnc = llm.utils.build_legacy_openai_schema(function_tool, internally_tagged=True)
return anthropic.types.ToolParam(
name=fnc["name"],
description=fnc["description"] or "",
input_schema=fnc["parameters"],
cache_control=cache_ctrl,
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-anthropic"
dynamic = ["version"]
description = "Agent Framework plugin for services from Anthropic"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17", "anthropic>=0.34"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/anthropic/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins AssemblyAI
Agent Framework plugin for AssemblyAI. Currently supports Streaming Speech-to-Text.
## Installation
```bash
pip install livekit-plugins-assemblyai
You’ll need to specify an AssemblyAI API Key. It can be set as environment variable: ASSEMBLYAI_API_KEY
.
## livekit-plugins/livekit-plugins-assemblyai/livekit/plugins/assemblyai/__init__.py
```py
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .log import logger
from .stt import STT, SpeechStream
from .version import __version__
__all__ = [
"STT",
"SpeechStream",
"logger",
"__version__",
]
from livekit.agents import Plugin
class AssemblyAIPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(AssemblyAIPlugin())
import logging
logger = logging.getLogger("livekit.plugins.assemblyai")
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass
from typing import Literal
from urllib.parse import urlencode
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectOptions,
APIStatusError,
stt,
utils,
)
from livekit.agents.stt import SpeechEvent
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from .log import logger
ENGLISH = "en"
DEFAULT_ENCODING = "pcm_s16le"
# Define bytes per frame for different encoding types
bytes_per_frame = {
"pcm_s16le": 2,
"pcm_mulaw": 1,
}
@dataclass
class STTOptions:
sample_rate: int
buffer_size_seconds: float
word_boost: NotGivenOr[list[str]] = NOT_GIVEN
encoding: NotGivenOr[Literal["pcm_s16le", "pcm_mulaw"]] = NOT_GIVEN
disable_partial_transcripts: bool = False
enable_extra_session_information: bool = False
end_utterance_silence_threshold: NotGivenOr[int] = NOT_GIVEN
# Buffer to collect frames to send to AssemblyAI
def __post_init__(self):
if self.encoding not in (NOT_GIVEN, "pcm_s16le", "pcm_mulaw"):
raise ValueError(f"Invalid encoding: {self.encoding}")
class STT(stt.STT):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 16000,
word_boost: NotGivenOr[list[str]] = NOT_GIVEN,
encoding: NotGivenOr[Literal["pcm_s16le", "pcm_mulaw"]] = NOT_GIVEN,
disable_partial_transcripts: bool = False,
enable_extra_session_information: bool = False,
end_utterance_silence_threshold: NotGivenOr[int] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
buffer_size_seconds: float = 0.05,
):
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
),
)
self._api_key = api_key if is_given(api_key) else os.environ.get("ASSEMBLYAI_API_KEY")
if not self._api_key:
raise ValueError(
"AssemblyAI API key is required. "
"Pass one in via the `api_key` parameter, "
"or set it as the `ASSEMBLYAI_API_KEY` environment variable"
)
self._opts = STTOptions(
sample_rate=sample_rate,
word_boost=word_boost,
encoding=encoding,
disable_partial_transcripts=disable_partial_transcripts,
enable_extra_session_information=enable_extra_session_information,
buffer_size_seconds=buffer_size_seconds,
end_utterance_silence_threshold=end_utterance_silence_threshold,
)
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
@property
def session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
raise NotImplementedError("Not implemented")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = dataclasses.replace(self._opts)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self.session,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
disable_partial_transcripts: NotGivenOr[bool] = NOT_GIVEN,
word_boost: NotGivenOr[list[str]] = NOT_GIVEN,
end_utterance_silence_threshold: NotGivenOr[int] = NOT_GIVEN,
enable_extra_session_information: NotGivenOr[bool] = NOT_GIVEN,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
):
if is_given(disable_partial_transcripts):
self._opts.disable_partial_transcripts = disable_partial_transcripts
if is_given(word_boost):
self._opts.word_boost = word_boost
if is_given(end_utterance_silence_threshold):
self._opts.end_utterance_silence_threshold = end_utterance_silence_threshold
if is_given(enable_extra_session_information):
self._opts.enable_extra_session_information = enable_extra_session_information
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
for stream in self._streams:
stream.update_options(
disable_partial_transcripts=disable_partial_transcripts,
word_boost=word_boost,
end_utterance_silence_threshold=end_utterance_silence_threshold,
enable_extra_session_information=enable_extra_session_information,
buffer_size_seconds=buffer_size_seconds,
)
class SpeechStream(stt.SpeechStream):
# Used to close websocket
_CLOSE_MSG: str = json.dumps({"terminate_session": True})
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
http_session: aiohttp.ClientSession,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._api_key = api_key
self._session = http_session
self._speech_duration: float = 0
# keep a list of final transcripts to combine them inside the END_OF_SPEECH event
self._final_events: list[SpeechEvent] = []
self._reconnect_event = asyncio.Event()
def update_options(
self,
*,
disable_partial_transcripts: NotGivenOr[bool] = NOT_GIVEN,
word_boost: NotGivenOr[list[str]] = NOT_GIVEN,
end_utterance_silence_threshold: NotGivenOr[int] = NOT_GIVEN,
enable_extra_session_information: NotGivenOr[bool] = NOT_GIVEN,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
):
if is_given(disable_partial_transcripts):
self._opts.disable_partial_transcripts = disable_partial_transcripts
if is_given(word_boost):
self._opts.word_boost = word_boost
if is_given(end_utterance_silence_threshold):
self._opts.end_utterance_silence_threshold = end_utterance_silence_threshold
if is_given(enable_extra_session_information):
self._opts.enable_extra_session_information = enable_extra_session_information
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
self._reconnect_event.set()
async def _run(self) -> None:
"""
Run a single websocket connection to AssemblyAI and make sure to reconnect
when something went wrong.
"""
closing_ws = False
async def send_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
if is_given(self._opts.end_utterance_silence_threshold):
await ws.send_str(
json.dumps(
{
"end_utterance_silence_threshold": self._opts.end_utterance_silence_threshold # noqa: E501
}
)
)
samples_per_buffer = self._opts.sample_rate // round(1 / self._opts.buffer_size_seconds)
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=samples_per_buffer,
)
# forward inputs to AssemblyAI
# if we receive a close message, signal it to AssemblyAI and break.
# the recv task will then make sure to process the remaining audio and stop
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
frames = audio_bstream.flush()
else:
frames = audio_bstream.write(data.data.tobytes())
for frame in frames:
self._speech_duration += frame.duration
await ws.send_bytes(frame.data.tobytes())
closing_ws = True
await ws.send_str(SpeechStream._CLOSE_MSG)
async def recv_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
while True:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
except asyncio.TimeoutError:
if closing_ws:
break
continue
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws: # close is expected, see SpeechStream.aclose
return
raise APIStatusError(
"AssemblyAI connection closed unexpectedly",
) # this will trigger a reconnection, see the _run loop
if msg.type != aiohttp.WSMsgType.TEXT:
logger.error("unexpected AssemblyAI message type %s", msg.type)
continue
try:
# received a message from AssemblyAI
data = json.loads(msg.data)
self._process_stream_event(data, closing_ws)
except Exception:
logger.exception("failed to process AssemblyAI message")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
ws = await self._connect_ws()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[asyncio.gather(*tasks), wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
) # type: ignore
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
finally:
if ws is not None:
await ws.close()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
live_config = {
"sample_rate": self._opts.sample_rate,
"word_boost": json.dumps(self._opts.word_boost)
if is_given(self._opts.word_boost)
else None,
"encoding": self._opts.encoding if is_given(self._opts.encoding) else DEFAULT_ENCODING,
"disable_partial_transcripts": self._opts.disable_partial_transcripts,
"enable_extra_session_information": self._opts.enable_extra_session_information,
}
headers = {
"Authorization": self._api_key,
"Content-Type": "application/json",
}
ws_url = "wss://api.assemblyai.com/v2/realtime/ws"
filtered_config = {k: v for k, v in live_config.items() if v is not None}
url = f"{ws_url}?{urlencode(filtered_config).lower()}"
ws = await self._session.ws_connect(url, headers=headers)
return ws
def _process_stream_event(self, data: dict, closing_ws: bool) -> None:
# see this page:
# https://www.assemblyai.com/docs/api-reference/streaming/realtime
# for more information about the different types of events
if "error" in data:
logger.error("Received error from AssemblyAI: %s", data["error"])
return
message_type = data.get("message_type")
if message_type == "SessionBegins":
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
elif message_type == "PartialTranscript":
alts = live_transcription_to_speech_data(ENGLISH, data)
if len(alts) > 0 and alts[0].text:
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=alts,
)
self._event_ch.send_nowait(interim_event)
elif message_type == "FinalTranscript":
alts = live_transcription_to_speech_data(ENGLISH, data)
if len(alts) > 0 and alts[0].text:
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=alts,
)
self._final_events.append(final_event)
self._event_ch.send_nowait(final_event)
# log metrics
if self._speech_duration > 0:
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=self._speech_duration),
)
self._event_ch.send_nowait(usage_event)
self._speech_duration = 0
elif message_type == "SessionTerminated":
if closing_ws:
pass
else:
raise Exception("AssemblyAI connection closed unexpectedly")
elif message_type == "SessionInformation":
logger.debug("AssemblyAI Session Information: %s", str(data))
else:
logger.warning(
"Received unexpected message type from AssemblyAI: %s",
message_type or "No message_type field",
)
def live_transcription_to_speech_data(
language: str,
data: dict,
) -> list[stt.SpeechData]:
return [
stt.SpeechData(
language=language,
start_time=data["words"][0]["start"] / 1000 if data["words"] else 0,
end_time=data["words"][-1]["end"] / 1000 if data["words"] else 0,
confidence=data["confidence"],
text=data["text"],
),
]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-assemblyai"
dynamic = ["version"]
description = "Agent Framework plugin for AssemblyAI"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/assemblyai/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins AWS
Agent Framework plugin for services from AWS.
- aws polly for tts
- aws transcribe for stt
- aws bedrock for llm
## Installation
```bash
pip install livekit-plugins-aws
You’ll need to specify an AWS Access Key and a Deployment Region. They can be set as environment variables: AWS_ACCESS_KEY_ID
, AWS_SECRET_ACCESS_KEY
and AWS_DEFAULT_REGION
, respectively.
## livekit-plugins/livekit-plugins-aws/livekit/plugins/aws/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .llm import LLM
from .stt import STT, SpeechStream
from .tts import TTS, ChunkedStream
from .version import __version__
__all__ = ["STT", "SpeechStream", "TTS", "ChunkedStream", "LLM", "__version__"]
from livekit.agents import Plugin
class AWSPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(AWSPlugin())
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Literal
import aioboto3
from livekit.agents import APIConnectionError, APIStatusError, llm
from livekit.agents.llm import ChatContext, FunctionTool, FunctionToolCall, ToolChoice
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .utils import get_aws_async_session, to_chat_ctx, to_fnc_ctx
TEXT_MODEL = Literal["anthropic.claude-3-5-sonnet-20241022-v2:0"]
@dataclass
class _LLMOptions:
model: str | TEXT_MODEL
temperature: NotGivenOr[float]
tool_choice: NotGivenOr[ToolChoice]
max_output_tokens: NotGivenOr[int]
top_p: NotGivenOr[float]
additional_request_fields: NotGivenOr[dict[str, Any]]
class LLM(llm.LLM):
def __init__(
self,
*,
model: NotGivenOr[str | TEXT_MODEL] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_secret: NotGivenOr[str] = NOT_GIVEN,
region: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
max_output_tokens: NotGivenOr[int] = NOT_GIVEN,
top_p: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
additional_request_fields: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
session: aioboto3.Session | None = None,
) -> None:
"""
Create a new instance of AWS Bedrock LLM.
``api_key`` and ``api_secret`` must be set to your AWS Access key id and secret access key, either using the argument or by setting the
``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environmental variables.
See https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime/client/converse_stream.html for more details on the AWS Bedrock Runtime API.
Args:
model (TEXT_MODEL, optional): model or inference profile arn to use(https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html). Defaults to 'anthropic.claude-3-5-sonnet-20240620-v1:0'.
api_key(str, optional): AWS access key id.
api_secret(str, optional): AWS secret access key
region (str, optional): The region to use for AWS API requests. Defaults value is "us-east-1".
temperature (float, optional): Sampling temperature for response generation. Defaults to 0.8.
max_output_tokens (int, optional): Maximum number of tokens to generate in the output. Defaults to None.
top_p (float, optional): The nucleus sampling probability for response generation. Defaults to None.
tool_choice (ToolChoice, optional): Specifies whether to use tools during response generation. Defaults to "auto".
additional_request_fields (dict[str, Any], optional): Additional request fields to send to the AWS Bedrock Converse API. Defaults to None.
session (aioboto3.Session, optional): Optional aioboto3 session to use.
""" # noqa: E501
super().__init__()
self._session = session or get_aws_async_session(
api_key=api_key if is_given(api_key) else None,
api_secret=api_secret if is_given(api_secret) else None,
region=region if is_given(region) else None,
)
model = model if is_given(model) else os.environ.get("BEDROCK_INFERENCE_PROFILE_ARN")
if not model:
raise ValueError(
"model or inference profile arn must be set using the argument or by setting the BEDROCK_INFERENCE_PROFILE_ARN environment variable." # noqa: E501
)
self._opts = _LLMOptions(
model=model,
temperature=temperature,
tool_choice=tool_choice,
max_output_tokens=max_output_tokens,
top_p=top_p,
additional_request_fields=additional_request_fields,
)
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
temperature: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
) -> LLMStream:
opts = {}
if is_given(self._opts.model):
opts["modelId"] = self._opts.model
def _get_tool_config() -> dict[str, Any] | None:
nonlocal tool_choice
if not tools:
return None
tool_config: dict[str, Any] = {"tools": to_fnc_ctx(tools)}
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice
if is_given(tool_choice):
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
tool_config["toolChoice"] = {"tool": {"name": tool_choice["function"]["name"]}}
elif tool_choice == "required":
tool_config["toolChoice"] = {"any": {}}
elif tool_choice == "auto":
tool_config["toolChoice"] = {"auto": {}}
else:
return None
return tool_config
tool_config = _get_tool_config()
if tool_config:
opts["toolConfig"] = tool_config
messages, system_message = to_chat_ctx(chat_ctx, id(self))
opts["messages"] = messages
if system_message:
opts["system"] = [system_message]
inference_config = {}
if is_given(self._opts.max_output_tokens):
inference_config["maxTokens"] = self._opts.max_output_tokens
temperature = temperature if is_given(temperature) else self._opts.temperature
if is_given(temperature):
inference_config["temperature"] = temperature
if is_given(self._opts.top_p):
inference_config["topP"] = self._opts.top_p
opts["inferenceConfig"] = inference_config
if is_given(self._opts.additional_request_fields):
opts["additionalModelRequestFields"] = self._opts.additional_request_fields
return LLMStream(
self,
chat_ctx=chat_ctx,
tools=tools,
session=self._session,
conn_options=conn_options,
extra_kwargs=opts,
)
class LLMStream(llm.LLMStream):
def __init__(
self,
llm: LLM,
*,
chat_ctx: ChatContext,
session: aioboto3.Session,
conn_options: APIConnectOptions,
tools: list[FunctionTool] | None,
extra_kwargs: dict[str, Any],
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._llm: LLM = llm
self._opts = extra_kwargs
self._session = session
self._tool_call_id: str | None = None
self._fnc_name: str | None = None
self._fnc_raw_arguments: str | None = None
self._text: str = ""
async def _run(self) -> None:
retryable = True
try:
async with self._session.client("bedrock-runtime") as client:
response = await client.converse_stream(**self._opts) # type: ignore
request_id = response["ResponseMetadata"]["RequestId"]
if response["ResponseMetadata"]["HTTPStatusCode"] != 200:
raise APIStatusError(
f"aws bedrock llm: error generating content: {response}",
retryable=False,
request_id=request_id,
)
async for chunk in response["stream"]:
chat_chunk = self._parse_chunk(request_id, chunk)
if chat_chunk is not None:
retryable = False
self._event_ch.send_nowait(chat_chunk)
except Exception as e:
raise APIConnectionError(
f"aws bedrock llm: error generating content: {e}",
retryable=retryable,
) from e
def _parse_chunk(self, request_id: str, chunk: dict) -> llm.ChatChunk | None:
if "contentBlockStart" in chunk:
tool_use = chunk["contentBlockStart"]["start"]["toolUse"]
self._tool_call_id = tool_use["toolUseId"]
self._fnc_name = tool_use["name"]
self._fnc_raw_arguments = ""
elif "contentBlockDelta" in chunk:
delta = chunk["contentBlockDelta"]["delta"]
if "toolUse" in delta:
self._fnc_raw_arguments += delta["toolUse"]["input"]
elif "text" in delta:
return llm.ChatChunk(
id=request_id,
delta=llm.ChoiceDelta(content=delta["text"], role="assistant"),
)
else:
logger.warning(f"aws bedrock llm: unknown chunk type: {chunk}")
elif "metadata" in chunk:
metadata = chunk["metadata"]
return llm.ChatChunk(
id=request_id,
usage=llm.CompletionUsage(
completion_tokens=metadata["usage"]["outputTokens"],
prompt_tokens=metadata["usage"]["inputTokens"],
total_tokens=metadata["usage"]["totalTokens"],
),
)
elif "contentBlockStop" in chunk:
if self._tool_call_id:
if self._tool_call_id is None:
logger.warning("aws bedrock llm: no tool call id in the response")
return None
if self._fnc_name is None:
logger.warning("aws bedrock llm: no function name in the response")
return None
if self._fnc_raw_arguments is None:
logger.warning("aws bedrock llm: no function arguments in the response")
return None
chat_chunk = llm.ChatChunk(
id=request_id,
delta=llm.ChoiceDelta(
role="assistant",
tool_calls=[
FunctionToolCall(
arguments=self._fnc_raw_arguments,
name=self._fnc_name,
call_id=self._tool_call_id,
),
],
),
)
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
return chat_chunk
return None
import logging
logger = logging.getLogger("livekit.plugins.aws")
from typing import Literal
TTS_SPEECH_ENGINE = Literal["standard", "neural", "long-form", "generative"]
TTS_LANGUAGE = Literal[
"arb",
"cmn-CN",
"cy-GB",
"da-DK",
"de-DE",
"en-AU",
"en-GB",
"en-GB-WLS",
"en-IN",
"en-US",
"es-ES",
"es-MX",
"es-US",
"fr-CA",
"fr-FR",
"is-IS",
"it-IT",
"ja-JP",
"hi-IN",
"ko-KR",
"nb-NO",
"nl-NL",
"pl-PL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"en-NZ",
"en-ZA",
"ca-ES",
"de-AT",
"yue-CN",
"ar-AE",
"fi-FI",
"en-IE",
"nl-BE",
"fr-BE",
"cs-CZ",
"de-CH",
]
TTS_OUTPUT_FORMAT = Literal["mp3"]
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import aioboto3
from amazon_transcribe.auth import StaticCredentialResolver
from amazon_transcribe.client import TranscribeStreamingClient
from amazon_transcribe.model import Result, TranscriptEvent
from livekit import rtc
from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions, stt, utils
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from .log import logger
from .utils import DEFAULT_REGION, get_aws_async_session
REFRESH_INTERVAL = 1800
@dataclass
class STTOptions:
sample_rate: int
language: str
encoding: str
vocabulary_name: NotGivenOr[str]
session_id: NotGivenOr[str]
vocab_filter_method: NotGivenOr[str]
vocab_filter_name: NotGivenOr[str]
show_speaker_label: NotGivenOr[bool]
enable_channel_identification: NotGivenOr[bool]
number_of_channels: NotGivenOr[int]
enable_partial_results_stabilization: NotGivenOr[bool]
partial_results_stability: NotGivenOr[str]
language_model_name: NotGivenOr[str]
class STT(stt.STT):
def __init__(
self,
*,
region: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_secret: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 48000,
language: str = "en-US",
encoding: str = "pcm",
vocabulary_name: NotGivenOr[str] = NOT_GIVEN,
session_id: NotGivenOr[str] = NOT_GIVEN,
vocab_filter_method: NotGivenOr[str] = NOT_GIVEN,
vocab_filter_name: NotGivenOr[str] = NOT_GIVEN,
show_speaker_label: NotGivenOr[bool] = NOT_GIVEN,
enable_channel_identification: NotGivenOr[bool] = NOT_GIVEN,
number_of_channels: NotGivenOr[int] = NOT_GIVEN,
enable_partial_results_stabilization: NotGivenOr[bool] = NOT_GIVEN,
partial_results_stability: NotGivenOr[str] = NOT_GIVEN,
language_model_name: NotGivenOr[str] = NOT_GIVEN,
session: aioboto3.Session | None = None,
refresh_interval: NotGivenOr[int] = NOT_GIVEN,
):
super().__init__(capabilities=stt.STTCapabilities(streaming=True, interim_results=True))
self._region = region if is_given(region) else DEFAULT_REGION
self._session = session or get_aws_async_session(
api_key=api_key if is_given(api_key) else None,
api_secret=api_secret if is_given(api_secret) else None,
region=self._region,
)
self._config = STTOptions(
language=language,
sample_rate=sample_rate,
encoding=encoding,
vocabulary_name=vocabulary_name,
session_id=session_id,
vocab_filter_method=vocab_filter_method,
vocab_filter_name=vocab_filter_name,
show_speaker_label=show_speaker_label,
enable_channel_identification=enable_channel_identification,
number_of_channels=number_of_channels,
enable_partial_results_stabilization=enable_partial_results_stabilization,
partial_results_stability=partial_results_stability,
language_model_name=language_model_name,
)
self._pool = utils.ConnectionPool[TranscribeStreamingClient](
connect_cb=self._create_client,
max_session_duration=refresh_interval
if is_given(refresh_interval)
else REFRESH_INTERVAL,
)
async def _create_client(self) -> TranscribeStreamingClient:
creds = await self._session.get_credentials()
frozen_credentials = await creds.get_frozen_credentials()
return TranscribeStreamingClient(
region=self._region,
credential_resolver=StaticCredentialResolver(
access_key_id=frozen_credentials.access_key,
secret_access_key=frozen_credentials.secret_key,
session_token=frozen_credentials.token,
),
)
async def aclose(self) -> None:
await self._pool.aclose()
await super().aclose()
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
raise NotImplementedError("Amazon Transcribe does not support single frame recognition")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
return SpeechStream(
stt=self,
pool=self._pool,
conn_options=conn_options,
opts=self._config,
)
class SpeechStream(stt.SpeechStream):
def __init__(
self,
stt: STT,
opts: STTOptions,
pool: utils.ConnectionPool[TranscribeStreamingClient],
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._pool = pool
async def _run(self) -> None:
async with self._pool.connection() as client:
live_config = {
"language_code": self._opts.language,
"media_sample_rate_hz": self._opts.sample_rate,
"media_encoding": self._opts.encoding,
"vocabulary_name": self._opts.vocabulary_name,
"session_id": self._opts.session_id,
"vocab_filter_method": self._opts.vocab_filter_method,
"vocab_filter_name": self._opts.vocab_filter_name,
"show_speaker_label": self._opts.show_speaker_label,
"enable_channel_identification": self._opts.enable_channel_identification,
"number_of_channels": self._opts.number_of_channels,
"enable_partial_results_stabilization": self._opts.enable_partial_results_stabilization, # noqa: E501
"partial_results_stability": self._opts.partial_results_stability,
"language_model_name": self._opts.language_model_name,
}
filtered_config = {k: v for k, v in live_config.items() if v and is_given(v)}
stream = await client.start_stream_transcription(**filtered_config)
@utils.log_exceptions(logger=logger)
async def input_generator():
async for frame in self._input_ch:
if isinstance(frame, rtc.AudioFrame):
await stream.input_stream.send_audio_event(audio_chunk=frame.data.tobytes())
await stream.input_stream.end_stream()
@utils.log_exceptions(logger=logger)
async def handle_transcript_events():
async for event in stream.output_stream:
if isinstance(event, TranscriptEvent):
self._process_transcript_event(event)
tasks = [
asyncio.create_task(input_generator()),
asyncio.create_task(handle_transcript_events()),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.gracefully_cancel(*tasks)
def _process_transcript_event(self, transcript_event: TranscriptEvent):
stream = transcript_event.transcript.results
for resp in stream:
if resp.start_time and resp.start_time == 0.0:
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
if resp.end_time and resp.end_time > 0.0:
if resp.is_partial:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[_streaming_recognize_response_to_speech_data(resp)],
)
)
else:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[_streaming_recognize_response_to_speech_data(resp)],
)
)
if not resp.is_partial:
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
def _streaming_recognize_response_to_speech_data(resp: Result) -> stt.SpeechData:
data = stt.SpeechData(
language="en-US",
start_time=resp.start_time if resp.start_time else 0.0,
end_time=resp.end_time if resp.end_time else 0.0,
text=resp.alternatives[0].transcript if resp.alternatives else "",
)
return data
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import aioboto3
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .models import TTS_LANGUAGE, TTS_SPEECH_ENGINE
from .utils import _strip_nones, get_aws_async_session
TTS_NUM_CHANNELS: int = 1
DEFAULT_SPEECH_ENGINE: TTS_SPEECH_ENGINE = "generative"
DEFAULT_VOICE = "Ruth"
DEFAULT_SAMPLE_RATE = 16000
@dataclass
class _TTSOptions:
# https://docs.aws.amazon.com/polly/latest/dg/API_SynthesizeSpeech.html
voice: NotGivenOr[str]
speech_engine: NotGivenOr[TTS_SPEECH_ENGINE]
region: str
sample_rate: int
language: NotGivenOr[TTS_LANGUAGE | str]
class TTS(tts.TTS):
def __init__(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[TTS_LANGUAGE | str] = NOT_GIVEN,
speech_engine: NotGivenOr[TTS_SPEECH_ENGINE] = NOT_GIVEN,
sample_rate: int = DEFAULT_SAMPLE_RATE,
region: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_secret: NotGivenOr[str] = NOT_GIVEN,
session: aioboto3.Session | None = None,
) -> None:
"""
Create a new instance of AWS Polly TTS.
``api_key`` and ``api_secret`` must be set to your AWS Access key id and secret access key, either using the argument or by setting the
``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environmental variables.
See https://docs.aws.amazon.com/polly/latest/dg/API_SynthesizeSpeech.html for more details on the the AWS Polly TTS.
Args:
Voice (TTSModels, optional): Voice ID to use for the synthesis. Defaults to "Ruth".
language (TTS_LANGUAGE, optional): language code for the Synthesize Speech request. This is only necessary if using a bilingual voice, such as Aditi, which can be used for either Indian English (en-IN) or Hindi (hi-IN).
sample_rate(int, optional): The audio frequency specified in Hz. Defaults to 16000.
speech_engine(TTS_SPEECH_ENGINE, optional): The engine to use for the synthesis. Defaults to "generative".
region(str, optional): The region to use for the synthesis. Defaults to "us-east-1".
api_key(str, optional): AWS access key id.
api_secret(str, optional): AWS secret access key.
session(aioboto3.Session, optional): Optional aioboto3 session to use.
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=sample_rate,
num_channels=TTS_NUM_CHANNELS,
)
self._session = session or get_aws_async_session(
api_key=api_key if is_given(api_key) else None,
api_secret=api_secret if is_given(api_secret) else None,
region=region if is_given(region) else None,
)
self._opts = _TTSOptions(
voice=voice,
speech_engine=speech_engine,
region=region,
language=language,
sample_rate=sample_rate,
)
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
text=text,
conn_options=conn_options,
session=self._session,
opts=self._opts,
)
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
text: str,
session: aioboto3.Session,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
opts: _TTSOptions,
) -> None:
super().__init__(tts=tts, input_text=text, conn_options=conn_options)
self._opts = opts
self._segment_id = utils.shortuuid()
self._session = session
async def _run(self):
request_id = utils.shortuuid()
try:
async with self._session.client("polly") as client:
params = {
"Text": self._input_text,
"OutputFormat": "mp3",
"Engine": self._opts.speech_engine
if is_given(self._opts.speech_engine)
else DEFAULT_SPEECH_ENGINE,
"VoiceId": self._opts.voice if is_given(self._opts.voice) else DEFAULT_VOICE,
"TextType": "text",
"SampleRate": str(self._opts.sample_rate),
"LanguageCode": self._opts.language if is_given(self._opts.language) else None,
}
response = await client.synthesize_speech(**_strip_nones(params))
if "AudioStream" in response:
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=1,
)
# Create a task to push data to the decoder
async def push_data():
try:
async with response["AudioStream"] as resp:
async for data, _ in resp.content.iter_chunks():
decoder.push(data)
finally:
decoder.end_input()
# Start pushing data to the decoder
push_task = asyncio.create_task(push_data())
try:
# Create emitter and process decoded frames
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=self._segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
await push_task
finally:
await utils.aio.gracefully_cancel(push_task)
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from None
except Exception as e:
raise APIConnectionError() from e
from __future__ import annotations
import json
from typing import Any
import aioboto3
import boto3
from botocore.exceptions import NoCredentialsError
from livekit.agents import llm
from livekit.agents.llm import ChatContext, FunctionTool, ImageContent, utils
__all__ = ["to_fnc_ctx", "to_chat_ctx", "get_aws_async_session"]
DEFAULT_REGION = "us-east-1"
def get_aws_async_session(
region: str | None = None,
api_key: str | None = None,
api_secret: str | None = None,
) -> aioboto3.Session:
_validate_aws_credentials(api_key, api_secret)
session = aioboto3.Session(
aws_access_key_id=api_key,
aws_secret_access_key=api_secret,
region_name=region or DEFAULT_REGION,
)
return session
def _validate_aws_credentials(
api_key: str | None = None,
api_secret: str | None = None,
) -> None:
try:
session = boto3.Session(aws_access_key_id=api_key, aws_secret_access_key=api_secret)
creds = session.get_credentials()
if not creds:
raise ValueError("No credentials found")
except (NoCredentialsError, Exception) as e:
raise ValueError(f"Unable to locate valid AWS credentials: {str(e)}") from e
def to_fnc_ctx(fncs: list[FunctionTool]) -> list[dict]:
return [_build_tool_spec(fnc) for fnc in fncs]
def to_chat_ctx(chat_ctx: ChatContext, cache_key: Any) -> tuple[list[dict], dict | None]:
messages: list[dict] = []
system_message: dict | None = None
current_role: str | None = None
current_content: list[dict] = []
for msg in chat_ctx.items:
if msg.type == "message" and msg.role == "system":
for content in msg.content:
if content and isinstance(content, str):
system_message = {"text": content}
continue
if msg.type == "message":
role = "assistant" if msg.role == "assistant" else "user"
elif msg.type == "function_call":
role = "assistant"
elif msg.type == "function_call_output":
role = "user"
# if the effective role changed, finalize the previous turn.
if role != current_role:
if current_content and current_role is not None:
messages.append({"role": current_role, "content": current_content})
current_content = []
current_role = role
if msg.type == "message":
for content in msg.content:
if content and isinstance(content, str):
current_content.append({"text": content})
elif isinstance(content, ImageContent):
current_content.append(_build_image(content, cache_key))
elif msg.type == "function_call":
current_content.append(
{
"toolUse": {
"toolUseId": msg.call_id,
"name": msg.name,
"input": json.loads(msg.arguments or "{}"),
}
}
)
elif msg.type == "function_call_output":
tool_response = {
"toolResult": {
"toolUseId": msg.call_id,
"content": [],
"status": "success",
}
}
if isinstance(msg.output, dict):
tool_response["toolResult"]["content"].append({"json": msg.output})
elif isinstance(msg.output, str):
tool_response["toolResult"]["content"].append({"text": msg.output})
current_content.append(tool_response)
# Finalize the last message if there’s any content left
if current_role is not None and current_content:
messages.append({"role": current_role, "content": current_content})
# Ensure the message list starts with a "user" message
if not messages or messages[0]["role"] != "user":
messages.insert(0, {"role": "user", "content": [{"text": "(empty)"}]})
return messages, system_message
def _build_tool_spec(fnc: FunctionTool) -> dict:
fnc = llm.utils.build_legacy_openai_schema(fnc, internally_tagged=True)
return {
"toolSpec": _strip_nones(
{
"name": fnc["name"],
"description": fnc["description"] if fnc["description"] else None,
"inputSchema": {"json": fnc["parameters"] if fnc["parameters"] else {}},
}
)
}
def _build_image(image: ImageContent, cache_key: Any) -> dict:
img = utils.serialize_image(image)
if img.external_url:
raise ValueError("external_url is not supported by AWS Bedrock.")
if cache_key not in image._cache:
image._cache[cache_key] = img.data_bytes
return {
"image": {
"format": "jpeg",
"source": {"bytes": image._cache[cache_key]},
}
}
def _strip_nones(d: dict) -> dict:
return {k: v for k, v in d.items() if v is not None}
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-aws"
dynamic = ["version"]
description = "LiveKit Agents Plugin for services from AWS"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit", "aws"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
"aioboto3==14.1.0",
"amazon-transcribe==0.6.2",
"boto3==1.37.1",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/aws/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Azure
Agent Framework plugin for services from Azure Cognitive Services. Currently supports STT and TTS.
## Installation
```bash
pip install livekit-plugins-azure
You’ll need to specify an Azure Speech Key and a Deployment Region. They can be set as environment variables: AZURE_SPEECH_KEY
and AZURE_SPEECH_REGION
, respectively.
## livekit-plugins/livekit-plugins-azure/livekit/plugins/azure/__init__.py
```py
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .stt import STT, SpeechStream
from .tts import TTS
from .version import __version__
__all__ = ["STT", "SpeechStream", "TTS", "__version__"]
from livekit.agents import Plugin
from .log import logger
class AzurePlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(AzurePlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.azure")
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextlib
import os
import weakref
from copy import deepcopy
from dataclasses import dataclass
import azure.cognitiveservices.speech as speechsdk # type: ignore
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
@dataclass
class STTOptions:
speech_key: NotGivenOr[str]
speech_region: NotGivenOr[str]
# see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-container-stt?tabs=container#use-the-container
speech_host: NotGivenOr[str]
# for using Microsoft Entra auth (see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-configure-azure-ad-auth?tabs=portal&pivots=programming-language-python)
speech_auth_token: NotGivenOr[str]
sample_rate: int
num_channels: int
segmentation_silence_timeout_ms: NotGivenOr[int]
segmentation_max_time_ms: NotGivenOr[int]
segmentation_strategy: NotGivenOr[str]
language: list[
str
] # see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=stt
speech_endpoint: NotGivenOr[str] = NOT_GIVEN
profanity: NotGivenOr[speechsdk.enums.ProfanityOption] = NOT_GIVEN
class STT(stt.STT):
def __init__(
self,
*,
speech_key: NotGivenOr[str] = NOT_GIVEN,
speech_region: NotGivenOr[str] = NOT_GIVEN,
speech_host: NotGivenOr[str] = NOT_GIVEN,
speech_auth_token: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 16000,
num_channels: int = 1,
segmentation_silence_timeout_ms: NotGivenOr[int] = NOT_GIVEN,
segmentation_max_time_ms: NotGivenOr[int] = NOT_GIVEN,
segmentation_strategy: NotGivenOr[str] = NOT_GIVEN,
# Azure handles multiple languages and can auto-detect the language used. It requires the candidate set to be set. # noqa: E501
language: NotGivenOr[str | list[str] | None] = NOT_GIVEN,
profanity: NotGivenOr[speechsdk.enums.ProfanityOption] = NOT_GIVEN,
speech_endpoint: NotGivenOr[str] = NOT_GIVEN,
):
"""
Create a new instance of Azure STT.
Either ``speech_host`` or ``speech_key`` and ``speech_region`` or
``speech_auth_token`` and ``speech_region`` or
``speech_key`` and ``speech_endpoint``
must be set using arguments.
Alternatively, set the ``AZURE_SPEECH_HOST``, ``AZURE_SPEECH_KEY``
and ``AZURE_SPEECH_REGION`` environmental variables, respectively.
``speech_auth_token`` must be set using the arguments as it's an ephemeral token.
"""
super().__init__(capabilities=stt.STTCapabilities(streaming=True, interim_results=True))
if not language or not is_given(language):
language = ["en-US"]
if isinstance(language, str):
language = [language]
if not is_given(speech_host):
speech_host = os.environ.get("AZURE_SPEECH_HOST")
if not is_given(speech_key):
speech_key = os.environ.get("AZURE_SPEECH_KEY")
if not is_given(speech_region):
speech_region = os.environ.get("AZURE_SPEECH_REGION")
if not (
is_given(speech_host)
or (is_given(speech_key) and is_given(speech_region))
or (is_given(speech_auth_token) and is_given(speech_region))
or (is_given(speech_key) and is_given(speech_endpoint))
):
raise ValueError(
"AZURE_SPEECH_HOST or AZURE_SPEECH_KEY and AZURE_SPEECH_REGION or speech_auth_token and AZURE_SPEECH_REGION or AZURE_SPEECH_KEY and speech_endpoint must be set" # noqa: E501
)
if speech_region and speech_endpoint:
logger.warning("speech_region and speech_endpoint both are set, using speech_endpoint")
speech_region = NOT_GIVEN
self._config = STTOptions(
speech_key=speech_key,
speech_region=speech_region,
speech_host=speech_host,
speech_auth_token=speech_auth_token,
language=language,
sample_rate=sample_rate,
num_channels=num_channels,
segmentation_silence_timeout_ms=segmentation_silence_timeout_ms,
segmentation_max_time_ms=segmentation_max_time_ms,
segmentation_strategy=segmentation_strategy,
profanity=profanity,
speech_endpoint=speech_endpoint,
)
self._streams = weakref.WeakSet[SpeechStream]()
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
raise NotImplementedError("Azure STT does not support single frame recognition")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = deepcopy(self._config)
if is_given(language):
config.language = [language]
stream = SpeechStream(stt=self, opts=config, conn_options=conn_options)
self._streams.add(stream)
return stream
def update_options(self, *, language: NotGivenOr[list[str] | str] = NOT_GIVEN):
if is_given(language):
if isinstance(language, str):
language = [language]
self._config.language = language
for stream in self._streams:
stream.update_options(language=language)
class SpeechStream(stt.SpeechStream):
def __init__(self, *, stt: STT, opts: STTOptions, conn_options: APIConnectOptions) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._speaking = False
self._session_stopped_event = asyncio.Event()
self._session_started_event = asyncio.Event()
self._loop = asyncio.get_running_loop()
self._reconnect_event = asyncio.Event()
def update_options(self, *, language: list[str]):
self._opts.language = language
self._reconnect_event.set()
async def _run(self) -> None:
while True:
self._stream = speechsdk.audio.PushAudioInputStream(
stream_format=speechsdk.audio.AudioStreamFormat(
samples_per_second=self._opts.sample_rate,
bits_per_sample=16,
channels=self._opts.num_channels,
)
)
self._recognizer = _create_speech_recognizer(config=self._opts, stream=self._stream)
self._recognizer.recognizing.connect(self._on_recognizing)
self._recognizer.recognized.connect(self._on_recognized)
self._recognizer.speech_start_detected.connect(self._on_speech_start)
self._recognizer.speech_end_detected.connect(self._on_speech_end)
self._recognizer.session_started.connect(self._on_session_started)
self._recognizer.session_stopped.connect(self._on_session_stopped)
self._recognizer.start_continuous_recognition()
try:
await asyncio.wait_for(
self._session_started_event.wait(), self._conn_options.timeout
)
async def process_input():
async for input in self._input_ch:
if isinstance(input, rtc.AudioFrame):
self._stream.write(input.data.tobytes())
process_input_task = asyncio.create_task(process_input())
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
wait_stopped_task = asyncio.create_task(self._session_stopped_event.wait())
try:
done, _ = await asyncio.wait(
[process_input_task, wait_reconnect_task, wait_stopped_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task not in [wait_reconnect_task, wait_stopped_task]:
task.result()
if wait_stopped_task in done:
raise APIConnectionError("SpeechRecognition session stopped")
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(process_input_task, wait_reconnect_task)
self._stream.close()
await self._session_stopped_event.wait()
finally:
def _cleanup():
self._recognizer.stop_continuous_recognition()
del self._recognizer
await asyncio.to_thread(_cleanup)
def _on_recognized(self, evt: speechsdk.SpeechRecognitionEventArgs):
detected_lg = speechsdk.AutoDetectSourceLanguageResult(evt.result).language
text = evt.result.text.strip()
if not text:
return
if not detected_lg and self._opts.language:
detected_lg = self._opts.language[0]
final_data = stt.SpeechData(language=detected_lg, confidence=1.0, text=evt.result.text)
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(
self._event_ch.send_nowait,
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT, alternatives=[final_data]
),
)
def _on_recognizing(self, evt: speechsdk.SpeechRecognitionEventArgs):
detected_lg = speechsdk.AutoDetectSourceLanguageResult(evt.result).language
text = evt.result.text.strip()
if not text:
return
if not detected_lg and self._opts.language:
detected_lg = self._opts.language[0]
interim_data = stt.SpeechData(language=detected_lg, confidence=0.0, text=evt.result.text)
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(
self._event_ch.send_nowait,
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[interim_data],
),
)
def _on_speech_start(self, evt: speechsdk.SpeechRecognitionEventArgs):
if self._speaking:
return
self._speaking = True
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(
self._event_ch.send_nowait,
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH),
)
def _on_speech_end(self, evt: speechsdk.SpeechRecognitionEventArgs):
if not self._speaking:
return
self._speaking = False
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(
self._event_ch.send_nowait,
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH),
)
def _on_session_started(self, evt: speechsdk.SpeechRecognitionEventArgs):
self._session_started_event.set()
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(self._session_started_event.set)
def _on_session_stopped(self, evt: speechsdk.SpeechRecognitionEventArgs):
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(self._session_stopped_event.set)
def _create_speech_recognizer(
*, config: STTOptions, stream: speechsdk.audio.AudioInputStream
) -> speechsdk.SpeechRecognizer:
# let the SpeechConfig constructor to validate the arguments
speech_config = speechsdk.SpeechConfig(
subscription=config.speech_key if is_given(config.speech_key) else None,
region=config.speech_region if is_given(config.speech_region) else None,
endpoint=config.speech_endpoint if is_given(config.speech_endpoint) else None,
host=config.speech_host if is_given(config.speech_host) else None,
auth_token=config.speech_auth_token if is_given(config.speech_auth_token) else None,
)
if config.segmentation_silence_timeout_ms:
speech_config.set_property(
speechsdk.enums.PropertyId.Speech_SegmentationSilenceTimeoutMs,
str(config.segmentation_silence_timeout_ms),
)
if config.segmentation_max_time_ms:
speech_config.set_property(
speechsdk.enums.PropertyId.Speech_SegmentationMaximumTimeMs,
str(config.segmentation_max_time_ms),
)
if config.segmentation_strategy:
speech_config.set_property(
speechsdk.enums.PropertyId.Speech_SegmentationStrategy,
str(config.segmentation_strategy),
)
if is_given(config.profanity):
speech_config.set_profanity(config.profanity)
auto_detect_source_language_config = None
if config.language and len(config.language) >= 1:
auto_detect_source_language_config = (
speechsdk.languageconfig.AutoDetectSourceLanguageConfig(languages=config.language)
)
audio_config = speechsdk.audio.AudioConfig(stream=stream)
speech_recognizer = speechsdk.SpeechRecognizer(
speech_config=speech_config,
audio_config=audio_config,
auto_detect_source_language_config=auto_detect_source_language_config, # type: ignore
)
return speech_recognizer
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import contextlib
import os
from dataclasses import dataclass
from typing import Callable, Literal
import azure.cognitiveservices.speech as speechsdk # type: ignore
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from .log import logger
# only raw & pcm
SUPPORTED_SAMPLE_RATE = {
8000: speechsdk.SpeechSynthesisOutputFormat.Raw8Khz16BitMonoPcm,
16000: speechsdk.SpeechSynthesisOutputFormat.Raw16Khz16BitMonoPcm,
22050: speechsdk.SpeechSynthesisOutputFormat.Raw22050Hz16BitMonoPcm,
24000: speechsdk.SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm,
44100: speechsdk.SpeechSynthesisOutputFormat.Raw44100Hz16BitMonoPcm,
48000: speechsdk.SpeechSynthesisOutputFormat.Raw48Khz16BitMonoPcm,
}
@dataclass
class ProsodyConfig:
"""
Prosody configuration for Azure TTS.
Args:
rate: Speaking rate. Can be one of "x-slow", "slow", "medium", "fast", "x-fast", or a float. A float value of 1.0 represents normal speed.
volume: Speaking volume. Can be one of "silent", "x-soft", "soft", "medium", "loud", "x-loud", or a float. A float value of 100 (x-loud) represents the highest volume and it's the default pitch.
pitch: Speaking pitch. Can be one of "x-low", "low", "medium", "high", "x-high". The default pitch is "medium".
""" # noqa: E501
rate: Literal["x-slow", "slow", "medium", "fast", "x-fast"] | float | None = None
volume: Literal["silent", "x-soft", "soft", "medium", "loud", "x-loud"] | float | None = None
pitch: Literal["x-low", "low", "medium", "high", "x-high"] | None = None
def validate(self) -> None:
if self.rate:
if isinstance(self.rate, float) and not 0.5 <= self.rate <= 2:
raise ValueError("Prosody rate must be between 0.5 and 2")
if isinstance(self.rate, str) and self.rate not in [
"x-slow",
"slow",
"medium",
"fast",
"x-fast",
]:
raise ValueError(
"Prosody rate must be one of 'x-slow', 'slow', 'medium', 'fast', 'x-fast'"
)
if self.volume:
if isinstance(self.volume, float) and not 0 <= self.volume <= 100:
raise ValueError("Prosody volume must be between 0 and 100")
if isinstance(self.volume, str) and self.volume not in [
"silent",
"x-soft",
"soft",
"medium",
"loud",
"x-loud",
]:
raise ValueError(
"Prosody volume must be one of 'silent', 'x-soft', 'soft', 'medium', 'loud', 'x-loud'" # noqa: E501
)
if self.pitch and self.pitch not in [
"x-low",
"low",
"medium",
"high",
"x-high",
]:
raise ValueError(
"Prosody pitch must be one of 'x-low', 'low', 'medium', 'high', 'x-high'"
)
def __post_init__(self):
self.validate()
@dataclass
class StyleConfig:
"""
Style configuration for Azure TTS neural voices.
Args:
style: Speaking style for neural voices. Examples: "cheerful", "sad", "angry", etc.
degree: Intensity of the style, from 0.1 to 2.0.
"""
style: str
degree: float | None = None
def validate(self) -> None:
if self.degree is not None and not 0.1 <= self.degree <= 2.0:
raise ValueError("Style degree must be between 0.1 and 2.0")
def __post_init__(self):
self.validate()
@dataclass
class _TTSOptions:
sample_rate: int
speech_key: NotGivenOr[str] = NOT_GIVEN
speech_region: NotGivenOr[str] = NOT_GIVEN
# see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-container-ntts?tabs=container#use-the-container
speech_host: NotGivenOr[str] = NOT_GIVEN
# see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts
voice: NotGivenOr[str] = NOT_GIVEN
# for using custom voices (see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis?tabs=browserjs%2Cterminal&pivots=programming-language-python#use-a-custom-endpoint)
endpoint_id: NotGivenOr[str] = NOT_GIVEN
# for using Microsoft Entra auth (see https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-configure-azure-ad-auth?tabs=portal&pivots=programming-language-python)
speech_auth_token: NotGivenOr[str] = NOT_GIVEN
# Useful to specify the language with multi-language voices
language: NotGivenOr[str] = NOT_GIVEN
# See https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-voice#adjust-prosody
prosody: NotGivenOr[ProsodyConfig] = NOT_GIVEN
speech_endpoint: NotGivenOr[str] = NOT_GIVEN
style: NotGivenOr[StyleConfig] = NOT_GIVEN
# See https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis?tabs=browserjs%2Cterminal&pivots=programming-language-python
on_bookmark_reached_event: NotGivenOr[Callable] = NOT_GIVEN
on_synthesis_canceled_event: NotGivenOr[Callable] = NOT_GIVEN
on_synthesis_completed_event: NotGivenOr[Callable] = NOT_GIVEN
on_synthesis_started_event: NotGivenOr[Callable] = NOT_GIVEN
on_synthesizing_event: NotGivenOr[Callable] = NOT_GIVEN
on_viseme_event: NotGivenOr[Callable] = NOT_GIVEN
on_word_boundary_event: NotGivenOr[Callable] = NOT_GIVEN
class TTS(tts.TTS):
def __init__(
self,
*,
sample_rate: int = 24000,
voice: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
prosody: NotGivenOr[ProsodyConfig] = NOT_GIVEN,
speech_key: NotGivenOr[str] = NOT_GIVEN,
speech_region: NotGivenOr[str] = NOT_GIVEN,
speech_host: NotGivenOr[str] = NOT_GIVEN,
speech_auth_token: NotGivenOr[str] = NOT_GIVEN,
endpoint_id: NotGivenOr[str] = NOT_GIVEN,
style: NotGivenOr[StyleConfig] = NOT_GIVEN,
on_bookmark_reached_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_canceled_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_completed_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_started_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesizing_event: NotGivenOr[Callable] = NOT_GIVEN,
on_viseme_event: NotGivenOr[Callable] = NOT_GIVEN,
on_word_boundary_event: NotGivenOr[Callable] = NOT_GIVEN,
speech_endpoint: NotGivenOr[str] = NOT_GIVEN,
) -> None:
"""
Create a new instance of Azure TTS.
Either ``speech_host`` or ``speech_key`` and ``speech_region`` or
``speech_auth_token`` and ``speech_region`` must be set using arguments.
Alternatively, set the ``AZURE_SPEECH_HOST``, ``AZURE_SPEECH_KEY``
and ``AZURE_SPEECH_REGION`` environmental variables, respectively.
``speech_auth_token`` must be set using the arguments as it's an ephemeral token.
"""
if sample_rate not in SUPPORTED_SAMPLE_RATE:
raise ValueError(
f"Unsupported sample rate {sample_rate}. Supported sample rates: {list(SUPPORTED_SAMPLE_RATE.keys())}" # noqa: E501
)
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=sample_rate,
num_channels=1,
)
if not is_given(speech_host):
speech_host = os.environ.get("AZURE_SPEECH_HOST")
if not is_given(speech_key):
speech_key = os.environ.get("AZURE_SPEECH_KEY")
if not is_given(speech_region):
speech_region = os.environ.get("AZURE_SPEECH_REGION")
if not (
is_given(speech_host)
or (is_given(speech_key) and is_given(speech_region))
or (is_given(speech_auth_token) and is_given(speech_region))
or (is_given(speech_key) and is_given(speech_endpoint))
):
raise ValueError(
"AZURE_SPEECH_HOST or AZURE_SPEECH_KEY and AZURE_SPEECH_REGION or speech_auth_token and AZURE_SPEECH_REGION or AZURE_SPEECH_KEY and speech_endpoint must be set" # noqa: E501
)
if is_given(prosody):
prosody.validate()
if is_given(style):
style.validate()
self._opts = _TTSOptions(
sample_rate=sample_rate,
speech_key=speech_key,
speech_region=speech_region,
speech_host=speech_host,
speech_auth_token=speech_auth_token,
voice=voice,
endpoint_id=endpoint_id,
language=language,
prosody=prosody,
style=style,
on_bookmark_reached_event=on_bookmark_reached_event,
on_synthesis_canceled_event=on_synthesis_canceled_event,
on_synthesis_completed_event=on_synthesis_completed_event,
on_synthesis_started_event=on_synthesis_started_event,
on_synthesizing_event=on_synthesizing_event,
on_viseme_event=on_viseme_event,
on_word_boundary_event=on_word_boundary_event,
speech_endpoint=speech_endpoint,
)
def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
prosody: NotGivenOr[ProsodyConfig] = NOT_GIVEN,
style: NotGivenOr[StyleConfig] = NOT_GIVEN,
on_bookmark_reached_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_canceled_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_completed_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesis_started_event: NotGivenOr[Callable] = NOT_GIVEN,
on_synthesizing_event: NotGivenOr[Callable] = NOT_GIVEN,
on_viseme_event: NotGivenOr[Callable] = NOT_GIVEN,
on_word_boundary_event: NotGivenOr[Callable] = NOT_GIVEN,
) -> None:
if is_given(voice):
self._opts.voice = voice
if is_given(language):
self._opts.language = language
if is_given(prosody):
self._opts.prosody = prosody
if is_given(style):
self._opts.style = style
if is_given(on_bookmark_reached_event):
self._opts.on_bookmark_reached_event = on_bookmark_reached_event
if is_given(on_synthesis_canceled_event):
self._opts.on_synthesis_canceled_event = on_synthesis_canceled_event
if is_given(on_synthesis_completed_event):
self._opts.on_synthesis_completed_event = on_synthesis_completed_event
if is_given(on_synthesis_started_event):
self._opts.on_synthesis_started_event = on_synthesis_started_event
if is_given(on_synthesizing_event):
self._opts.on_synthesizing_event = on_synthesizing_event
if is_given(on_viseme_event):
self._opts.on_viseme_event = on_viseme_event
if is_given(on_word_boundary_event):
self._opts.on_word_boundary_event = on_word_boundary_event
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options, opts=self._opts)
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts = opts
async def _run(self):
stream_callback = speechsdk.audio.PushAudioOutputStream(
_PushAudioOutputStreamCallback(
self._opts.sample_rate, asyncio.get_running_loop(), self._event_ch
)
)
synthesizer = _create_speech_synthesizer(
config=self._opts,
stream=stream_callback,
)
def _synthesize() -> speechsdk.SpeechSynthesisResult:
if self._opts.prosody or self._opts.style:
ssml = (
'<speak version="1.0" '
'xmlns="http://www.w3.org/2001/10/synthesis" '
'xmlns:mstts="http://www.w3.org/2001/mstts" '
f'xml:lang="{self._opts.language or "en-US"}">'
)
ssml += f'<voice name="{self._opts.voice}">'
# Add style if specified
if self._opts.style:
style_degree = (
f' styledegree="{self._opts.style.degree}"'
if self._opts.style.degree
else ""
)
ssml += f'<mstts:express-as style="{self._opts.style.style}"{style_degree}>'
# Add prosody if specified
if self._opts.prosody:
ssml += "<prosody"
if self._opts.prosody.rate:
ssml += f' rate="{self._opts.prosody.rate}"'
if self._opts.prosody.volume:
ssml += f' volume="{self._opts.prosody.volume}"'
if self._opts.prosody.pitch:
ssml += f' pitch="{self._opts.prosody.pitch}"'
ssml += ">"
ssml += self._input_text
ssml += "</prosody>"
else:
ssml += self._input_text
# Close style tag if it was opened
if self._opts.style:
ssml += "</mstts:express-as>"
ssml += "</voice></speak>"
return synthesizer.speak_ssml_async(ssml).get() # type: ignore
return synthesizer.speak_text_async(self.input_text).get() # type: ignore
result = None
try:
result = await asyncio.to_thread(_synthesize)
if result.reason != speechsdk.ResultReason.SynthesizingAudioCompleted:
if (
result.cancellation_details.error_code
== speechsdk.CancellationErrorCode.ServiceTimeout
):
raise APITimeoutError()
else:
cancel_details = result.cancellation_details
raise APIConnectionError(cancel_details.error_details)
finally:
def _cleanup() -> None:
# cleanup resources inside an Executor
# to avoid blocking the event loop
nonlocal synthesizer, stream_callback, result
del synthesizer
del stream_callback
if result is not None:
del result
try:
await asyncio.to_thread(_cleanup)
except Exception:
logger.exception("failed to cleanup Azure TTS resources")
class _PushAudioOutputStreamCallback(speechsdk.audio.PushAudioOutputStreamCallback):
def __init__(
self,
sample_rate: int,
loop: asyncio.AbstractEventLoop,
event_ch: utils.aio.ChanSender[tts.SynthesizedAudio],
):
super().__init__()
self._event_ch = event_ch
self._loop = loop
self._request_id = utils.shortuuid()
self._bstream = utils.audio.AudioByteStream(sample_rate=sample_rate, num_channels=1)
def write(self, audio_buffer: memoryview) -> int:
for frame in self._bstream.write(audio_buffer.tobytes()):
audio = tts.SynthesizedAudio(
request_id=self._request_id,
frame=frame,
)
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(self._event_ch.send_nowait, audio)
return audio_buffer.nbytes
def close(self) -> None:
for frame in self._bstream.flush():
audio = tts.SynthesizedAudio(
request_id=self._request_id,
frame=frame,
)
with contextlib.suppress(RuntimeError):
self._loop.call_soon_threadsafe(self._event_ch.send_nowait, audio)
def _create_speech_synthesizer(
*, config: _TTSOptions, stream: speechsdk.audio.AudioOutputStream
) -> speechsdk.SpeechSynthesizer:
# let the SpeechConfig constructor to validate the arguments
speech_config = speechsdk.SpeechConfig(
subscription=config.speech_key if is_given(config.speech_key) else None,
region=config.speech_region if is_given(config.speech_region) else None,
endpoint=config.speech_endpoint if is_given(config.speech_endpoint) else None,
host=config.speech_host if is_given(config.speech_host) else None,
auth_token=config.speech_auth_token if is_given(config.speech_auth_token) else None,
speech_recognition_language=config.language if is_given(config.language) else "en-US",
)
speech_config.set_speech_synthesis_output_format(SUPPORTED_SAMPLE_RATE[config.sample_rate])
stream_config = speechsdk.audio.AudioOutputConfig(stream=stream)
if is_given(config.voice):
speech_config.speech_synthesis_voice_name = config.voice
if is_given(config.endpoint_id):
speech_config.endpoint_id = config.endpoint_id
synthesizer = speechsdk.SpeechSynthesizer(
speech_config=speech_config, audio_config=stream_config
)
if is_given(config.on_bookmark_reached_event):
synthesizer.bookmark_reached.connect(config.on_bookmark_reached_event)
if is_given(config.on_synthesis_canceled_event):
synthesizer.synthesis_canceled.connect(config.on_synthesis_canceled_event)
if is_given(config.on_synthesis_completed_event):
synthesizer.synthesis_completed.connect(config.on_synthesis_completed_event)
if is_given(config.on_synthesis_started_event):
synthesizer.synthesis_started.connect(config.on_synthesis_started_event)
if is_given(config.on_synthesizing_event):
synthesizer.synthesizing.connect(config.on_synthesizing_event)
if is_given(config.on_viseme_event):
synthesizer.viseme_received.connect(config.on_viseme_event)
if is_given(config.on_word_boundary_event):
synthesizer.synthesis_word_boundary.connect(config.on_word_boundary_event)
return synthesizer
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-azure"
dynamic = ["version"]
description = "Agent Framework plugin for services from Azure"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
"azure-cognitiveservices-speech>=1.43.0",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/azure/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Beyond Presence
Agent Framework Plugin for human avatars with [Beyond Presence](https://docs.bey.dev)'s API.
Currently supports speech to video.
## Installation
```bash
pip install livekit-plugins-bey
Create a developer API key from the creator dashboard and set the BEY_API_KEY
environment variable with it:
export BEY_API_KEY=<your-bey-api-key>
## livekit-plugins/livekit-plugins-bey/livekit/plugins/bey/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .avatar import AvatarSession, BeyException
from .version import __version__
__all__ = [
"BeyException",
"AvatarSession",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class BeyPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(BeyPlugin())
from __future__ import annotations
import asyncio
import os
import aiohttp
from livekit import api, rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
AgentSession,
APIConnectionError,
APIConnectOptions,
APIStatusError,
NotGivenOr,
utils,
)
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
from .log import logger
EGE_STOCK_AVATAR_ID = "b9be11b8-89fb-4227-8f86-4a881393cbdb"
_DEFAULT_API_URL = "https://api.bey.dev"
_AVATAR_AGENT_IDENTITY = "bey-avatar-agent"
_AVATAR_AGENT_NAME = "bey-avatar-agent"
class BeyException(Exception):
"""Exception for Beyond Presence errors"""
class AvatarSession:
"""A Beyond Presence avatar session"""
def __init__(
self,
*,
avatar_id: NotGivenOr[str | None] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
self._avatar_id = avatar_id or EGE_STOCK_AVATAR_ID
self._api_url = api_url or os.getenv("BEY_API_URL", _DEFAULT_API_URL)
self._api_key = api_key or os.getenv("BEY_API_KEY")
if self._api_key is None:
raise BeyException(
"The api_key must be set either by passing api_key to the client or "
"by setting the BEY_API_KEY environment variable"
)
self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY
self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME
self._http_session: aiohttp.ClientSession | None = None
self._conn_options = conn_options
def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is None:
self._http_session = utils.http_context.http_session()
return self._http_session
async def start(
self,
agent_session: AgentSession,
room: rtc.Room,
*,
livekit_url: NotGivenOr[str] = NOT_GIVEN,
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
) -> None:
livekit_url = livekit_url or os.getenv("LIVEKIT_URL")
livekit_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
livekit_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
if not livekit_url or not livekit_api_key or not livekit_api_secret:
raise BeyException(
"livekit_url, livekit_api_key, and livekit_api_secret must be set "
"by arguments or environment variables"
)
livekit_token = (
api.AccessToken()
.with_kind("agent")
.with_identity(self._avatar_participant_identity)
.with_name(self._avatar_participant_name)
.with_grants(api.VideoGrants(room_join=True, room=room.name))
# allow the avatar agent to publish audio and video on behalf of your local agent
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: room.local_participant.identity})
.to_jwt()
)
logger.debug("starting avatar session")
await self._start_agent(livekit_url, livekit_token)
logger.debug("waiting for avatar agent to join the room")
await utils.wait_for_participant(room=room, identity=self._avatar_participant_identity)
agent_session.output.audio = DataStreamAudioOutput(
room=room,
destination_identity=self._avatar_participant_identity,
)
async def _start_agent(self, livekit_url: str, livekit_token: str) -> None:
assert self._api_key is not None
for i in range(self._conn_options.max_retry):
try:
async with self._ensure_http_session().post(
f"{self._api_url}/v1/session",
headers={
"x-api-key": self._api_key,
},
json={
"avatar_id": self._avatar_id,
"livekit_url": livekit_url,
"livekit_token": livekit_token,
},
timeout=self._conn_options.timeout,
) as response:
if not response.ok:
text = await response.text()
raise APIStatusError(
"Server returned an error", status_code=response.status, body=text
)
return
except Exception as e:
if isinstance(e, APIConnectionError):
logger.warning("failed to call bey presence api", extra={"error": str(e)})
else:
logger.exception("failed to call bey presence api")
if i < self._conn_options.max_retry - 1:
await asyncio.sleep(self._conn_options.retry_interval)
raise APIConnectionError("Failed to start Bey Avatar Session after all retries")
import logging
logger = logging.getLogger("livekit.plugins.bey")
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-bey"
dynamic = ["version"]
description = "Agent Framework plugin for services from Beyond Presence"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "support@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/bey/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins BitHuman Avatar Runtime
Agent Framework Plugin for avatars with [bitHuman](https://www.bithuman.ai/)'s local runtime SDK.
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .avatar import AvatarSession, BitHumanException
from .version import __version__
__all__ = [
"BitHumanException",
"AvatarSession",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class BitHumanPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(BitHumanPlugin())
from __future__ import annotations
import os
import sys
from collections.abc import AsyncGenerator, AsyncIterator
import cv2
import numpy as np
from loguru import logger as _logger
from bithuman import AsyncBithuman
from livekit import rtc
from livekit.agents import NOT_GIVEN, AgentSession, NotGivenOr, utils
from livekit.agents.voice.avatar import (
AudioSegmentEnd,
AvatarOptions,
AvatarRunner,
QueueAudioOutput,
VideoGenerator,
)
from .log import logger
_logger.remove()
_logger.add(sys.stdout, level="INFO")
class BitHumanException(Exception):
"""Exception for BitHuman errors"""
class AvatarSession:
"""A Beyond Presence avatar session"""
def __init__(
self,
*,
model_path: NotGivenOr[str | None] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
api_secret: NotGivenOr[str] = NOT_GIVEN,
api_token: NotGivenOr[str] = NOT_GIVEN,
) -> None:
self._api_url = api_url or os.getenv("BITHUMAN_API_URL")
self._api_secret = api_secret or os.getenv("BITHUMAN_API_SECRET")
self._api_token = api_token or os.getenv("BITHUMAN_API_TOKEN")
self._model_path = model_path or os.getenv("BITHUMAN_MODEL_PATH")
if self._api_secret is None and self._api_token is None:
raise BitHumanException("BITHUMAN_API_SECRET or BITHUMAN_API_TOKEN must be set")
if self._model_path is None:
raise BitHumanException("BITHUMAN_MODEL_PATH must be set")
self._avatar_runner: AvatarRunner | None = None
async def start(self, agent_session: AgentSession, room: rtc.Room) -> None:
kwargs = {
"model_path": self._model_path,
}
if self._api_secret:
kwargs["api_secret"] = self._api_secret
if self._api_token:
kwargs["token"] = self._api_token
if self._api_url:
kwargs["api_url"] = self._api_url
runtime = await AsyncBithuman.create(**kwargs)
await runtime.start()
video_generator = BithumanGenerator(runtime)
output_width, output_height = video_generator.video_resolution
avatar_options = AvatarOptions(
video_width=output_width,
video_height=output_height,
video_fps=video_generator.video_fps,
audio_sample_rate=video_generator.audio_sample_rate,
audio_channels=1,
)
audio_buffer = QueueAudioOutput(sample_rate=runtime.settings.INPUT_SAMPLE_RATE)
# create avatar runner
self._avatar_runner = AvatarRunner(
room=room,
video_gen=video_generator,
audio_recv=audio_buffer,
options=avatar_options,
)
await self._avatar_runner.start()
agent_session.output.audio = audio_buffer
class BithumanGenerator(VideoGenerator):
def __init__(self, runtime: AsyncBithuman):
self._runtime = runtime
@property
def video_resolution(self) -> tuple[int, int]:
frame = self._runtime.get_first_frame()
if frame is None:
raise ValueError("Failed to read frame")
return frame.shape[1], frame.shape[0]
@property
def video_fps(self) -> int:
return self._runtime.settings.FPS
@property
def audio_sample_rate(self) -> int:
return self._runtime.settings.INPUT_SAMPLE_RATE
@utils.log_exceptions(logger=logger)
async def push_audio(self, frame: rtc.AudioFrame | AudioSegmentEnd) -> None:
if isinstance(frame, AudioSegmentEnd):
await self._runtime.flush()
return
await self._runtime.push_audio(bytes(frame.data), frame.sample_rate, last_chunk=False)
def clear_buffer(self) -> None:
self._runtime.interrupt()
def __aiter__(self) -> AsyncIterator[rtc.VideoFrame | rtc.AudioFrame | AudioSegmentEnd]:
return self._stream_impl()
async def _stream_impl(
self,
) -> AsyncGenerator[rtc.VideoFrame | rtc.AudioFrame | AudioSegmentEnd, None]:
def create_video_frame(image: np.ndarray) -> rtc.VideoFrame:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGBA)
return rtc.VideoFrame(
width=image.shape[1],
height=image.shape[0],
type=rtc.VideoBufferType.RGBA,
data=image.tobytes(),
)
async for frame in self._runtime.run():
if frame.bgr_image is not None:
video_frame = create_video_frame(frame.bgr_image)
yield video_frame
audio_chunk = frame.audio_chunk
if audio_chunk is not None:
audio_frame = rtc.AudioFrame(
data=audio_chunk.bytes,
sample_rate=audio_chunk.sample_rate,
num_channels=1,
samples_per_channel=len(audio_chunk.array),
)
yield audio_frame
if frame.end_of_speech:
yield AudioSegmentEnd()
async def stop(self) -> None:
await self._runtime.stop()
import logging
logger = logging.getLogger("livekit.plugins.bithuman")
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-bithuman"
dynamic = ["version"]
description = "Agent Framework plugin for services from BitHuman Avatar Rendering"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "support@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17", "bithuman>=0.5.5"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/bithuman/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# Defines the Chromium style for automatic reformatting.
# http://clang.llvm.org/docs/ClangFormatStyleOptions.html
BasedOnStyle: Chromium
---
Language: ObjC
BasedOnStyle: Google
BinPackParameters: false
BinPackArguments: false
ColumnLimit: 100
ObjCBlockIndentWidth: 2
AllowAllParametersOfDeclarationOnNextLine: true
AlignOperands: false
AlwaysBreakBeforeMultilineStrings: false
AllowShortFunctionsOnASingleLine: Inline
BreakBeforeTernaryOperators: false
IndentWrappedFunctionNames: true
ContinuationIndentWidth: 4
ObjCSpaceBeforeProtocolList: true
---
Language: Cpp
IncludeBlocks: Regroup
cmake_minimum_required(VERSION 3.19)
set(CMAKE_CONFIGURATION_TYPES Debug Release)
project(livekit-cef)
set_property(GLOBAL PROPERTY OS_FOLDERS ON)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # useful for clangd as the language server
set(USE_SANDBOX OFF) # TODO(theomonnom): I don't think we want to enable sandbox
# for now, it add complexity
# Specify the CEF distribution version.
if(NOT DEFINED CEF_VERSION)
# set(CEF_VERSION "122.1.10+gc902316+chromium-122.0.6261.112")
set(CEF_VERSION "127.3.5+g114ea2a+chromium-127.0.6533.120")
endif()
if("${CMAKE_SYSTEM_NAME}" STREQUAL "Darwin")
if("${PROJECT_ARCH}" STREQUAL "arm64")
set(CEF_PLATFORM "macosarm64")
elseif("${PROJECT_ARCH}" STREQUAL "x86_64")
set(CEF_PLATFORM "macosx64")
elseif("${CMAKE_HOST_SYSTEM_PROCESSOR}" STREQUAL "arm64")
set(PROJECT_ARCH "arm64")
set(CEF_PLATFORM "macosarm64")
else()
set(PROJECT_ARCH "x86_64")
set(CEF_PLATFORM "macosx64")
endif()
elseif("${CMAKE_SYSTEM_NAME}" STREQUAL "Linux")
if(CMAKE_SIZEOF_VOID_P MATCHES 8)
set(CEF_PLATFORM "linux64")
else()
set(CEF_PLATFORM "linux32")
endif()
elseif("${CMAKE_SYSTEM_NAME}" STREQUAL "Windows")
if(CMAKE_SIZEOF_VOID_P MATCHES 8)
set(CEF_PLATFORM "windows64")
else()
set(CEF_PLATFORM "windows32")
endif()
endif()
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Download and extract the CEF binary distribution (executes DownloadCEF.cmake).
include(DownloadCEF)
downloadcef("${CEF_PLATFORM}" "${CEF_VERSION}"
"${CMAKE_SOURCE_DIR}/third_party/cef")
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CEF_ROOT}/cmake")
# Load the CEF configuration (executes FindCEF.cmake).
find_package(CEF REQUIRED)
# Python
find_package(PythonInterp REQUIRED)
find_package(pybind11 REQUIRED)
message(STATUS "Using Python: ${PYTHON_EXECUTABLE}")
add_subdirectory(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)
add_subdirectory(src)
print_cef_config()
// Copyright (c) 2008-2016 Marshall A. Greenblatt. Portions Copyright (c)
// 2006-2009 Google Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the name Chromium Embedded
// Framework nor the names of its contributors may be used to endorse
// or promote products derived from this software without specific prior
// written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# LiveKit Plugins Browser
Chromium Embedded Framework (CEF) for LiveKit Agents
# Copyright (c) 2016 The Chromium Embedded Framework Authors. All rights
# reserved. Use of this source code is governed by a BSD-style license that
# can be found in the LICENSE file.
# Download the CEF binary distribution for |platform| and |version| to
# |download_dir|. The |CEF_ROOT| variable will be set in global scope pointing
# to the extracted location.
# Visit https://cef-builds.spotifycdn.com/index.html for the list of
# supported platforms and versions.
function(DownloadCEF platform version download_dir)
# Specify the binary distribution type and download directory.
set(CEF_DISTRIBUTION "cef_binary_${version}_${platform}")
set(CEF_DOWNLOAD_DIR "${download_dir}")
# The location where we expect the extracted binary distribution.
set(CEF_ROOT "${CEF_DOWNLOAD_DIR}/${CEF_DISTRIBUTION}" CACHE INTERNAL "CEF_ROOT")
# Download and/or extract the binary distribution if necessary.
if(NOT IS_DIRECTORY "${CEF_ROOT}")
set(CEF_DOWNLOAD_FILENAME "${CEF_DISTRIBUTION}.tar.bz2")
set(CEF_DOWNLOAD_PATH "${CEF_DOWNLOAD_DIR}/${CEF_DOWNLOAD_FILENAME}")
if(NOT EXISTS "${CEF_DOWNLOAD_PATH}")
set(CEF_DOWNLOAD_URL "https://cef-builds.spotifycdn.com/${CEF_DOWNLOAD_FILENAME}")
string(REPLACE "+" "%2B" CEF_DOWNLOAD_URL_ESCAPED ${CEF_DOWNLOAD_URL})
# Download the SHA1 hash for the binary distribution.
message(STATUS "Downloading ${CEF_DOWNLOAD_PATH}.sha1 from ${CEF_DOWNLOAD_URL_ESCAPED}...")
file(DOWNLOAD "${CEF_DOWNLOAD_URL_ESCAPED}.sha1" "${CEF_DOWNLOAD_PATH}.sha1")
file(READ "${CEF_DOWNLOAD_PATH}.sha1" CEF_SHA1)
# Download the binary distribution and verify the hash.
message(STATUS "Downloading ${CEF_DOWNLOAD_PATH}...")
file(
DOWNLOAD "${CEF_DOWNLOAD_URL_ESCAPED}" "${CEF_DOWNLOAD_PATH}"
EXPECTED_HASH SHA1=${CEF_SHA1}
SHOW_PROGRESS
)
endif()
# Extract the binary distribution.
message(STATUS "Extracting ${CEF_DOWNLOAD_PATH}...")
execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xzf "${CEF_DOWNLOAD_DIR}/${CEF_DOWNLOAD_FILENAME}"
WORKING_DIRECTORY ${CEF_DOWNLOAD_DIR}
)
endif()
endfunction()
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from livekit.agents import Plugin
from .log import logger
from .proc import BrowserContext, BrowserPage
from .version import __version__
__all__ = ["BrowserContext", "BrowserPage"]
class BrowserPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(BrowserPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.browser")
from __future__ import annotations
import asyncio
import contextlib
import multiprocessing as mp
import multiprocessing.context as mpc
import multiprocessing.shared_memory as mp_shm
import socket
import tempfile
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Callable, Literal
from livekit import rtc
from livekit.agents import ipc, utils
from . import logger, proc_main, proto
@dataclass
class _PageOptions:
page_id: int
url: str
width: int
height: int
framerate: int
EventTypes = Literal["paint"]
@dataclass
class PaintData:
dirty_rects: list[tuple[int, int, int, int]]
frame: rtc.VideoFrame
width: int
height: int
@dataclass
class BrowserOptions:
url: str
framerate: int
width: int
height: int
paint_callback: Callable[[PaintData], None]
class BrowserPage(utils.EventEmitter[EventTypes]):
def __init__(
self,
mp_ctx: mpc.SpawnContext,
opts: _PageOptions,
ctx_duplex: utils.aio.duplex_unix._AsyncDuplex,
) -> None:
super().__init__()
self._mp_ctx = mp_ctx
self._opts = opts
self._ctx_duplex = ctx_duplex
self._view_width = 0
self._view_height = 0
self._created_fut = asyncio.Future()
self._close_fut = asyncio.Future()
@property
def id(self) -> int:
return self._opts.page_id
async def start(self) -> None:
shm_name = f"lkcef_browser_{utils.shortuuid()}"
self._shm = mp_shm.SharedMemory(
create=True,
size=proto.SHM_MAX_WIDTH * proto.SHM_MAX_HEIGHT * 4,
name=shm_name,
)
self._framebuffer = rtc.VideoFrame(
proto.SHM_MAX_WIDTH,
proto.SHM_MAX_HEIGHT,
rtc.VideoBufferType.BGRA,
bytearray(proto.SHM_MAX_WIDTH * proto.SHM_MAX_HEIGHT * 4),
)
req = proto.CreateBrowserRequest(
page_id=self._opts.page_id,
width=self._opts.width,
height=self._opts.height,
shm_name=shm_name,
url=self._opts.url,
framerate=self._opts.framerate,
)
await ipc.channel.asend_message(self._ctx_duplex, req)
# TODO(theomonnom): create timeout (would prevent never resolving futures if the
# browser process crashed for some reasons)
await asyncio.shield(self._created_fut)
async def aclose(self) -> None:
await ipc.channel.asend_message(
self._ctx_duplex, proto.CloseBrowserRequest(page_id=self.id)
)
await asyncio.shield(self._close_fut)
self._shm.unlink()
self._shm.close()
async def _handle_created(self, msg: proto.CreateBrowserResponse) -> None:
self._created_fut.set_result(None)
async def _handle_paint(self, acq: proto.AcquirePaintData) -> None:
old_width = self._view_width
old_height = self._view_height
self._view_width = acq.width
self._view_height = acq.height
# TODO(theomonnom): remove hacky alloc-free resizing
self._framebuffer._width = acq.width
self._framebuffer._height = acq.height
proto.copy_paint_data(acq, old_width, old_height, self._shm.buf, self._framebuffer.data)
paint_data = PaintData(
dirty_rects=acq.dirty_rects,
frame=self._framebuffer,
width=acq.width,
height=acq.height,
)
self.emit("paint", paint_data)
release_paint = proto.ReleasePaintData(page_id=acq.page_id)
await ipc.channel.asend_message(self._ctx_duplex, release_paint)
async def _handle_close(self, msg: proto.BrowserClosed) -> None:
logger.debug("browser page closed", extra={"page_id": self.id})
self._close_fut.set_result(None)
class BrowserContext:
def __init__(self, *, dev_mode: bool, remote_debugging_port: int = 0) -> None:
self._mp_ctx = mp.get_context("spawn")
self._pages: dict[int, BrowserPage] = {}
self._dev_mode = dev_mode
self._initialized = False
self._next_page_id = 1
self._remote_debugging_port = remote_debugging_port
async def initialize(self) -> None:
mp_pch, mp_cch = socket.socketpair()
self._duplex = await utils.aio.duplex_unix._AsyncDuplex.open(mp_pch)
self._proc = self._mp_ctx.Process(target=proc_main.main, args=(mp_cch,))
self._proc.start()
mp_cch.close()
if not self._remote_debugging_port:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("", 0))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._remote_debugging_port = s.getsockname()[1]
logger.debug("using remote debugging port %d", self._remote_debugging_port)
await ipc.channel.asend_message(
self._duplex,
proto.InitializeContextRequest(
dev_mode=self._dev_mode,
remote_debugging_port=self._remote_debugging_port,
root_cache_path=tempfile.mkdtemp(), # TODO(theomonnom): cleanup
),
)
resp = await ipc.channel.arecv_message(self._duplex, proto.IPC_MESSAGES)
assert isinstance(resp, proto.ContextInitializedResponse)
self._initialized = True
logger.debug("browser context initialized", extra={"pid": self._proc.pid})
self._main_atask = asyncio.create_task(self._main_task(self._duplex))
@asynccontextmanager
async def playwright(self, timeout: float | None = None):
if not self._initialized:
raise RuntimeError("BrowserContext not initialized")
from playwright.async_api import async_playwright
async with async_playwright() as p:
url = f"http://localhost:{self._remote_debugging_port}"
browser = await p.chromium.connect_over_cdp(url, timeout=timeout)
try:
yield browser
finally:
await browser.close()
@utils.log_exceptions(logger)
async def _main_task(self, duplex: utils.aio.duplex_unix._AsyncDuplex) -> None:
while True:
try:
msg = await ipc.channel.arecv_message(duplex, proto.IPC_MESSAGES)
except utils.aio.duplex_unix.DuplexClosed:
break
if isinstance(msg, proto.CreateBrowserResponse):
page = self._pages[msg.page_id]
await page._handle_created(msg)
elif isinstance(msg, proto.AcquirePaintData):
page = self._pages[msg.page_id]
await page._handle_paint(msg)
elif isinstance(msg, proto.BrowserClosed):
page = self._pages[msg.page_id]
await page._handle_close(msg)
async def new_page(
self, *, url: str, width: int = 800, height: int = 600, framerate: int = 30
) -> BrowserPage:
if not self._initialized:
raise RuntimeError("BrowserContext not initialized")
page_id = self._next_page_id
self._next_page_id += 1
page = BrowserPage(
self._mp_ctx,
_PageOptions(
page_id=page_id,
url=url,
width=width,
height=height,
framerate=framerate,
),
self._duplex,
)
self._pages[page_id] = page
await page.start()
return page
import importlib.resources
import multiprocessing.shared_memory as mp_shm
import socket
import threading
from livekit.agents import ipc, utils
from . import logger, proto
class BrowserServer:
def __init__(
self,
duplex: utils.aio.duplex_unix._Duplex,
shm: mp_shm.SharedMemory,
page_id: int,
):
self._duplex = duplex
self._shm = shm
self._page_id = page_id
self._view_width = 0
self._view_height = 0
self._closing = False
self._release_paint_e = threading.Event()
@staticmethod
def create(
*,
duplex: utils.aio.duplex_unix._Duplex,
create_req: proto.CreateBrowserRequest,
browser_app,
) -> "BrowserServer":
logger.debug(
"creating browser",
extra={
"page_id": create_req.page_id,
"url": create_req.url,
"framerate": create_req.framerate,
"width": create_req.width,
"height": create_req.height,
"shm_name": create_req.shm_name,
},
)
import lkcef_python as lkcef
opts = lkcef.BrowserOptions()
opts.framerate = create_req.framerate
opts.width = create_req.width
opts.height = create_req.height
shm = mp_shm.SharedMemory(name=create_req.shm_name)
bserver = BrowserServer(duplex, shm, create_req.page_id)
opts.created_callback = bserver._browser_created
opts.paint_callback = bserver._paint
opts.close_callback = bserver._closed
browser_app.create_browser(create_req.url, opts)
return bserver
def _browser_created(self, impl):
browser_id = impl.identifier()
logger.debug(
"browser created",
extra={"browser_id": browser_id, "page_id": self._page_id},
)
self._impl = impl
try:
ipc.channel.send_message(
self._duplex,
proto.CreateBrowserResponse(page_id=self._page_id, browser_id=browser_id),
)
except utils.aio.duplex_unix.DuplexClosed:
logger.exception("failed to send CreateBrowserResponse")
def _paint(self, frame_data):
if self._closing:
return # make sure to not use the shm
acq = proto.AcquirePaintData()
acq.page_id = self._page_id
acq.width = frame_data.width
acq.height = frame_data.height
dirty_rects = []
for rect in frame_data.dirty_rects:
dirty_rects.append((rect.x, rect.y, rect.width, rect.height))
acq.dirty_rects = dirty_rects
old_width = self._view_width
old_height = self._view_height
self._view_width = frame_data.width
self._view_height = frame_data.height
proto.copy_paint_data(acq, old_width, old_height, frame_data.buffer, self._shm.buf)
try:
ipc.channel.send_message(self._duplex, acq)
self._release_paint_e.wait() # wait for release
self._release_paint_e.clear()
except utils.aio.duplex_unix.DuplexClosed:
logger.exception("failed to send AcquirePaintData")
def _closed(self) -> None:
ipc.channel.send_message(self._duplex, proto.BrowserClosed(page_id=self._page_id))
def handle_release_paint(self, msg: proto.ReleasePaintData):
self._release_paint_e.set()
def handle_close(self, msg: proto.CloseBrowserRequest):
self._closing = True
self._impl.close()
def _manager_thread(duplex: utils.aio.duplex_unix._Duplex, browser_app):
browsers: dict[int, BrowserServer] = {}
while True:
try:
msg = ipc.channel.recv_message(duplex, proto.IPC_MESSAGES)
except utils.aio.duplex_unix.DuplexClosed:
break
if isinstance(msg, proto.CreateBrowserRequest):
server = BrowserServer.create(duplex=duplex, create_req=msg, browser_app=browser_app)
browsers[msg.page_id] = server
elif isinstance(msg, proto.ReleasePaintData):
server = browsers[msg.page_id]
server.handle_release_paint(msg)
elif isinstance(msg, proto.CloseBrowserRequest):
server = browsers[msg.page_id]
server.handle_close(msg)
del browsers[msg.page_id]
def main(mp_cch: socket.socket):
import lkcef_python as lkcef
duplex = utils.aio.duplex_unix._Duplex.open(mp_cch)
init_req = ipc.channel.recv_message(duplex, proto.IPC_MESSAGES)
assert isinstance(init_req, proto.InitializeContextRequest)
logger.debug("initializing browser context", extra={"dev_mode": init_req.dev_mode})
def _context_initialized():
try:
ipc.channel.send_message(duplex, proto.ContextInitializedResponse())
except utils.aio.duplex_unix.DuplexClosed:
logger.exception("failed to send ContextInitializedResponse")
opts = lkcef.AppOptions()
opts.dev_mode = init_req.dev_mode
opts.remote_debugging_port = init_req.remote_debugging_port
opts.root_cache_path = init_req.root_cache_path
opts.initialized_callback = _context_initialized
res = importlib.resources.files("livekit.plugins.browser.resources") / "lkcef_app.app"
with importlib.resources.as_file(res) as path:
opts.framework_path = str(
path / "Contents" / "Frameworks" / "Chromium Embedded Framework.framework"
)
opts.main_bundle_path = str(path)
opts.subprocess_path = str(
path
/ "Contents"
/ "Frameworks"
/ "lkcef Helper.app"
/ "Contents"
/ "MacOS"
/ "lkcef Helper"
)
app = lkcef.BrowserApp(opts)
man_t = threading.Thread(target=_manager_thread, args=(duplex, app))
man_t.start()
app.run() # run indefinitely
import io
from dataclasses import dataclass, field
from typing import ClassVar
import numpy as np
from livekit.agents.ipc import channel
# there is no risk to increase these values. just using these defaults for now
SHM_MAX_WIDTH = 1920
SHM_MAX_HEIGHT = 1080
@dataclass
class InitializeContextRequest:
MSG_ID: ClassVar[int] = 0
dev_mode: bool = False
remote_debugging_port: int = 0
root_cache_path: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_bool(b, self.dev_mode)
channel.write_int(b, self.remote_debugging_port)
channel.write_string(b, self.root_cache_path)
def read(self, b: io.BytesIO) -> None:
self.dev_mode = channel.read_bool(b)
self.remote_debugging_port = channel.read_int(b)
self.root_cache_path = channel.read_string(b)
@dataclass
class ContextInitializedResponse:
MSG_ID: ClassVar[int] = 1
@dataclass
class CreateBrowserRequest:
MSG_ID: ClassVar[int] = 2
page_id: int = -1
url: str = ""
framerate: int = 0
width: int = 0
height: int = 0
shm_name: str = ""
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
channel.write_string(b, self.url)
channel.write_int(b, self.framerate)
channel.write_int(b, self.width)
channel.write_int(b, self.height)
channel.write_string(b, self.shm_name)
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
self.url = channel.read_string(b)
self.framerate = channel.read_int(b)
self.width = channel.read_int(b)
self.height = channel.read_int(b)
self.shm_name = channel.read_string(b)
@dataclass
class CreateBrowserResponse:
"""
This is going to wait for the created_callback to be called.
(The create_browser function will be async)
"""
MSG_ID: ClassVar[int] = 3
page_id: int = -1
browser_id: int = 0
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
channel.write_int(b, self.browser_id)
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
self.browser_id = channel.read_int(b)
@dataclass
class AcquirePaintData:
MSG_ID: ClassVar[int] = 4
page_id: int = -1
width: int = 0
height: int = 0
dirty_rects: list[tuple[int, int, int, int]] = field(default_factory=list)
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
channel.write_int(b, self.width)
channel.write_int(b, self.height)
channel.write_int(b, len(self.dirty_rects))
for rect in self.dirty_rects:
channel.write_int(b, rect[0])
channel.write_int(b, rect[1])
channel.write_int(b, rect[2])
channel.write_int(b, rect[3])
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
self.width = channel.read_int(b)
self.height = channel.read_int(b)
num_rects = channel.read_int(b)
self.dirty_rects = []
for _ in range(num_rects):
x = channel.read_int(b)
y = channel.read_int(b)
width = channel.read_int(b)
height = channel.read_int(b)
self.dirty_rects.append((x, y, width, height))
@dataclass
class ReleasePaintData:
MSG_ID: ClassVar[int] = 5
page_id: int = -1
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
@dataclass
class CloseBrowserRequest:
MSG_ID: ClassVar[int] = 6
page_id: int = -1
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
@dataclass
class BrowserClosed:
MSG_ID: ClassVar[int] = 7
page_id: int = -1
def write(self, b: io.BytesIO) -> None:
channel.write_int(b, self.page_id)
def read(self, b: io.BytesIO) -> None:
self.page_id = channel.read_int(b)
IPC_MESSAGES = {
InitializeContextRequest.MSG_ID: InitializeContextRequest,
ContextInitializedResponse.MSG_ID: ContextInitializedResponse,
CreateBrowserRequest.MSG_ID: CreateBrowserRequest,
CreateBrowserResponse.MSG_ID: CreateBrowserResponse,
AcquirePaintData.MSG_ID: AcquirePaintData,
ReleasePaintData.MSG_ID: ReleasePaintData,
CloseBrowserRequest.MSG_ID: CloseBrowserRequest,
BrowserClosed.MSG_ID: BrowserClosed,
}
def copy_paint_data(
acq: AcquirePaintData,
old_width: int,
old_height: int,
source: memoryview,
dest: memoryview,
):
dirty_rects = acq.dirty_rects
# source_arr = np.frombuffer(source, dtype=np.uint32).reshape((acq.height, acq.width))
source_arr = np.ndarray(
(acq.height, acq.width),
dtype=np.uint32,
buffer=source,
)
dest_arr = np.ndarray(
(acq.height, acq.width),
dtype=np.uint32,
buffer=dest,
)
has_fullscreen_rect = len(dirty_rects) == 1 and dirty_rects[0] == (
0,
0,
acq.width,
acq.height,
)
if old_width != acq.width or old_height != acq.height or has_fullscreen_rect:
np.copyto(dest_arr, source_arr)
else:
for rect in dirty_rects:
x, y, w, h = rect
dest_arr[y : y + h, x : x + w] = source_arr[y : y + h, x : x + w]
"""Used by importlib.resources and setuptools"""
# Copyright 2023 LiveKit, Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.cibuildwheel.macos]
repair-wheel-command = "" # getting issues with unresolved files
[tool.cibuildwheel]
before-build = "pip install pybind11[global]"
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pathlib
import re
import subprocess
import sys
from pathlib import Path
import setuptools
from setuptools import Extension
from setuptools.command.build_ext import build_ext
here = pathlib.Path(__file__).parent.resolve()
about = {}
with open(os.path.join(here, "livekit", "plugins", "browser", "version.py")) as f:
exec(f.read(), about)
class CMakeExtension(Extension):
def __init__(self, name: str, sourcedir: str = "") -> None:
super().__init__(name, sources=[])
self.sourcedir = os.fspath(Path(sourcedir).resolve())
class CMakeBuild(build_ext):
def build_extension(self, ext: CMakeExtension) -> None:
# Must be in this form due to bug in .resolve() only fixed in Python 3.10+
ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name)
extdir = ext_fullpath.parent.resolve()
debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
cfg = "Debug" if debug else "Release"
cmake_args = [
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}",
f"-DPYTHON_EXECUTABLE={sys.executable}",
f"-DCMAKE_BUILD_TYPE={cfg}",
]
print(f"cmake_args: {cmake_args}")
if sys.platform.startswith("darwin"):
# Cross-compile support for macOS - respect ARCHFLAGS if set
archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", ""))
if archs:
cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))]
self.build_temp = Path(self.build_temp) / ext.name
if not self.build_temp.exists():
self.build_temp.mkdir(parents=True)
subprocess.run(["cmake", ext.sourcedir, *cmake_args], cwd=self.build_temp, check=True)
subprocess.run(["cmake", "--build", "."], cwd=self.build_temp, check=True)
build_output = self.build_temp / "src" / cfg
for f in build_output.iterdir():
if f.suffix == ".so":
self.copy_file(f, extdir / f.name)
if sys.platform.startswith("darwin"):
# on macos, copy the dummy app
app = build_output / "lkcef_app.app"
self.copy_tree(
app,
str(extdir / "livekit" / "plugins" / "browser" / "resources" / "lkcef_app.app"),
)
setuptools.setup(
name="livekit-plugins-browser",
version=about["__version__"],
description="Chromium Embedded Framework (CEF) for LiveKit Agents",
long_description=(here / "README.md").read_text(encoding="utf-8"),
long_description_content_type="text/markdown",
url="https://github.com/livekit/agents",
classifiers=[
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
],
keywords=["webrtc", "realtime", "audio", "video", "livekit"],
license="Apache-2.0",
ext_modules=[CMakeExtension("lkcef_python")],
cmdclass={"build_ext": CMakeBuild},
packages=setuptools.find_namespace_packages(include=["livekit.*"]),
python_requires=">=3.9.0",
install_requires=["livekit-agents>=1.0.0.dev5"],
package_data={
"livekit.plugins.browser": ["py.typed"],
"livekit.plugins.browser.resources": ["**", "lkcef_app.app"],
},
project_urls={
"Documentation": "https://docs.livekit.io",
"Website": "https://livekit.io/",
"Source": "https://github.com/livekit/agents",
},
)
include(FetchContent)
set(FETCHCONTENT_QUIET off)
# I don't want to write a different code per platform for the dev mode.
# so use glfw and imgui like I do for my other side projects...
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_INSTALL OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw GIT_REPOSITORY https://github.com/glfw/glfw.git GIT_TAG 3.4)
FetchContent_MakeAvailable(glfw)
FetchContent_Declare(
imgui
GIT_REPOSITORY https://github.com/ocornut/imgui
GIT_TAG origin/docking
GIT_SHALLOW TRUE
)
FetchContent_GetProperties(imgui)
FetchContent_Populate(imgui)
FetchContent_MakeAvailable(imgui)
file(GLOB IMGUI_SOURCES ${imgui_SOURCE_DIR}/*.cpp)
add_library(imgui STATIC ${IMGUI_SOURCES}
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
${imgui_SOURCE_DIR}/misc/cpp/imgui_stdlib.cpp
)
set_target_properties(imgui PROPERTIES CXX_STANDARD 17)
target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/misc/cpp ${imgui_SOURCE_DIR}/backends ${GLFW_INCLUDE_DIR})
target_link_libraries(imgui PRIVATE glfw)
set(LKCEF_SRCS app.cpp app.hpp handler.hpp handler.cpp dev_renderer.hpp dev_renderer.cpp gleq.h browser_handle.hpp browser_handle.cpp)
set(LKCEF_SRCS_LINUX main_linux.cpp)
set(LKCEF_SRCS_MAC app_mac.mm)
set(LKCEF_SRCS_WINDOWS main_win.cpp )
append_platform_sources(LKCEF_SRCS)
source_group(lkcef FILES ${LKCEF_SRCS})
set(LKCEF_HELPER_SRCS )
set(LKCEF_HELPER_SRCS_LINUX helper_main_linux.cpp)
set(LKCEF_HELPER_SRCS_MAC helper_main_mac.mm)
set(LKCEF_HELPER_SRCS_WINDOWS helper_main_win.cpp)
append_platform_sources(LKCEF_HELPER_SRCS)
source_group(lkcef FILES ${LKCEF_HELPER_SRCS})
set(LKCEF_PYTHON_SRCS agents_python.hpp
agents_python.cpp)
if(OS_LINUX OR OS_WINDOWS)
# Logical target used to link the libcef library on Linux and Windows. On
# macOS the CEF framework is loaded dynamically at startup.
add_logical_target("libcef_lib" "${CEF_LIB_DEBUG}" "${CEF_LIB_RELEASE}")
endif()
set_cef_target_out_dir() # Determine the target output directory.
if(OS_LINUX)
# Helper executable target.
add_executable(lkcef_helper ${LKCEF_HELPER_SRCS})
set_executable_target_properties(lkcef_helper)
add_dependencies(lkcef_helper libcef_dll_wrapper)
target_link_libraries(lkcef_helper libcef_lib libcef_dll_wrapper
${CEF_STANDARD_LIBS})
# Set rpath so that libraries can be placed next to the executable.
set_target_properties(lkcef_helper PROPERTIES INSTALL_RPATH "$ORIGIN")
set_target_properties(lkcef_helper PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE)
# library target.
add_library(lkcef SHARED ${LKCEF_SRCS})
set_library_target_properties(lkcef)
add_dependencies(lkcef libcef_dll_wrapper lkcef_helper)
target_link_libraries(lkcef libcef_lib libcef_dll_wrapper
${CEF_STANDARD_LIBS})
# Set rpath so that libraries can be placed next to the library.
set_target_properties(lkcef PROPERTIES INSTALL_RPATH "$ORIGIN")
set_target_properties(lkcef PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE)
# Copy binary and resource files to the target output directory.
copy_files("lkcef" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR}"
"${CEF_TARGET_OUT_DIR}")
copy_files("lkcef" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}"
"${CEF_TARGET_OUT_DIR}")
endif()
if(OS_MAC)
# Avoid CMP0042 policy errors.
set(CMAKE_MACOSX_RPATH 1)
# Avoid CMP0068 policy errors.
if(POLICY CMP0068)
cmake_policy(SET CMP0068 NEW)
endif()
add_executable(lkcef_app MACOSX_BUNDLE dummy.cpp) # dummy app
set_target_properties(lkcef_app PROPERTIES
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/resources/lkcefapp-Info.plist"
OUTPUT_NAME "lkcef_app"
)
# library target.
add_library(lkcef STATIC ${LKCEF_SRCS})
set_library_target_properties(lkcef)
add_dependencies(lkcef libcef_dll_wrapper)
target_include_directories(lkcef PRIVATE ${GLFW_INCLUDE_DIR})
target_link_libraries(lkcef libcef_dll_wrapper ${CEF_STANDARD_LIBS} glfw imgui)
add_custom_command(
TARGET lkcef
POST_BUILD
# Copy the CEF framework into the main app bundle.
COMMAND
${CMAKE_COMMAND} -E copy_directory
"${CEF_BINARY_DIR}/Chromium Embedded Framework.framework"
"$<TARGET_BUNDLE_DIR:lkcef_app>/Contents/Frameworks/Chromium Embedded Framework.framework"
VERBATIM)
# Create the multiple Helper app bundle targets.
foreach(_suffix_list ${CEF_HELPER_APP_SUFFIXES})
# Convert to a list and extract the suffix values.
string(REPLACE ":" ";" _suffix_list ${_suffix_list})
list(GET _suffix_list 0 _name_suffix)
list(GET _suffix_list 1 _target_suffix)
list(GET _suffix_list 2 _plist_suffix)
# Define Helper target and output names.
set(_helper_target "lkcef_Helper${_target_suffix}")
set(_helper_output_name "lkcef Helper${_name_suffix}")
# Create Helper-specific variants of the helper-Info.plist file.
set(_helper_info_plist
"${CMAKE_CURRENT_BINARY_DIR}/lkcef-Info${_target_suffix}.plist")
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/resources/lkcefhelper-Info.plist"
_plist_contents)
string(REPLACE "\${EXECUTABLE_NAME}" "${_helper_output_name}"
_plist_contents ${_plist_contents})
string(REPLACE "\${PRODUCT_NAME}" "${_helper_output_name}" _plist_contents
${_plist_contents})
string(REPLACE "\${BUNDLE_ID_SUFFIX}" "${_plist_suffix}" _plist_contents
${_plist_contents})
file(WRITE ${_helper_info_plist} ${_plist_contents})
# Create Helper executable target.
add_executable(${_helper_target} MACOSX_BUNDLE ${LKCEF_HELPER_SRCS})
set_executable_target_properties(${_helper_target})
add_dependencies(${_helper_target} libcef_dll_wrapper)
target_link_libraries(${_helper_target} libcef_dll_wrapper
${CEF_STANDARD_LIBS})
set_target_properties(
${_helper_target}
PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${_helper_info_plist}
OUTPUT_NAME ${_helper_output_name})
# Add the Helper as a dependency of the main executable target.
add_dependencies(lkcef "${_helper_target}")
# Copy the Helper app bundle into the Frameworks directory.
add_custom_command(
TARGET lkcef
POST_BUILD
COMMAND
${CMAKE_COMMAND} -E copy_directory
"${CEF_TARGET_OUT_DIR}/${_helper_output_name}.app"
"$<TARGET_BUNDLE_DIR:lkcef_app>/Contents/Frameworks/${_helper_output_name}.app"
VERBATIM)
endforeach()
endif()
if(OS_WINDOWS)
# Helper executable target.
add_executable(lkcef_helper WIN32 ${LKCEF_HELPER_SRCS})
set_executable_target_properties(lkcef_helper)
add_dependencies(lkcef_helper libcef_dll_wrapper)
target_link_libraries(lkcef_helper libcef_lib libcef_dll_wrapper
${CEF_STANDARD_LIBS})
# library target.
add_library(lkcef SHARED ${LKCEF_SRCS})
set_library_target_properties(lkcef)
add_dependencies(lkcef libcef_dll_wrapper lkcef_helper)
target_link_libraries(lkcef libcef_lib libcef_dll_wrapper
${CEF_STANDARD_LIBS})
# Add the custom manifest files to the DLL and helper EXE.
add_windows_manifest("${CMAKE_CURRENT_SOURCE_DIR}" "lkcef" "dll")
add_windows_manifest("${CMAKE_CURRENT_SOURCE_DIR}" "lkcef_helper" "exe")
# Copy binary and resource files to the target output directory.
copy_files("lkcef" "${CEF_BINARY_FILES}" "${CEF_BINARY_DIR}"
"${CEF_TARGET_OUT_DIR}")
copy_files("lkcef" "${CEF_RESOURCE_FILES}" "${CEF_RESOURCE_DIR}"
"${CEF_TARGET_OUT_DIR}")
endif()
# TODO(theomonnom): should be pretty similar for NodeJS
pybind11_add_module(lkcef_python ${LKCEF_PYTHON_SRCS})
set_target_properties(lkcef_python PROPERTIES INSTALL_RPATH "$ORIGIN")
set_target_properties(lkcef_python PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE)
target_include_directories(lkcef_python PRIVATE ${CEF_INCLUDE_PATH})
target_link_libraries(lkcef_python PUBLIC lkcef)
target_link_libraries(lkcef_python PUBLIC libcef_dll_wrapper ${CEF_STANDARD_LIBS})
add_dependencies(lkcef_python libcef_dll_wrapper)
#include "agents_python.hpp"
#include <pybind11/functional.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include "app.hpp"
#include "include/base/cef_callback.h"
#include "include/internal/cef_mac.h"
#include "include/wrapper/cef_closure_task.h"
namespace py = pybind11;
BrowserApp::BrowserApp(const AppOptions& options) : options_(options) {
app_ = new AgentApp(options_.dev_mode, options.remote_debugging_port,
options.root_cache_path, options.framework_path,
options.main_bundle_path, options.subprocess_path,
options_.initialized_callback);
}
bool BrowserApp::CreateBrowser(const std::string& url,
const BrowserOptions& options) {
if (CefCurrentlyOn(TID_UI)) {
CreateBrowserOnUIThread(url, options);
return true;
}
// TODO(theomonnom): Document base::Unretained
CefPostTask(TID_UI, base::BindOnce(&BrowserApp::CreateBrowserOnUIThread,
base::Unretained(this), url, options));
return true;
}
void BrowserApp::CreateBrowserOnUIThread(const std::string& url,
const BrowserOptions& options) {
std::shared_ptr<BrowserImpl> browser_impl = std::make_shared<BrowserImpl>();
browsers_.push_back(browser_impl);
CefRefPtr<BrowserHandle> handle = app_->CreateBrowser(
url, options.framerate, options.width, options.height,
[options, browser_impl]() { options.created_callback(browser_impl); },
[options](std::vector<CefRect> dirtyRects, const void* buffer, int width,
int height) {
PaintData event{};
std::vector<PaintRect> rects;
rects.reserve(dirtyRects.size());
for (const auto& rect : dirtyRects) {
rects.push_back({rect.x, rect.y, rect.width, rect.height});
}
event.dirtyRect = rects;
event.buffer = buffer;
event.width = width;
event.height = height;
options.paint_callback(event);
},
options.close_callback);
browser_impl->handle = handle;
}
int BrowserApp::Run() {
return RunAgentApp(app_);
}
BrowserImpl::BrowserImpl() {}
void BrowserImpl::SetSize(int width, int height) {
if (handle)
handle->SetSize(width, height);
}
void BrowserImpl::Close() {
if (handle)
handle->Close();
}
int BrowserImpl::Identifier() const {
return handle->GetBrowser()->GetIdentifier();
}
py::memoryview paint_data_to_memoryview(const PaintData& event) {
return py::memoryview::from_buffer(
const_cast<uint32_t*>(static_cast<const uint32_t*>(event.buffer)),
{event.height * event.width}, {sizeof(uint32_t)}, true);
}
PYBIND11_MODULE(lkcef_python, m) {
// Isn't that fucking cool? llm using browsers
m.doc() = "Chromium Embedded Framework (CEF) for LiveKit Agents";
py::class_<AppOptions>(m, "AppOptions")
.def(py::init())
.def_readwrite("dev_mode", &AppOptions::dev_mode)
.def_readwrite("remote_debugging_port",
&AppOptions::remote_debugging_port)
.def_readwrite("root_cache_path", &AppOptions::root_cache_path)
.def_readwrite("framework_path", &AppOptions::framework_path)
.def_readwrite("main_bundle_path", &AppOptions::main_bundle_path)
.def_readwrite("subprocess_path", &AppOptions::subprocess_path)
.def_readwrite("initialized_callback", &AppOptions::initialized_callback);
py::class_<BrowserOptions>(m, "BrowserOptions")
.def(py::init())
.def_readwrite("framerate", &BrowserOptions::framerate)
.def_readwrite("width", &BrowserOptions::width)
.def_readwrite("height", &BrowserOptions::height)
.def_readwrite("created_callback", &BrowserOptions::created_callback)
.def_readwrite("paint_callback", &BrowserOptions::paint_callback)
.def_readwrite("close_callback", &BrowserOptions::close_callback);
py::class_<BrowserApp>(m, "BrowserApp")
.def(py::init<const AppOptions&>())
.def("create_browser", &BrowserApp::CreateBrowser)
.def("run", &BrowserApp::Run, py::call_guard<py::gil_scoped_release>());
py::class_<BrowserImpl, std::shared_ptr<BrowserImpl>>(m, "BrowserImpl")
.def("set_size", &BrowserImpl::SetSize)
.def("close", &BrowserImpl::Close)
.def("identifier", &BrowserImpl::Identifier);
py::class_<PaintRect>(m, "PaintRect")
.def_readwrite("x", &PaintRect::x)
.def_readwrite("y", &PaintRect::y)
.def_readwrite("width", &PaintRect::width)
.def_readwrite("height", &PaintRect::height);
py::class_<PaintData>(m, "PaintData")
.def(py::init())
.def_readwrite("dirty_rects", &PaintData::dirtyRect)
.def_readwrite("width", &PaintData::width)
.def_readwrite("height", &PaintData::height)
.def_property_readonly("buffer", [](const PaintData& event) {
return paint_data_to_memoryview(event);
});
}
#ifndef LKCEF_AGENTS_PYTHON_HPP
#define LKCEF_AGENTS_PYTHON_HPP
#include <functional>
#include <memory>
#include "app.hpp"
class BrowserImpl;
struct PaintData;
struct AppOptions {
bool dev_mode = false;
int remote_debugging_port = 0;
std::string root_cache_path;
std::string framework_path;
std::string main_bundle_path;
std::string subprocess_path;
std::function<void()> initialized_callback = nullptr;
};
struct BrowserOptions {
int framerate = 30;
int width = 800;
int height = 600;
std::function<void(std::shared_ptr<BrowserImpl>)> created_callback = nullptr;
std::function<void(const PaintData&)> paint_callback = nullptr;
std::function<void()> close_callback = nullptr;
};
struct BrowserApp {
BrowserApp(const AppOptions& options);
bool CreateBrowser(const std::string& url, const BrowserOptions& options);
void CreateBrowserOnUIThread(const std::string& url, const BrowserOptions& options);
int Run();
private:
AppOptions options_;
CefRefPtr<AgentApp> app_;
std::list<std::shared_ptr<BrowserImpl>> browsers_;
};
struct BrowserImpl {
BrowserImpl();
void SetSize(int width, int height);
void Close();
int Identifier() const;
CefRefPtr<BrowserHandle> handle = nullptr;
};
struct PaintRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
struct PaintData {
std::vector<PaintRect> dirtyRect;
const void* buffer;
int width;
int height;
};
#endif // LKCEF_AGENTS_PYTHON_HPP
#include "app.hpp"
#include <iostream>
#include <string>
#include <utility>
#include "include/cef_command_line.h"
#include "include/views/cef_window.h"
#include "include/wrapper/cef_helpers.h"
AgentApp::AgentApp(bool dev_mode,
int remote_debugging_port,
std::string root_cache_path,
std::string framework_path,
std::string main_bundle_path,
std::string subprocess_path,
std::function<void()> initialized_callback)
: dev_mode_(dev_mode),
remote_debugging_port_(remote_debugging_port),
root_cache_path_(std::move(root_cache_path)),
framework_path_(std::move(framework_path)),
main_bundle_path_(std::move(main_bundle_path)),
subprocess_path_(std::move(subprocess_path)),
initialized_callback_(std::move(initialized_callback)) {
browser_store_ = CefRefPtr<BrowserStore>(new BrowserStore());
if (dev_mode)
dev_renderer_ = CefRefPtr<DevRenderer>(new DevRenderer(browser_store_));
}
void AgentApp::OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) {
command_line->AppendSwitch("--disable-gpu");
command_line->AppendSwitch("--disable-gpu-compositing");
command_line->AppendSwitch("--enable-chrome-runtime");
// command_line->AppendSwitch("--enable-begin-frame-scheduling");
}
void AgentApp::OnContextInitialized() {
CEF_REQUIRE_UI_THREAD(); // Main thread in our case
client_ =
CefRefPtr<AgentHandler>(new AgentHandler(browser_store_, dev_renderer_));
dev_client_ = CefRefPtr<DevToolsHandler>(new DevToolsHandler());
if (initialized_callback_)
initialized_callback_();
}
CefRefPtr<CefClient> AgentApp::GetDefaultClient() {
return client_;
}
CefRefPtr<BrowserHandle> AgentApp::CreateBrowser(
const std::string& url,
int framerate,
int width,
int height,
std::function<void()> created_callback,
std::function<void(std::vector<CefRect> dirtyRects,
const void* buffer,
int width,
int height)> paint_callback,
std::function<void()> close_callback) {
CEF_REQUIRE_UI_THREAD();
// windowInfo.SetAsWindowless(dev_renderer_->getNativeWindowHandle());
CefWindowInfo windowInfo;
windowInfo.SetAsWindowless(nullptr);
CefBrowserSettings settings;
settings.windowless_frame_rate = framerate;
settings.background_color = CefColorSetARGB(255, 255, 255, 255);
CefRefPtr<BrowserHandle> browser_handle =
new BrowserHandle(std::move(created_callback), std::move(paint_callback),
std::move(close_callback), width, height);
browser_store_->AddPendingHandle(browser_handle);
bool result = CefBrowserHost::CreateBrowser(windowInfo, client_, url,
settings, nullptr, nullptr);
if (!result) {
browser_store_->RemovePendingHandle(browser_handle);
return nullptr;
}
return browser_handle;
}
int AgentApp::Run() {
if (dev_mode_) {
dev_renderer_->Run();
} else {
CefRunMessageLoop();
}
// Close all browsers
return 0;
}
#ifndef LKCEF_APP_HPP
#define LKCEF_APP_HPP
#include "browser_handle.hpp"
#include "dev_renderer.hpp"
#include "handler.hpp"
#include "include/cef_app.h"
#include "include/cef_base.h"
#include "include/cef_browser_process_handler.h"
#include "include/cef_client.h"
#include "include/internal/cef_ptr.h"
class AgentApp : public CefApp, public CefBrowserProcessHandler {
public:
AgentApp(bool dev_mode,
int remote_debugging_port,
std::string root_cache_path,
std::string framework_path,
std::string main_bundle_path,
std::string subprocess_path,
std::function<void()> initialized_callback);
CefRefPtr<CefBrowserProcessHandler> GetBrowserProcessHandler() override {
return this;
}
void OnBeforeCommandLineProcessing(
const CefString& process_type,
CefRefPtr<CefCommandLine> command_line) override;
void OnContextInitialized() override;
CefRefPtr<CefClient> GetDefaultClient() override;
CefRefPtr<BrowserHandle> CreateBrowser(
const std::string& url,
int framerate,
int width,
int height,
std::function<void()> created_callback,
std::function<void(std::vector<CefRect> dirtyRect,
const void* buffer,
int width,
int height)> paint_callback,
std::function<void()> close_callback);
int Run();
bool IsDevMode() const { return dev_mode_; }
int GetRemoteDebuggingPort() const { return remote_debugging_port_; }
std::string GetRootCachePath() const { return root_cache_path_; }
std::string GetFrameworkPath() const { return framework_path_; }
std::string GetMainBundlePath() const { return main_bundle_path_; }
std::string GetSubprocessPath() const { return subprocess_path_; }
private:
IMPLEMENT_REFCOUNTING(AgentApp);
CefRefPtr<BrowserStore> browser_store_;
CefRefPtr<AgentHandler> client_;
CefRefPtr<DevToolsHandler> dev_client_;
CefRefPtr<DevRenderer> dev_renderer_;
bool dev_mode_;
int remote_debugging_port_;
std::string root_cache_path_;
std::string framework_path_;
std::string main_bundle_path_;
std::string subprocess_path_;
std::function<void()> initialized_callback_;
};
int RunAgentApp(CefRefPtr<AgentApp> app);
#endif // LKCEF_APP_HPP
#import <Cocoa/Cocoa.h>
#include <iostream>
#import <Cocoa/Cocoa.h>
#include <objc/runtime.h>
#include "app.hpp"
#include "handler.hpp"
#include "include/cef_application_mac.h"
#include "include/cef_command_line.h"
#include "include/wrapper/cef_library_loader.h"
BOOL g_handling_send_event = false;
@interface NSApplication (AgentsApplication) <CefAppProtocol>
- (BOOL)isHandlingSendEvent;
- (void)setHandlingSendEvent:(BOOL)handlingSendEvent;
- (void)_swizzled_sendEvent:(NSEvent*)event;
- (void)_swizzled_terminate:(id)sender;
@end
@implementation NSApplication (AgentsApplication)
// This selector is called very early during the application initialization.
+ (void)load {
NSLog(@"AgentsApplication::load");
// Swap NSApplication::sendEvent with _swizzled_sendEvent.
Method original = class_getInstanceMethod(self, @selector(sendEvent));
Method swizzled =
class_getInstanceMethod(self, @selector(_swizzled_sendEvent));
method_exchangeImplementations(original, swizzled);
Method originalTerm = class_getInstanceMethod(self, @selector(terminate:));
Method swizzledTerm =
class_getInstanceMethod(self, @selector(_swizzled_terminate:));
method_exchangeImplementations(originalTerm, swizzledTerm);
}
- (BOOL)isHandlingSendEvent {
return g_handling_send_event;
}
- (void)setHandlingSendEvent:(BOOL)handlingSendEvent {
g_handling_send_event = handlingSendEvent;
}
- (void)_swizzled_sendEvent:(NSEvent*)event {
CefScopedSendingEvent sendingEventScoper;
// Calls NSApplication::sendEvent due to the swizzling.
[self _swizzled_sendEvent:event];
}
- (void)_swizzled_terminate:(id)sender {
[self _swizzled_terminate:sender];
}
@end
// Entry point function for the browser process.
int RunAgentApp(CefRefPtr<AgentApp> app) {
CefMainArgs main_args(0, nullptr);
@autoreleasepool {
[NSApplication sharedApplication];
// If there was an invocation to NSApp prior to this method, then the NSApp
// will not be a AgentsApplication, but will instead be an NSApplication.
// This is undesirable and we must enforce that this doesn't happen.
CHECK([NSApp isKindOfClass:[NSApplication class]]);
std::string framework_lib = app->GetFrameworkPath() + "/Chromium Embedded Framework";
if (!cef_load_library(framework_lib.c_str())) {
std::cerr << "lkcef: Failed to load CEF library" << std::endl;
return 1;
}
CefSettings settings{};
settings.chrome_runtime = true;
settings.external_message_pump = app->IsDevMode();
settings.remote_debugging_port = app->GetRemoteDebuggingPort();
CefString(&settings.root_cache_path).FromString(app->GetRootCachePath());
CefString(&settings.framework_dir_path).FromString(app->GetFrameworkPath());
CefString(&settings.main_bundle_path).FromString(app->GetMainBundlePath());
CefString(&settings.browser_subprocess_path).FromString(app->GetSubprocessPath());
settings.no_sandbox = true; // No sandbox for MacOS, for livekit-agents,
// we're only going to support Linux
settings.windowless_rendering_enabled = true;
// Initialize the CEF browser process. May return false if initialization
// fails or if early exit is desired (for example, due to process singleton
// relaunch behavior).
if (!CefInitialize(main_args, settings, app.get(), nullptr)) {
std::cerr << "lkcef: Failed to initialize CEF" << std::endl;
// TODO(theomonnom): Use CefGetExitCode();
return 1;
}
app->Run();
CefShutdown();
cef_unload_library();
} // @autoreleasepool
return 0;
}
#include "browser_handle.hpp"
void BrowserHandle::SetSize(int width, int height) {
width_ = width;
height_ = height;
if (browser_)
browser_->GetHost()->WasResized();
}
void BrowserHandle::Close() {
if (browser_)
browser_->GetHost()->CloseBrowser(true);
}
#ifndef LKCEF_BROWSER_HANDLE_HPP
#define LKCEF_BROWSER_HANDLE_HPP
#include <list>
#include "include/cef_client.h"
#include "include/wrapper/cef_helpers.h"
class BrowserHandle : public CefBaseRefCounted {
public:
BrowserHandle(
std::function<void()> created_callback,
std::function<void(std::vector<CefRect> dirtyRects,
const void* buffer,
int width,
int height)> paint_callback,
std::function<void()> close_callback,
int width,
int height)
: created_callback_(std::move(created_callback)),
paint_callback_(std::move(paint_callback)),
close_callback_(std::move(close_callback)),
width_(width),
height_(height) {}
CefRefPtr<CefBrowser> browser_ = nullptr;
std::function<void()> created_callback_ = nullptr;
std::function<void(std::vector<CefRect> dirtyRect,
const void* buffer,
int width,
int height)>
paint_callback_ = nullptr;
std::function<void()> close_callback_ = nullptr;
void SetSize(int width, int height);
void Close();
int GetWidth() const { return width_; }
int GetHeight() const { return height_; }
CefRefPtr<CefBrowser> GetBrowser() const { return browser_; }
private:
int width_ = 0;
int height_ = 0;
IMPLEMENT_REFCOUNTING(BrowserHandle);
};
struct BrowserStore : public CefBaseRefCounted {
std::unordered_map<int, CefRefPtr<BrowserHandle>> browser_handles_;
std::list<CefRefPtr<BrowserHandle>> pending_handles_;
void AddPendingHandle(CefRefPtr<BrowserHandle> handle) {
CEF_REQUIRE_UI_THREAD();
pending_handles_.push_back(handle);
}
void RemovePendingHandle(CefRefPtr<BrowserHandle> handle) {
CEF_REQUIRE_UI_THREAD();
pending_handles_.remove(handle);
}
CefRefPtr<BrowserHandle> GetBrowserHandle(int identifier) {
CEF_REQUIRE_UI_THREAD();
return browser_handles_[identifier];
}
IMPLEMENT_REFCOUNTING(BrowserStore);
};
#endif // LKCEF_BROWSER_HANDLE_HPP
#include "dev_renderer.hpp"
#include <iostream>
#include "handler.hpp"
#define IMGUI_DEFINE_MATH_OPERATORS
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "imgui_stdlib.h"
#include "include/cef_app.h"
#include "include/wrapper/cef_helpers.h"
#include "keyboard_codes.h"
#define GLEQ_IMPLEMENTATION
#define GLEQ_STATIC
#include "gleq.h"
// DCHECK on gl errors.
#if DCHECK_IS_ON()
#define VERIFY_NO_ERROR \
{ \
int _gl_error = glGetError(); \
DCHECK(_gl_error == GL_NO_ERROR) << "glGetError returned " << _gl_error; \
}
#else
#define VERIFY_NO_ERROR
#endif
int glfw_key_to_cef_key(int glfwKey) {
switch (glfwKey) {
case GLFW_KEY_SPACE:
return WebCore::VK_SPACE;
case GLFW_KEY_APOSTROPHE:
return WebCore::VK_OEM_7;
case GLFW_KEY_COMMA:
return WebCore::VK_OEM_COMMA;
case GLFW_KEY_MINUS:
return WebCore::VK_OEM_MINUS;
case GLFW_KEY_PERIOD:
return WebCore::VK_OEM_PERIOD;
case GLFW_KEY_SLASH:
return WebCore::VK_OEM_2;
case GLFW_KEY_0:
return WebCore::VK_0;
case GLFW_KEY_1:
return WebCore::VK_1;
case GLFW_KEY_2:
return WebCore::VK_2;
case GLFW_KEY_3:
return WebCore::VK_3;
case GLFW_KEY_4:
return WebCore::VK_4;
case GLFW_KEY_5:
return WebCore::VK_5;
case GLFW_KEY_6:
return WebCore::VK_6;
case GLFW_KEY_7:
return WebCore::VK_7;
case GLFW_KEY_8:
return WebCore::VK_8;
case GLFW_KEY_9:
return WebCore::VK_9;
case GLFW_KEY_SEMICOLON:
return WebCore::VK_OEM_1;
case GLFW_KEY_EQUAL:
return WebCore::VK_OEM_PLUS;
case GLFW_KEY_A:
return WebCore::VK_A;
case GLFW_KEY_B:
return WebCore::VK_B;
case GLFW_KEY_C:
return WebCore::VK_C;
case GLFW_KEY_D:
return WebCore::VK_D;
case GLFW_KEY_E:
return WebCore::VK_E;
case GLFW_KEY_F:
return WebCore::VK_F;
case GLFW_KEY_G:
return WebCore::VK_G;
case GLFW_KEY_H:
return WebCore::VK_H;
case GLFW_KEY_I:
return WebCore::VK_I;
case GLFW_KEY_J:
return WebCore::VK_J;
case GLFW_KEY_K:
return WebCore::VK_K;
case GLFW_KEY_L:
return WebCore::VK_L;
case GLFW_KEY_M:
return WebCore::VK_M;
case GLFW_KEY_N:
return WebCore::VK_N;
case GLFW_KEY_O:
return WebCore::VK_O;
case GLFW_KEY_P:
return WebCore::VK_P;
case GLFW_KEY_Q:
return WebCore::VK_Q;
case GLFW_KEY_R:
return WebCore::VK_R;
case GLFW_KEY_S:
return WebCore::VK_S;
case GLFW_KEY_T:
return WebCore::VK_T;
case GLFW_KEY_U:
return WebCore::VK_U;
case GLFW_KEY_V:
return WebCore::VK_V;
case GLFW_KEY_W:
return WebCore::VK_W;
case GLFW_KEY_X:
return WebCore::VK_X;
case GLFW_KEY_Y:
return WebCore::VK_Y;
case GLFW_KEY_Z:
return WebCore::VK_Z;
case GLFW_KEY_LEFT_BRACKET:
return WebCore::VK_OEM_4;
case GLFW_KEY_BACKSLASH:
return WebCore::VK_OEM_5;
case GLFW_KEY_RIGHT_BRACKET:
return WebCore::VK_OEM_6;
case GLFW_KEY_GRAVE_ACCENT:
return WebCore::VK_OEM_3;
case GLFW_KEY_ESCAPE:
return WebCore::VK_ESCAPE;
case GLFW_KEY_ENTER:
return WebCore::VK_RETURN;
case GLFW_KEY_TAB:
return WebCore::VK_TAB;
case GLFW_KEY_BACKSPACE:
return WebCore::VK_BACK;
case GLFW_KEY_INSERT:
return WebCore::VK_INSERT;
case GLFW_KEY_DELETE:
return WebCore::VK_DELETE;
case GLFW_KEY_RIGHT:
return WebCore::VK_RIGHT;
case GLFW_KEY_LEFT:
return WebCore::VK_LEFT;
case GLFW_KEY_DOWN:
return WebCore::VK_DOWN;
case GLFW_KEY_UP:
return WebCore::VK_UP;
case GLFW_KEY_PAGE_UP:
return WebCore::VK_PRIOR;
case GLFW_KEY_PAGE_DOWN:
return WebCore::VK_NEXT;
case GLFW_KEY_HOME:
return WebCore::VK_HOME;
case GLFW_KEY_END:
return WebCore::VK_END;
case GLFW_KEY_CAPS_LOCK:
return WebCore::VK_CAPITAL;
case GLFW_KEY_SCROLL_LOCK:
return WebCore::VK_SCROLL;
case GLFW_KEY_NUM_LOCK:
return WebCore::VK_NUMLOCK;
case GLFW_KEY_PRINT_SCREEN:
return WebCore::VK_SNAPSHOT;
case GLFW_KEY_PAUSE:
return WebCore::VK_PAUSE;
case GLFW_KEY_F1:
return WebCore::VK_F1;
case GLFW_KEY_F2:
return WebCore::VK_F2;
case GLFW_KEY_F3:
return WebCore::VK_F3;
case GLFW_KEY_F4:
return WebCore::VK_F4;
case GLFW_KEY_F5:
return WebCore::VK_F5;
case GLFW_KEY_F6:
return WebCore::VK_F6;
case GLFW_KEY_F7:
return WebCore::VK_F7;
case GLFW_KEY_F8:
return WebCore::VK_F8;
case GLFW_KEY_F9:
return WebCore::VK_F9;
case GLFW_KEY_F10:
return WebCore::VK_F10;
case GLFW_KEY_F11:
return WebCore::VK_F11;
case GLFW_KEY_F12:
return WebCore::VK_F12;
// Add more cases as needed
default:
return WebCore::VK_UNKNOWN;
}
}
static uint32_t glfw_mods_to_cef_mods(int glfw_mods) {
uint32_t cef_flags = 0;
if (glfw_mods & 0x0001) { // GLFW_MOD_SHIFT
cef_flags |= (1 << 1); // EVENTFLAG_SHIFT_DOWN
}
if (glfw_mods & 0x0002) { // GLFW_MOD_CONTROL
cef_flags |= (1 << 2); // EVENTFLAG_CONTROL_DOWN
}
if (glfw_mods & 0x0004) { // GLFW_MOD_ALT
cef_flags |= (1 << 3); // EVENTFLAG_ALT_DOWN
}
if (glfw_mods & 0x0008) { // GLFW_MOD_SUPER
cef_flags |=
(1 << 7); // EVENTFLAG_COMMAND_DOWN (Super key -> Command on Mac)
}
if (glfw_mods & 0x0010) { // GLFW_MOD_CAPS_LOCK
cef_flags |= (1 << 0); // EVENTFLAG_CAPS_LOCK_ON
}
if (glfw_mods & 0x0020) { // GLFW_MOD_NUM_LOCK
cef_flags |= (1 << 8); // EVENTFLAG_NUM_LOCK_ON
}
return cef_flags;
}
static std::optional<CefBrowserHost::MouseButtonType> glfw_button_to_cef_button(
int button) {
switch (button) {
case GLFW_MOUSE_BUTTON_LEFT:
return CefBrowserHost::MouseButtonType::MBT_LEFT;
case GLFW_MOUSE_BUTTON_MIDDLE:
return CefBrowserHost::MouseButtonType::MBT_MIDDLE;
case GLFW_MOUSE_BUTTON_RIGHT:
return CefBrowserHost::MouseButtonType::MBT_RIGHT;
default:
return std::nullopt;
}
}
static void glfw_error_callback(int error, const char* description) {
fprintf(stderr, "GLFW Error %d: %s\n", error, description);
}
DevRenderer::DevRenderer(CefRefPtr<BrowserStore> browser_store)
: browser_store_(browser_store) {}
void DevRenderer::OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
BrowserData* data = &browser_data_[identifier];
data->title = title;
}
void DevRenderer::OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) {
if (!isLoading) {
int identifier = browser->GetIdentifier();
BrowserData* data = &browser_data_[identifier];
data->url = browser->GetMainFrame()->GetURL();
}
}
void DevRenderer::OnAfterCreated(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
unsigned int texture_id;
glGenTextures(1, &texture_id);
VERIFY_NO_ERROR;
BrowserData data{};
data.browser = browser;
data.texture_id = texture_id;
browser_data_.insert({identifier, data});
glBindTexture(GL_TEXTURE_2D, texture_id);
VERIFY_NO_ERROR;
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
VERIFY_NO_ERROR;
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
}
void DevRenderer::OnPaint(CefRefPtr<CefBrowser> browser,
CefRenderHandler::PaintElementType type,
const CefRenderHandler::RectList& dirtyRects,
const void* buffer,
int width,
int height) {
CEF_REQUIRE_UI_THREAD();
if (type != CefRenderHandler::PaintElementType::PET_VIEW) {
return; // Ignore PET_POPUP for now, bc I'm lazy
}
int identifier = browser->GetIdentifier();
BrowserData* data = &browser_data_[identifier];
int old_width = data->view_width;
int old_height = data->view_height;
data->view_width = width;
data->view_height = height;
glBindTexture(GL_TEXTURE_2D, data->texture_id);
glPixelStorei(GL_UNPACK_ROW_LENGTH, width);
VERIFY_NO_ERROR;
bool has_fullscreen_rect =
dirtyRects.size() == 1 && dirtyRects[0] == CefRect(0, 0, width, height);
if (old_width != width || old_height != height || has_fullscreen_rect) {
glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
VERIFY_NO_ERROR;
glPixelStorei(GL_UNPACK_SKIP_ROWS, 0);
VERIFY_NO_ERROR;
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA,
GL_UNSIGNED_INT_8_8_8_8_REV, buffer);
VERIFY_NO_ERROR;
} else {
CefRenderHandler::RectList::const_iterator i = dirtyRects.begin();
for (; i != dirtyRects.end(); ++i) {
const CefRect& rect = *i;
glPixelStorei(GL_UNPACK_SKIP_PIXELS, rect.x);
VERIFY_NO_ERROR;
glPixelStorei(GL_UNPACK_SKIP_ROWS, rect.y);
VERIFY_NO_ERROR;
glTexSubImage2D(GL_TEXTURE_2D, 0, rect.x, rect.y, rect.width, rect.height,
GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, buffer);
VERIFY_NO_ERROR;
}
}
}
void DevRenderer::OnBeforeClose(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
BrowserData* data = &browser_data_[identifier];
glDeleteTextures(1, &data->texture_id);
browser_data_.erase(identifier);
}
void DevRenderer::Run() {
glfwSetErrorCallback(glfw_error_callback);
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return;
}
gleqInit();
const char* glsl_version = "#version 150";
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
window_ =
glfwCreateWindow(800, 600, "livekit-plugins-browser (Development Window)",
nullptr, nullptr);
gleqTrackWindow(window_);
if (!window_) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return;
}
glfwMakeContextCurrent(window_);
glfwSwapInterval(1); // Enable vsync
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
ImGui_ImplGlfw_InitForOpenGL(window_, true);
ImGui_ImplOpenGL3_Init(glsl_version);
ImVec4 clear_color = ImVec4(0.03f, 0.03f, 0.03f, 1.0f);
while (!glfwWindowShouldClose(window_)) {
glfwPollEvents();
CefDoMessageLoopWork();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// Flags used for the "invisible" dockspace frame
ImGuiWindowFlags windowFlags =
ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoBackground;
ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->Pos);
ImGui::SetNextWindowSize(viewport->Size);
ImGui::SetNextWindowViewport(viewport->ID);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("Editor", nullptr, windowFlags);
ImGui::PopStyleVar(3);
ImGui::DockSpace(ImGui::GetID("EditorDockSpace"), ImVec2(),
ImGuiDockNodeFlags_PassthruCentralNode);
// Focused browser input states
BrowserData* focused_browser = nullptr;
int browser_view_x = 0;
int browser_view_y = 0;
for (auto& [identifier, data] : browser_data_) {
std::string name =
(data.title.empty() ? "Browser #" + std::to_string(identifier)
: data.title) +
"###Browser" + std::to_string(identifier);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
if (ImGui::Begin(name.c_str())) {
ImGui::BeginDisabled(!data.browser->CanGoBack());
if (ImGui::ArrowButton("##BrowserBack", ImGuiDir_Left)) {
data.browser->GoBack();
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(!data.browser->CanGoForward());
if (ImGui::ArrowButton("##BrowserForward", ImGuiDir_Right)) {
data.browser->GoForward();
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::InputText("##BrowserURL", &data.url,
ImGuiInputTextFlags_EnterReturnsTrue)) {
data.browser->GetMainFrame()->LoadURL(data.url);
}
ImGui::SameLine();
if (ImGui::Button("Show DevTools")) {
CefWindowInfo windowInfo{};
CefBrowserSettings settings{};
data.browser->GetHost()->ShowDevTools(
windowInfo, DevToolsHandler::GetInstance(), settings, CefPoint());
}
ImVec2 size = ImGui::GetContentRegionAvail();
// Resize the browser view if needed
if (size.x > 0 && size.y > 0 &&
(data.view_width != static_cast<int>(size.x) ||
data.view_height != static_cast<int>(size.y))) {
browser_store_->GetBrowserHandle(identifier)
->SetSize(static_cast<int>(size.x), static_cast<int>(size.y));
}
ImVec2 cursor_pos = ImGui::GetCursorScreenPos();
bool is_focused = ImGui::IsWindowFocused();
if (is_focused) {
focused_browser = &data;
browser_view_x = static_cast<int>(cursor_pos.x);
browser_view_y = static_cast<int>(cursor_pos.y);
data.browser->GetHost()->SetFocus(true);
}
// Render the browser tex
ImGui::Image((ImTextureID)(intptr_t)data.texture_id,
ImVec2((float)data.view_width, (float)data.view_height));
}
ImGui::End();
ImGui::PopStyleVar();
}
GLEQevent event;
while (gleqNextEvent(&event)) {
switch (event.type) {
case GLEQ_CURSOR_MOVED:
case GLEQ_BUTTON_PRESSED:
case GLEQ_SCROLLED:
case GLEQ_BUTTON_RELEASED:
if (focused_browser) {
CefMouseEvent cef_event;
if (event.type == GLEQ_CURSOR_MOVED) {
cef_event.x = event.pos.x - browser_view_x;
cef_event.y = event.pos.y - browser_view_y;
focused_browser->browser->GetHost()->SendMouseMoveEvent(cef_event,
false);
} else if (event.type == GLEQ_SCROLLED) {
double xpos, ypos;
glfwGetCursorPos(window_, &xpos, &ypos);
cef_event.x = static_cast<int>(xpos) - browser_view_x;
cef_event.y = static_cast<int>(ypos) - browser_view_y;
static const int scrollbarPixelsPerTick = 20;
int scroll_x =
static_cast<int>(event.scroll.x * scrollbarPixelsPerTick);
int scroll_y =
static_cast<int>(event.scroll.y * scrollbarPixelsPerTick);
focused_browser->browser->GetHost()->SendMouseWheelEvent(
cef_event, scroll_x, scroll_y);
} else {
double xpos, ypos;
glfwGetCursorPos(window_, &xpos, &ypos);
cef_event.x = static_cast<int>(xpos) - browser_view_x;
cef_event.y = static_cast<int>(ypos) - browser_view_y;
cef_event.modifiers = glfw_mods_to_cef_mods(event.mouse.mods);
std::optional<CefBrowserHost::MouseButtonType> cef_button =
glfw_button_to_cef_button(event.mouse.button);
if (cef_button.has_value()) {
focused_browser->browser->GetHost()->SendMouseClickEvent(
cef_event, cef_button.value(),
event.type == GLEQ_BUTTON_RELEASED, 1);
}
}
}
break;
case GLEQ_KEY_PRESSED:
case GLEQ_KEY_RELEASED:
if (focused_browser) {
CefKeyEvent cef_event;
cef_event.windows_key_code =
glfw_key_to_cef_key(event.keyboard.key);
cef_event.native_key_code = event.keyboard.scancode;
cef_event.modifiers = glfw_mods_to_cef_mods(event.keyboard.mods);
cef_event.is_system_key = false;
if (event.type == GLEQ_KEY_PRESSED) {
cef_event.type = KEYEVENT_RAWKEYDOWN;
focused_browser->browser->GetHost()->SendKeyEvent(cef_event);
} else {
cef_event.type = KEYEVENT_KEYUP;
focused_browser->browser->GetHost()->SendKeyEvent(cef_event);
}
}
break;
case GLEQ_CODEPOINT_INPUT:
if (focused_browser) {
CefKeyEvent cef_event;
cef_event.type = KEYEVENT_CHAR;
cef_event.windows_key_code = 0;
cef_event.native_key_code = 0;
cef_event.modifiers = 0;
cef_event.is_system_key = false;
cef_event.unmodified_character = event.codepoint;
cef_event.character = event.codepoint;
focused_browser->browser->GetHost()->SendKeyEvent(cef_event);
}
break;
default:
break;
}
gleqFreeEvent(&event);
}
ImGui::End();
ImGui::Render();
int display_w, display_h;
glfwGetFramebufferSize(window_, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(clear_color.x * clear_color.w, clear_color.y * clear_color.w,
clear_color.z * clear_color.w, clear_color.w);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window_);
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
glfwDestroyWindow(window_);
glfwTerminate();
}
void DevRenderer::Close() {
// glfwSetWindowShouldClose(window_, GLFW_TRUE);
}
#ifndef LKCEF_DEV_RENDERER_HPP
#define LKCEF_DEV_RENDERER_HPP
#include "include/cef_app.h"
#include "browser_handle.hpp"
#define GL_SILENCE_DEPRECATION
#include <GLFW/glfw3.h> // Will drag system OpenGL headers
#define GLFW_EXPOSE_NATIVE_COCOA
//#define GLFW_NATIVE_INCLUDE_NONE
#include <GLFW/glfw3native.h>
class DevRenderer: public CefBaseRefCounted {
public:
DevRenderer(CefRefPtr<BrowserStore> browser_store);
void Run();
void Close();
void OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString &title);
void OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward);
void OnAfterCreated(CefRefPtr<CefBrowser> browser);
void OnPaint(CefRefPtr<CefBrowser> browser,
CefRenderHandler::PaintElementType type,
const CefRenderHandler::RectList&ts,
const void* buffer,
int width,
int height);
void OnBeforeClose(CefRefPtr<CefBrowser> browser);
void* getNativeWindowHandle() const {
return glfwGetCocoaWindow(window_);
}
private:
struct BrowserData{
CefRefPtr<CefBrowser> browser;
unsigned int texture_id;
int view_width;
int view_height;
std::string title;
std::string url;
};
GLFWwindow* window_ = nullptr;
std::unordered_map<int, BrowserData> browser_data_;
CefRefPtr<BrowserStore> browser_store_;
IMPLEMENT_REFCOUNTING(DevRenderer);
};
#endif // LKCEF_DEV_RENDERER_HPP
int main() {
return 0;
}
/*
* GLEQ - A basic event queue for GLFW 3
* Copyright © Camilla Löwy <elmindreda@glfw.org>
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgment in the product documentation would
* be appreciated but is not required.
*
* 2. Altered source versions must be plainly marked as such, and must not
* be misrepresented as being the original software.
*
* 3. This notice may not be removed or altered from any source
* distribution.
*/
#ifndef GLEQ_HEADER_FILE
#define GLEQ_HEADER_FILE
#include <GLFW/glfw3.h>
#ifdef GLEQ_STATIC
#define GLEQDEF static
#else
#define GLEQDEF extern
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef enum
{
GLEQ_NONE,
GLEQ_WINDOW_MOVED,
GLEQ_WINDOW_RESIZED,
GLEQ_WINDOW_CLOSED,
GLEQ_WINDOW_REFRESH,
GLEQ_WINDOW_FOCUSED,
GLEQ_WINDOW_DEFOCUSED,
GLEQ_WINDOW_ICONIFIED,
GLEQ_WINDOW_UNICONIFIED,
GLEQ_FRAMEBUFFER_RESIZED,
GLEQ_BUTTON_PRESSED,
GLEQ_BUTTON_RELEASED,
GLEQ_CURSOR_MOVED,
GLEQ_CURSOR_ENTERED,
GLEQ_CURSOR_LEFT,
GLEQ_SCROLLED,
GLEQ_KEY_PRESSED,
GLEQ_KEY_REPEATED,
GLEQ_KEY_RELEASED,
GLEQ_CODEPOINT_INPUT,
GLEQ_MONITOR_CONNECTED,
GLEQ_MONITOR_DISCONNECTED,
#if GLFW_VERSION_MINOR >= 1
GLEQ_FILE_DROPPED,
#endif
#if GLFW_VERSION_MINOR >= 2
GLEQ_JOYSTICK_CONNECTED,
GLEQ_JOYSTICK_DISCONNECTED,
#endif
#if GLFW_VERSION_MINOR >= 3
GLEQ_WINDOW_MAXIMIZED,
GLEQ_WINDOW_UNMAXIMIZED,
GLEQ_WINDOW_SCALE_CHANGED,
#endif
} GLEQtype;
typedef struct GLEQevent
{
GLEQtype type;
union {
GLFWwindow* window;
GLFWmonitor* monitor;
int joystick;
};
union {
struct {
int x;
int y;
} pos;
struct {
int width;
int height;
} size;
struct {
double x;
double y;
} scroll;
struct {
int key;
int scancode;
int mods;
} keyboard;
struct {
int button;
int mods;
} mouse;
unsigned int codepoint;
#if GLFW_VERSION_MINOR >= 1
struct {
char** paths;
int count;
} file;
#endif
#if GLFW_VERSION_MINOR >= 3
struct {
float x;
float y;
} scale;
#endif
};
} GLEQevent;
GLEQDEF void gleqInit(void);
GLEQDEF void gleqTrackWindow(GLFWwindow* window);
GLEQDEF int gleqNextEvent(GLEQevent* event);
GLEQDEF void gleqFreeEvent(GLEQevent* event);
#ifdef __cplusplus
}
#endif
#ifdef GLEQ_IMPLEMENTATION
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#ifndef GLEQ_CAPACITY
#define GLEQ_CAPACITY 1024
#endif
static struct
{
GLEQevent events[GLEQ_CAPACITY];
size_t head;
size_t tail;
} gleq_queue = { {}, 0, 0 };
static char* gleq_strdup(const char* string)
{
const size_t size = strlen(string) + 1;
char* result = (char*) malloc(size);
memcpy(result, string, size);
return result;
}
static GLEQevent* gleq_new_event(void)
{
GLEQevent* event = gleq_queue.events + gleq_queue.head;
gleq_queue.head = (gleq_queue.head + 1) % GLEQ_CAPACITY;
assert(gleq_queue.head != gleq_queue.tail);
memset(event, 0, sizeof(GLEQevent));
return event;
}
static void gleq_window_pos_callback(GLFWwindow* window, int x, int y)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_WINDOW_MOVED;
event->window = window;
event->pos.x = x;
event->pos.y = y;
}
static void gleq_window_size_callback(GLFWwindow* window, int width, int height)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_WINDOW_RESIZED;
event->window = window;
event->size.width = width;
event->size.height = height;
}
static void gleq_window_close_callback(GLFWwindow* window)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_WINDOW_CLOSED;
event->window = window;
}
static void gleq_window_refresh_callback(GLFWwindow* window)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_WINDOW_REFRESH;
event->window = window;
}
static void gleq_window_focus_callback(GLFWwindow* window, int focused)
{
GLEQevent* event = gleq_new_event();
event->window = window;
if (focused)
event->type = GLEQ_WINDOW_FOCUSED;
else
event->type = GLEQ_WINDOW_DEFOCUSED;
}
static void gleq_window_iconify_callback(GLFWwindow* window, int iconified)
{
GLEQevent* event = gleq_new_event();
event->window = window;
if (iconified)
event->type = GLEQ_WINDOW_ICONIFIED;
else
event->type = GLEQ_WINDOW_UNICONIFIED;
}
static void gleq_framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_FRAMEBUFFER_RESIZED;
event->window = window;
event->size.width = width;
event->size.height = height;
}
static void gleq_mouse_button_callback(GLFWwindow* window, int button, int action, int mods)
{
GLEQevent* event = gleq_new_event();
event->window = window;
event->mouse.button = button;
event->mouse.mods = mods;
if (action == GLFW_PRESS)
event->type = GLEQ_BUTTON_PRESSED;
else if (action == GLFW_RELEASE)
event->type = GLEQ_BUTTON_RELEASED;
}
static void gleq_cursor_pos_callback(GLFWwindow* window, double x, double y)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_CURSOR_MOVED;
event->window = window;
event->pos.x = (int) x;
event->pos.y = (int) y;
}
static void gleq_cursor_enter_callback(GLFWwindow* window, int entered)
{
GLEQevent* event = gleq_new_event();
event->window = window;
if (entered)
event->type = GLEQ_CURSOR_ENTERED;
else
event->type = GLEQ_CURSOR_LEFT;
}
static void gleq_scroll_callback(GLFWwindow* window, double x, double y)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_SCROLLED;
event->window = window;
event->scroll.x = x;
event->scroll.y = y;
}
static void gleq_key_callback(GLFWwindow* window, int key, int scancode, int action, int mods)
{
GLEQevent* event = gleq_new_event();
event->window = window;
event->keyboard.key = key;
event->keyboard.scancode = scancode;
event->keyboard.mods = mods;
if (action == GLFW_PRESS)
event->type = GLEQ_KEY_PRESSED;
else if (action == GLFW_RELEASE)
event->type = GLEQ_KEY_RELEASED;
else if (action == GLFW_REPEAT)
event->type = GLEQ_KEY_REPEATED;
}
static void gleq_char_callback(GLFWwindow* window, unsigned int codepoint)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_CODEPOINT_INPUT;
event->window = window;
event->codepoint = codepoint;
}
static void gleq_monitor_callback(GLFWmonitor* monitor, int action)
{
GLEQevent* event = gleq_new_event();
event->monitor = monitor;
if (action == GLFW_CONNECTED)
event->type = GLEQ_MONITOR_CONNECTED;
else if (action == GLFW_DISCONNECTED)
event->type = GLEQ_MONITOR_DISCONNECTED;
}
#if GLFW_VERSION_MINOR >= 1
static void gleq_file_drop_callback(GLFWwindow* window, int count, const char** paths)
{
GLEQevent* event = gleq_new_event();
event->type = GLEQ_FILE_DROPPED;
event->window = window;
event->file.paths = (char**) malloc(count * sizeof(char*));
event->file.count = count;
while (count--)
event->file.paths[count] = gleq_strdup(paths[count]);
}
#endif
#if GLFW_VERSION_MINOR >= 2
static void gleq_joystick_callback(int jid, int action)
{
GLEQevent* event = gleq_new_event();
event->joystick = jid;
if (action == GLFW_CONNECTED)
event->type = GLEQ_JOYSTICK_CONNECTED;
else if (action == GLFW_DISCONNECTED)
event->type = GLEQ_JOYSTICK_DISCONNECTED;
}
#endif
#if GLFW_VERSION_MINOR >= 3
static void gleq_window_maximize_callback(GLFWwindow* window, int maximized)
{
GLEQevent* event = gleq_new_event();
event->window = window;
if (maximized)
event->type = GLEQ_WINDOW_MAXIMIZED;
else
event->type = GLEQ_WINDOW_UNMAXIMIZED;
}
static void gleq_window_content_scale_callback(GLFWwindow* window, float xscale, float yscale)
{
GLEQevent* event = gleq_new_event();
event->window = window;
event->type = GLEQ_WINDOW_SCALE_CHANGED;
event->scale.x = xscale;
event->scale.y = yscale;
}
#endif
GLEQDEF void gleqInit(void)
{
glfwSetMonitorCallback(gleq_monitor_callback);
#if GLFW_VERSION_MINOR >= 2
glfwSetJoystickCallback(gleq_joystick_callback);
#endif
}
GLEQDEF void gleqTrackWindow(GLFWwindow* window)
{
glfwSetWindowPosCallback(window, gleq_window_pos_callback);
glfwSetWindowSizeCallback(window, gleq_window_size_callback);
glfwSetWindowCloseCallback(window, gleq_window_close_callback);
glfwSetWindowRefreshCallback(window, gleq_window_refresh_callback);
glfwSetWindowFocusCallback(window, gleq_window_focus_callback);
glfwSetWindowIconifyCallback(window, gleq_window_iconify_callback);
glfwSetFramebufferSizeCallback(window, gleq_framebuffer_size_callback);
glfwSetMouseButtonCallback(window, gleq_mouse_button_callback);
glfwSetCursorPosCallback(window, gleq_cursor_pos_callback);
glfwSetCursorEnterCallback(window, gleq_cursor_enter_callback);
glfwSetScrollCallback(window, gleq_scroll_callback);
glfwSetKeyCallback(window, gleq_key_callback);
glfwSetCharCallback(window, gleq_char_callback);
#if GLFW_VERSION_MINOR >= 1
glfwSetDropCallback(window, gleq_file_drop_callback);
#endif
#if GLFW_VERSION_MINOR >= 3
glfwSetWindowMaximizeCallback(window, gleq_window_maximize_callback);
glfwSetWindowContentScaleCallback(window, gleq_window_content_scale_callback);
#endif
}
GLEQDEF int gleqNextEvent(GLEQevent* event)
{
memset(event, 0, sizeof(GLEQevent));
if (gleq_queue.head != gleq_queue.tail)
{
*event = gleq_queue.events[gleq_queue.tail];
gleq_queue.tail = (gleq_queue.tail + 1) % GLEQ_CAPACITY;
}
return event->type != GLEQ_NONE;
}
GLEQDEF void gleqFreeEvent(GLEQevent* event)
{
#if GLFW_VERSION_MINOR >= 1
if (event->type == GLEQ_FILE_DROPPED)
{
while (event->file.count--)
free(event->file.paths[event->file.count]);
free(event->file.paths);
}
#endif
memset(event, 0, sizeof(GLEQevent));
}
#endif /* GLEQ_IMPLEMENTATION */
#endif /* GLEQ_HEADER_FILE */
#include "handler.hpp"
#include <iostream>
#include "include/base/cef_callback.h"
#include "include/cef_parser.h"
#include "include/views/cef_browser_view.h"
#include "include/wrapper/cef_closure_task.h"
#include "include/wrapper/cef_helpers.h"
DevToolsHandler* g_dev_instance = nullptr;
DevToolsHandler::DevToolsHandler() {
g_dev_instance = this;
}
DevToolsHandler::~DevToolsHandler() {
g_dev_instance = nullptr;
}
DevToolsHandler* DevToolsHandler::GetInstance() {
return g_dev_instance;
}
AgentHandler* g_instance = nullptr;
AgentHandler::AgentHandler(CefRefPtr<BrowserStore> browser_store,
CefRefPtr<DevRenderer> dev_renderer)
: browser_store_(std::move(browser_store)),
dev_renderer_(std::move(dev_renderer)) {
g_instance = this;
}
AgentHandler::~AgentHandler() {
g_instance = nullptr;
}
AgentHandler* AgentHandler::GetInstance() {
return g_instance;
}
void AgentHandler::OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) {
CEF_REQUIRE_UI_THREAD();
if (dev_renderer_)
dev_renderer_->OnTitleChange(browser, title);
}
void AgentHandler::OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type,
const RectList& dirtyRects,
const void* buffer,
int width,
int height) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
CefRefPtr<BrowserHandle> handle =
browser_store_->browser_handles_[identifier];
if (handle->paint_callback_)
handle->paint_callback_(dirtyRects, buffer, width, height);
if (dev_renderer_)
dev_renderer_->OnPaint(browser, type, dirtyRects, buffer, width, height);
}
void AgentHandler::GetViewRect(CefRefPtr<CefBrowser> browser, CefRect& rect) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
CefRefPtr<BrowserHandle>& handle =
browser_store_->browser_handles_[identifier];
rect.Set(0, 0, handle->GetWidth(), handle->GetHeight());
};
void AgentHandler::OnAudioStreamPacket(CefRefPtr<CefBrowser> browser,
const float** data,
int frames,
int64_t pts) {
// std::cout << "OnAudioStreamPacket" << std::endl;
}
void AgentHandler::OnAudioStreamStarted(CefRefPtr<CefBrowser> browser,
const CefAudioParameters& params,
int channels) {}
void AgentHandler::OnAudioStreamStopped(CefRefPtr<CefBrowser> browser) {}
void AgentHandler::OnAudioStreamError(CefRefPtr<CefBrowser> browser,
const CefString& message) {}
bool AgentHandler::OnBeforePopup(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
const CefString& target_url,
const CefString& target_frame_name,
WindowOpenDisposition target_disposition,
bool user_gesture,
const CefPopupFeatures& popupFeatures,
CefWindowInfo& windowInfo,
CefRefPtr<CefClient>& client,
CefBrowserSettings& settings,
CefRefPtr<CefDictionaryValue>& extra_info,
bool* no_javascript_access) {
browser->GetMainFrame()->LoadURL(target_url);
return true;
}
void AgentHandler::OnAfterCreated(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
if (browser->IsPopup()) {
return;
}
int identifier = browser->GetIdentifier();
CefRefPtr<BrowserHandle> handle = browser_store_->pending_handles_.front();
browser_store_->pending_handles_.pop_front();
handle->browser_ = browser;
browser_store_->browser_handles_[identifier] = handle;
if (handle->created_callback_)
handle->created_callback_();
if (dev_renderer_)
dev_renderer_->OnAfterCreated(browser);
}
bool AgentHandler::DoClose(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
int identifier = browser->GetIdentifier();
CefRefPtr<BrowserHandle> handle =
browser_store_->browser_handles_[identifier];
browser_store_->browser_handles_.erase(identifier);
if (handle->close_callback_)
handle->close_callback_();
return false;
}
void AgentHandler::OnBeforeClose(CefRefPtr<CefBrowser> browser) {
CEF_REQUIRE_UI_THREAD();
if (dev_renderer_)
dev_renderer_->OnBeforeClose(browser);
}
void AgentHandler::OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) {
CEF_REQUIRE_UI_THREAD();
if (dev_renderer_)
dev_renderer_->OnLoadingStateChange(browser, isLoading, canGoBack,
canGoForward);
}
void AgentHandler::CloseAllBrowsers(bool force_close) {
if (!CefCurrentlyOn(TID_UI)) {
// Execute on the UI thread.
CefPostTask(TID_UI, base::BindOnce(&AgentHandler::CloseAllBrowsers, this,
force_close));
return;
}
if (browser_store_->browser_handles_.empty()) {
return;
}
for (const auto& pair : browser_store_->browser_handles_) {
pair.second->browser_->GetHost()->CloseBrowser(force_close);
}
}
#if !defined(OS_MAC)
void AgentHandler::PlatformShowWindow(CefRefPtr<CefBrowser> browser) {
NOTIMPLEMENTED();
}
#endif
#ifndef LKCEF_HANDLER_HPP
#define LKCEF_HANDLER_HPP
#include <list>
#include "dev_renderer.hpp"
#include "browser_handle.hpp"
#include "include/cef_client.h"
#include "include/wrapper/cef_helpers.h"
class DevToolsHandler : public CefClient {
public:
DevToolsHandler();
~DevToolsHandler();
static DevToolsHandler* GetInstance();
private:
IMPLEMENT_REFCOUNTING(DevToolsHandler);
};
class AgentHandler : public CefClient,
public CefDisplayHandler,
public CefRenderHandler,
public CefAudioHandler,
public CefLifeSpanHandler,
public CefLoadHandler {
public:
AgentHandler(CefRefPtr<BrowserStore> browser_store, CefRefPtr<DevRenderer> dev_renderer);
~AgentHandler();
static AgentHandler* GetInstance();
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
CefRefPtr<CefRenderHandler> GetRenderHandler() override { return this; }
CefRefPtr<CefAudioHandler> GetAudioHandler() override { return this; }
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
CefRefPtr<CefLoadHandler> GetLoadHandler() override { return this; }
// CefDisplayHandler methods
void OnTitleChange(CefRefPtr<CefBrowser> browser,
const CefString& title) override;
// CefRenderHandler methods
void OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type,
const RectList& dirtyRects,
const void* buffer,
int width,
int height) override;
void GetViewRect(CefRefPtr<CefBrowser> browser, CefRect& rect) override;
// CefAudioHandler methods
void OnAudioStreamPacket(CefRefPtr<CefBrowser> browser,
const float** data,
int frames,
int64_t pts) override;
void OnAudioStreamStarted(CefRefPtr<CefBrowser> browser,
const CefAudioParameters& params,
int channels) override;
void OnAudioStreamStopped(CefRefPtr<CefBrowser> browser) override;
void OnAudioStreamError(CefRefPtr<CefBrowser> browser,
const CefString& message) override;
// CefLifeSpanHandler methods
bool OnBeforePopup(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame,
const CefString& target_url,
const CefString& target_frame_name,
WindowOpenDisposition target_disposition,
bool user_gesture,
const CefPopupFeatures& popupFeatures,
CefWindowInfo& windowInfo,
CefRefPtr<CefClient>& client,
CefBrowserSettings& settings,
CefRefPtr<CefDictionaryValue>& extra_info,
bool* no_javascript_access) override;
void OnAfterCreated(CefRefPtr<CefBrowser> browser) override;
bool DoClose(CefRefPtr<CefBrowser> browser) override;
void OnBeforeClose(CefRefPtr<CefBrowser> browser) override;
// CefLoadHandler methods
void OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
bool isLoading,
bool canGoBack,
bool canGoForward) override;
void CloseAllBrowsers(bool force_close);
private:
CefRefPtr<BrowserStore> browser_store_;
CefRefPtr<DevRenderer> dev_renderer_;
IMPLEMENT_REFCOUNTING(AgentHandler);
};
#endif // LKCEF_HANDLER_HPP
#include "include/cef_app.h"
#include "include/wrapper/cef_library_loader.h"
int main(int argc, char* argv[]) {
CefScopedLibraryLoader library_loader;
if (!library_loader.LoadInHelper()) {
return 1;
}
CefMainArgs main_args(argc, argv);
return CefExecuteProcess(main_args, nullptr, nullptr);
}
#ifndef LKCEF_KEYBOARD_CODES_H
#define LKCEF_KEYBOARD_CODES_H
namespace WebCore {
// VK_LBUTTON (01) Left mouse button
// VK_RBUTTON (02) Right mouse button
// VK_CANCEL (03) Control-break processing
// VK_MBUTTON (04) Middle mouse button (three-button mouse)
// VK_XBUTTON1 (05)
// VK_XBUTTON2 (06)
// VK_BACK (08) BACKSPACE key
const int VK_BACK = 0x08;
// VK_TAB (09) TAB key
const int VK_TAB = 0x09;
// VK_CLEAR (0C) CLEAR key
const int VK_CLEAR = 0x0C;
// VK_RETURN (0D)
const int VK_RETURN = 0x0D;
// VK_SHIFT (10) SHIFT key
const int VK_SHIFT = 0x10;
// VK_CONTROL (11) CTRL key
const int VK_CONTROL = 0x11;
// VK_MENU (12) ALT key
const int VK_MENU = 0x12;
// VK_PAUSE (13) PAUSE key
const int VK_PAUSE = 0x13;
// VK_CAPITAL (14) CAPS LOCK key
const int VK_CAPITAL = 0x14;
// VK_KANA (15) Input Method Editor (IME) Kana mode
const int VK_KANA = 0x15;
// VK_HANGUEL (15) IME Hanguel mode (maintained for compatibility; use
// VK_HANGUL) VK_HANGUL (15) IME Hangul mode
const int VK_HANGUL = 0x15;
// VK_JUNJA (17) IME Junja mode
const int VK_JUNJA = 0x17;
// VK_FINAL (18) IME final mode
const int VK_FINAL = 0x18;
// VK_HANJA (19) IME Hanja mode
const int VK_HANJA = 0x19;
// VK_KANJI (19) IME Kanji mode
const int VK_KANJI = 0x19;
// VK_ESCAPE (1B) ESC key
const int VK_ESCAPE = 0x1B;
// VK_CONVERT (1C) IME convert
const int VK_CONVERT = 0x1C;
// VK_NONCONVERT (1D) IME nonconvert
const int VK_NONCONVERT = 0x1D;
// VK_ACCEPT (1E) IME accept
const int VK_ACCEPT = 0x1E;
// VK_MODECHANGE (1F) IME mode change request
const int VK_MODECHANGE = 0x1F;
// VK_SPACE (20) SPACEBAR
const int VK_SPACE = 0x20;
// VK_PRIOR (21) PAGE UP key
const int VK_PRIOR = 0x21;
// VK_NEXT (22) PAGE DOWN key
const int VK_NEXT = 0x22;
// VK_END (23) END key
const int VK_END = 0x23;
// VK_HOME (24) HOME key
const int VK_HOME = 0x24;
// VK_LEFT (25) LEFT ARROW key
const int VK_LEFT = 0x25;
// VK_UP (26) UP ARROW key
const int VK_UP = 0x26;
// VK_RIGHT (27) RIGHT ARROW key
const int VK_RIGHT = 0x27;
// VK_DOWN (28) DOWN ARROW key
const int VK_DOWN = 0x28;
// VK_SELECT (29) SELECT key
const int VK_SELECT = 0x29;
// VK_PRINT (2A) PRINT key
const int VK_PRINT = 0x2A;
// VK_EXECUTE (2B) EXECUTE key
const int VK_EXECUTE = 0x2B;
// VK_SNAPSHOT (2C) PRINT SCREEN key
const int VK_SNAPSHOT = 0x2C;
// VK_INSERT (2D) INS key
const int VK_INSERT = 0x2D;
// VK_DELETE (2E) DEL key
const int VK_DELETE = 0x2E;
// VK_HELP (2F) HELP key
const int VK_HELP = 0x2F;
// (30) 0 key
const int VK_0 = 0x30;
// (31) 1 key
const int VK_1 = 0x31;
// (32) 2 key
const int VK_2 = 0x32;
// (33) 3 key
const int VK_3 = 0x33;
// (34) 4 key
const int VK_4 = 0x34;
// (35) 5 key;
const int VK_5 = 0x35;
// (36) 6 key
const int VK_6 = 0x36;
// (37) 7 key
const int VK_7 = 0x37;
// (38) 8 key
const int VK_8 = 0x38;
// (39) 9 key
const int VK_9 = 0x39;
// (41) A key
const int VK_A = 0x41;
// (42) B key
const int VK_B = 0x42;
// (43) C key
const int VK_C = 0x43;
// (44) D key
const int VK_D = 0x44;
// (45) E key
const int VK_E = 0x45;
// (46) F key
const int VK_F = 0x46;
// (47) G key
const int VK_G = 0x47;
// (48) H key
const int VK_H = 0x48;
// (49) I key
const int VK_I = 0x49;
// (4A) J key
const int VK_J = 0x4A;
// (4B) K key
const int VK_K = 0x4B;
// (4C) L key
const int VK_L = 0x4C;
// (4D) M key
const int VK_M = 0x4D;
// (4E) N key
const int VK_N = 0x4E;
// (4F) O key
const int VK_O = 0x4F;
// (50) P key
const int VK_P = 0x50;
// (51) Q key
const int VK_Q = 0x51;
// (52) R key
const int VK_R = 0x52;
// (53) S key
const int VK_S = 0x53;
// (54) T key
const int VK_T = 0x54;
// (55) U key
const int VK_U = 0x55;
// (56) V key
const int VK_V = 0x56;
// (57) W key
const int VK_W = 0x57;
// (58) X key
const int VK_X = 0x58;
// (59) Y key
const int VK_Y = 0x59;
// (5A) Z key
const int VK_Z = 0x5A;
// VK_LWIN (5B) Left Windows key (Microsoft Natural keyboard)
const int VK_LWIN = 0x5B;
// VK_RWIN (5C) Right Windows key (Natural keyboard)
const int VK_RWIN = 0x5C;
// VK_APPS (5D) Applications key (Natural keyboard)
const int VK_APPS = 0x5D;
// VK_SLEEP (5F) Computer Sleep key
const int VK_SLEEP = 0x5F;
// VK_NUMPAD0 (60) Numeric keypad 0 key
const int VK_NUMPAD0 = 0x60;
// VK_NUMPAD1 (61) Numeric keypad 1 key
const int VK_NUMPAD1 = 0x61;
// VK_NUMPAD2 (62) Numeric keypad 2 key
const int VK_NUMPAD2 = 0x62;
// VK_NUMPAD3 (63) Numeric keypad 3 key
const int VK_NUMPAD3 = 0x63;
// VK_NUMPAD4 (64) Numeric keypad 4 key
const int VK_NUMPAD4 = 0x64;
// VK_NUMPAD5 (65) Numeric keypad 5 key
const int VK_NUMPAD5 = 0x65;
// VK_NUMPAD6 (66) Numeric keypad 6 key
const int VK_NUMPAD6 = 0x66;
// VK_NUMPAD7 (67) Numeric keypad 7 key
const int VK_NUMPAD7 = 0x67;
// VK_NUMPAD8 (68) Numeric keypad 8 key
const int VK_NUMPAD8 = 0x68;
// VK_NUMPAD9 (69) Numeric keypad 9 key
const int VK_NUMPAD9 = 0x69;
// VK_MULTIPLY (6A) Multiply key
const int VK_MULTIPLY = 0x6A;
// VK_ADD (6B) Add key
const int VK_ADD = 0x6B;
// VK_SEPARATOR (6C) Separator key
const int VK_SEPARATOR = 0x6C;
// VK_SUBTRACT (6D) Subtract key
const int VK_SUBTRACT = 0x6D;
// VK_DECIMAL (6E) Decimal key
const int VK_DECIMAL = 0x6E;
// VK_DIVIDE (6F) Divide key
const int VK_DIVIDE = 0x6F;
// VK_F1 (70) F1 key
const int VK_F1 = 0x70;
// VK_F2 (71) F2 key
const int VK_F2 = 0x71;
// VK_F3 (72) F3 key
const int VK_F3 = 0x72;
// VK_F4 (73) F4 key
const int VK_F4 = 0x73;
// VK_F5 (74) F5 key
const int VK_F5 = 0x74;
// VK_F6 (75) F6 key
const int VK_F6 = 0x75;
// VK_F7 (76) F7 key
const int VK_F7 = 0x76;
// VK_F8 (77) F8 key
const int VK_F8 = 0x77;
// VK_F9 (78) F9 key
const int VK_F9 = 0x78;
// VK_F10 (79) F10 key
const int VK_F10 = 0x79;
// VK_F11 (7A) F11 key
const int VK_F11 = 0x7A;
// VK_F12 (7B) F12 key
const int VK_F12 = 0x7B;
// VK_F13 (7C) F13 key
const int VK_F13 = 0x7C;
// VK_F14 (7D) F14 key
const int VK_F14 = 0x7D;
// VK_F15 (7E) F15 key
const int VK_F15 = 0x7E;
// VK_F16 (7F) F16 key
const int VK_F16 = 0x7F;
// VK_F17 (80H) F17 key
const int VK_F17 = 0x80;
// VK_F18 (81H) F18 key
const int VK_F18 = 0x81;
// VK_F19 (82H) F19 key
const int VK_F19 = 0x82;
// VK_F20 (83H) F20 key
const int VK_F20 = 0x83;
// VK_F21 (84H) F21 key
const int VK_F21 = 0x84;
// VK_F22 (85H) F22 key
const int VK_F22 = 0x85;
// VK_F23 (86H) F23 key
const int VK_F23 = 0x86;
// VK_F24 (87H) F24 key
const int VK_F24 = 0x87;
// VK_NUMLOCK (90) NUM LOCK key
const int VK_NUMLOCK = 0x90;
// VK_SCROLL (91) SCROLL LOCK key
const int VK_SCROLL = 0x91;
// VK_LSHIFT (A0) Left SHIFT key
const int VK_LSHIFT = 0xA0;
// VK_RSHIFT (A1) Right SHIFT key
const int VK_RSHIFT = 0xA1;
// VK_LCONTROL (A2) Left CONTROL key
const int VK_LCONTROL = 0xA2;
// VK_RCONTROL (A3) Right CONTROL key
const int VK_RCONTROL = 0xA3;
// VK_LMENU (A4) Left MENU key
const int VK_LMENU = 0xA4;
// VK_RMENU (A5) Right MENU key
const int VK_RMENU = 0xA5;
// VK_BROWSER_BACK (A6) Windows 2000/XP: Browser Back key
const int VK_BROWSER_BACK = 0xA6;
// VK_BROWSER_FORWARD (A7) Windows 2000/XP: Browser Forward key
const int VK_BROWSER_FORWARD = 0xA7;
// VK_BROWSER_REFRESH (A8) Windows 2000/XP: Browser Refresh key
const int VK_BROWSER_REFRESH = 0xA8;
// VK_BROWSER_STOP (A9) Windows 2000/XP: Browser Stop key
const int VK_BROWSER_STOP = 0xA9;
// VK_BROWSER_SEARCH (AA) Windows 2000/XP: Browser Search key
const int VK_BROWSER_SEARCH = 0xAA;
// VK_BROWSER_FAVORITES (AB) Windows 2000/XP: Browser Favorites key
const int VK_BROWSER_FAVORITES = 0xAB;
// VK_BROWSER_HOME (AC) Windows 2000/XP: Browser Start and Home key
const int VK_BROWSER_HOME = 0xAC;
// VK_VOLUME_MUTE (AD) Windows 2000/XP: Volume Mute key
const int VK_VOLUME_MUTE = 0xAD;
// VK_VOLUME_DOWN (AE) Windows 2000/XP: Volume Down key
const int VK_VOLUME_DOWN = 0xAE;
// VK_VOLUME_UP (AF) Windows 2000/XP: Volume Up key
const int VK_VOLUME_UP = 0xAF;
// VK_MEDIA_NEXT_TRACK (B0) Windows 2000/XP: Next Track key
const int VK_MEDIA_NEXT_TRACK = 0xB0;
// VK_MEDIA_PREV_TRACK (B1) Windows 2000/XP: Previous Track key
const int VK_MEDIA_PREV_TRACK = 0xB1;
// VK_MEDIA_STOP (B2) Windows 2000/XP: Stop Media key
const int VK_MEDIA_STOP = 0xB2;
// VK_MEDIA_PLAY_PAUSE (B3) Windows 2000/XP: Play/Pause Media key
const int VK_MEDIA_PLAY_PAUSE = 0xB3;
// VK_LAUNCH_MAIL (B4) Windows 2000/XP: Start Mail key
const int VK_MEDIA_LAUNCH_MAIL = 0xB4;
// VK_LAUNCH_MEDIA_SELECT (B5) Windows 2000/XP: Select Media key
const int VK_MEDIA_LAUNCH_MEDIA_SELECT = 0xB5;
// VK_LAUNCH_APP1 (B6) Windows 2000/XP: Start Application 1 key
const int VK_MEDIA_LAUNCH_APP1 = 0xB6;
// VK_LAUNCH_APP2 (B7) Windows 2000/XP: Start Application 2 key
const int VK_MEDIA_LAUNCH_APP2 = 0xB7;
// VK_OEM_1 (BA) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the ';:' key
const int VK_OEM_1 = 0xBA;
// VK_OEM_PLUS (BB) Windows 2000/XP: For any country/region, the '+' key
const int VK_OEM_PLUS = 0xBB;
// VK_OEM_COMMA (BC) Windows 2000/XP: For any country/region, the ',' key
const int VK_OEM_COMMA = 0xBC;
// VK_OEM_MINUS (BD) Windows 2000/XP: For any country/region, the '-' key
const int VK_OEM_MINUS = 0xBD;
// VK_OEM_PERIOD (BE) Windows 2000/XP: For any country/region, the '.' key
const int VK_OEM_PERIOD = 0xBE;
// VK_OEM_2 (BF) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the '/?' key
const int VK_OEM_2 = 0xBF;
// VK_OEM_3 (C0) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the '`~' key
const int VK_OEM_3 = 0xC0;
// VK_OEM_4 (DB) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the '[{' key
const int VK_OEM_4 = 0xDB;
// VK_OEM_5 (DC) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the '\|' key
const int VK_OEM_5 = 0xDC;
// VK_OEM_6 (DD) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the ']}' key
const int VK_OEM_6 = 0xDD;
// VK_OEM_7 (DE) Used for miscellaneous characters; it can vary by keyboard.
// Windows 2000/XP: For the US standard keyboard, the
// 'single-quote/double-quote' key
const int VK_OEM_7 = 0xDE;
// VK_OEM_8 (DF) Used for miscellaneous characters; it can vary by keyboard.
const int VK_OEM_8 = 0xDF;
// VK_OEM_102 (E2) Windows 2000/XP: Either the angle bracket key or the
// backslash key on the RT 102-key keyboard
const int VK_OEM_102 = 0xE2;
// VK_PROCESSKEY (E5) Windows 95/98/Me, Windows NT 4.0, Windows 2000/XP: IME
// PROCESS key
const int VK_PROCESSKEY = 0xE5;
// VK_PACKET (E7) Windows 2000/XP: Used to pass Unicode characters as if they
// were keystrokes. The VK_PACKET key is the low word of a 32-bit Virtual Key
// value used for non-keyboard input methods. For more information, see Remark
// in KEYBDINPUT,SendInput, WM_KEYDOWN, and WM_KEYUP
const int VK_PACKET = 0xE7;
// VK_ATTN (F6) Attn key
const int VK_ATTN = 0xF6;
// VK_CRSEL (F7) CrSel key
const int VK_CRSEL = 0xF7;
// VK_EXSEL (F8) ExSel key
const int VK_EXSEL = 0xF8;
// VK_EREOF (F9) Erase EOF key
const int VK_EREOF = 0xF9;
// VK_PLAY (FA) Play key
const int VK_PLAY = 0xFA;
// VK_ZOOM (FB) Zoom key
const int VK_ZOOM = 0xFB;
// VK_NONAME (FC) Reserved for future use
const int VK_NONAME = 0xFC;
// VK_PA1 (FD) PA1 key
const int VK_PA1 = 0xFD;
// VK_OEM_CLEAR (FE) Clear key
const int VK_OEM_CLEAR = 0xFE;
const int VK_UNKNOWN = 0;
} // namespace WebCore
#endif // LKCEF_KEYBOARD_CODES_H
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>lkcef_app</string>
<key>CFBundleIdentifier</key>
<string>io.livekit.cef</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>lkcef-agents</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>LSEnvironment</key>
<dict>
<key>MallocNanoZone</key>
<string>0</string>
</dict>
<key>LSFileQuarantineEnabled</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>10.11.0</string>
<key>LSUIElement</key>
<string>1</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleIdentifier</key>
<string>io.livekit.cef.helper${BUNDLE_ID_SUFFIX}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>LSEnvironment</key>
<dict>
<key>MallocNanoZone</key>
<string>0</string>
</dict>
<key>LSFileQuarantineEnabled</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>10.11.0</string>
<key>LSUIElement</key>
<string>1</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
# flake8: noqa
import sys
print("cwd: ", sys.path[0])
sys.path.insert(0, "./Debug")
import lkcef_python as lkcef
print("lkcef __dict__: ", lkcef.__dict__)
print("BrowserImpl __dict__: ", lkcef.BrowserImpl.__dict__)
def _context_initialized():
opts = lkcef.BrowserOptions()
opts.framerate = 30
def _browser_created(browser_impl):
print("run_browser.py - Browser created")
opts.created_callback = _browser_created
def _on_paint(frame_data):
pass
opts.paint_callback = _on_paint
def _on_closed():
print("run_browser.py - Browser closed")
opts.close_callback = _on_closed
app.create_browser("http://www.livekit.io", opts)
print("run_browser.py - Context initialized")
opts = lkcef.AppOptions()
opts.dev_mode = True
opts.initialized_callback = _context_initialized
opts.framework_path = "/Users/theomonnom/livekit/agents/livekit-plugins/livekit-plugins-browser/cef/src/Debug/lkcef_app.app/Contents/Frameworks/Chromium Embedded Framework.framework"
opts.main_bundle_path = "/Users/theomonnom/livekit/agents/livekit-plugins/livekit-plugins-browser/cef/src/Debug/lkcef_app.app"
opts.subprocess_path = "/Users/theomonnom/livekit/agents/livekit-plugins/livekit-plugins-browser/cef/src/Debug/lkcef_app.app/Contents/Frameworks/lkcef Helper.app/Contents/MacOS/lkcef Helper"
app = lkcef.BrowserApp(opts)
app.run()
# LiveKit Plugins Cartesia
Agent Framework plugin for voice synthesis with [Cartesia](https://cartesia.ai/) API.
## Installation
```bash
pip install livekit-plugins-cartesia
You’ll need an API key from Cartesia. It can be set as an environment variable: CARTESIA_API_KEY
## livekit-plugins/livekit-plugins-cartesia/livekit/plugins/cartesia/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .tts import TTS, ChunkedStream
from .version import __version__
__all__ = ["TTS", "ChunkedStream", "__version__"]
from livekit.agents import Plugin
from .log import logger
class CartesiaPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(CartesiaPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.cartesia")
from typing import Literal
TTSEncoding = Literal[
"pcm_s16le",
# Not yet supported
# "pcm_f32le",
# "pcm_mulaw",
# "pcm_alaw",
]
TTSModels = Literal["sonic", "sonic-2", "sonic-lite", "sonic-preview", "sonic-turbo"]
TTSLanguages = Literal["en", "es", "fr", "de", "pt", "zh", "ja"]
TTSDefaultVoiceId = "794f9389-aac1-45b6-b726-9d9369183238"
TTSVoiceSpeed = Literal["fastest", "fast", "normal", "slow", "slowest"]
TTSVoiceEmotion = Literal[
"anger:lowest",
"anger:low",
"anger",
"anger:high",
"anger:highest",
"positivity:lowest",
"positivity:low",
"positivity",
"positivity:high",
"positivity:highest",
"surprise:lowest",
"surprise:low",
"surprise",
"surprise:high",
"surprise:highest",
"sadness:lowest",
"sadness:low",
"sadness",
"sadness:high",
"sadness:highest",
"curiosity:lowest",
"curiosity:low",
"curiosity",
"curiosity:high",
"curiosity:highest",
]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import json
import os
import weakref
from dataclasses import dataclass
from typing import Any
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import (
TTSDefaultVoiceId,
TTSEncoding,
TTSModels,
TTSVoiceEmotion,
TTSVoiceSpeed,
)
API_AUTH_HEADER = "X-API-Key"
API_VERSION_HEADER = "Cartesia-Version"
API_VERSION = "2024-06-10"
NUM_CHANNELS = 1
BUFFERED_WORDS_COUNT = 3
@dataclass
class _TTSOptions:
model: TTSModels | str
encoding: TTSEncoding
sample_rate: int
voice: str | list[float]
speed: NotGivenOr[TTSVoiceSpeed | float]
emotion: NotGivenOr[list[TTSVoiceEmotion | str]]
api_key: str
language: str
base_url: str
def get_http_url(self, path: str) -> str:
return f"{self.base_url}{path}"
def get_ws_url(self, path: str) -> str:
return f"{self.base_url.replace('http', 'ws', 1)}{path}"
class TTS(tts.TTS):
def __init__(
self,
*,
model: TTSModels | str = "sonic-2",
language: str = "en",
encoding: TTSEncoding = "pcm_s16le",
voice: str | list[float] = TTSDefaultVoiceId,
speed: NotGivenOr[TTSVoiceSpeed | float] = NOT_GIVEN,
emotion: NotGivenOr[list[TTSVoiceEmotion | str]] = NOT_GIVEN,
sample_rate: int = 24000,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
base_url: str = "https://api.cartesia.ai",
) -> None:
"""
Create a new instance of Cartesia TTS.
See https://docs.cartesia.ai/reference/web-socket/stream-speech/stream-speech for more details on the the Cartesia API.
Args:
model (TTSModels, optional): The Cartesia TTS model to use. Defaults to "sonic-2".
language (str, optional): The language code for synthesis. Defaults to "en".
encoding (TTSEncoding, optional): The audio encoding format. Defaults to "pcm_s16le".
voice (str | list[float], optional): The voice ID or embedding array.
speed (TTSVoiceSpeed | float, optional): Voice Control - Speed (https://docs.cartesia.ai/user-guides/voice-control)
emotion (list[TTSVoiceEmotion], optional): Voice Control - Emotion (https://docs.cartesia.ai/user-guides/voice-control)
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 24000.
api_key (str, optional): The Cartesia API key. If not provided, it will be read from the CARTESIA_API_KEY environment variable.
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
base_url (str, optional): The base URL for the Cartesia API. Defaults to "https://api.cartesia.ai".
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
cartesia_api_key = api_key if is_given(api_key) else os.environ.get("CARTESIA_API_KEY")
if not cartesia_api_key:
raise ValueError("CARTESIA_API_KEY must be set")
self._opts = _TTSOptions(
model=model,
language=language,
encoding=encoding,
sample_rate=sample_rate,
voice=voice,
speed=speed,
emotion=emotion,
api_key=cartesia_api_key,
base_url=base_url,
)
self._session = http_session
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=300,
mark_refreshed_on_get=True,
)
self._streams = weakref.WeakSet[SynthesizeStream]()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
url = self._opts.get_ws_url(
f"/tts/websocket?api_key={self._opts.api_key}&cartesia_version={API_VERSION}"
)
return await asyncio.wait_for(session.ws_connect(url), self._conn_options.timeout)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def prewarm(self) -> None:
self._pool.prewarm()
def update_options(
self,
*,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
voice: NotGivenOr[str | list[float]] = NOT_GIVEN,
speed: NotGivenOr[TTSVoiceSpeed | float] = NOT_GIVEN,
emotion: NotGivenOr[list[TTSVoiceEmotion | str]] = NOT_GIVEN,
) -> None:
"""
Update the Text-to-Speech (TTS) configuration options.
This method allows updating the TTS settings, including model type, language, voice, speed,
and emotion. If any parameter is not provided, the existing value will be retained.
Args:
model (TTSModels, optional): The Cartesia TTS model to use. Defaults to "sonic-2".
language (str, optional): The language code for synthesis. Defaults to "en".
voice (str | list[float], optional): The voice ID or embedding array.
speed (TTSVoiceSpeed | float, optional): Voice Control - Speed (https://docs.cartesia.ai/user-guides/voice-control)
emotion (list[TTSVoiceEmotion], optional): Voice Control - Emotion (https://docs.cartesia.ai/user-guides/voice-control)
"""
if is_given(model):
self._opts.model = model
if is_given(language):
self._opts.language = language
if is_given(voice):
self._opts.voice = voice
if is_given(speed):
self._opts.speed = speed
if is_given(emotion):
self._opts.emotion = emotion
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
return SynthesizeStream(
tts=self,
pool=self._pool,
opts=self._opts,
)
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
await super().aclose()
class ChunkedStream(tts.ChunkedStream):
"""Synthesize chunked text using the bytes endpoint"""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
session: aiohttp.ClientSession,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._session = opts, session
async def _run(self) -> None:
request_id = utils.shortuuid()
bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate, num_channels=NUM_CHANNELS
)
json = _to_cartesia_options(self._opts)
json["transcript"] = self._input_text
headers = {
API_AUTH_HEADER: self._opts.api_key,
API_VERSION_HEADER: API_VERSION,
}
try:
async with self._session.post(
self._opts.get_http_url("/tts/bytes"),
headers=headers,
json=json,
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=self._conn_options.timeout,
),
) as resp:
resp.raise_for_status()
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for data, _ in resp.content.iter_chunks():
for frame in bstream.write(data):
emitter.push(frame)
for frame in bstream.flush():
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from None
except Exception as e:
raise APIConnectionError() from e
class SynthesizeStream(tts.SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
opts: _TTSOptions,
pool: utils.ConnectionPool[aiohttp.ClientWebSocketResponse],
):
super().__init__(tts=tts)
self._opts, self._pool = opts, pool
self._sent_tokenizer_stream = tokenize.basic.SentenceTokenizer(
min_sentence_len=BUFFERED_WORDS_COUNT
).stream()
async def _run(self) -> None:
request_id = utils.shortuuid()
async def _sentence_stream_task(ws: aiohttp.ClientWebSocketResponse):
base_pkt = _to_cartesia_options(self._opts)
async for ev in self._sent_tokenizer_stream:
token_pkt = base_pkt.copy()
token_pkt["context_id"] = request_id
token_pkt["transcript"] = ev.token + " "
token_pkt["continue"] = True
self._mark_started()
await ws.send_str(json.dumps(token_pkt))
end_pkt = base_pkt.copy()
end_pkt["context_id"] = request_id
end_pkt["transcript"] = " "
end_pkt["continue"] = False
await ws.send_str(json.dumps(end_pkt))
async def _input_task():
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
self._sent_tokenizer_stream.flush()
continue
self._sent_tokenizer_stream.push_text(data)
self._sent_tokenizer_stream.end_input()
async def _recv_task(ws: aiohttp.ClientWebSocketResponse):
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
)
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"Cartesia connection closed unexpectedly",
request_id=request_id,
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected Cartesia message type %s", msg.type)
continue
data = json.loads(msg.data)
segment_id = data.get("context_id")
emitter._segment_id = segment_id
if data.get("data"):
b64data = base64.b64decode(data["data"])
for frame in audio_bstream.write(b64data):
emitter.push(frame)
elif data.get("done"):
for frame in audio_bstream.flush():
emitter.push(frame)
emitter.flush()
if segment_id == request_id:
# we're not going to receive more frames, end stream
break
else:
logger.error("unexpected Cartesia message %s", data)
async with self._pool.connection() as ws:
tasks = [
asyncio.create_task(_input_task()),
asyncio.create_task(_sentence_stream_task(ws)),
asyncio.create_task(_recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.gracefully_cancel(*tasks)
def _to_cartesia_options(opts: _TTSOptions) -> dict[str, Any]:
voice: dict[str, Any] = {}
if is_given(opts.voice):
if isinstance(opts.voice, str):
voice["mode"] = "id"
voice["id"] = opts.voice
else:
voice["mode"] = "embedding"
voice["embedding"] = opts.voice
voice_controls: dict = {}
if is_given(opts.speed):
voice_controls["speed"] = opts.speed
if is_given(opts.emotion):
voice_controls["emotion"] = opts.emotion
if voice_controls:
voice["__experimental_controls"] = voice_controls
return {
"model_id": opts.model,
"voice": voice,
"output_format": {
"container": "raw",
"encoding": opts.encoding,
"sample_rate": opts.sample_rate,
},
"language": opts.language,
}
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-cartesia"
dynamic = ["version"]
description = "LiveKit Agents Plugin for Cartesia"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/cartesia/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Clova
Agent Framework plugin for speech-to-text with [Clova](https://api.ncloud-docs.com/docs/)'s API. Currently supports speech-to-text.
## Installation
```bash
pip install livekit-plugins-clova
You need invoke url and secret key from Naver cloud platform -> Clova Speech and set as environment variables: CLOVA_STT_INVOKE_URL
& CLOVA_STT_SECRET_KEY
## livekit-plugins/livekit-plugins-clova/livekit/plugins/clova/__init__.py
```py
from .stt import STT
from .version import __version__
__all__ = [
"STT",
"__version__",
]
from livekit.agents import Plugin
class ClovaSTTPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__)
def download_files(self):
pass
Plugin.register_plugin(ClovaSTTPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import io
from pydub import AudioSegment
def resample_audio(audio_bytes, original_sample_rate, target_sample_rate):
resampled_audio = AudioSegment.from_raw(
io.BytesIO(audio_bytes),
sample_width=2,
frame_rate=original_sample_rate,
channels=1,
).set_frame_rate(target_sample_rate)
return resampled_audio.raw_data
CLOVA_INPUT_SAMPLE_RATE = 16000
LIVEKIT_INPUT_SAMPLE_RATE = 48000
import logging
logger = logging.getLogger("livekit.plugins.clova")
from typing import Literal
ClovaSttLanguages = Literal["ko-KR", "en-US", "enko", "ja", "zh-cn", "zh-tw"]
ClovaSpeechAPIType = Literal["recognizer/object-storage", "recognizer/url", "recognizer/upload"]
clova_languages_mapping = {
"en": "en-US",
"ko-KR": "ko-KR",
"en-US": "en-US",
"enko": "enko",
"ja": "ja",
"zh-cn": "zh-cn",
"zh-tw": "zh-tw",
}
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import io
import json
import os
import time
import wave
import aiohttp
from livekit.agents import (
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.stt import SpeechEventType, STTCapabilities
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given, merge_frames
from livekit.plugins.clova.constants import CLOVA_INPUT_SAMPLE_RATE
from .common import resample_audio
from .log import logger
from .models import ClovaSpeechAPIType, ClovaSttLanguages, clova_languages_mapping
class STT(stt.STT):
def __init__(
self,
*,
language: ClovaSttLanguages | str = "en-US",
secret: NotGivenOr[str] = NOT_GIVEN,
invoke_url: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
threshold: float = 0.5,
):
"""
Create a new instance of Clova STT.
``secret`` and ``invoke_url`` must be set, either using arguments or by setting the
``CLOVA_STT_SECRET_KEY`` and ``CLOVA_STT_INVOKE_URL`` environmental variables, respectively.
"""
super().__init__(capabilities=STTCapabilities(streaming=False, interim_results=True))
self._secret = secret if is_given(secret) else os.environ.get("CLOVA_STT_SECRET_KEY")
self._invoke_url = (
invoke_url if is_given(invoke_url) else os.environ.get("CLOVA_STT_INVOKE_URL")
)
self._language = clova_languages_mapping.get(language, language)
self._session = http_session
if self._secret is None:
raise ValueError(
"Clova STT secret key is required. It should be set with env CLOVA_STT_SECRET_KEY"
)
self.threshold = threshold
def update_options(self, *, language: NotGivenOr[str] = NOT_GIVEN) -> None:
if is_given(language):
self._language = clova_languages_mapping.get(language, language)
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def url_builder(self, process_method: ClovaSpeechAPIType = "recognizer/upload") -> str:
return f"{self._invoke_url}/{process_method}"
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[ClovaSttLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
try:
url = self.url_builder()
if is_given(language):
self._language = clova_languages_mapping.get(language, language)
payload = json.dumps({"language": self._language, "completion": "sync"})
buffer = merge_frames(buffer)
buffer_bytes = resample_audio(
buffer.data.tobytes(), buffer.sample_rate, CLOVA_INPUT_SAMPLE_RATE
)
io_buffer = io.BytesIO()
with wave.open(io_buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2) # 16-bit
wav.setframerate(CLOVA_INPUT_SAMPLE_RATE)
wav.writeframes(buffer_bytes)
io_buffer.seek(0)
headers = {"X-CLOVASPEECH-API-KEY": self._secret}
form_data = aiohttp.FormData()
form_data.add_field("params", payload)
form_data.add_field("media", io_buffer, filename="audio.wav", content_type="audio/wav")
start = time.time()
async with self._ensure_session().post(
url,
data=form_data,
headers=headers,
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=conn_options.timeout,
),
) as response:
response_data = await response.json()
end = time.time()
text = response_data.get("text")
confidence = response_data.get("confidence")
logger.info(f"{text} | {confidence} | total_seconds: {end - start}")
if not text or "error" in response_data:
raise ValueError(f"Unexpected response: {response_data}")
if confidence < self.threshold:
raise ValueError(
f"Confidence: {confidence} is bellow threshold {self.threshold}. Skipping."
)
logger.info(f"final event: {response_data}")
return self._transcription_to_speech_event(text=text)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
def _transcription_to_speech_event(
self,
text: str,
event_type: SpeechEventType = stt.SpeechEventType.INTERIM_TRANSCRIPT,
) -> stt.SpeechEvent:
return stt.SpeechEvent(
type=event_type,
alternatives=[stt.SpeechData(text=text, language=self._language)],
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-clova"
dynamic = ["version"]
description = "LiveKit Agents Plugin for LINE Clova STT"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17", "pydub~=0.25.1"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/clova/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins DeepGram
Agent Framework plugin for speech-to-text with [DeepGram](https://deepgram.com/)'s API. Currently supports speech-to-text.
## Installation
```bash
pip install livekit-plugins-deepgram
You’ll need an API key from DeepGram. It can be set as an environment variable: DEEPGRAM_API_KEY
## livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/__init__.py
```py
from .stt import STT, AudioEnergyFilter, SpeechStream
from .tts import TTS
from .version import __version__
__all__ = ["STT", "SpeechStream", "AudioEnergyFilter", "__version__", "TTS"]
from livekit.agents import Plugin
from .log import logger
class DeepgramPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(DeepgramPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import time
from typing import Callable, Generic, Optional, TypeVar
T = TypeVar("T")
class PeriodicCollector(Generic[T]):
def __init__(self, callback: Callable[[T], None], *, duration: float) -> None:
"""
Create a new periodic collector that accumulates values and calls the callback
after the specified duration if there are values to report.
Args:
duration: Time in seconds between callback invocations
callback: Function to call with accumulated value when duration expires
"""
self._duration = duration
self._callback = callback
self._last_flush_time = time.monotonic()
self._total: Optional[T] = None
def push(self, value: T) -> None:
"""Add a value to the accumulator"""
if self._total is None:
self._total = value
else:
self._total += value # type: ignore
if time.monotonic() - self._last_flush_time >= self._duration:
self.flush()
def flush(self) -> None:
"""Force callback to be called with current total if non-zero"""
if self._total is not None:
self._callback(self._total)
self._total = None
self._last_flush_time = time.monotonic()
import logging
logger = logging.getLogger("livekit.plugins.deepgram")
from typing import Literal
DeepgramModels = Literal[
"nova-general",
"nova-phonecall",
"nova-meeting",
"nova-2-general",
"nova-2-meeting",
"nova-2-phonecall",
"nova-2-finance",
"nova-2-conversationalai",
"nova-2-voicemail",
"nova-2-video",
"nova-2-medical",
"nova-2-drivethru",
"nova-2-automotive",
"nova-3",
"nova-3-general",
"nova-3-medical",
"enhanced-general",
"enhanced-meeting",
"enhanced-phonecall",
"enhanced-finance",
"base",
"meeting",
"phonecall",
"finance",
"conversationalai",
"voicemail",
"video",
"whisper-tiny",
"whisper-base",
"whisper-small",
"whisper-medium",
"whisper-large",
]
DeepgramLanguages = Literal[
"zh",
"zh-CN",
"zh-TW",
"da",
"nl",
"en",
"en-US",
"en-AU",
"en-GB",
"en-NZ",
"en-IN",
"fr",
"fr-CA",
"de",
"hi",
"hi-Latn",
"pt",
"pt-BR",
"es",
"es-419",
"hi",
"hi-Latn",
"it",
"ja",
"ko",
"no",
"pl",
"pt",
"pt-BR",
"es-LATAM",
"sv",
"ta",
"taq",
"uk",
"tr",
"sv",
"id",
"pt",
"pt-BR",
"ru",
"th",
"multi",
]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass
from enum import Enum
from typing import Any
from urllib.parse import urlencode
import aiohttp
import numpy as np
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from ._utils import PeriodicCollector
from .log import logger
from .models import DeepgramLanguages, DeepgramModels
BASE_URL = "https://api.deepgram.com/v1/listen"
# This is the magic number during testing that we use to determine if a frame is loud enough
# to possibly contain speech. It's very conservative.
MAGIC_NUMBER_THRESHOLD = 0.004**2
class AudioEnergyFilter:
class State(Enum):
START = 0
SPEAKING = 1
SILENCE = 2
END = 3
def __init__(self, *, min_silence: float = 1.5, rms_threshold: float = MAGIC_NUMBER_THRESHOLD):
self._cooldown_seconds = min_silence
self._cooldown = min_silence
self._state = self.State.SILENCE
self._rms_threshold = rms_threshold
def update(self, frame: rtc.AudioFrame) -> State:
arr = np.frombuffer(frame.data, dtype=np.int16)
float_arr = arr.astype(np.float32) / 32768.0
rms = np.mean(np.square(float_arr))
if rms > self._rms_threshold:
self._cooldown = self._cooldown_seconds
if self._state in (self.State.SILENCE, self.State.END):
self._state = self.State.START
else:
self._state = self.State.SPEAKING
else:
if self._cooldown <= 0:
if self._state in (self.State.SPEAKING, self.State.START):
self._state = self.State.END
elif self._state == self.State.END:
self._state = self.State.SILENCE
else:
# keep speaking during cooldown
self._cooldown -= frame.duration
self._state = self.State.SPEAKING
return self._state
@dataclass
class STTOptions:
language: DeepgramLanguages | str
detect_language: bool
interim_results: bool
punctuate: bool
model: DeepgramModels | str
smart_format: bool
no_delay: bool
endpointing_ms: int
filler_words: bool
sample_rate: int
num_channels: int
keywords: list[tuple[str, float]]
keyterms: list[str]
profanity_filter: bool
energy_filter: AudioEnergyFilter | bool = False
numerals: bool = False
mip_opt_out: bool = False
tags: NotGivenOr[list[str]] = NOT_GIVEN
class STT(stt.STT):
def __init__(
self,
*,
model: DeepgramModels | str = "nova-3",
language: DeepgramLanguages | str = "en-US",
detect_language: bool = False,
interim_results: bool = True,
punctuate: bool = True,
smart_format: bool = True,
sample_rate: int = 16000,
no_delay: bool = True,
endpointing_ms: int = 25,
# enable filler words by default to improve turn detector accuracy
filler_words: bool = True,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
profanity_filter: bool = False,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
base_url: str = BASE_URL,
energy_filter: AudioEnergyFilter | bool = False,
numerals: bool = False,
mip_opt_out: bool = False,
) -> None:
"""Create a new instance of Deepgram STT.
Args:
model: The Deepgram model to use for speech recognition. Defaults to "nova-2-general".
language: The language code for recognition. Defaults to "en-US".
detect_language: Whether to enable automatic language detection. Defaults to False.
interim_results: Whether to return interim (non-final) transcription results. Defaults to True.
punctuate: Whether to add punctuations to the transcription. Defaults to True. Turn detector will work better with punctuations.
smart_format: Whether to apply smart formatting to numbers, dates, etc. Defaults to True.
sample_rate: The sample rate of the audio in Hz. Defaults to 16000.
no_delay: When smart_format is used, ensures it does not wait for sequence to be complete before returning results. Defaults to True.
endpointing_ms: Time in milliseconds of silence to consider end of speech. Set to 0 to disable. Defaults to 25.
filler_words: Whether to include filler words (um, uh, etc.) in transcription. Defaults to True.
keywords: List of tuples containing keywords and their boost values for improved recognition.
Each tuple should be (keyword: str, boost: float). Defaults to None.
`keywords` does not work with Nova-3 models. Use `keyterms` instead.
keyterms: List of key terms to improve recognition accuracy. Defaults to None.
`keyterms` is supported by Nova-3 models.
tags: List of tags to add to the requests for usage reporting. Defaults to NOT_GIVEN.
profanity_filter: Whether to filter profanity from the transcription. Defaults to False.
api_key: Your Deepgram API key. If not provided, will look for DEEPGRAM_API_KEY environment variable.
http_session: Optional aiohttp ClientSession to use for requests.
base_url: The base URL for Deepgram API. Defaults to "https://api.deepgram.com/v1/listen".
energy_filter: Audio energy filter configuration for voice activity detection.
Can be a boolean or AudioEnergyFilter instance. Defaults to False.
numerals: Whether to include numerals in the transcription. Defaults to False.
mip_opt_out: Whether to take part in the model improvement program
Raises:
ValueError: If no API key is provided or found in environment variables.
Note:
The api_key must be set either through the constructor argument or by setting
the DEEPGRAM_API_KEY environmental variable.
""" # noqa: E501
super().__init__(
capabilities=stt.STTCapabilities(streaming=True, interim_results=interim_results)
)
self._base_url = base_url
self._api_key = api_key if is_given(api_key) else os.environ.get("DEEPGRAM_API_KEY")
if not self._api_key:
raise ValueError("Deepgram API key is required")
model = _validate_model(model, language)
_validate_keyterms(model, language, keyterms, keywords)
self._opts = STTOptions(
language=language,
detect_language=detect_language,
interim_results=interim_results,
punctuate=punctuate,
model=model,
smart_format=smart_format,
no_delay=no_delay,
endpointing_ms=endpointing_ms,
filler_words=filler_words,
sample_rate=sample_rate,
num_channels=1,
keywords=keywords if is_given(keywords) else [],
keyterms=keyterms if is_given(keyterms) else [],
profanity_filter=profanity_filter,
energy_filter=energy_filter,
numerals=numerals,
mip_opt_out=mip_opt_out,
tags=_validate_tags(tags) if is_given(tags) else [],
)
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
config = self._sanitize_options(language=language)
recognize_config = {
"model": str(config.model),
"punctuate": config.punctuate,
"detect_language": config.detect_language,
"smart_format": config.smart_format,
"keywords": self._opts.keywords,
"profanity_filter": config.profanity_filter,
"numerals": config.numerals,
}
if config.language:
recognize_config["language"] = config.language
try:
async with self._ensure_session().post(
url=_to_deepgram_url(recognize_config, self._base_url, websocket=False),
data=rtc.combine_audio_frames(buffer).to_wav_bytes(),
headers={
"Authorization": f"Token {self._api_key}",
"Accept": "application/json",
"Content-Type": "audio/wav",
},
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=conn_options.timeout,
),
) as res:
return prerecorded_transcription_to_speech_event(
config.language,
await res.json(),
)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
def stream(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = self._sanitize_options(language=language)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self._ensure_session(),
base_url=self._base_url,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
model: NotGivenOr[DeepgramModels | str] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
smart_format: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
no_delay: NotGivenOr[bool] = NOT_GIVEN,
endpointing_ms: NotGivenOr[int] = NOT_GIVEN,
filler_words: NotGivenOr[bool] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
profanity_filter: NotGivenOr[bool] = NOT_GIVEN,
numerals: NotGivenOr[bool] = NOT_GIVEN,
mip_opt_out: NotGivenOr[bool] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
):
if is_given(language):
self._opts.language = language
if is_given(model):
self._opts.model = _validate_model(model, language)
if is_given(interim_results):
self._opts.interim_results = interim_results
if is_given(punctuate):
self._opts.punctuate = punctuate
if is_given(smart_format):
self._opts.smart_format = smart_format
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(no_delay):
self._opts.no_delay = no_delay
if is_given(endpointing_ms):
self._opts.endpointing_ms = endpointing_ms
if is_given(filler_words):
self._opts.filler_words = filler_words
if is_given(keywords):
self._opts.keywords = keywords
if is_given(keyterms):
self._opts.keyterms = keyterms
if is_given(profanity_filter):
self._opts.profanity_filter = profanity_filter
if is_given(numerals):
self._opts.numerals = numerals
if is_given(mip_opt_out):
self._opts.mip_opt_out = mip_opt_out
if is_given(tags):
self._opts.tags = _validate_tags(tags)
for stream in self._streams:
stream.update_options(
language=language,
model=model,
interim_results=interim_results,
punctuate=punctuate,
smart_format=smart_format,
sample_rate=sample_rate,
no_delay=no_delay,
endpointing_ms=endpointing_ms,
filler_words=filler_words,
keywords=keywords,
keyterms=keyterms,
profanity_filter=profanity_filter,
numerals=numerals,
mip_opt_out=mip_opt_out,
)
def _sanitize_options(
self, *, language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN
) -> STTOptions:
config = dataclasses.replace(self._opts)
if is_given(language):
config.language = language
if config.detect_language:
config.language = None
return config
class SpeechStream(stt.SpeechStream):
_KEEPALIVE_MSG: str = json.dumps({"type": "KeepAlive"})
_CLOSE_MSG: str = json.dumps({"type": "CloseStream"})
_FINALIZE_MSG: str = json.dumps({"type": "Finalize"})
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
http_session: aiohttp.ClientSession,
base_url: str,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
if opts.detect_language or opts.language is None:
raise ValueError(
"language detection is not supported in streaming mode, "
"please disable it and specify a language"
)
self._opts = opts
self._api_key = api_key
self._session = http_session
self._base_url = base_url
self._speaking = False
self._audio_duration_collector = PeriodicCollector(
callback=self._on_audio_duration_report,
duration=5.0,
)
self._audio_energy_filter: AudioEnergyFilter | None = None
if opts.energy_filter:
if isinstance(opts.energy_filter, AudioEnergyFilter):
self._audio_energy_filter = opts.energy_filter
else:
self._audio_energy_filter = AudioEnergyFilter()
self._request_id = ""
self._reconnect_event = asyncio.Event()
def update_options(
self,
*,
language: NotGivenOr[DeepgramLanguages | str] = NOT_GIVEN,
model: NotGivenOr[DeepgramModels | str] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
smart_format: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
no_delay: NotGivenOr[bool] = NOT_GIVEN,
endpointing_ms: NotGivenOr[int] = NOT_GIVEN,
filler_words: NotGivenOr[bool] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
profanity_filter: NotGivenOr[bool] = NOT_GIVEN,
numerals: NotGivenOr[bool] = NOT_GIVEN,
mip_opt_out: NotGivenOr[bool] = NOT_GIVEN,
tags: NotGivenOr[list[str]] = NOT_GIVEN,
):
if is_given(language):
self._opts.language = language
if is_given(model):
self._opts.model = _validate_model(model, language)
if is_given(interim_results):
self._opts.interim_results = interim_results
if is_given(punctuate):
self._opts.punctuate = punctuate
if is_given(smart_format):
self._opts.smart_format = smart_format
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(no_delay):
self._opts.no_delay = no_delay
if is_given(endpointing_ms):
self._opts.endpointing_ms = endpointing_ms
if is_given(filler_words):
self._opts.filler_words = filler_words
if is_given(keywords):
self._opts.keywords = keywords
if is_given(keyterms):
self._opts.keyterms = keyterms
if is_given(profanity_filter):
self._opts.profanity_filter = profanity_filter
if is_given(numerals):
self._opts.numerals = numerals
if is_given(mip_opt_out):
self._opts.mip_opt_out = mip_opt_out
if is_given(tags):
self._opts.tags = _validate_tags(tags)
self._reconnect_event.set()
async def _run(self) -> None:
closing_ws = False
async def keepalive_task(ws: aiohttp.ClientWebSocketResponse):
# if we want to keep the connection alive even if no audio is sent,
# Deepgram expects a keepalive message.
# https://developers.deepgram.com/reference/listen-live#stream-keepalive
try:
while True:
await ws.send_str(SpeechStream._KEEPALIVE_MSG)
await asyncio.sleep(5)
except Exception:
return
@utils.log_exceptions(logger=logger)
async def send_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
# forward audio to deepgram in chunks of 50ms
samples_50ms = self._opts.sample_rate // 20
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=self._opts.num_channels,
samples_per_channel=samples_50ms,
)
has_ended = False
last_frame: rtc.AudioFrame | None = None
async for data in self._input_ch:
frames: list[rtc.AudioFrame] = []
if isinstance(data, rtc.AudioFrame):
state = self._check_energy_state(data)
if state in (
AudioEnergyFilter.State.START,
AudioEnergyFilter.State.SPEAKING,
):
if last_frame:
frames.extend(audio_bstream.write(last_frame.data.tobytes()))
last_frame = None
frames.extend(audio_bstream.write(data.data.tobytes()))
elif state == AudioEnergyFilter.State.END:
# no need to buffer as we have cooldown period
frames.extend(audio_bstream.flush())
has_ended = True
elif state == AudioEnergyFilter.State.SILENCE:
# buffer the last silence frame, since it could contain beginning of speech
# TODO: improve accuracy by using a ring buffer with longer window
last_frame = data
elif isinstance(data, self._FlushSentinel):
frames.extend(audio_bstream.flush())
has_ended = True
for frame in frames:
self._audio_duration_collector.push(frame.duration)
await ws.send_bytes(frame.data.tobytes())
if has_ended:
self._audio_duration_collector.flush()
await ws.send_str(SpeechStream._FINALIZE_MSG)
has_ended = False
# tell deepgram we are done sending audio/inputs
closing_ws = True
await ws.send_str(SpeechStream._CLOSE_MSG)
@utils.log_exceptions(logger=logger)
async def recv_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
# close is expected, see SpeechStream.aclose
# or when the agent session ends, the http session is closed
if closing_ws or self._session.closed:
return
# this will trigger a reconnection, see the _run loop
raise APIStatusError(message="deepgram connection closed unexpectedly")
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected deepgram message type %s", msg.type)
continue
try:
self._process_stream_event(json.loads(msg.data))
except Exception:
logger.exception("failed to process deepgram message")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
ws = await self._connect_ws()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
asyncio.create_task(keepalive_task(ws)),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[asyncio.gather(*tasks), wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
) # type: ignore
# propagate exceptions from completed tasks
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
finally:
if ws is not None:
await ws.close()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
live_config: dict[str, Any] = {
"model": self._opts.model,
"punctuate": self._opts.punctuate,
"smart_format": self._opts.smart_format,
"no_delay": self._opts.no_delay,
"interim_results": self._opts.interim_results,
"encoding": "linear16",
"vad_events": True,
"sample_rate": self._opts.sample_rate,
"channels": self._opts.num_channels,
"endpointing": False if self._opts.endpointing_ms == 0 else self._opts.endpointing_ms,
"filler_words": self._opts.filler_words,
"profanity_filter": self._opts.profanity_filter,
"numerals": self._opts.numerals,
"mip_opt_out": self._opts.mip_opt_out,
}
if self._opts.keywords:
live_config["keywords"] = self._opts.keywords
if self._opts.keyterms:
# the query param is `keyterm`
# See: https://developers.deepgram.com/docs/keyterm
live_config["keyterm"] = self._opts.keyterms
if self._opts.language:
live_config["language"] = self._opts.language
if self._opts.tags:
live_config["tag"] = self._opts.tags
ws = await asyncio.wait_for(
self._session.ws_connect(
_to_deepgram_url(live_config, base_url=self._base_url, websocket=True),
headers={"Authorization": f"Token {self._api_key}"},
),
self._conn_options.timeout,
)
return ws
def _check_energy_state(self, frame: rtc.AudioFrame) -> AudioEnergyFilter.State:
if self._audio_energy_filter:
return self._audio_energy_filter.update(frame)
return AudioEnergyFilter.State.SPEAKING
def _on_audio_duration_report(self, duration: float) -> None:
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=self._request_id,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
self._event_ch.send_nowait(usage_event)
def _process_stream_event(self, data: dict) -> None:
assert self._opts.language is not None
if data["type"] == "SpeechStarted":
# This is a normal case. Deepgram's SpeechStarted events
# are not correlated with speech_final or utterance end.
# It's possible that we receive two in a row without an endpoint
# It's also possible we receive a transcript without a SpeechStarted event.
if self._speaking:
return
self._speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
# see this page:
# https://developers.deepgram.com/docs/understand-endpointing-interim-results#using-endpointing-speech_final
# for more information about the different types of events
elif data["type"] == "Results":
metadata = data["metadata"]
request_id = metadata["request_id"]
is_final_transcript = data["is_final"]
is_endpoint = data["speech_final"]
self._request_id = request_id
alts = live_transcription_to_speech_data(self._opts.language, data)
# If, for some reason, we didn't get a SpeechStarted event but we got
# a transcript with text, we should start speaking. It's rare but has
# been observed.
if len(alts) > 0 and alts[0].text:
if not self._speaking:
self._speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
if is_final_transcript:
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=request_id,
alternatives=alts,
)
self._event_ch.send_nowait(final_event)
else:
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=request_id,
alternatives=alts,
)
self._event_ch.send_nowait(interim_event)
# if we receive an endpoint, only end the speech if
# we either had a SpeechStarted event or we have a seen
# a non-empty transcript (deepgram doesn't have a SpeechEnded event)
if is_endpoint and self._speaking:
self._speaking = False
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
elif data["type"] == "Metadata":
pass # metadata is too noisy
else:
logger.warning("received unexpected message from deepgram %s", data)
def live_transcription_to_speech_data(language: str, data: dict) -> list[stt.SpeechData]:
dg_alts = data["channel"]["alternatives"]
speech_data = []
for alt in dg_alts:
sd = stt.SpeechData(
language=language,
start_time=alt["words"][0]["start"] if alt["words"] else 0,
end_time=alt["words"][-1]["end"] if alt["words"] else 0,
confidence=alt["confidence"],
text=alt["transcript"],
)
if language == "multi" and "languages" in alt:
sd.language = alt["languages"][0] # TODO: handle multiple languages
speech_data.append(sd)
return speech_data
def prerecorded_transcription_to_speech_event(
language: str | None, # language should be None when 'detect_language' is enabled
data: dict,
) -> stt.SpeechEvent:
# We only support one channel for now
request_id = data["metadata"]["request_id"]
channel = data["results"]["channels"][0]
dg_alts = channel["alternatives"]
# Use the detected language if enabled
# https://developers.deepgram.com/docs/language-detection
detected_language = channel.get("detected_language")
return stt.SpeechEvent(
request_id=request_id,
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=language or detected_language,
start_time=alt["words"][0]["start"] if alt["words"] else 0,
end_time=alt["words"][-1]["end"] if alt["words"] else 0,
confidence=alt["confidence"],
text=alt["transcript"],
)
for alt in dg_alts
],
)
def _to_deepgram_url(opts: dict, base_url: str, *, websocket: bool) -> str:
# don't modify the original opts
opts = opts.copy()
if opts.get("keywords"):
# convert keywords to a list of "keyword:intensifier"
opts["keywords"] = [
f"{keyword}:{intensifier}" for (keyword, intensifier) in opts["keywords"]
]
# lowercase bools
opts = {k: str(v).lower() if isinstance(v, bool) else v for k, v in opts.items()}
if websocket and base_url.startswith("http"):
base_url = base_url.replace("http", "ws", 1)
elif not websocket and base_url.startswith("ws"):
base_url = base_url.replace("ws", "http", 1)
return f"{base_url}?{urlencode(opts, doseq=True)}"
def _validate_model(
model: DeepgramModels | str, language: NotGivenOr[DeepgramLanguages | str]
) -> DeepgramModels | str:
en_only_models = {
"nova-2-meeting",
"nova-2-phonecall",
"nova-2-finance",
"nova-2-conversationalai",
"nova-2-voicemail",
"nova-2-video",
"nova-2-medical",
"nova-2-drivethru",
"nova-2-automotive",
}
if is_given(language) and language not in ("en-US", "en") and model in en_only_models:
logger.warning(
f"{model} does not support language {language}, falling back to nova-2-general"
)
return "nova-2-general"
return model
def _validate_tags(tags: list[str]) -> list[str]:
for tag in tags:
if len(tag) > 128:
raise ValueError("tag must be no more than 128 characters")
return tags
def _validate_keyterms(
model: DeepgramModels | str,
language: NotGivenOr[DeepgramLanguages | str],
keyterms: NotGivenOr[list[str]],
keywords: NotGivenOr[list[tuple[str, float]]],
) -> None:
"""
Validating keyterms and keywords for model compatibility.
See: https://developers.deepgram.com/docs/keyterm and https://developers.deepgram.com/docs/keywords
"""
if model.startswith("nova-3") and is_given(keywords):
raise ValueError(
"Keywords is only available for use with Nova-2, Nova-1, Enhanced, and "
"Base speech to text models. For Nova-3, use Keyterm Prompting."
)
if is_given(keyterms) and (
(model.startswith("nova-3") and language not in ("en-US", "en"))
or not model.startswith("nova-3")
):
raise ValueError(
"Keyterm Prompting is only available for English transcription using the Nova-3 Model. "
"To boost recognition of keywords using another model, use the Keywords feature."
)
from __future__ import annotations
import asyncio
import json
import os
import weakref
from dataclasses import dataclass
from urllib.parse import urlencode
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
BASE_URL = "https://api.deepgram.com/v1/speak"
NUM_CHANNELS = 1
@dataclass
class _TTSOptions:
model: str
encoding: str
sample_rate: int
word_tokenizer: tokenize.WordTokenizer
class TTS(tts.TTS):
def __init__(
self,
*,
model: str = "aura-asteria-en",
encoding: str = "linear16",
sample_rate: int = 24000,
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: str = BASE_URL,
word_tokenizer: NotGivenOr[tokenize.WordTokenizer] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""
Create a new instance of Deepgram TTS.
Args:
model (str): TTS model to use. Defaults to "aura-asteria-en".
encoding (str): Audio encoding to use. Defaults to "linear16".
sample_rate (int): Sample rate of audio. Defaults to 24000.
api_key (str): Deepgram API key. If not provided, will look for DEEPGRAM_API_KEY in environment.
base_url (str): Base URL for Deepgram TTS API. Defaults to "https://api.deepgram.com/v1/speak"
word_tokenizer (tokenize.WordTokenizer): Tokenizer for processing text. Defaults to basic WordTokenizer.
http_session (aiohttp.ClientSession): Optional aiohttp session to use for requests.
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
self._api_key = api_key if is_given(api_key) else os.environ.get("DEEPGRAM_API_KEY")
if not self._api_key:
raise ValueError("Deepgram API key required. Set DEEPGRAM_API_KEY or provide api_key.")
if not is_given(word_tokenizer):
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
self._opts = _TTSOptions(
model=model,
encoding=encoding,
sample_rate=sample_rate,
word_tokenizer=word_tokenizer,
)
self._session = http_session
self._base_url = base_url
self._streams = weakref.WeakSet[SynthesizeStream]()
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=3600, # 1 hour
mark_refreshed_on_get=False,
)
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
config = {
"encoding": self._opts.encoding,
"model": self._opts.model,
"sample_rate": self._opts.sample_rate,
}
return await asyncio.wait_for(
session.ws_connect(
_to_deepgram_url(config, self._base_url, websocket=True),
headers={"Authorization": f"Token {self._api_key}"},
),
self._conn_options.timeout,
)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
) -> None:
"""
args:
model (str): TTS model to use.
sample_rate (int): Sample rate of audio.
"""
if is_given(model):
self._opts.model = model
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
for stream in self._streams:
stream.update_options(
model=model,
sample_rate=sample_rate,
)
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
base_url=self._base_url,
api_key=self._api_key,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
stream = SynthesizeStream(
tts=self,
conn_options=conn_options,
base_url=self._base_url,
api_key=self._api_key,
opts=self._opts,
session=self._ensure_session(),
)
self._streams.add(stream)
return stream
def prewarm(self) -> None:
self._pool.prewarm()
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
await super().aclose()
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
base_url: str,
api_key: str,
input_text: str,
opts: _TTSOptions,
session: aiohttp.ClientSession,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts = opts
self._session = session
self._base_url = base_url
self._api_key = api_key
async def _run(self) -> None:
request_id = utils.shortuuid()
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
)
try:
config = {
"encoding": self._opts.encoding,
"model": self._opts.model,
"sample_rate": self._opts.sample_rate,
}
async with self._session.post(
_to_deepgram_url(config, self._base_url, websocket=False),
headers={
"Authorization": f"Token {self._api_key}",
"Content-Type": "application/json",
},
json={"text": self._input_text},
timeout=self._conn_options.timeout,
) as res:
if res.status != 200:
raise APIStatusError(
message=res.reason or "Unknown error occurred.",
status_code=res.status,
request_id=request_id,
body=await res.json(),
)
async for bytes_data, _ in res.content.iter_chunks():
for frame in audio_bstream.write(bytes_data):
self._event_ch.send_nowait(
tts.SynthesizedAudio(
request_id=request_id,
frame=frame,
)
)
for frame in audio_bstream.flush():
self._event_ch.send_nowait(
tts.SynthesizedAudio(request_id=request_id, frame=frame)
)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
class SynthesizeStream(tts.SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
base_url: str,
api_key: str,
opts: _TTSOptions,
session: aiohttp.ClientSession,
conn_options: APIConnectOptions,
):
super().__init__(tts=tts, conn_options=conn_options)
self._opts = opts
self._session = session
self._base_url = base_url
self._api_key = api_key
self._segments_ch = utils.aio.Chan[tokenize.WordStream]()
self._reconnect_event = asyncio.Event()
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
) -> None:
if is_given(model):
self._opts.model = model
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
self._reconnect_event.set()
async def _run(self) -> None:
closing_ws = False
request_id = utils.shortuuid()
segment_id = utils.shortuuid()
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
)
@utils.log_exceptions(logger=logger)
async def _tokenize_input():
# Converts incoming text into WordStreams and sends them into _segments_ch
word_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if word_stream is None:
word_stream = self._opts.word_tokenizer.stream()
self._segments_ch.send_nowait(word_stream)
word_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if word_stream:
word_stream.end_input()
word_stream = None
self._segments_ch.close()
@utils.log_exceptions(logger=logger)
async def _run_segments(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
async for word_stream in self._segments_ch:
async for word in word_stream:
speak_msg = {"type": "Speak", "text": f"{word.token} "}
self._mark_started()
await ws.send_str(json.dumps(speak_msg))
# Always flush after a segment
flush_msg = {"type": "Flush"}
await ws.send_str(json.dumps(flush_msg))
# after all segments, close
close_msg = {"type": "Close"}
closing_ws = True
await ws.send_str(json.dumps(close_msg))
async def recv_task(ws: aiohttp.ClientWebSocketResponse):
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=segment_id,
)
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
if not closing_ws:
raise APIStatusError(
"Deepgram websocket connection closed unexpectedly",
request_id=request_id,
)
return
if msg.type == aiohttp.WSMsgType.BINARY:
data = msg.data
for frame in audio_bstream.write(data):
emitter.push(frame)
elif msg.type == aiohttp.WSMsgType.TEXT:
resp = json.loads(msg.data)
mtype = resp.get("type")
if mtype == "Flushed":
for frame in audio_bstream.flush():
emitter.push(frame)
emitter.flush()
break
elif mtype == "Warning":
logger.warning("Deepgram warning: %s", resp.get("warn_msg"))
elif mtype == "Metadata":
pass
else:
logger.debug("Unknown message type: %s", resp)
async def _connection_timeout():
# Deepgram has a 60-minute timeout period for websocket connections
await asyncio.sleep(3300)
logger.warning("Deepgram TTS maximum connection time reached. Reconnecting...")
self._reconnect_event.set()
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
config = {
"encoding": self._opts.encoding,
"model": self._opts.model,
"sample_rate": self._opts.sample_rate,
}
ws = await asyncio.wait_for(
self._session.ws_connect(
_to_deepgram_url(config, self._base_url, websocket=True),
headers={"Authorization": f"Token {self._api_key}"},
),
self._conn_options.timeout,
)
closing_ws = False
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_run_segments(ws)),
asyncio.create_task(recv_task(ws)),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
connection_timeout_task = asyncio.create_task(_connection_timeout())
try:
done, _ = await asyncio.wait(
[
asyncio.gather(*tasks),
wait_reconnect_task,
connection_timeout_task,
],
return_when=asyncio.FIRST_COMPLETED,
) # type: ignore
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(
*tasks, wait_reconnect_task, connection_timeout_task
)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
if ws is not None and not ws.closed:
await ws.close()
def _to_deepgram_url(
opts: dict,
base_url: str,
*,
websocket: bool,
) -> str:
if websocket and base_url.startswith("http"):
base_url = base_url.replace("http", "ws", 1)
elif not websocket and base_url.startswith("ws"):
base_url = base_url.replace("ws", "http", 1)
return f"{base_url}?{urlencode(opts, doseq=True)}"
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-deepgram"
dynamic = ["version"]
description = "Agent Framework plugin for services using Deepgram's API."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents[codecs]>=1.0.17", "numpy>=1.26"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/deepgram/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Elevenlabs
Agent Framework plugin for voice synthesis with [ElevenLabs](https://elevenlabs.io/) API.
## Installation
```bash
pip install livekit-plugins-elevenlabs
You’ll need an API key from ElevenLabs. It can be set as an environment variable: ELEVEN_API_KEY
## livekit-plugins/livekit-plugins-elevenlabs/livekit/plugins/elevenlabs/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .models import TTSEncoding, TTSModels
from .tts import DEFAULT_VOICE_ID, TTS, Voice, VoiceSettings
from .version import __version__
__all__ = [
"TTS",
"Voice",
"VoiceSettings",
"TTSEncoding",
"TTSModels",
"DEFAULT_VOICE_ID",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class ElevenLabsPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(ElevenLabsPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.elevenlabs")
from typing import Literal
TTSModels = Literal[
"eleven_monolingual_v1",
"eleven_multilingual_v1",
"eleven_multilingual_v2",
"eleven_turbo_v2",
"eleven_turbo_v2_5",
"eleven_flash_v2_5",
"eleven_flash_v2",
]
TTSEncoding = Literal[
"mp3_22050_32",
"mp3_44100",
"mp3_44100_32",
"mp3_44100_64",
"mp3_44100_96",
"mp3_44100_128",
"mp3_44100_192",
]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass
from typing import Any
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import TTSEncoding, TTSModels
# by default, use 22.05kHz sample rate at 32kbps
# in our testing, reduce TTFB by about ~110ms
_DefaultEncoding: TTSEncoding = "mp3_22050_32"
def _sample_rate_from_format(output_format: TTSEncoding) -> int:
split = output_format.split("_") # e.g: mp3_44100
return int(split[1])
@dataclass
class VoiceSettings:
stability: float # [0.0 - 1.0]
similarity_boost: float # [0.0 - 1.0]
style: NotGivenOr[float] = NOT_GIVEN # [0.0 - 1.0]
speed: NotGivenOr[float] = NOT_GIVEN # [0.8 - 1.2]
use_speaker_boost: NotGivenOr[bool] = NOT_GIVEN
@dataclass
class Voice:
id: str
name: str
category: str
DEFAULT_VOICE_ID = "EXAVITQu4vr4xnSDxMaL"
API_BASE_URL_V1 = "https://api.elevenlabs.io/v1"
AUTHORIZATION_HEADER = "xi-api-key"
WS_INACTIVITY_TIMEOUT = 300
@dataclass
class _TTSOptions:
api_key: str
voice_id: str
voice_settings: NotGivenOr[VoiceSettings]
model: TTSModels | str
language: NotGivenOr[str]
base_url: str
encoding: TTSEncoding
sample_rate: int
streaming_latency: NotGivenOr[int]
word_tokenizer: tokenize.WordTokenizer
chunk_length_schedule: list[int]
enable_ssml_parsing: bool
inactivity_timeout: int
class TTS(tts.TTS):
def __init__(
self,
*,
voice_id: str = DEFAULT_VOICE_ID,
voice_settings: NotGivenOr[VoiceSettings] = NOT_GIVEN,
model: TTSModels | str = "eleven_flash_v2_5",
encoding: NotGivenOr[TTSEncoding] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
streaming_latency: NotGivenOr[int] = NOT_GIVEN,
inactivity_timeout: int = WS_INACTIVITY_TIMEOUT,
word_tokenizer: NotGivenOr[tokenize.WordTokenizer] = NOT_GIVEN,
enable_ssml_parsing: bool = False,
chunk_length_schedule: NotGivenOr[list[int]] = NOT_GIVEN, # range is [50, 500]
http_session: aiohttp.ClientSession | None = None,
language: NotGivenOr[str] = NOT_GIVEN,
) -> None:
"""
Create a new instance of ElevenLabs TTS.
Args:
voice_id (str): Voice ID. Defaults to `DEFAULT_VOICE_ID`.
voice_settings (NotGivenOr[VoiceSettings]): Voice settings.
model (TTSModels | str): TTS model to use. Defaults to "eleven_turbo_v2_5".
api_key (NotGivenOr[str]): ElevenLabs API key. Can be set via argument or `ELEVEN_API_KEY` environment variable.
base_url (NotGivenOr[str]): Custom base URL for the API. Optional.
streaming_latency (NotGivenOr[int]): Optimize for streaming latency, defaults to 0 - disabled. 4 for max latency optimizations. deprecated
inactivity_timeout (int): Inactivity timeout in seconds for the websocket connection. Defaults to 300.
word_tokenizer (NotGivenOr[tokenize.WordTokenizer]): Tokenizer for processing text. Defaults to basic WordTokenizer.
enable_ssml_parsing (bool): Enable SSML parsing for input text. Defaults to False.
chunk_length_schedule (NotGivenOr[list[int]]): Schedule for chunk lengths, ranging from 50 to 500. Defaults to [80, 120, 200, 260].
http_session (aiohttp.ClientSession | None): Custom HTTP session for API requests. Optional.
language (NotGivenOr[str]): Language code for the TTS model, as of 10/24/24 only valid for "eleven_turbo_v2_5".
""" # noqa: E501
if not is_given(chunk_length_schedule):
chunk_length_schedule = [80, 120, 200, 260]
if not is_given(encoding):
encoding = _DefaultEncoding
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=True,
),
sample_rate=_sample_rate_from_format(encoding),
num_channels=1,
)
elevenlabs_api_key = api_key if is_given(api_key) else os.environ.get("ELEVEN_API_KEY")
if not elevenlabs_api_key:
raise ValueError(
"ElevenLabs API key is required, either as argument or set ELEVEN_API_KEY environmental variable" # noqa: E501
)
if not is_given(word_tokenizer):
word_tokenizer = tokenize.basic.WordTokenizer(
ignore_punctuation=False # punctuation can help for intonation
)
self._opts = _TTSOptions(
voice_id=voice_id,
voice_settings=voice_settings,
model=model,
api_key=elevenlabs_api_key,
base_url=base_url if is_given(base_url) else API_BASE_URL_V1,
encoding=encoding,
sample_rate=self.sample_rate,
streaming_latency=streaming_latency,
word_tokenizer=word_tokenizer,
chunk_length_schedule=chunk_length_schedule,
enable_ssml_parsing=enable_ssml_parsing,
language=language,
inactivity_timeout=inactivity_timeout,
)
self._session = http_session
self._streams = weakref.WeakSet[SynthesizeStream]()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def list_voices(self) -> list[Voice]:
async with self._ensure_session().get(
f"{self._opts.base_url}/voices",
headers={AUTHORIZATION_HEADER: self._opts.api_key},
) as resp:
return _dict_to_voices_list(await resp.json())
def update_options(
self,
*,
voice_id: NotGivenOr[str] = NOT_GIVEN,
voice_settings: NotGivenOr[VoiceSettings] = NOT_GIVEN,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
) -> None:
"""
Args:
voice_id (NotGivenOr[str]): Voice ID.
voice_settings (NotGivenOr[VoiceSettings]): Voice settings.
model (NotGivenOr[TTSModels | str]): TTS model to use.
language (NotGivenOr[str]): Language code for the TTS model.
"""
if is_given(model):
self._opts.model = model
if is_given(voice_id):
self._opts.voice_id = voice_id
if is_given(voice_settings):
self._opts.voice_settings = voice_settings
if is_given(language):
self._opts.language = language
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
stream = SynthesizeStream(
tts=self,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
self._streams.add(stream)
return stream
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await super().aclose()
class ChunkedStream(tts.ChunkedStream):
"""Synthesize using the chunked api endpoint"""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
conn_options: APIConnectOptions,
session: aiohttp.ClientSession,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._session = opts, session
async def _run(self) -> None:
request_id = utils.shortuuid()
voice_settings = (
_strip_nones(dataclasses.asdict(self._opts.voice_settings))
if is_given(self._opts.voice_settings)
else None
)
data = {
"text": self._input_text,
"model_id": self._opts.model,
"voice_settings": voice_settings,
}
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=1,
)
decode_task: asyncio.Task | None = None
try:
async with self._session.post(
_synthesize_url(self._opts),
headers={AUTHORIZATION_HEADER: self._opts.api_key},
json=data,
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=self._conn_options.timeout,
),
) as resp:
if not resp.content_type.startswith("audio/"):
content = await resp.text()
logger.error("11labs returned non-audio data: %s", content)
return
async def _decode_loop():
try:
async for bytes_data, _ in resp.content.iter_chunks():
decoder.push(bytes_data)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
class SynthesizeStream(tts.SynthesizeStream):
"""Streamed API using websockets"""
def __init__(
self,
*,
tts: TTS,
session: aiohttp.ClientSession,
opts: _TTSOptions,
conn_options: APIConnectOptions,
):
super().__init__(tts=tts, conn_options=conn_options)
self._opts, self._session = opts, session
async def _run(self) -> None:
request_id = utils.shortuuid()
self._segments_ch = utils.aio.Chan[tokenize.WordStream]()
@utils.log_exceptions(logger=logger)
async def _tokenize_input():
"""tokenize text from the input_ch to words"""
word_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if word_stream is None:
# new segment (after flush for e.g)
word_stream = self._opts.word_tokenizer.stream()
self._segments_ch.send_nowait(word_stream)
word_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if word_stream is not None:
word_stream.end_input()
word_stream = None
if word_stream is not None:
word_stream.end_input()
self._segments_ch.close()
@utils.log_exceptions(logger=logger)
async def _process_segments():
async for word_stream in self._segments_ch:
await self._run_ws(word_stream, request_id)
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_process_segments()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
async def _run_ws(
self,
word_stream: tokenize.WordStream,
request_id: str,
) -> None:
ws_conn = await self._session.ws_connect(
_stream_url(self._opts),
headers={AUTHORIZATION_HEADER: self._opts.api_key},
)
segment_id = utils.shortuuid()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=1,
)
# 11labs protocol expects the first message to be an "init msg"
init_pkt = {
"text": " ",
"voice_settings": _strip_nones(dataclasses.asdict(self._opts.voice_settings))
if is_given(self._opts.voice_settings)
else None,
"generation_config": {"chunk_length_schedule": self._opts.chunk_length_schedule},
}
await ws_conn.send_str(json.dumps(init_pkt))
eos_sent = False
@utils.log_exceptions(logger=logger)
async def send_task():
nonlocal eos_sent
xml_content = []
async for data in word_stream:
text = data.token
# send the xml phoneme in one go
if (
self._opts.enable_ssml_parsing
and data.token.startswith("<phoneme")
or xml_content
):
xml_content.append(text)
if data.token.find("</phoneme>") > -1:
text = self._opts.word_tokenizer.format_words(xml_content)
xml_content = []
else:
continue
data_pkt = {"text": f"{text} "} # must always end with a space
self._mark_started()
await ws_conn.send_str(json.dumps(data_pkt))
if xml_content:
logger.warning("11labs stream ended with incomplete xml content")
# no more token, mark eos
eos_pkt = {"text": ""}
await ws_conn.send_str(json.dumps(eos_pkt))
eos_sent = True
# consumes from decoder and generates events
@utils.log_exceptions(logger=logger)
async def generate_task():
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
# receives from ws and decodes audio
@utils.log_exceptions(logger=logger)
async def recv_task():
nonlocal eos_sent
while True:
msg = await ws_conn.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if not eos_sent:
raise APIStatusError(
"11labs connection closed unexpectedly, not all tokens have been consumed", # noqa: E501
request_id=request_id,
)
return
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected 11labs message type %s", msg.type)
continue
data = json.loads(msg.data)
if data.get("audio"):
b64data = base64.b64decode(data["audio"])
decoder.push(b64data)
elif data.get("isFinal"):
decoder.end_input()
break
elif data.get("error"):
raise APIStatusError(
message=data["error"],
status_code=500,
request_id=request_id,
body=None,
)
else:
raise APIStatusError(
message=f"unexpected 11labs message {data}",
status_code=500,
request_id=request_id,
body=None,
)
tasks = [
asyncio.create_task(send_task()),
asyncio.create_task(recv_task()),
asyncio.create_task(generate_task()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
await decoder.aclose()
if ws_conn is not None:
await ws_conn.close()
def _dict_to_voices_list(data: dict[str, Any]):
voices: list[Voice] = []
for voice in data["voices"]:
voices.append(
Voice(
id=voice["voice_id"],
name=voice["name"],
category=voice["category"],
)
)
return voices
def _strip_nones(data: dict[str, Any]):
return {k: v for k, v in data.items() if is_given(v) and v is not None}
def _synthesize_url(opts: _TTSOptions) -> str:
base_url = opts.base_url
voice_id = opts.voice_id
model_id = opts.model
output_format = opts.encoding
url = (
f"{base_url}/text-to-speech/{voice_id}/stream?"
f"model_id={model_id}&output_format={output_format}"
)
if is_given(opts.streaming_latency):
url += f"&optimize_streaming_latency={opts.streaming_latency}"
return url
def _stream_url(opts: _TTSOptions) -> str:
base_url = opts.base_url
voice_id = opts.voice_id
model_id = opts.model
output_format = opts.encoding
enable_ssml = str(opts.enable_ssml_parsing).lower()
language = opts.language
inactivity_timeout = opts.inactivity_timeout
url = (
f"{base_url}/text-to-speech/{voice_id}/stream-input?"
f"model_id={model_id}&output_format={output_format}&"
f"enable_ssml_parsing={enable_ssml}&inactivity_timeout={inactivity_timeout}"
)
if is_given(language):
url += f"&language_code={language}"
if is_given(opts.streaming_latency):
url += f"&optimize_streaming_latency={opts.streaming_latency}"
return url
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-elevenlabs"
dynamic = ["version"]
description = "Agent Framework plugin for voice synthesis with ElevenLabs' API."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit", "elevenlabs"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents[codecs]>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/elevenlabs/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins fal
This plugin provides a simple way to integrate fal.ai models into the LiveKit Agent Framework. currently supports Wizper model for STT.
## Installation
```bash
pip install livekit-plugins-fal
You’ll need an API key from fal. It can be set as an environment variable: FAL_KEY
## livekit-plugins/livekit-plugins-fal/livekit/plugins/fal/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .stt import WizperSTT
from .version import __version__
__all__ = ["WizperSTT", "__version__"]
from livekit.agents import Plugin
from .log import logger
class FalPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(FalPlugin())
import logging
logger = logging.getLogger("livekit.plugins.fal")
from __future__ import annotations
import os
from dataclasses import dataclass
import fal_client
from livekit import rtc
from livekit.agents import APIConnectionError, APIConnectOptions, stt
from livekit.agents.stt import SpeechEventType, STTCapabilities
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
@dataclass
class _STTOptions:
language: str = "en"
task: str = "transcribe"
chunk_level: str = "segment"
version: str = "3"
class WizperSTT(stt.STT):
def __init__(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
):
super().__init__(capabilities=STTCapabilities(streaming=False, interim_results=True))
self._api_key = api_key if is_given(api_key) else os.getenv("FAL_KEY")
if not self._api_key:
raise ValueError("fal AI API key is required. It should be set with env FAL_KEY")
self._opts = _STTOptions(language=language)
self._fal_client = fal_client.AsyncClient(key=self._api_key)
def update_options(self, *, language: NotGivenOr[str] = NOT_GIVEN) -> None:
if is_given(language):
self._opts.language = language
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
try:
if is_given(language):
self._opts.language = language
data_uri = fal_client.encode(
rtc.combine_audio_frames(buffer).to_wav_bytes(), "audio/x-wav"
)
response = await self._fal_client.run(
"fal-ai/wizper",
arguments={
"audio_url": data_uri,
"task": self._opts.task,
"language": self._opts.language,
"chunk_level": self._opts.chunk_level,
"version": self._opts.version,
},
timeout=conn_options.timeout,
)
text = response.get("text", "")
return self._transcription_to_speech_event(text=text)
except fal_client.client.FalClientError as e:
raise APIConnectionError() from e
def _transcription_to_speech_event(self, text: str) -> stt.SpeechEvent:
return stt.SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[stt.SpeechData(text=text, language=self._opts.language)],
)
async def aclose(self) -> None:
await self._fal_client._client.aclose()
# Copyright 2023 LiveKit, Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-fal"
dynamic = ["version"]
description = "fal plugin template for LiveKit Agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17", "fal_client"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/fal/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# Gladia plugin for LiveKit
Agent Framework plugin for speech-to-text with [Gladia](https://gladia.io/)'s API. Currently supports streaming speech-to-text with optional translation.
## Installation
```bash
pip install livekit-plugins-gladia
You’ll need an API key from Gladia. It can be set as an environment variable: GLADIA_API_KEY
from livekit.stt import STT
from livekit.plugins.gladia.stt import STT as GladiaSTT
# Basic initialization
stt = GladiaSTT(
api_key="your-api-key-here", # or use GLADIA_API_KEY env var
interim_results=True
)
# With more options
stt = GladiaSTT(
languages=["en", "fr"], # Specify languages or let Gladia auto-detect
code_switching=True, # Allow switching between languages during recognition
sample_rate=16000, # Audio sample rate in Hz
bit_depth=16, # Audio bit depth
channels=1, # Number of audio channels
encoding="wav/pcm", # Audio encoding format
energy_filter=True, # Enable voice activity detection
translation_enabled=True,
translation_target_languages=["en"],
translation_model="base",
translation_match_original_utterances=True
)
# Update options after initialization
stt.update_options(
languages=["ja", "en"],
translation_enabled=True,
translation_target_languages=["fr"]
)
from livekit.agents import Agent
from livekit.plugins.gladia.stt import STT as GladiaSTT
agent = Agent(
stt=GladiaSTT(
api_key="your-api-key-here",
languages=["en"],
translation_enabled=True,
translation_target_languages=["es"]
)
)
# Rest of your agent setup...
## livekit-plugins/livekit-plugins-gladia/livekit/plugins/gladia/__init__.py
```py
from .stt import STT, AudioEnergyFilter, SpeechStream
from .version import __version__
__all__ = ["STT", "SpeechStream", "AudioEnergyFilter", "__version__"]
from livekit.agents import Plugin
from .log import logger
class GladiaPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(GladiaPlugin())
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import time
from typing import Callable, Generic, Optional, TypeVar
T = TypeVar("T")
class PeriodicCollector(Generic[T]):
def __init__(self, callback: Callable[[T], None], *, duration: float) -> None:
"""
Create a new periodic collector that accumulates values and calls the callback
after the specified duration if there are values to report.
Args:
duration: Time in seconds between callback invocations
callback: Function to call with accumulated value when duration expires
"""
self._duration = duration
self._callback = callback
self._last_flush_time = time.monotonic()
self._total: Optional[T] = None
def push(self, value: T) -> None:
"""Add a value to the accumulator"""
if self._total is None:
self._total = value
else:
self._total += value
if time.monotonic() - self._last_flush_time >= self._duration:
self.flush()
def flush(self) -> None:
"""Force callback to be called with current total if non-zero"""
if self._total is not None:
self._callback(self._total)
self._total = None
self._last_flush_time = time.monotonic()
import logging
logger = logging.getLogger("livekit.plugins.gladia")
from typing import Literal
GladiaModels = Literal["base"]
GladiaLanguages = Literal[
"af",
"sq",
"am",
"ar",
"hy",
"as",
"ast",
"az",
"ba",
"eu",
"be",
"bn",
"bs",
"br",
"bg",
"my",
"es",
"ca",
"ceb",
"zh",
"hr",
"cs",
"da",
"nl",
"en",
"et",
"fo",
"fi",
"fr",
"fy",
"ff",
"gd",
"gl",
"lg",
"ka",
"de",
"el",
"gu",
"ht",
"ha",
"haw",
"he",
"hi",
"hu",
"is",
"ig",
"ilo",
"id",
"ga",
"it",
"ja",
"jv",
"kn",
"kk",
"km",
"ko",
"lo",
"la",
"lv",
"lb",
"ln",
"lt",
"mk",
"mg",
"ms",
"ml",
"mt",
"mi",
"mr",
"mo",
"mn",
"ne",
"no",
"nn",
"oc",
"or",
"pa",
"ps",
"fa",
"pl",
"pt",
"ro",
"ru",
"sa",
"sr",
"sn",
"sd",
"si",
"sk",
"sl",
"so",
"su",
"sw",
"ss",
"sv",
"tl",
"tg",
"ta",
"tt",
"te",
"th",
"bo",
"tn",
"tr",
"tk",
"uk",
"ur",
"uz",
"vi",
"cy",
"wo",
"xh",
"yi",
"yo",
"zu",
]
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass
from enum import Enum
from typing import Literal
import aiohttp
import numpy as np
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.utils import AudioBuffer
from ._utils import PeriodicCollector
from .log import logger
BASE_URL = "https://api.gladia.io/v2/live"
MAGIC_NUMBER_THRESHOLD = 0.004**2
class AudioEnergyFilter:
class State(Enum):
START = 0
SPEAKING = 1
SILENCE = 2
END = 3
def __init__(self, *, min_silence: float = 1.5, rms_threshold: float = MAGIC_NUMBER_THRESHOLD):
self._cooldown_seconds = min_silence
self._cooldown = min_silence
self._state = self.State.SILENCE
self._rms_threshold = rms_threshold
def update(self, frame: rtc.AudioFrame) -> State:
arr = np.frombuffer(frame.data, dtype=np.int16)
float_arr = arr.astype(np.float32) / 32768.0
rms = np.mean(np.square(float_arr))
if rms > self._rms_threshold:
self._cooldown = self._cooldown_seconds
if self._state in (self.State.SILENCE, self.State.END):
self._state = self.State.START
else:
self._state = self.State.SPEAKING
else:
if self._cooldown <= 0:
if self._state in (self.State.SPEAKING, self.State.START):
self._state = self.State.END
elif self._state == self.State.END:
self._state = self.State.SILENCE
else:
self._cooldown -= frame.duration
self._state = self.State.SPEAKING
return self._state
@dataclass
class LanguageConfiguration:
languages: list[str] | None = None
code_switching: bool = True
@dataclass
class TranslationConfiguration:
enabled: bool = False
target_languages: list[str] = dataclasses.field(default_factory=list)
model: str = "base"
match_original_utterances: bool = True
@dataclass
class STTOptions:
language_config: LanguageConfiguration
interim_results: bool
sample_rate: int
bit_depth: Literal[8, 16, 24, 32]
channels: int
encoding: Literal["wav/pcm", "wav/alaw", "wav/ulaw"]
translation_config: TranslationConfiguration = dataclasses.field(
default_factory=TranslationConfiguration
)
energy_filter: AudioEnergyFilter | bool = False
class STT(stt.STT):
def __init__(
self,
*,
interim_results: bool = True,
languages: list[str] | None = None,
code_switching: bool = True,
sample_rate: int = 16000,
bit_depth: Literal[8, 16, 24, 32] = 16,
channels: int = 1,
encoding: Literal["wav/pcm", "wav/alaw", "wav/ulaw"] = "wav/pcm",
api_key: str | None = None,
http_session: aiohttp.ClientSession | None = None,
base_url: str = BASE_URL,
energy_filter: AudioEnergyFilter | bool = False,
translation_enabled: bool = False,
translation_target_languages: list[str] | None = None,
translation_model: str = "base",
translation_match_original_utterances: bool = True,
) -> None:
"""Create a new instance of Gladia STT.
Args:
interim_results: Whether to return interim (non-final) transcription results.
Defaults to True.
languages: List of language codes to use for recognition. Defaults to None
(auto-detect).
code_switching: Whether to allow switching between languages during recognition.
Defaults to True.
sample_rate: The sample rate of the audio in Hz. Defaults to 16000.
bit_depth: The bit depth of the audio. Defaults to 16.
channels: The number of audio channels. Defaults to 1.
encoding: The encoding of the audio. Defaults to "wav/pcm".
api_key: Your Gladia API key. If not provided, will look for GLADIA_API_KEY
environment variable.
http_session: Optional aiohttp ClientSession to use for requests.
base_url: The base URL for Gladia API. Defaults to "https://api.gladia.io/v2/live".
energy_filter: Audio energy filter configuration for voice activity detection.
Can be a boolean or AudioEnergyFilter instance. Defaults to False.
translation_enabled: Whether to enable translation. Defaults to False.
translation_target_languages: List of target languages for translation.
Required if translation_enabled is True.
translation_model: Translation model to use. Defaults to "base".
translation_match_original_utterances: Whether to match original utterances with
translations. Defaults to True.
Raises:
ValueError: If no API key is provided or found in environment variables.
"""
super().__init__(
capabilities=stt.STTCapabilities(streaming=True, interim_results=interim_results)
)
self._base_url = base_url
api_key = api_key or os.environ.get("GLADIA_API_KEY")
if api_key is None:
raise ValueError("Gladia API key is required")
self._api_key = api_key
language_config = LanguageConfiguration(languages=languages, code_switching=code_switching)
translation_config = TranslationConfiguration(
enabled=translation_enabled,
target_languages=translation_target_languages or [],
model=translation_model,
match_original_utterances=translation_match_original_utterances,
)
if translation_enabled and not translation_target_languages:
raise ValueError(
"translation_target_languages is required when translation_enabled is True"
)
self._opts = STTOptions(
language_config=language_config,
interim_results=interim_results,
sample_rate=sample_rate,
bit_depth=bit_depth,
channels=channels,
encoding=encoding,
translation_config=translation_config,
energy_filter=energy_filter,
)
self._session = http_session
self._streams = weakref.WeakSet()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: list[str] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
"""Implement synchronous speech recognition for Gladia using the live endpoint."""
config = self._sanitize_options(languages=language)
streaming_config = {
"encoding": config.encoding,
"sample_rate": config.sample_rate,
"bit_depth": config.bit_depth,
"channels": config.channels,
"language_config": {
"languages": config.language_config.languages or [],
"code_switching": config.language_config.code_switching,
},
"realtime_processing": {
"words_accurate_timestamps": False,
"custom_vocabulary": False,
"custom_vocabulary_config": {
"vocabulary": [
"Gladia",
{"value": "Gladia", "intensity": 0.5},
],
"default_intensity": 0.5,
},
"custom_spelling": False,
"custom_spelling_config": {
"spelling_dictionary": {
"SQL": ["Sequel"],
}
},
},
}
# Add translation configuration if enabled
if config.translation_config.enabled:
streaming_config["realtime_processing"]["translation"] = True
streaming_config["realtime_processing"]["translation_config"] = {
"target_languages": config.translation_config.target_languages,
"model": config.translation_config.model,
"match_original_utterances": config.translation_config.match_original_utterances,
}
try:
# Initialize a session with Gladia
session_response = await self._init_live_session(streaming_config, conn_options)
session_id = session_response["id"]
session_url = session_response["url"]
# Connect to the WebSocket
async with self._ensure_session().ws_connect(
session_url,
timeout=aiohttp.ClientTimeout(
total=30, # Keep a reasonable total timeout
sock_connect=conn_options.timeout,
),
) as ws:
# Combine audio frames to get a single frame with all raw PCM data
combined_frame = rtc.combine_audio_frames(buffer)
# Get the raw bytes from the combined frame
pcm_data = combined_frame.data.tobytes()
bytes_per_second = config.sample_rate * config.channels * (config.bit_depth // 8)
chunk_size = (bytes_per_second * 150) // 1000
chunk_size = max(chunk_size, 1024)
# Send raw PCM audio data in chunks
for i in range(0, len(pcm_data), chunk_size):
chunk = pcm_data[i : i + chunk_size]
chunk_b64 = base64.b64encode(chunk).decode("utf-8")
await ws.send_str(
json.dumps({"type": "audio_chunk", "data": {"chunk": chunk_b64}})
)
# Tell Gladia we're done sending audio
await ws.send_str(json.dumps({"type": "stop_recording"}))
# Wait for final transcript
utterances = []
# Receive messages until we get the post_final_transcript message
try:
# Set a timeout for waiting for the final results after sending stop_recording
receive_timeout = conn_options.timeout * 5
async for msg in ws.iter(timeout=receive_timeout):
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
# Collect final utterances
if data["type"] == "transcript" and data["data"]["is_final"]:
utterance = data["data"]["utterance"]
utterances.append(utterance)
# Check for translation as the final result if enabled
elif (
data["type"] == "translation" and config.translation_config.enabled
):
pass
elif data["type"] == "post_final_transcript":
break
elif data["type"] == "error":
raise APIConnectionError(
f"Gladia WebSocket error: {data.get('data')}"
) from None
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"Gladia WebSocket connection error: {ws.exception()}")
raise ws.exception() or APIConnectionError(
"Gladia WebSocket connection error"
)
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
logger.warning(
"Gladia WebSocket closed unexpectedly during result receiving: "
f"type={msg.type}"
)
break
except asyncio.TimeoutError:
logger.warning(
f"Timeout waiting for Gladia final transcript ({receive_timeout}s)"
)
if not utterances:
raise APITimeoutError(
f"Timeout waiting for Gladia final transcript ({receive_timeout}s)"
) from None
# Create a speech event from the collected final utterances
return self._create_speech_event(
utterances, session_id, config.language_config.languages
)
except asyncio.TimeoutError as e:
# Catch timeout during connection or initial phase
logger.error(f"Timeout during Gladia connection/initialization: {e}")
raise APITimeoutError("Timeout connecting to or initializing Gladia session") from e
except aiohttp.ClientResponseError as e:
# Error during session initialization POST request
logger.error(f"Gladia API status error during session init: {e.status} {e.message}")
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=e.headers.get(
"X-Request-ID"
), # Check if Gladia provides a request ID header
body=await e.response.text() if hasattr(e, "response") else None,
) from e
except aiohttp.ClientError as e:
# General client errors (connection refused, DNS resolution etc.)
logger.error(f"Gladia connection error: {e}")
raise APIConnectionError(f"Gladia connection error: {e}") from e
except Exception as e:
# Catch-all for other unexpected errors
logger.exception(
f"Unexpected error during Gladia synchronous recognition: {e}"
) # Use logger.exception to include stack trace
raise APIConnectionError(f"An unexpected error occurred: {e}") from e
async def _init_live_session(self, config: dict, conn_options: APIConnectOptions) -> dict:
"""Initialize a live transcription session with Gladia."""
try:
async with self._ensure_session().post(
url=self._base_url,
json=config,
headers={"X-Gladia-Key": self._api_key},
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=conn_options.timeout,
),
) as res:
# Gladia returns 201 Created when successfully creating a session
if res.status not in (200, 201):
raise APIStatusError(
message=f"Failed to initialize Gladia session: {res.status}",
status_code=res.status,
request_id=None,
body=await res.text(),
)
return await res.json()
except Exception as e:
logger.exception(f"Failed to initialize Gladia session: {e}")
raise APIConnectionError(f"Failed to initialize Gladia session: {str(e)}") from e
def _create_speech_event(
self, utterances: list[dict], session_id: str, languages: list[str] | None
) -> stt.SpeechEvent:
"""Create a SpeechEvent from Gladia's transcript data."""
alternatives = []
# Process each utterance into a SpeechData object
for utterance in utterances:
text = utterance.get("text", "").strip()
if text:
alternatives.append(
stt.SpeechData(
language=utterance.get("language", languages[0] if languages else "en"),
start_time=utterance.get("start", 0),
end_time=utterance.get("end", 0),
confidence=utterance.get("confidence", 1.0),
text=text,
)
)
if not alternatives:
alternatives.append(
stt.SpeechData(
language=languages[0] if languages and len(languages) > 0 else "en",
start_time=0,
end_time=0,
confidence=1.0,
text="",
)
)
return stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=session_id,
alternatives=alternatives,
)
def stream(
self,
*,
language: list[str] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = self._sanitize_options(languages=language)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self._ensure_session(),
base_url=self._base_url,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
languages: list[str] | None = None,
code_switching: bool | None = None,
interim_results: bool | None = None,
sample_rate: int | None = None,
bit_depth: Literal[8, 16, 24, 32] | None = None,
channels: int | None = None,
encoding: Literal["wav/pcm", "wav/alaw", "wav/ulaw"] | None = None,
translation_enabled: bool | None = None,
translation_target_languages: list[str] | None = None,
translation_model: str | None = None,
translation_match_original_utterances: bool | None = None,
):
if languages is not None or code_switching is not None:
language_config = dataclasses.replace(
self._opts.language_config,
languages=languages
if languages is not None
else self._opts.language_config.languages,
code_switching=code_switching
if code_switching is not None
else self._opts.language_config.code_switching,
)
self._opts.language_config = language_config
if (
translation_enabled is not None
or translation_target_languages is not None
or translation_model is not None
or translation_match_original_utterances is not None
):
translation_config = dataclasses.replace(
self._opts.translation_config,
enabled=translation_enabled
if translation_enabled is not None
else self._opts.translation_config.enabled,
target_languages=translation_target_languages
if translation_target_languages is not None
else self._opts.translation_config.target_languages,
model=translation_model
if translation_model is not None
else self._opts.translation_config.model,
match_original_utterances=translation_match_original_utterances
if translation_match_original_utterances is not None
else self._opts.translation_config.match_original_utterances,
)
self._opts.translation_config = translation_config
if interim_results is not None:
self._opts.interim_results = interim_results
if sample_rate is not None:
self._opts.sample_rate = sample_rate
if bit_depth is not None:
self._opts.bit_depth = bit_depth
if channels is not None:
self._opts.channels = channels
if encoding is not None:
self._opts.encoding = encoding
for stream in self._streams:
stream.update_options(
languages=languages,
code_switching=code_switching,
interim_results=interim_results,
sample_rate=sample_rate,
bit_depth=bit_depth,
channels=channels,
encoding=encoding,
translation_enabled=translation_enabled,
translation_target_languages=translation_target_languages,
translation_model=translation_model,
translation_match_original_utterances=translation_match_original_utterances,
)
def _sanitize_options(self, *, languages: list[str] | None = None) -> STTOptions:
config = dataclasses.replace(self._opts)
if languages is not None:
language_config = dataclasses.replace(
config.language_config,
languages=languages,
)
config.language_config = language_config
return config
class SpeechStream(stt.SpeechStream):
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
http_session: aiohttp.ClientSession,
base_url: str,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._api_key = api_key
self._session = http_session
self._base_url = base_url
self._speaking = False
self._audio_duration_collector = PeriodicCollector(
callback=self._on_audio_duration_report,
duration=5.0,
)
self._audio_energy_filter: AudioEnergyFilter | None = None
if opts.energy_filter:
if isinstance(opts.energy_filter, AudioEnergyFilter):
self._audio_energy_filter = opts.energy_filter
else:
self._audio_energy_filter = AudioEnergyFilter()
self._pushed_audio_duration = 0.0
self._request_id = ""
self._reconnect_event = asyncio.Event()
self._ws: aiohttp.ClientWebSocketResponse | None = None
def update_options(
self,
*,
languages: list[str] | None = None,
code_switching: bool | None = None,
interim_results: bool | None = None,
sample_rate: int | None = None,
bit_depth: Literal[8, 16, 24, 32] | None = None,
channels: int | None = None,
encoding: Literal["wav/pcm", "wav/alaw", "wav/ulaw"] | None = None,
translation_enabled: bool | None = None,
translation_target_languages: list[str] | None = None,
translation_model: str | None = None,
translation_match_original_utterances: bool | None = None,
):
if languages is not None or code_switching is not None:
language_config = dataclasses.replace(
self._opts.language_config,
languages=languages
if languages is not None
else self._opts.language_config.languages,
code_switching=code_switching
if code_switching is not None
else self._opts.language_config.code_switching,
)
self._opts.language_config = language_config
if (
translation_enabled is not None
or translation_target_languages is not None
or translation_model is not None
or translation_match_original_utterances is not None
):
translation_config = dataclasses.replace(
self._opts.translation_config,
enabled=translation_enabled
if translation_enabled is not None
else self._opts.translation_config.enabled,
target_languages=translation_target_languages
if translation_target_languages is not None
else self._opts.translation_config.target_languages,
model=translation_model
if translation_model is not None
else self._opts.translation_config.model,
match_original_utterances=translation_match_original_utterances
if translation_match_original_utterances is not None
else self._opts.translation_config.match_original_utterances,
)
self._opts.translation_config = translation_config
if interim_results is not None:
self._opts.interim_results = interim_results
if sample_rate is not None:
self._opts.sample_rate = sample_rate
if bit_depth is not None:
self._opts.bit_depth = bit_depth
if channels is not None:
self._opts.channels = channels
if encoding is not None:
self._opts.encoding = encoding
self._reconnect_event.set()
async def _run(self) -> None:
backoff_time = 1.0
max_backoff = 30.0
while True:
try:
# Initialize the Gladia session
session_info = await self._init_live_session()
session_url = session_info["url"]
self._request_id = session_info["id"]
# Reset backoff on success
backoff_time = 1.0
# Connect to the WebSocket
async with self._session.ws_connect(session_url) as ws:
self._ws = ws
logger.info(f"Connected to Gladia session {self._request_id}")
send_task = asyncio.create_task(self._send_audio_task())
recv_task = asyncio.create_task(self._recv_messages_task())
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[send_task, recv_task, wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
logger.info("Reconnecting Gladia session due to options change")
finally:
await utils.aio.gracefully_cancel(send_task, recv_task, wait_reconnect_task)
self._ws = None
except APIStatusError as e:
if e.status_code == 429:
logger.warning(
f"Rate limited by Gladia API. Backing off for {backoff_time} seconds."
)
await asyncio.sleep(backoff_time)
backoff_time = min(backoff_time * 2, max_backoff)
else:
logger.exception(f"Error in speech stream: {e}")
await asyncio.sleep(backoff_time)
except Exception as e:
logger.exception(f"Error in speech stream: {e}")
# Wait a bit before reconnecting to avoid rapid reconnection attempts
await asyncio.sleep(backoff_time)
async def _init_live_session(self) -> dict:
"""Initialize a live session with Gladia."""
streaming_config = {
"encoding": self._opts.encoding,
"sample_rate": self._opts.sample_rate,
"bit_depth": self._opts.bit_depth,
"channels": self._opts.channels,
"language_config": {
"languages": self._opts.language_config.languages or [],
"code_switching": self._opts.language_config.code_switching,
},
"realtime_processing": {},
}
# Add translation configuration if enabled
if self._opts.translation_config.enabled:
streaming_config["realtime_processing"]["translation"] = True
streaming_config["realtime_processing"]["translation_config"] = {
"target_languages": self._opts.translation_config.target_languages,
"model": self._opts.translation_config.model,
"match_original_utterances": (
self._opts.translation_config.match_original_utterances
),
}
try:
async with self._session.post(
url=self._base_url,
json=streaming_config,
headers={"X-Gladia-Key": self._api_key},
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=self._conn_options.timeout,
),
) as res:
# Gladia returns 201 Created when successfully creating a session
if res.status not in (200, 201):
raise APIStatusError(
message=f"Failed to initialize Gladia session: {res.status}",
status_code=res.status,
request_id=None,
body=await res.text(),
)
return await res.json()
except Exception as e:
logger.exception(f"Failed to initialize Gladia session: {e}")
raise APIConnectionError(f"Failed to initialize Gladia session: {str(e)}") from e
async def _send_audio_task(self):
"""Send audio data to Gladia WebSocket."""
if not self._ws:
return
# We'll aim to send audio chunks every ~100ms
samples_100ms = self._opts.sample_rate // 10
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=self._opts.channels,
samples_per_channel=samples_100ms,
)
has_ended = False
last_frame: rtc.AudioFrame | None = None
async for data in self._input_ch:
if not self._ws:
break
frames: list[rtc.AudioFrame] = []
if isinstance(data, rtc.AudioFrame):
state = self._check_energy_state(data)
if state in (
AudioEnergyFilter.State.START,
AudioEnergyFilter.State.SPEAKING,
):
if last_frame:
frames.extend(audio_bstream.write(last_frame.data.tobytes()))
last_frame = None
frames.extend(audio_bstream.write(data.data.tobytes()))
elif state == AudioEnergyFilter.State.END:
frames = audio_bstream.flush()
has_ended = True
elif state == AudioEnergyFilter.State.SILENCE:
last_frame = data
elif isinstance(data, self._FlushSentinel):
frames = audio_bstream.flush()
has_ended = True
for frame in frames:
self._audio_duration_collector.push(frame.duration)
# Encode the audio data as base64
chunk_b64 = base64.b64encode(frame.data.tobytes()).decode("utf-8")
message = json.dumps({"type": "audio_chunk", "data": {"chunk": chunk_b64}})
await self._ws.send_str(message)
if has_ended:
self._audio_duration_collector.flush()
await self._ws.send_str(json.dumps({"type": "stop_recording"}))
has_ended = False
# Tell Gladia we're done sending audio when the stream ends
if self._ws:
await self._ws.send_str(json.dumps({"type": "stop_recording"}))
async def _recv_messages_task(self):
"""Receive and process messages from Gladia WebSocket."""
if not self._ws:
return
async for msg in self._ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
self._process_gladia_message(data)
except Exception as e:
logger.exception(f"Error processing Gladia message: {e}")
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
break
else:
logger.warning(f"Unexpected message type from Gladia: {msg.type}")
def _process_gladia_message(self, data: dict):
"""Process messages from Gladia WebSocket."""
if data["type"] == "transcript":
is_final = data["data"]["is_final"]
utterance = data["data"]["utterance"]
text = utterance.get("text", "").strip()
if not self._speaking and text:
self._speaking = True
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.START_OF_SPEECH, request_id=self._request_id
)
)
if text:
language = utterance.get(
"language",
self._opts.language_config.languages[0]
if self._opts.language_config.languages
else "en",
)
speech_data = stt.SpeechData(
language=language,
start_time=utterance.get("start", 0),
end_time=utterance.get("end", 0),
confidence=utterance.get("confidence", 1.0),
text=text,
)
if is_final:
# Only emit FINAL_TRANSCRIPT for the *original* language
# if translation is NOT enabled.
if not self._opts.translation_config.enabled:
event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=self._request_id,
alternatives=[speech_data],
)
self._event_ch.send_nowait(event)
# End of speech after final original transcript only if not translating
if self._speaking:
self._speaking = False
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.END_OF_SPEECH,
request_id=self._request_id,
)
)
# If translation *is* enabled, we suppress this final event
# and wait for the 'translation' message to emit the final event.
elif self._opts.interim_results:
# Always send INTERIM_TRANSCRIPT for the original language if enabled
event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=self._request_id,
alternatives=[speech_data],
)
self._event_ch.send_nowait(event)
elif data["type"] == "translation":
# Process translation messages according to Gladia's documentation:
# https://docs.gladia.io/reference/realtime-messages/translation
if self._opts.translation_config.enabled and "data" in data:
translation_data = data["data"]
# Extract translated utterance
translated_utterance = translation_data.get("translated_utterance", {})
if not translated_utterance:
logger.warning(
f"No translated_utterance in translation message: {translation_data}"
)
return
# Get language information
target_language = translation_data.get("target_language", "")
language = translated_utterance.get("language", target_language)
# Get the translated text
translated_text = translated_utterance.get("text", "").strip()
if translated_text and language:
# Create speech data for the translation
speech_data = stt.SpeechData(
language=language, # Use the target language
start_time=translated_utterance.get("start", 0),
end_time=translated_utterance.get("end", 0),
confidence=translated_utterance.get("confidence", 1.0),
text=translated_text, # Use the translated text
)
# Emit FINAL_TRANSCRIPT containing the TRANSLATION
event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=self._request_id,
alternatives=[speech_data], # Now contains translated data
)
self._event_ch.send_nowait(event)
# Emit END_OF_SPEECH after the final *translated* transcript
if self._speaking:
self._speaking = False
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.END_OF_SPEECH, request_id=self._request_id
)
)
elif data["type"] == "post_final_transcript":
# This is sent at the end of a session
# We now tie END_OF_SPEECH to the emission of the relevant FINAL_TRANSCRIPT
# (either original if no translation, or translated if translation is enabled).
# So, we might not strictly need to act on this message anymore for END_OF_SPEECH,
# but ensure speaking state is reset if somehow missed.
if self._speaking:
self._speaking = False
def _check_energy_state(self, frame: rtc.AudioFrame) -> AudioEnergyFilter.State:
"""Check the energy state of an audio frame."""
if self._audio_energy_filter:
return self._audio_energy_filter.update(frame)
return AudioEnergyFilter.State.SPEAKING
def _on_audio_duration_report(self, duration: float) -> None:
"""Report the audio duration for usage tracking."""
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=self._request_id,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
self._event_ch.send_nowait(usage_event)
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-gladia"
dynamic = ["version"]
description = "Agent Framework plugin for services using Gladia's API."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "support@livekit.io" }]
keywords = [
"webrtc",
"realtime",
"audio",
"video",
"livekit",
"gladia",
"speech-to-text",
]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents[codecs]>=1.0.17",
"numpy>=1.26",
"aiohttp>=3.8.0",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/gladia/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Google
Agent Framework plugin for services from Google Cloud. Currently supporting Google's [Speech-to-Text](https://cloud.google.com/speech-to-text) API.
## Installation
```bash
pip install livekit-plugins-google
For credentials, you’ll need a Google Cloud account and obtain the correct credentials. Credentials can be passed directly or via Application Default Credentials as specified in How Application Default Credentials works.
To use the STT and TTS API, you’ll need to enable the respective services for your Google Cloud project.
Gemini Multimodal Live can be used with the MultimodalAgent
class. See examples/multimodal_agent/gemini_agent.py for an example.
You can push video frames to your Gemini Multimodal Live session alongside the audio automatically handled by the MultimodalAgent
. The basic approach is to subscribe to the video track, create a video stream, sample frames at a suitable frame rate, and push them into the RealtimeSession:
# Make sure you subscribe to audio and video tracks
await ctx.connect(auto_subscribe=AutoSubscribe.SUBSCRIBE_ALL)
# Create your RealtimeModel and store a reference
model = google.beta.realtime.RealtimeModel(
# ...
)
# Create your MultimodalAgent as usual
agent = MultimodalAgent(
model=model,
# ...
)
# Async method to process the video track and push frames to Gemini
async def _process_video_track(self, track: Track):
video_stream = VideoStream(track)
last_frame_time = 0
async for event in video_stream:
current_time = asyncio.get_event_loop().time()
# Sample at 1 FPS
if current_time - last_frame_time < 1.0:
continue
last_frame_time = current_time
frame = event.frame
# Push the frame into the RealtimeSession
model.sessions[0].push_video(frame)
await video_stream.aclose()
# Subscribe to new tracks and process them
@ctx.room.on("track_subscribed")
def _on_track_subscribed(track: Track, pub, participant):
if track.kind == TrackKind.KIND_VIDEO:
asyncio.create_task(self._process_video_track(track))
## livekit-plugins/livekit-plugins-google/livekit/plugins/google/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from . import beta
from .llm import LLM
from .stt import STT, SpeechStream
from .tts import TTS
from .version import __version__
__all__ = ["STT", "TTS", "SpeechStream", "__version__", "beta", "LLM"]
from livekit.agents import Plugin
from .log import logger
class GooglePlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(GooglePlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
from . import realtime
__all__ = ["realtime"]
from .api_proto import ClientEvents, LiveAPIModels, Voice
from .realtime_api import RealtimeModel
__all__ = [
"RealtimeModel",
"ClientEvents",
"LiveAPIModels",
"Voice",
]
from __future__ import annotations
from collections.abc import Sequence
from typing import Literal, Union
from google.genai import types
LiveAPIModels = Literal["gemini-2.0-flash-exp", "gemini-2.0-flash-live-001"]
Voice = Literal["Puck", "Charon", "Kore", "Fenrir", "Aoede"]
ClientEvents = Union[
types.ContentListUnion,
types.ContentListUnionDict,
types.LiveClientContentOrDict,
types.LiveClientRealtimeInput,
types.LiveClientRealtimeInputOrDict,
types.LiveClientToolResponseOrDict,
types.FunctionResponseOrDict,
Sequence[types.FunctionResponseOrDict],
]
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import weakref
from collections.abc import Iterator
from dataclasses import dataclass
from google import genai
from google.genai.types import (
AudioTranscriptionConfig,
Blob,
Content,
FunctionDeclaration,
GenerationConfig,
LiveClientContent,
LiveClientRealtimeInput,
LiveConnectConfig,
LiveServerContent,
LiveServerGoAway,
LiveServerToolCall,
LiveServerToolCallCancellation,
Modality,
Part,
PrebuiltVoiceConfig,
SpeechConfig,
Tool,
UsageMetadata,
VoiceConfig,
)
from livekit import rtc
from livekit.agents import llm, utils
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import audio as audio_utils, images, is_given
from livekit.plugins.google.beta.realtime.api_proto import ClientEvents, LiveAPIModels, Voice
from ...log import logger
from ...utils import _build_gemini_fnc, get_tool_results_for_realtime, to_chat_ctx
INPUT_AUDIO_SAMPLE_RATE = 16000
INPUT_AUDIO_CHANNELS = 1
OUTPUT_AUDIO_SAMPLE_RATE = 24000
OUTPUT_AUDIO_CHANNELS = 1
DEFAULT_ENCODE_OPTIONS = images.EncodeOptions(
format="JPEG",
quality=75,
resize_options=images.ResizeOptions(width=1024, height=1024, strategy="scale_aspect_fit"),
)
@dataclass
class InputTranscription:
item_id: str
transcript: str
@dataclass
class _RealtimeOptions:
model: LiveAPIModels | str
api_key: str | None
voice: Voice | str
response_modalities: NotGivenOr[list[Modality]]
vertexai: bool
project: str | None
location: str | None
candidate_count: int
temperature: NotGivenOr[float]
max_output_tokens: NotGivenOr[int]
top_p: NotGivenOr[float]
top_k: NotGivenOr[int]
presence_penalty: NotGivenOr[float]
frequency_penalty: NotGivenOr[float]
instructions: NotGivenOr[str]
input_audio_transcription: AudioTranscriptionConfig | None
output_audio_transcription: AudioTranscriptionConfig | None
@dataclass
class _MessageGeneration:
message_id: str
text_ch: utils.aio.Chan[str]
audio_ch: utils.aio.Chan[rtc.AudioFrame]
@dataclass
class _ResponseGeneration:
message_ch: utils.aio.Chan[llm.MessageGeneration]
function_ch: utils.aio.Chan[llm.FunctionCall]
messages: dict[str, _MessageGeneration]
class RealtimeModel(llm.RealtimeModel):
def __init__(
self,
*,
instructions: NotGivenOr[str] = NOT_GIVEN,
model: LiveAPIModels | str = "gemini-2.0-flash-live-001",
api_key: NotGivenOr[str] = NOT_GIVEN,
voice: Voice | str = "Puck",
modalities: NotGivenOr[list[Modality]] = NOT_GIVEN,
vertexai: bool = False,
project: NotGivenOr[str] = NOT_GIVEN,
location: NotGivenOr[str] = NOT_GIVEN,
candidate_count: int = 1,
temperature: NotGivenOr[float] = NOT_GIVEN,
max_output_tokens: NotGivenOr[int] = NOT_GIVEN,
top_p: NotGivenOr[float] = NOT_GIVEN,
top_k: NotGivenOr[int] = NOT_GIVEN,
presence_penalty: NotGivenOr[float] = NOT_GIVEN,
frequency_penalty: NotGivenOr[float] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[AudioTranscriptionConfig | None] = NOT_GIVEN,
output_audio_transcription: NotGivenOr[AudioTranscriptionConfig | None] = NOT_GIVEN,
) -> None:
"""
Initializes a RealtimeModel instance for interacting with Google's Realtime API.
Environment Requirements:
- For VertexAI: Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of the service account key file.
The Google Cloud project and location can be set via `project` and `location` arguments or the environment variables
`GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`. By default, the project is inferred from the service account key file,
and the location defaults to "us-central1".
- For Google Gemini API: Set the `api_key` argument or the `GOOGLE_API_KEY` environment variable.
Args:
instructions (str, optional): Initial system instructions for the model. Defaults to "".
api_key (str, optional): Google Gemini API key. If None, will attempt to read from the environment variable GOOGLE_API_KEY.
modalities (list[Modality], optional): Modalities to use, such as ["TEXT", "AUDIO"]. Defaults to ["AUDIO"].
model (str, optional): The name of the model to use. Defaults to "gemini-2.0-flash-live-001".
voice (api_proto.Voice, optional): Voice setting for audio outputs. Defaults to "Puck".
temperature (float, optional): Sampling temperature for response generation. Defaults to 0.8.
vertexai (bool, optional): Whether to use VertexAI for the API. Defaults to False.
project (str, optional): The project id to use for the API. Defaults to None. (for vertexai)
location (str, optional): The location to use for the API. Defaults to None. (for vertexai)
candidate_count (int, optional): The number of candidate responses to generate. Defaults to 1.
top_p (float, optional): The top-p value for response generation
top_k (int, optional): The top-k value for response generation
presence_penalty (float, optional): The presence penalty for response generation
frequency_penalty (float, optional): The frequency penalty for response generation
input_audio_transcription (AudioTranscriptionConfig | None, optional): The configuration for input audio transcription. Defaults to None.)
output_audio_transcription (AudioTranscriptionConfig | None, optional): The configuration for output audio transcription. Defaults to AudioTranscriptionConfig().
Raises:
ValueError: If the API key is required but not found.
""" # noqa: E501
super().__init__(
capabilities=llm.RealtimeCapabilities(
message_truncation=False,
turn_detection=True,
user_transcription=is_given(input_audio_transcription),
)
)
gemini_api_key = api_key if is_given(api_key) else os.environ.get("GOOGLE_API_KEY")
gcp_project = project if is_given(project) else os.environ.get("GOOGLE_CLOUD_PROJECT")
gcp_location = location if is_given(location) else os.environ.get("GOOGLE_CLOUD_LOCATION")
if vertexai:
if not gcp_project or not gcp_location:
raise ValueError(
"Project and location are required for VertexAI either via project and location or GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables" # noqa: E501
)
gemini_api_key = None # VertexAI does not require an API key
else:
gcp_project = None
gcp_location = None
if not gemini_api_key:
raise ValueError(
"API key is required for Google API either via api_key or GOOGLE_API_KEY environment variable" # noqa: E501
)
if not is_given(input_audio_transcription):
input_audio_transcription = None
if not is_given(output_audio_transcription):
output_audio_transcription = AudioTranscriptionConfig()
self._opts = _RealtimeOptions(
model=model,
api_key=gemini_api_key,
voice=voice,
response_modalities=modalities,
vertexai=vertexai,
project=gcp_project,
location=gcp_location,
candidate_count=candidate_count,
temperature=temperature,
max_output_tokens=max_output_tokens,
top_p=top_p,
top_k=top_k,
presence_penalty=presence_penalty,
frequency_penalty=frequency_penalty,
instructions=instructions,
input_audio_transcription=input_audio_transcription,
output_audio_transcription=output_audio_transcription,
)
self._sessions = weakref.WeakSet[RealtimeSession]()
def session(self) -> RealtimeSession:
sess = RealtimeSession(self)
self._sessions.add(sess)
return sess
def update_options(
self, *, voice: NotGivenOr[str] = NOT_GIVEN, temperature: NotGivenOr[float] = NOT_GIVEN
) -> None:
if is_given(voice):
self._opts.voice = voice
if is_given(temperature):
self._opts.temperature = temperature
for sess in self._sessions:
sess.update_options(voice=self._opts.voice, temperature=self._opts.temperature)
async def aclose(self) -> None:
pass
class RealtimeSession(llm.RealtimeSession):
def __init__(self, realtime_model: RealtimeModel) -> None:
super().__init__(realtime_model)
self._opts = realtime_model._opts
self._tools = llm.ToolContext.empty()
self._gemini_declarations: list[FunctionDeclaration] = []
self._chat_ctx = llm.ChatContext.empty()
self._msg_ch = utils.aio.Chan[ClientEvents]()
self._input_resampler: rtc.AudioResampler | None = None
# 50ms chunks
self._bstream = audio_utils.AudioByteStream(
INPUT_AUDIO_SAMPLE_RATE,
INPUT_AUDIO_CHANNELS,
samples_per_channel=INPUT_AUDIO_SAMPLE_RATE // 20,
)
self._client = genai.Client(
api_key=self._opts.api_key,
vertexai=self._opts.vertexai,
project=self._opts.project,
location=self._opts.location,
)
self._main_atask = asyncio.create_task(self._main_task(), name="gemini-realtime-session")
self._current_generation: _ResponseGeneration | None = None
self._active_session: genai.LiveSession | None = None
# indicates if the underlying session should end
self._session_should_close = asyncio.Event()
self._response_created_futures: dict[str, asyncio.Future[llm.GenerationCreatedEvent]] = {}
self._pending_generation_fut: asyncio.Future[llm.GenerationCreatedEvent] | None = None
self._update_lock = asyncio.Lock()
self._session_lock = asyncio.Lock()
async def _close_active_session(self) -> None:
async with self._session_lock:
if self._active_session:
try:
await self._active_session.close()
except Exception as e:
logger.warning(f"error closing Gemini session: {e}")
finally:
self._active_session = None
def _mark_restart_needed(self):
if not self._session_should_close.is_set():
self._session_should_close.set()
# reset the msg_ch, do not send messages from previous session
self._msg_ch = utils.aio.Chan[ClientEvents]()
async def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
) -> None:
async with self._update_lock:
should_restart = False
if is_given(voice) and self._opts.voice != voice:
self._opts.voice = voice
should_restart = True
if is_given(temperature) and self._opts.temperature != temperature:
self._opts.temperature = temperature if is_given(temperature) else NOT_GIVEN
should_restart = True
if should_restart:
self._mark_restart_needed()
async def update_instructions(self, instructions: str) -> None:
async with self._update_lock:
if not is_given(self._opts.instructions) or self._opts.instructions != instructions:
self._opts.instructions = instructions
self._mark_restart_needed()
async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None:
async with self._update_lock:
self._chat_ctx = chat_ctx.copy()
turns, _ = to_chat_ctx(self._chat_ctx, id(self), ignore_functions=True)
tool_results = get_tool_results_for_realtime(self._chat_ctx)
# TODO(dz): need to compute delta and then either append or recreate session
if turns:
self._send_client_event(LiveClientContent(turns=turns, turn_complete=False))
if tool_results:
self._send_client_event(tool_results)
async def update_tools(self, tools: list[llm.FunctionTool]) -> None:
async with self._update_lock:
new_declarations: list[FunctionDeclaration] = [
_build_gemini_fnc(tool) for tool in tools
]
current_tool_names = {f.name for f in self._gemini_declarations}
new_tool_names = {f.name for f in new_declarations}
if current_tool_names != new_tool_names:
self._gemini_declarations = new_declarations
self._tools = llm.ToolContext(tools)
self._mark_restart_needed()
@property
def chat_ctx(self) -> llm.ChatContext:
return self._chat_ctx.copy()
@property
def tools(self) -> llm.ToolContext:
return self._tools.copy()
def push_audio(self, frame: rtc.AudioFrame) -> None:
for f in self._resample_audio(frame):
for nf in self._bstream.write(f.data.tobytes()):
realtime_input = LiveClientRealtimeInput(
media_chunks=[Blob(data=nf.data.tobytes(), mime_type="audio/pcm")]
)
self._send_client_event(realtime_input)
def push_video(self, frame: rtc.VideoFrame) -> None:
encoded_data = images.encode(frame, DEFAULT_ENCODE_OPTIONS)
realtime_input = LiveClientRealtimeInput(
media_chunks=[Blob(data=encoded_data, mime_type="image/jpeg")]
)
self._send_client_event(realtime_input)
def _send_client_event(self, event: ClientEvents) -> None:
with contextlib.suppress(utils.aio.channel.ChanClosed):
self._msg_ch.send_nowait(event)
def generate_reply(
self, *, instructions: NotGivenOr[str] = NOT_GIVEN
) -> asyncio.Future[llm.GenerationCreatedEvent]:
if self._pending_generation_fut and not self._pending_generation_fut.done():
logger.warning(
"generate_reply called while another generation is pending, cancelling previous."
)
self._pending_generation_fut.cancel("Superseded by new generate_reply call")
fut = asyncio.Future()
self._pending_generation_fut = fut
# Gemini requires the last message to end with user's turn
# so we need to add a placeholder user turn in order to trigger a new generation
event = LiveClientContent(turns=[], turn_complete=True)
if is_given(instructions):
event.turns.append(Content(parts=[Part(text=instructions)], role="model"))
event.turns.append(Content(parts=[Part(text=".")], role="user"))
self._send_client_event(event)
def _on_timeout() -> None:
if not fut.done():
fut.set_exception(
llm.RealtimeError(
"generate_reply timed out waiting for generation_created event."
)
)
if self._pending_generation_fut is fut:
self._pending_generation_fut = None
timeout_handle = asyncio.get_event_loop().call_later(5.0, _on_timeout)
fut.add_done_callback(lambda _: timeout_handle.cancel())
return fut
def interrupt(self) -> None:
pass
def truncate(self, *, message_id: str, audio_end_ms: int) -> None:
logger.warning("truncate is not supported by the Google Realtime API.")
pass
async def aclose(self) -> None:
self._msg_ch.close()
self._session_should_close.set()
if self._main_atask:
await utils.aio.cancel_and_wait(self._main_atask)
await self._close_active_session()
if self._pending_generation_fut and not self._pending_generation_fut.done():
self._pending_generation_fut.cancel("Session closed")
for fut in self._response_created_futures.values():
if not fut.done():
fut.set_exception(llm.RealtimeError("Session closed before response created"))
self._response_created_futures.clear()
if self._current_generation:
self._finalize_response(closed=True)
@utils.log_exceptions(logger=logger)
async def _main_task(self):
while not self._msg_ch.closed:
# previous session might not be closed yet, we'll do it here.
await self._close_active_session()
self._session_should_close.clear()
config = self._build_connect_config()
session = None
try:
logger.debug("connecting to Gemini Realtime API...")
async with self._client.aio.live.connect(
model=self._opts.model, config=config
) as session:
async with self._session_lock:
self._active_session = session
# queue up existing chat context
send_task = asyncio.create_task(
self._send_task(session), name="gemini-realtime-send"
)
recv_task = asyncio.create_task(
self._recv_task(session), name="gemini-realtime-recv"
)
restart_wait_task = asyncio.create_task(
self._session_should_close.wait(), name="gemini-restart-wait"
)
done, pending = await asyncio.wait(
[send_task, recv_task, restart_wait_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task is not restart_wait_task and task.exception():
logger.error(f"error in task {task.get_name()}: {task.exception()}")
raise task.exception() or Exception(f"{task.get_name()} failed")
if restart_wait_task not in done and self._msg_ch.closed:
break
for task in pending:
await utils.aio.cancel_and_wait(task)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Gemini Realtime API error: {e}", exc_info=e)
if not self._msg_ch.closed:
logger.info("attempting to reconnect after 1 seconds...")
await asyncio.sleep(1)
finally:
await self._close_active_session()
async def _send_task(self, session: genai.LiveSession):
try:
async for msg in self._msg_ch:
async with self._session_lock:
if self._session_should_close.is_set() or (
not self._active_session or self._active_session != session
):
break
if isinstance(msg, LiveClientContent):
await session.send(input=msg)
else:
await session.send(input=msg)
except Exception as e:
if not self._session_should_close.is_set():
logger.error(f"error in send task: {e}", exc_info=e)
self._mark_restart_needed()
finally:
logger.debug("send task finished.")
async def _recv_task(self, session: genai.LiveSession):
try:
while True:
async with self._session_lock:
if self._session_should_close.is_set() or (
not self._active_session or self._active_session != session
):
logger.debug("receive task: Session changed or closed, stopping receive.")
break
async for response in session.receive():
if not self._current_generation and (
response.server_content or response.tool_call
):
self._start_new_generation()
if response.server_content:
self._handle_server_content(response.server_content)
if response.tool_call:
self._handle_tool_calls(response.tool_call)
if response.tool_call_cancellation:
self._handle_tool_call_cancellation(response.tool_call_cancellation)
if response.usage_metadata:
self._handle_usage_metadata(response.usage_metadata)
if response.go_away:
self._handle_go_away(response.go_away)
# TODO(dz): a server-side turn is complete
except Exception as e:
if not self._session_should_close.is_set():
logger.error(f"error in receive task: {e}", exc_info=e)
self._mark_restart_needed()
finally:
self._finalize_response(closed=True)
def _build_connect_config(self) -> LiveConnectConfig:
temp = self._opts.temperature if is_given(self._opts.temperature) else None
return LiveConnectConfig(
response_modalities=self._opts.response_modalities
if is_given(self._opts.response_modalities)
else [Modality.AUDIO],
generation_config=GenerationConfig(
candidate_count=self._opts.candidate_count,
temperature=temp,
max_output_tokens=self._opts.max_output_tokens
if is_given(self._opts.max_output_tokens)
else None,
top_p=self._opts.top_p if is_given(self._opts.top_p) else None,
top_k=self._opts.top_k if is_given(self._opts.top_k) else None,
presence_penalty=self._opts.presence_penalty
if is_given(self._opts.presence_penalty)
else None,
frequency_penalty=self._opts.frequency_penalty
if is_given(self._opts.frequency_penalty)
else None,
),
system_instruction=Content(parts=[Part(text=self._opts.instructions)])
if is_given(self._opts.instructions)
else None,
speech_config=SpeechConfig(
voice_config=VoiceConfig(
prebuilt_voice_config=PrebuiltVoiceConfig(voice_name=self._opts.voice)
)
),
tools=[Tool(function_declarations=self._gemini_declarations)],
input_audio_transcription=self._opts.input_audio_transcription,
output_audio_transcription=self._opts.output_audio_transcription,
)
def _start_new_generation(self):
if self._current_generation:
logger.warning("starting new generation while another is active. Finalizing previous.")
self._finalize_response(closed=True)
response_id = utils.shortuuid("gemini-turn-")
self._current_generation = _ResponseGeneration(
message_ch=utils.aio.Chan[llm.MessageGeneration](),
function_ch=utils.aio.Chan[llm.FunctionCall](),
messages={},
)
item_generation = _MessageGeneration(
message_id=response_id,
text_ch=utils.aio.Chan[str](),
audio_ch=utils.aio.Chan[rtc.AudioFrame](),
)
self._current_generation.messages[response_id] = item_generation
self._current_generation.message_ch.send_nowait(
llm.MessageGeneration(
message_id=response_id,
text_stream=item_generation.text_ch,
audio_stream=item_generation.audio_ch,
)
)
generation_event = llm.GenerationCreatedEvent(
message_stream=self._current_generation.message_ch,
function_stream=self._current_generation.function_ch,
user_initiated=False,
)
if self._pending_generation_fut and not self._pending_generation_fut.done():
generation_event.user_initiated = True
self._pending_generation_fut.set_result(generation_event)
self._pending_generation_fut = None
self.emit("generation_created", generation_event)
def _handle_server_content(self, server_content: LiveServerContent):
if not self._current_generation:
logger.warning("received server content but no active generation.")
return
response_id = list(self._current_generation.messages.keys())[0]
item_generation = self._current_generation.messages[response_id]
if model_turn := server_content.model_turn:
for part in model_turn.parts:
if part.text:
item_generation.text_ch.send_nowait(part.text)
if part.inline_data:
frame_data = part.inline_data.data
try:
frame = rtc.AudioFrame(
data=frame_data,
sample_rate=OUTPUT_AUDIO_SAMPLE_RATE,
num_channels=OUTPUT_AUDIO_CHANNELS,
samples_per_channel=len(frame_data) // (2 * OUTPUT_AUDIO_CHANNELS),
)
item_generation.audio_ch.send_nowait(frame)
except ValueError as e:
logger.error(f"Error creating audio frame from Gemini data: {e}")
if input_transcription := server_content.input_transcription:
if input_transcription.text:
self.emit(
"input_audio_transcription_completed",
llm.InputTranscriptionCompleted(
item_id=response_id, transcript=input_transcription.text
),
)
self._handle_input_speech_started()
if output_transcription := server_content.output_transcription:
if output_transcription.text:
item_generation.text_ch.send_nowait(output_transcription.text)
if server_content.interrupted:
self._finalize_response(interrupted=True)
self._handle_input_speech_started()
if server_content.turn_complete:
self._finalize_response()
def _finalize_response(self, interrupted: bool = False, closed: bool = False) -> None:
if not self._current_generation:
return
gen = self._current_generation
self._current_generation = None
for item_generation in gen.messages.values():
if not item_generation.text_ch.closed:
item_generation.text_ch.close()
if not item_generation.audio_ch.closed:
item_generation.audio_ch.close()
gen.function_ch.close()
gen.message_ch.close()
def _handle_input_speech_started(self):
self.emit("input_speech_started", llm.InputSpeechStartedEvent())
def _handle_tool_calls(self, tool_call: LiveServerToolCall):
if not self._current_generation:
logger.warning("received tool call but no active generation.")
return
gen = self._current_generation
for fnc_call in tool_call.function_calls:
arguments = json.dumps(fnc_call.args)
gen.function_ch.send_nowait(
llm.FunctionCall(
call_id=fnc_call.id or utils.shortuuid("fnc-call-"),
name=fnc_call.name,
arguments=arguments,
)
)
self._finalize_response()
def _handle_tool_call_cancellation(
self, tool_call_cancellation: LiveServerToolCallCancellation
):
logger.warning(
"server cancelled tool calls",
extra={"function_call_ids": tool_call_cancellation.ids},
)
def _handle_usage_metadata(self, usage_metadata: UsageMetadata):
# TODO: handle metrics
logger.debug("usage metadata", extra={"usage_metadata": usage_metadata})
def _handle_go_away(self, go_away: LiveServerGoAway):
logger.warning(
f"Gemini server indicates disconnection soon. Time left: {go_away.time_left}"
)
# TODO(dz): this isn't a seamless reconnection just yet
self._session_should_close.set()
def commit_audio(self) -> None:
pass
def clear_audio(self) -> None:
self._bstream.clear()
def _resample_audio(self, frame: rtc.AudioFrame) -> Iterator[rtc.AudioFrame]:
if self._input_resampler:
if frame.sample_rate != self._input_resampler._input_rate:
# input audio changed to a different sample rate
self._input_resampler = None
if self._input_resampler is None and (
frame.sample_rate != INPUT_AUDIO_SAMPLE_RATE
or frame.num_channels != INPUT_AUDIO_CHANNELS
):
self._input_resampler = rtc.AudioResampler(
input_rate=frame.sample_rate,
output_rate=INPUT_AUDIO_SAMPLE_RATE,
num_channels=INPUT_AUDIO_CHANNELS,
)
if self._input_resampler:
# TODO(long): flush the resampler when the input source is changed
yield from self._input_resampler.push(frame)
else:
yield frame
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, cast
from google import genai
from google.auth._default_async import default_async
from google.genai import types
from google.genai.errors import APIError, ClientError, ServerError
from livekit.agents import APIConnectionError, APIStatusError, llm, utils
from livekit.agents.llm import FunctionTool, ToolChoice, utils as llm_utils
from livekit.agents.llm.tool_context import get_function_info
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import ChatModels
from .utils import to_chat_ctx, to_fnc_ctx, to_response_format
@dataclass
class _LLMOptions:
model: ChatModels | str
temperature: NotGivenOr[float]
tool_choice: NotGivenOr[ToolChoice]
vertexai: NotGivenOr[bool]
project: NotGivenOr[str]
location: NotGivenOr[str]
max_output_tokens: NotGivenOr[int]
top_p: NotGivenOr[float]
top_k: NotGivenOr[float]
presence_penalty: NotGivenOr[float]
frequency_penalty: NotGivenOr[float]
thinking_config: NotGivenOr[types.ThinkingConfigOrDict]
class LLM(llm.LLM):
def __init__(
self,
*,
model: ChatModels | str = "gemini-2.0-flash-001",
api_key: NotGivenOr[str] = NOT_GIVEN,
vertexai: NotGivenOr[bool] = False,
project: NotGivenOr[str] = NOT_GIVEN,
location: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
max_output_tokens: NotGivenOr[int] = NOT_GIVEN,
top_p: NotGivenOr[float] = NOT_GIVEN,
top_k: NotGivenOr[float] = NOT_GIVEN,
presence_penalty: NotGivenOr[float] = NOT_GIVEN,
frequency_penalty: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
thinking_config: NotGivenOr[types.ThinkingConfigOrDict] = NOT_GIVEN,
) -> None:
"""
Create a new instance of Google GenAI LLM.
Environment Requirements:
- For VertexAI: Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of the service account key file.
The Google Cloud project and location can be set via `project` and `location` arguments or the environment variables
`GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`. By default, the project is inferred from the service account key file,
and the location defaults to "us-central1".
- For Google Gemini API: Set the `api_key` argument or the `GOOGLE_API_KEY` environment variable.
Args:
model (ChatModels | str, optional): The model name to use. Defaults to "gemini-2.0-flash-001".
api_key (str, optional): The API key for Google Gemini. If not provided, it attempts to read from the `GOOGLE_API_KEY` environment variable.
vertexai (bool, optional): Whether to use VertexAI. Defaults to False.
project (str, optional): The Google Cloud project to use (only for VertexAI). Defaults to None.
location (str, optional): The location to use for VertexAI API requests. Defaults value is "us-central1".
temperature (float, optional): Sampling temperature for response generation. Defaults to 0.8.
max_output_tokens (int, optional): Maximum number of tokens to generate in the output. Defaults to None.
top_p (float, optional): The nucleus sampling probability for response generation. Defaults to None.
top_k (int, optional): The top-k sampling value for response generation. Defaults to None.
presence_penalty (float, optional): Penalizes the model for generating previously mentioned concepts. Defaults to None.
frequency_penalty (float, optional): Penalizes the model for repeating words. Defaults to None.
tool_choice (ToolChoice, optional): Specifies whether to use tools during response generation. Defaults to "auto".
thinking_config (ThinkingConfigOrDict, optional): The thinking configuration for response generation. Defaults to None.
""" # noqa: E501
super().__init__()
gcp_project = project if is_given(project) else os.environ.get("GOOGLE_CLOUD_PROJECT")
gcp_location = location if is_given(location) else os.environ.get("GOOGLE_CLOUD_LOCATION")
gemini_api_key = api_key if is_given(api_key) else os.environ.get("GOOGLE_API_KEY")
_gac = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if _gac is None:
logger.warning(
"`GOOGLE_APPLICATION_CREDENTIALS` environment variable is not set. please set it to the path of the service account key file. Otherwise, use any of the other Google Cloud auth methods." # noqa: E501
)
if is_given(vertexai) and vertexai:
if not gcp_project:
_, gcp_project = default_async(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
gemini_api_key = None # VertexAI does not require an API key
else:
gcp_project = None
gcp_location = None
if not gemini_api_key:
raise ValueError(
"API key is required for Google API either via api_key or GOOGLE_API_KEY environment variable" # noqa: E501
)
# Validate thinking_config
if is_given(thinking_config):
_thinking_budget = None
if isinstance(thinking_config, dict):
_thinking_budget = thinking_config.get("thinking_budget")
elif isinstance(thinking_config, types.ThinkingConfig):
_thinking_budget = thinking_config.thinking_budget
if _thinking_budget is not None:
if not isinstance(_thinking_budget, int):
raise ValueError("thinking_budget inside thinking_config must be an integer")
if not (0 <= _thinking_budget <= 24576):
raise ValueError(
"thinking_budget inside thinking_config must be between 0 and 24576"
)
self._opts = _LLMOptions(
model=model,
temperature=temperature,
tool_choice=tool_choice,
vertexai=vertexai,
project=project,
location=location,
max_output_tokens=max_output_tokens,
top_p=top_p,
top_k=top_k,
presence_penalty=presence_penalty,
frequency_penalty=frequency_penalty,
thinking_config=thinking_config,
)
self._client = genai.Client(
api_key=gemini_api_key,
vertexai=is_given(vertexai) and vertexai,
project=gcp_project,
location=gcp_location,
)
def chat(
self,
*,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
response_format: NotGivenOr[
types.SchemaUnion | type[llm_utils.ResponseFormatT]
] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
extra = {}
if is_given(extra_kwargs):
extra.update(extra_kwargs)
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice
if is_given(tool_choice):
gemini_tool_choice: types.ToolConfig
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
gemini_tool_choice = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[tool_choice["function"]["name"]],
)
)
extra["tool_config"] = gemini_tool_choice
elif tool_choice == "required":
gemini_tool_choice = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[get_function_info(fnc).name for fnc in tools]
if tools
else None,
)
)
extra["tool_config"] = gemini_tool_choice
elif tool_choice == "auto":
gemini_tool_choice = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode="AUTO",
)
)
extra["tool_config"] = gemini_tool_choice
elif tool_choice == "none":
gemini_tool_choice = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode="NONE",
)
)
extra["tool_config"] = gemini_tool_choice
if is_given(response_format):
extra["response_schema"] = to_response_format(response_format)
extra["response_mime_type"] = "application/json"
if is_given(self._opts.temperature):
extra["temperature"] = self._opts.temperature
if is_given(self._opts.max_output_tokens):
extra["max_output_tokens"] = self._opts.max_output_tokens
if is_given(self._opts.top_p):
extra["top_p"] = self._opts.top_p
if is_given(self._opts.top_k):
extra["top_k"] = self._opts.top_k
if is_given(self._opts.presence_penalty):
extra["presence_penalty"] = self._opts.presence_penalty
if is_given(self._opts.frequency_penalty):
extra["frequency_penalty"] = self._opts.frequency_penalty
# Add thinking config if thinking_budget is provided
if is_given(self._opts.thinking_config):
extra["thinking_config"] = self._opts.thinking_config
return LLMStream(
self,
client=self._client,
model=self._opts.model,
chat_ctx=chat_ctx,
tools=tools,
conn_options=conn_options,
extra_kwargs=extra,
)
class LLMStream(llm.LLMStream):
def __init__(
self,
llm: LLM,
*,
client: genai.Client,
model: str | ChatModels,
chat_ctx: llm.ChatContext,
conn_options: APIConnectOptions,
tools: list[FunctionTool] | None,
extra_kwargs: dict[str, Any],
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._client = client
self._model = model
self._llm: LLM = llm
self._extra_kwargs = extra_kwargs
async def _run(self) -> None:
retryable = True
request_id = utils.shortuuid()
try:
turns, system_instruction = to_chat_ctx(self._chat_ctx, id(self._llm))
function_declarations = to_fnc_ctx(self._tools)
if function_declarations:
self._extra_kwargs["tools"] = [
types.Tool(function_declarations=function_declarations)
]
config = types.GenerateContentConfig(
system_instruction=system_instruction,
**self._extra_kwargs,
)
stream = await self._client.aio.models.generate_content_stream(
model=self._model,
contents=cast(types.ContentListUnion, turns),
config=config,
)
async for response in stream:
if response.prompt_feedback:
raise APIStatusError(
response.prompt_feedback.json(),
retryable=False,
request_id=request_id,
)
if (
not response.candidates
or not response.candidates[0].content
or not response.candidates[0].content.parts
):
raise APIStatusError(
"No candidates in the response",
retryable=True,
request_id=request_id,
)
if len(response.candidates) > 1:
logger.warning(
"gemini llm: there are multiple candidates in the response, returning response from the first one." # noqa: E501
)
for part in response.candidates[0].content.parts:
chat_chunk = self._parse_part(request_id, part)
if chat_chunk is not None:
retryable = False
self._event_ch.send_nowait(chat_chunk)
if response.usage_metadata is not None:
usage = response.usage_metadata
self._event_ch.send_nowait(
llm.ChatChunk(
id=request_id,
usage=llm.CompletionUsage(
completion_tokens=usage.candidates_token_count or 0,
prompt_tokens=usage.prompt_token_count or 0,
total_tokens=usage.total_token_count or 0,
),
)
)
except ClientError as e:
raise APIStatusError(
"gemini llm: client error",
status_code=e.code,
body=f"{e.message} {e.status}",
request_id=request_id,
retryable=False if e.code != 429 else True,
) from e
except ServerError as e:
raise APIStatusError(
"gemini llm: server error",
status_code=e.code,
body=f"{e.message} {e.status}",
request_id=request_id,
retryable=retryable,
) from e
except APIError as e:
raise APIStatusError(
"gemini llm: api error",
status_code=e.code,
body=f"{e.message} {e.status}",
request_id=request_id,
retryable=retryable,
) from e
except Exception as e:
raise APIConnectionError(
f"gemini llm: error generating content {str(e)}",
retryable=retryable,
) from e
def _parse_part(self, id: str, part: types.Part) -> llm.ChatChunk | None:
if part.function_call:
chat_chunk = llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
role="assistant",
tool_calls=[
llm.FunctionToolCall(
arguments=json.dumps(part.function_call.args),
name=part.function_call.name,
call_id=part.function_call.id or utils.shortuuid("function_call_"),
)
],
content=part.text,
),
)
return chat_chunk
return llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(content=part.text, role="assistant"),
)
import logging
logger = logging.getLogger("livekit.plugins.google")
from typing import Literal
# Speech to Text v2
SpeechModels = Literal[
"long",
"short",
"telephony",
"medical_dictation",
"medical_conversation",
"chirp",
"chirp_2",
"latest_long",
"latest_short",
]
SpeechLanguages = Literal[
"en-US",
"ja-JP",
"en-IN",
"en-GB",
"hi-IN",
"af-ZA",
"sq-AL",
"am-ET",
"ar-EG",
"hy-AM",
"ast-ES",
"az-AZ",
"eu-ES",
"be-BY",
"bs-BA",
"bg-BG",
"my-MM",
"ca-ES",
"ceb-PH",
"ckb-IQ",
"zh-Hans-CN",
"yue-Hant-HK",
"zh-TW",
"hr-HR",
"cs-CZ",
"da-DK",
"nl-NL",
"en-AU",
"et-EE",
"fil-PH",
"fi-FI",
"fr-CA",
"fr-FR",
"gl-ES",
"ka-GE",
"de-DE",
"el-GR",
"gu-IN",
"ha-NG",
"iw-IL",
"hi-IN",
"hu-HU",
"is-IS",
"id-ID",
"it-IT",
"ja-JP",
"jv-ID",
"kea-CV",
"kam-KE",
"kn-IN",
"kk-KZ",
"km-KH",
"ko-KR",
"ky-KG",
"lo-LA",
"lv-LV",
"ln-CD",
"lt-LT",
"luo-KE",
"lb-LU",
"mk-MK",
"no-NO",
"pl-PL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"es-CO",
"es-MX",
"es-US",
"th-TH",
"tr-TR",
"uk-UA",
"vi-VN",
"da-DK",
]
Gender = Literal["male", "female", "neutral"]
ChatModels = Literal[
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-pro-exp-02-05",
"gemini-1.5-pro",
]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import time
import weakref
from dataclasses import dataclass
from typing import Callable, Union
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import DeadlineExceeded, GoogleAPICallError
from google.auth import default as gauth_default
from google.auth.exceptions import DefaultCredentialsError
from google.cloud.speech_v2 import SpeechAsyncClient
from google.cloud.speech_v2.types import cloud_speech
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import SpeechLanguages, SpeechModels
LgType = Union[SpeechLanguages, str]
LanguageCode = Union[LgType, list[LgType]]
# Google STT has a timeout of 5 mins, we'll attempt to restart the session
# before that timeout is reached
_max_session_duration = 240
# Google is very sensitive to background noise, so we'll ignore results with low confidence
_min_confidence = 0.65
# This class is only be used internally to encapsulate the options
@dataclass
class STTOptions:
languages: list[LgType]
detect_language: bool
interim_results: bool
punctuate: bool
spoken_punctuation: bool
model: SpeechModels | str
sample_rate: int
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN
def build_adaptation(self) -> cloud_speech.SpeechAdaptation | None:
if is_given(self.keywords):
return cloud_speech.SpeechAdaptation(
phrase_sets=[
cloud_speech.SpeechAdaptation.AdaptationPhraseSet(
inline_phrase_set=cloud_speech.PhraseSet(
phrases=[
cloud_speech.PhraseSet.Phrase(value=keyword, boost=boost)
for keyword, boost in self.keywords
]
)
)
]
)
return None
class STT(stt.STT):
def __init__(
self,
*,
languages: LanguageCode = "en-US", # Google STT can accept multiple languages
detect_language: bool = True,
interim_results: bool = True,
punctuate: bool = True,
spoken_punctuation: bool = False,
model: SpeechModels | str = "latest_long",
location: str = "global",
sample_rate: int = 16000,
credentials_info: NotGivenOr[dict] = NOT_GIVEN,
credentials_file: NotGivenOr[str] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
):
"""
Create a new instance of Google STT.
Credentials must be provided, either by using the ``credentials_info`` dict, or reading
from the file specified in ``credentials_file`` or via Application Default Credentials as
described in https://cloud.google.com/docs/authentication/application-default-credentials
args:
languages(LanguageCode): list of language codes to recognize (default: "en-US")
detect_language(bool): whether to detect the language of the audio (default: True)
interim_results(bool): whether to return interim results (default: True)
punctuate(bool): whether to punctuate the audio (default: True)
spoken_punctuation(bool): whether to use spoken punctuation (default: False)
model(SpeechModels): the model to use for recognition default: "latest_long"
location(str): the location to use for recognition default: "global"
sample_rate(int): the sample rate of the audio default: 16000
credentials_info(dict): the credentials info to use for recognition (default: None)
credentials_file(str): the credentials file to use for recognition (default: None)
keywords(List[tuple[str, float]]): list of keywords to recognize (default: None)
"""
super().__init__(capabilities=stt.STTCapabilities(streaming=True, interim_results=True))
self._location = location
self._credentials_info = credentials_info
self._credentials_file = credentials_file
if not is_given(credentials_file) and not is_given(credentials_info):
try:
gauth_default()
except DefaultCredentialsError:
raise ValueError(
"Application default credentials must be available "
"when using Google STT without explicitly passing "
"credentials through credentials_info or credentials_file."
) from None
if isinstance(languages, str):
languages = [languages]
self._config = STTOptions(
languages=languages,
detect_language=detect_language,
interim_results=interim_results,
punctuate=punctuate,
spoken_punctuation=spoken_punctuation,
model=model,
sample_rate=sample_rate,
keywords=keywords,
)
self._streams = weakref.WeakSet[SpeechStream]()
self._pool = utils.ConnectionPool[SpeechAsyncClient](
max_session_duration=_max_session_duration,
connect_cb=self._create_client,
)
async def _create_client(self) -> SpeechAsyncClient:
# Add support for passing a specific location that matches recognizer
# see: https://cloud.google.com/speech-to-text/v2/docs/speech-to-text-supported-languages
client_options = None
client: SpeechAsyncClient | None = None
if self._location != "global":
client_options = ClientOptions(api_endpoint=f"{self._location}-speech.googleapis.com")
if is_given(self._credentials_info):
client = SpeechAsyncClient.from_service_account_info(
self._credentials_info, client_options=client_options
)
elif is_given(self._credentials_file):
client = SpeechAsyncClient.from_service_account_file(
self._credentials_file, client_options=client_options
)
else:
client = SpeechAsyncClient(client_options=client_options)
assert client is not None
return client
def _get_recognizer(self, client: SpeechAsyncClient) -> str:
# TODO(theomonnom): should we use recognizers?
# recognizers may improve latency https://cloud.google.com/speech-to-text/v2/docs/recognizers#understand_recognizers
# TODO(theomonnom): find a better way to access the project_id
try:
project_id = client.transport._credentials.project_id # type: ignore
except AttributeError:
from google.auth import default as ga_default
_, project_id = ga_default()
return f"projects/{project_id}/locations/{self._location}/recognizers/_"
def _sanitize_options(self, *, language: NotGivenOr[str] = NOT_GIVEN) -> STTOptions:
config = dataclasses.replace(self._config)
if is_given(language):
config.languages = [language]
if not isinstance(config.languages, list):
config.languages = [config.languages]
elif not config.detect_language:
if len(config.languages) > 1:
logger.warning("multiple languages provided, but language detection is disabled")
config.languages = [config.languages[0]]
return config
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[SpeechLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
config = self._sanitize_options(language=language)
frame = rtc.combine_audio_frames(buffer)
config = cloud_speech.RecognitionConfig(
explicit_decoding_config=cloud_speech.ExplicitDecodingConfig(
encoding=cloud_speech.ExplicitDecodingConfig.AudioEncoding.LINEAR16,
sample_rate_hertz=frame.sample_rate,
audio_channel_count=frame.num_channels,
),
adaptation=config.build_adaptation(),
features=cloud_speech.RecognitionFeatures(
enable_automatic_punctuation=config.punctuate,
enable_spoken_punctuation=config.spoken_punctuation,
enable_word_time_offsets=True,
),
model=config.model,
language_codes=config.languages,
)
try:
async with self._pool.connection() as client:
raw = await client.recognize(
cloud_speech.RecognizeRequest(
recognizer=self._get_recognizer(client),
config=config,
content=frame.data.tobytes(),
),
timeout=conn_options.timeout,
)
return _recognize_response_to_speech_event(raw)
except DeadlineExceeded:
raise APITimeoutError() from None
except GoogleAPICallError as e:
raise APIStatusError(e.message, status_code=e.code or -1) from None
except Exception as e:
raise APIConnectionError() from e
def stream(
self,
*,
language: NotGivenOr[SpeechLanguages | str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = self._sanitize_options(language=language)
stream = SpeechStream(
stt=self,
pool=self._pool,
recognizer_cb=self._get_recognizer,
config=config,
conn_options=conn_options,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
languages: NotGivenOr[LanguageCode] = NOT_GIVEN,
detect_language: NotGivenOr[bool] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
spoken_punctuation: NotGivenOr[bool] = NOT_GIVEN,
model: NotGivenOr[SpeechModels] = NOT_GIVEN,
location: NotGivenOr[str] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
):
if is_given(languages):
if isinstance(languages, str):
languages = [languages]
self._config.languages = languages
if is_given(detect_language):
self._config.detect_language = detect_language
if is_given(interim_results):
self._config.interim_results = interim_results
if is_given(punctuate):
self._config.punctuate = punctuate
if is_given(spoken_punctuation):
self._config.spoken_punctuation = spoken_punctuation
if is_given(model):
self._config.model = model
if is_given(location):
self._location = location
# if location is changed, fetch a new client and recognizer as per the new location
self._pool.invalidate()
if is_given(keywords):
self._config.keywords = keywords
for stream in self._streams:
stream.update_options(
languages=languages,
detect_language=detect_language,
interim_results=interim_results,
punctuate=punctuate,
spoken_punctuation=spoken_punctuation,
model=model,
keywords=keywords,
)
async def aclose(self) -> None:
await self._pool.aclose()
await super().aclose()
class SpeechStream(stt.SpeechStream):
def __init__(
self,
*,
stt: STT,
conn_options: APIConnectOptions,
pool: utils.ConnectionPool[SpeechAsyncClient],
recognizer_cb: Callable[[SpeechAsyncClient], str],
config: STTOptions,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=config.sample_rate)
self._pool = pool
self._recognizer_cb = recognizer_cb
self._config = config
self._reconnect_event = asyncio.Event()
self._session_connected_at: float = 0
def update_options(
self,
*,
languages: NotGivenOr[LanguageCode] = NOT_GIVEN,
detect_language: NotGivenOr[bool] = NOT_GIVEN,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
punctuate: NotGivenOr[bool] = NOT_GIVEN,
spoken_punctuation: NotGivenOr[bool] = NOT_GIVEN,
model: NotGivenOr[SpeechModels] = NOT_GIVEN,
keywords: NotGivenOr[list[tuple[str, float]]] = NOT_GIVEN,
):
if is_given(languages):
if isinstance(languages, str):
languages = [languages]
self._config.languages = languages
if is_given(detect_language):
self._config.detect_language = detect_language
if is_given(interim_results):
self._config.interim_results = interim_results
if is_given(punctuate):
self._config.punctuate = punctuate
if is_given(spoken_punctuation):
self._config.spoken_punctuation = spoken_punctuation
if is_given(model):
self._config.model = model
if is_given(keywords):
self._config.keywords = keywords
self._reconnect_event.set()
async def _run(self) -> None:
# google requires a async generator when calling streaming_recognize
# this function basically convert the queue into a async generator
async def input_generator(client: SpeechAsyncClient, should_stop: asyncio.Event):
try:
# first request should contain the config
yield cloud_speech.StreamingRecognizeRequest(
recognizer=self._recognizer_cb(client),
streaming_config=self._streaming_config,
)
async for frame in self._input_ch:
# when the stream is aborted due to reconnect, this input_generator
# needs to stop consuming frames
# when the generator stops, the previous gRPC stream will close
if should_stop.is_set():
return
if isinstance(frame, rtc.AudioFrame):
yield cloud_speech.StreamingRecognizeRequest(audio=frame.data.tobytes())
except Exception:
logger.exception("an error occurred while streaming input to google STT")
async def process_stream(client: SpeechAsyncClient, stream):
has_started = False
async for resp in stream:
if (
resp.speech_event_type
== cloud_speech.StreamingRecognizeResponse.SpeechEventType.SPEECH_ACTIVITY_BEGIN
):
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
has_started = True
if (
resp.speech_event_type
== cloud_speech.StreamingRecognizeResponse.SpeechEventType.SPEECH_EVENT_TYPE_UNSPECIFIED # noqa: E501
):
result = resp.results[0]
speech_data = _streaming_recognize_response_to_speech_data(resp)
if speech_data is None:
continue
if not result.is_final:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[speech_data],
)
)
else:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[speech_data],
)
)
if time.time() - self._session_connected_at > _max_session_duration:
logger.debug(
"Google STT maximum connection time reached. Reconnecting..."
)
self._pool.remove(client)
if has_started:
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
has_started = False
self._reconnect_event.set()
return
if (
resp.speech_event_type
== cloud_speech.StreamingRecognizeResponse.SpeechEventType.SPEECH_ACTIVITY_END
):
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
has_started = False
while True:
try:
async with self._pool.connection() as client:
self._streaming_config = cloud_speech.StreamingRecognitionConfig(
config=cloud_speech.RecognitionConfig(
explicit_decoding_config=cloud_speech.ExplicitDecodingConfig(
encoding=cloud_speech.ExplicitDecodingConfig.AudioEncoding.LINEAR16,
sample_rate_hertz=self._config.sample_rate,
audio_channel_count=1,
),
adaptation=self._config.build_adaptation(),
language_codes=self._config.languages,
model=self._config.model,
features=cloud_speech.RecognitionFeatures(
enable_automatic_punctuation=self._config.punctuate,
enable_word_time_offsets=True,
),
),
streaming_features=cloud_speech.StreamingRecognitionFeatures(
interim_results=self._config.interim_results,
),
)
should_stop = asyncio.Event()
stream = await client.streaming_recognize(
requests=input_generator(client, should_stop),
)
self._session_connected_at = time.time()
process_stream_task = asyncio.create_task(process_stream(client, stream))
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[process_stream_task, wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(process_stream_task, wait_reconnect_task)
should_stop.set()
except DeadlineExceeded:
raise APITimeoutError() from None
except GoogleAPICallError as e:
raise APIStatusError(e.message, status_code=e.code or -1) from None
except Exception as e:
raise APIConnectionError() from e
def _recognize_response_to_speech_event(
resp: cloud_speech.RecognizeResponse,
) -> stt.SpeechEvent:
text = ""
confidence = 0.0
for result in resp.results:
text += result.alternatives[0].transcript
confidence += result.alternatives[0].confidence
# not sure why start_offset and end_offset returns a timedelta
start_offset = resp.results[0].alternatives[0].words[0].start_offset
end_offset = resp.results[-1].alternatives[0].words[-1].end_offset
confidence /= len(resp.results)
lg = resp.results[0].language_code
return stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=lg,
start_time=start_offset.total_seconds(), # type: ignore
end_time=end_offset.total_seconds(), # type: ignore
confidence=confidence,
text=text,
)
],
)
def _streaming_recognize_response_to_speech_data(
resp: cloud_speech.StreamingRecognizeResponse,
) -> stt.SpeechData | None:
text = ""
confidence = 0.0
for result in resp.results:
if len(result.alternatives) == 0:
continue
text += result.alternatives[0].transcript
confidence += result.alternatives[0].confidence
confidence /= len(resp.results)
lg = resp.results[0].language_code
if confidence < _min_confidence:
return None
if text == "":
return None
data = stt.SpeechData(language=lg, start_time=0, end_time=0, confidence=confidence, text=text)
return data
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from dataclasses import dataclass
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import DeadlineExceeded, GoogleAPICallError
from google.cloud import texttospeech
from google.cloud.texttospeech_v1.types import SsmlVoiceGender, SynthesizeSpeechResponse
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .models import Gender, SpeechLanguages
@dataclass
class _TTSOptions:
voice: texttospeech.VoiceSelectionParams
audio_config: texttospeech.AudioConfig
class TTS(tts.TTS):
def __init__(
self,
*,
language: NotGivenOr[SpeechLanguages | str] = NOT_GIVEN,
gender: NotGivenOr[Gender | str] = NOT_GIVEN,
voice_name: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 24000,
pitch: int = 0,
effects_profile_id: str = "",
speaking_rate: float = 1.0,
location: str = "global",
credentials_info: NotGivenOr[dict] = NOT_GIVEN,
credentials_file: NotGivenOr[str] = NOT_GIVEN,
) -> None:
"""
Create a new instance of Google TTS.
Credentials must be provided, either by using the ``credentials_info`` dict, or reading
from the file specified in ``credentials_file`` or the ``GOOGLE_APPLICATION_CREDENTIALS``
environmental variable.
Args:
language (SpeechLanguages | str, optional): Language code (e.g., "en-US"). Default is "en-US".
gender (Gender | str, optional): Voice gender ("male", "female", "neutral"). Default is "neutral".
voice_name (str, optional): Specific voice name. Default is an empty string.
sample_rate (int, optional): Audio sample rate in Hz. Default is 24000.
location (str, optional): Location for the TTS client. Default is "global".
pitch (float, optional): Speaking pitch, ranging from -20.0 to 20.0 semitones relative to the original pitch. Default is 0.
effects_profile_id (str): Optional identifier for selecting audio effects profiles to apply to the synthesized speech.
speaking_rate (float, optional): Speed of speech. Default is 1.0.
credentials_info (dict, optional): Dictionary containing Google Cloud credentials. Default is None.
credentials_file (str, optional): Path to the Google Cloud credentials JSON file. Default is None.
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=sample_rate,
num_channels=1,
)
self._client: texttospeech.TextToSpeechAsyncClient | None = None
self._credentials_info = credentials_info
self._credentials_file = credentials_file
self._location = location
lang = language if is_given(language) else "en-US"
ssml_gender = _gender_from_str("neutral" if not is_given(gender) else gender)
name = "" if not is_given(voice_name) else voice_name
voice_params = texttospeech.VoiceSelectionParams(
name=name,
language_code=lang,
ssml_gender=ssml_gender,
)
self._opts = _TTSOptions(
voice=voice_params,
audio_config=texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.PCM,
sample_rate_hertz=sample_rate,
pitch=pitch,
effects_profile_id=effects_profile_id,
speaking_rate=speaking_rate,
),
)
def update_options(
self,
*,
language: NotGivenOr[SpeechLanguages | str] = NOT_GIVEN,
gender: NotGivenOr[Gender | str] = NOT_GIVEN,
voice_name: NotGivenOr[str] = NOT_GIVEN,
speaking_rate: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Update the TTS options.
Args:
language (SpeechLanguages | str, optional): Language code (e.g., "en-US").
gender (Gender | str, optional): Voice gender ("male", "female", "neutral").
voice_name (str, optional): Specific voice name.
speaking_rate (float, optional): Speed of speech.
""" # noqa: E501
params = {}
if is_given(language):
params["language_code"] = str(language)
if is_given(gender):
params["ssml_gender"] = _gender_from_str(str(gender))
if is_given(voice_name):
params["name"] = voice_name
if params:
self._opts.voice = texttospeech.VoiceSelectionParams(**params)
if is_given(speaking_rate):
self._opts.audio_config.speaking_rate = speaking_rate
def _ensure_client(self) -> texttospeech.TextToSpeechAsyncClient:
api_endpoint = "texttospeech.googleapis.com"
if self._location != "global":
api_endpoint = f"{self._location}-texttospeech.googleapis.com"
if self._client is None:
if self._credentials_info:
self._client = texttospeech.TextToSpeechAsyncClient.from_service_account_info(
self._credentials_info, client_options=ClientOptions(api_endpoint=api_endpoint)
)
elif self._credentials_file:
self._client = texttospeech.TextToSpeechAsyncClient.from_service_account_file(
self._credentials_file, client_options=ClientOptions(api_endpoint=api_endpoint)
)
else:
self._client = texttospeech.TextToSpeechAsyncClient(
client_options=ClientOptions(api_endpoint=api_endpoint)
)
assert self._client is not None
return self._client
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
client=self._ensure_client(),
)
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
client: texttospeech.TextToSpeechAsyncClient,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._client = opts, client
async def _run(self) -> None:
request_id = utils.shortuuid()
try:
response: SynthesizeSpeechResponse = await self._client.synthesize_speech(
input=texttospeech.SynthesisInput(text=self._input_text),
voice=self._opts.voice,
audio_config=self._opts.audio_config,
timeout=self._conn_options.timeout,
)
# Create AudioStreamDecoder for OGG format
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.audio_config.sample_rate_hertz,
num_channels=1,
)
try:
decoder.push(response.audio_content)
decoder.end_input()
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
finally:
await decoder.aclose()
except DeadlineExceeded:
raise APITimeoutError() from None
except GoogleAPICallError as e:
raise APIStatusError(
e.message, status_code=e.code or -1, request_id=None, body=None
) from None
except Exception as e:
raise APIConnectionError() from e
def _gender_from_str(gender: str) -> SsmlVoiceGender:
ssml_gender = SsmlVoiceGender.NEUTRAL
if gender == "male":
ssml_gender = SsmlVoiceGender.MALE
elif gender == "female":
ssml_gender = SsmlVoiceGender.FEMALE
return ssml_gender # type: ignore
from __future__ import annotations
import json
import re
from copy import deepcopy
from typing import Any
from pydantic import TypeAdapter
from google.genai import types
from livekit.agents import llm
from livekit.agents.llm import FunctionTool, utils as llm_utils
from .log import logger
__all__ = ["to_chat_ctx", "to_fnc_ctx"]
def to_fnc_ctx(fncs: list[FunctionTool]) -> list[types.FunctionDeclaration]:
return [_build_gemini_fnc(fnc) for fnc in fncs]
def get_tool_results_for_realtime(chat_ctx: llm.ChatContext) -> types.LiveClientToolResponse | None:
function_responses: list[types.FunctionResponse] = []
for msg in chat_ctx.items:
if msg.type == "function_call_output":
function_responses.append(
types.FunctionResponse(
id=msg.call_id,
name=msg.name,
response={"output": msg.output},
)
)
return (
types.LiveClientToolResponse(function_responses=function_responses)
if function_responses
else None
)
def to_chat_ctx(
chat_ctx: llm.ChatContext, cache_key: Any, ignore_functions: bool = False
) -> tuple[list[types.Content], types.Content | None]:
turns: list[types.Content] = []
system_instruction: types.Content | None = None
current_role: str | None = None
parts: list[types.Part] = []
for msg in chat_ctx.items:
if msg.type == "message" and msg.role == "system":
sys_parts = []
for content in msg.content:
if content and isinstance(content, str):
sys_parts.append(types.Part(text=content))
system_instruction = types.Content(parts=sys_parts)
continue
if msg.type == "message":
role = "model" if msg.role == "assistant" else "user"
elif msg.type == "function_call":
role = "model"
elif msg.type == "function_call_output":
role = "user"
# if the effective role changed, finalize the previous turn.
if role != current_role:
if current_role is not None and parts:
turns.append(types.Content(role=current_role, parts=parts))
parts = []
current_role = role
if msg.type == "message":
for content in msg.content:
if content and isinstance(content, str):
parts.append(types.Part(text=content))
elif content and isinstance(content, dict):
parts.append(types.Part(text=json.dumps(content)))
elif isinstance(content, llm.ImageContent):
parts.append(_to_image_part(content, cache_key))
elif msg.type == "function_call" and not ignore_functions:
parts.append(
types.Part(
function_call=types.FunctionCall(
name=msg.name,
args=json.loads(msg.arguments),
)
)
)
elif msg.type == "function_call_output" and not ignore_functions:
parts.append(
types.Part(
function_response=types.FunctionResponse(
name=msg.name,
response={"text": msg.output},
)
)
)
if current_role is not None and parts:
turns.append(types.Content(role=current_role, parts=parts))
# # Gemini requires the last message to end with user's turn before they can generate
# # currently not used because to_chat_ctx should not be used to force a new generation
# if current_role != "user":
# turns.append(types.Content(role="user", parts=[types.Part(text=".")]))
return turns, system_instruction
def _to_image_part(image: llm.ImageContent, cache_key: Any) -> types.Part:
img = llm.utils.serialize_image(image)
if img.external_url:
if img.mime_type:
mime_type = img.mime_type
else:
logger.debug("No media type provided for image, defaulting to image/jpeg.")
mime_type = "image/jpeg"
return types.Part.from_uri(file_uri=img.external_url, mime_type=mime_type)
if cache_key not in image._cache:
image._cache[cache_key] = img.data_bytes
return types.Part.from_bytes(data=image._cache[cache_key], mime_type=img.mime_type)
def _build_gemini_fnc(function_tool: FunctionTool) -> types.FunctionDeclaration:
fnc = llm.utils.build_legacy_openai_schema(function_tool, internally_tagged=True)
json_schema = _GeminiJsonSchema(fnc["parameters"]).simplify()
return types.FunctionDeclaration(
name=fnc["name"],
description=fnc["description"],
parameters=json_schema,
)
def to_response_format(response_format: type | dict) -> types.SchemaUnion:
_, json_schema_type = llm_utils.to_response_format_param(response_format)
if isinstance(json_schema_type, TypeAdapter):
schema = json_schema_type.json_schema()
else:
schema = json_schema_type.model_json_schema()
return _GeminiJsonSchema(schema).simplify()
class _GeminiJsonSchema:
"""
Transforms the JSON Schema from Pydantic to be suitable for Gemini.
based on pydantic-ai implementation
https://github.com/pydantic/pydantic-ai/blob/085a9542a7360b7e388ce575323ce189b397d7ad/pydantic_ai_slim/pydantic_ai/models/gemini.py#L809
"""
# Type mapping from JSON Schema to Gemini Schema
TYPE_MAPPING: dict[str, types.Type] = {
"string": types.Type.STRING,
"number": types.Type.NUMBER,
"integer": types.Type.INTEGER,
"boolean": types.Type.BOOLEAN,
"array": types.Type.ARRAY,
"object": types.Type.OBJECT,
}
def __init__(self, schema: dict[str, Any]):
self.schema = deepcopy(schema)
self.defs = self.schema.pop("$defs", {})
def simplify(self) -> dict[str, Any] | None:
self._simplify(self.schema, refs_stack=())
# If the schema is an OBJECT with no properties, return None.
if self.schema.get("type") == types.Type.OBJECT and not self.schema.get("properties"):
return None
return self.schema
def _simplify(self, schema: dict[str, Any], refs_stack: tuple[str, ...]) -> None:
schema.pop("title", None)
schema.pop("default", None)
schema.pop("additionalProperties", None)
if ref := schema.pop("$ref", None):
key = re.sub(r"^#/\$defs/", "", ref)
if key in refs_stack:
raise ValueError("Recursive `$ref`s in JSON Schema are not supported by Gemini")
refs_stack += (key,)
schema_def = self.defs[key]
self._simplify(schema_def, refs_stack)
schema.update(schema_def)
return
# Convert type value to Gemini format
if "type" in schema and schema["type"] != "null":
json_type = schema["type"]
if json_type in self.TYPE_MAPPING:
schema["type"] = self.TYPE_MAPPING[json_type]
elif isinstance(json_type, types.Type):
schema["type"] = json_type
else:
raise ValueError(f"Unsupported type in JSON Schema: {json_type}")
# Map field names that differ between JSON Schema and Gemini
self._map_field_names(schema)
# Handle anyOf - map to any_of
if any_of := schema.pop("anyOf", None):
if any_of:
mapped_any_of = []
has_null = False
non_null_schema = None
for item_schema in any_of:
self._simplify(item_schema, refs_stack)
if item_schema == {"type": "null"}:
has_null = True
else:
non_null_schema = item_schema
mapped_any_of.append(item_schema)
if has_null and len(any_of) == 2 and non_null_schema:
schema.update(non_null_schema)
schema["nullable"] = True
else:
schema["any_of"] = mapped_any_of
type_ = schema.get("type")
if type_ == types.Type.OBJECT:
self._object(schema, refs_stack)
elif type_ == types.Type.ARRAY:
self._array(schema, refs_stack)
def _map_field_names(self, schema: dict[str, Any]) -> None:
"""Map JSON Schema field names to Gemini Schema field names."""
mappings = {
"minLength": "min_length",
"maxLength": "max_length",
"minItems": "min_items",
"maxItems": "max_items",
"minProperties": "min_properties",
"maxProperties": "max_properties",
}
for json_name, gemini_name in mappings.items():
if json_name in schema:
schema[gemini_name] = schema.pop(json_name)
def _object(self, schema: dict[str, Any], refs_stack: tuple[str, ...]) -> None:
if properties := schema.get("properties"):
for value in properties.values():
self._simplify(value, refs_stack)
def _array(self, schema: dict[str, Any], refs_stack: tuple[str, ...]) -> None:
if prefix_items := schema.get("prefixItems"):
for prefix_item in prefix_items:
self._simplify(prefix_item, refs_stack)
if items_schema := schema.get("items"):
self._simplify(items_schema, refs_stack)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-google"
dynamic = ["version"]
description = "Agent Framework plugin for services from Google Cloud"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"google-auth >= 2, < 3",
"google-cloud-speech >= 2, < 3",
"google-cloud-texttospeech >= 2, < 3",
"google-genai >= 1.11.0",
"livekit-agents>=1.0.17",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/google/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Groq
Agent Framework plugin for services from Groq. Currently supporting STT, and LLM
## Installation
```bash
pip install livekit-plugins-groq
For credentials, you’ll need a Groq Cloud account and obtain the correct credentials. Credentials can be passed directly or via GROQ_API_KEY environment variable
## livekit-plugins/livekit-plugins-groq/livekit/plugins/groq/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from livekit.agents import Plugin
from .log import logger
from .services import LLM, STT
from .tts import TTS
from .version import __version__
__all__ = ["TTS", "LLM", "STT", "__version__"]
class GroqPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(GroqPlugin())
import logging
logger = logging.getLogger("livekit.plugins.groq")
from typing import Literal
# listing production models from https://console.groq.com/docs/models
STTModels = Literal[
"whisper-large-v3",
"whisper-large-v3-turbo",
"distil-whisper-large-v3-en",
]
LLMModels = Literal[
"llama3-8b-8192",
"llama3-70b-8192",
"llama-guard-3-8b",
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"meta-llama/llama-4-scout-17b-16e-instruct",
"meta-llama/llama-4-maverick-17b-128e-instruct",
"deepseek-r1-distill-llama-70b",
]
TTSModels = Literal[
"playai-tts",
"playai-tts-arabic",
]
TTSVoices = Literal[
# english voices
"Arista-PlayAI",
"Atlas-PlayAI",
"Basil-PlayAI",
"Briggs-PlayAI",
"Calum-PlayAI",
"Celeste-PlayAI",
"Cheyenne-PlayAI",
"Chip-PlayAI",
"Cillian-PlayAI",
"Deedee-PlayAI",
"Fritz-PlayAI",
"Gail-PlayAI",
"Indigo-PlayAI",
"Mamaw-PlayAI",
"Mason-PlayAI",
"Mikail-PlayAI",
"Mitch-PlayAI",
"Quinn-PlayAI",
"Thunder-PlayAI",
# arabic voices
"Nasser-PlayAI",
"Khalid-PlayAI",
"Amira-PlayAI",
"Ahmad-PlayAI",
]
from __future__ import annotations
import os
import openai
from livekit.agents.llm import ToolChoice
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from livekit.plugins.openai import LLM as OpenAILLM, STT as OpenAISTT
from .models import LLMModels, STTModels
class LLM(OpenAILLM):
def __init__(
self,
*,
model: str | LLMModels = "llama-3.3-70b-versatile",
api_key: NotGivenOr[str] = NOT_GIVEN,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
base_url: str | None = "https://api.groq.com/openai/v1",
client: openai.AsyncClient | None = None,
):
"""
Create a new instance of Groq LLM.
``api_key`` must be set to your Groq API key, either using the argument or by setting
the ``GROQ_API_KEY`` environmental variable.
"""
super().__init__(
model=model,
api_key=_get_api_key(api_key),
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
class STT(OpenAISTT):
def __init__(
self,
*,
model: STTModels | str = "whisper-large-v3-turbo",
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: str = "https://api.groq.com/openai/v1",
client: openai.AsyncClient | None = None,
language: str = "en",
prompt: NotGivenOr[str] = NOT_GIVEN,
detect_language: bool = False,
):
"""
Create a new instance of Groq STT.
``api_key`` must be set to your Groq API key, either using the argument or by setting
the ``GROQ_API_KEY`` environmental variable.
"""
super().__init__(
model=model,
api_key=_get_api_key(api_key),
base_url=base_url,
client=client,
language=language,
detect_language=detect_language,
prompt=prompt,
use_realtime=False,
)
def _get_api_key(key: NotGivenOr[str]) -> str:
groq_api_key = key if is_given(key) else os.environ.get("GROQ_API_KEY")
if not groq_api_key:
raise ValueError(
"GROQ_API_KEY is required, either as argument or set GROQ_API_KEY environmental variable" # noqa: E501
)
return groq_api_key
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import TTSModels, TTSVoices
DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
@dataclass
class _TTSOptions:
model: TTSModels | str
voice: TTSVoices | str
api_key: str
base_url: str
class TTS(tts.TTS):
def __init__(
self,
*,
base_url: NotGivenOr[str] = NOT_GIVEN,
model: TTSModels | str = "playai-tts",
voice: TTSVoices | str = "Arista-PlayAI",
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""
Create a new instance of Groq TTS.
if `api_key` is not provided, it will be read from the ``GROQ_API_KEY``
environmental variable.
Args:
model (SpeechModels | str, optional): Model to use. Default is "playai-tts".
voice (SpeechVoices | str, optional): Voice to use. Default is "Autumn-PlayAI".
api_key (str | None, optional): API key to use. Default is None.
"""
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=SAMPLE_RATE,
num_channels=1,
)
self._session = http_session
if not base_url:
base_url = DEFAULT_BASE_URL
groq_api_key = api_key if is_given(api_key) else os.getenv("GROQ_API_KEY")
if not groq_api_key:
raise ValueError("GROQ_API_KEY is not set")
self._opts = _TTSOptions(
model=model,
voice=voice,
api_key=groq_api_key,
base_url=base_url,
)
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def update_options(
self,
*,
model: NotGivenOr[TTSModels] = NOT_GIVEN,
voice: NotGivenOr[TTSVoices] = NOT_GIVEN,
) -> None:
"""
Update the TTS options.
Args:
model (SpeechModels | str, optional): Model to use. Default is None.
voice (SpeechVoices | str, optional): Voice to use. Default is None.
"""
if is_given(model):
self._opts.model = model
if is_given(voice):
self._opts.voice = voice
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
segment_id: NotGivenOr[str] = NOT_GIVEN,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
segment_id=segment_id,
)
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
input_text: str,
conn_options: APIConnectOptions,
opts: _TTSOptions,
session: aiohttp.ClientSession,
segment_id: NotGivenOr[str],
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts = opts
self._session = session
self._segment_id = segment_id if is_given(segment_id) else None
async def _run(self) -> None:
request_id = utils.shortuuid()
headers = {
"Authorization": f"Bearer {self._opts.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self._opts.model,
"voice": self._opts.voice,
"input": self._input_text,
"response_format": "wav",
}
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
)
decode_task: asyncio.Task | None = None
api_url = f"{self._opts.base_url}/audio/speech"
try:
async with self._session.post(api_url, headers=headers, json=payload) as response:
if not response.content_type.startswith("audio"):
content = await response.text()
logger.error("Groq returned non-audio data: %s", content)
return
async def _decode_loop():
try:
async for bytes_data, _ in response.content.iter_chunks():
decoder.push(bytes_data)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=self._segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
# Copyright 2023 LiveKit, Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-groq"
dynamic = ["version"]
description = "Groq inference plugin for LiveKit Agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "livekit", "groq"]
classifiers = [
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents[codecs]>=1.0.17",
"livekit-plugins-openai>=1.0.0.dev5",
"aiohttp",
"livekit",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/groq/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Hume AI TTS
LiveKit Agents Framework plugin for [Hume](https://www.hume.ai/) Text-to-Speech API.
## Installation
```bash
pip install livekit-plugins-hume
You will need an API Key from Hume, it can be set as an environment variable: HUME_API_KEY
. You can get it from here
## livekit-plugins/livekit-plugins-hume/livekit/plugins/hume/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
__version__ = "1.0.0"
# make imports available
from hume.tts import (
Format,
PostedContext,
PostedUtterance,
PostedUtteranceVoiceWithId,
PostedUtteranceVoiceWithName,
)
from livekit.agents import Plugin
from .tts import TTS
# all exports
__all__ = [
"TTS",
"Format",
"PostedUtterance",
"PostedContext",
"PostedUtteranceVoiceWithName",
"PostedUtteranceVoiceWithId",
]
class HumeAIPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(HumeAIPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.hume")
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import os
from dataclasses import dataclass
import aiohttp
from hume import AsyncHumeClient
from hume.tts import Format, FormatWav, PostedContext, PostedUtterance, PostedUtteranceVoiceWithName
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
# Default audio settings
DEFAULT_SAMPLE_RATE = 24000
DEFAULT_NUM_CHANNELS = 1
# Default TTS settings
DEFAULT_VOICE = PostedUtteranceVoiceWithName(name="Colton Rivers", provider="HUME_AI")
# text is required in PostedUtterance but it is declared as an empty string
# it will be overwritten when input tokens are received
DEFAULT_UTTERANCE = PostedUtterance(
voice=DEFAULT_VOICE, speed=1, trailing_silence=0.35, description="", text=""
)
@dataclass
class _TTSOptions:
"""TTS options for Hume API"""
api_key: str
utterance_options: PostedUtterance
context: PostedContext | None
format: Format
sample_rate: int
split_utterances: bool
strip_headers: bool
num_generations: int
instant_mode: bool
word_tokenizer: tokenize.WordTokenizer
class TTS(tts.TTS):
def __init__(
self,
*,
utterance_options: NotGivenOr[PostedUtterance] = NOT_GIVEN,
context: NotGivenOr[PostedContext] = NOT_GIVEN,
format: NotGivenOr[Format] = NOT_GIVEN,
split_utterances: bool = False,
num_generations: int = 1,
instant_mode: bool = False,
strip_headers: bool = True,
api_key: NotGivenOr[str] = NOT_GIVEN,
word_tokenizer: tokenize.WordTokenizer | None = None,
http_session: aiohttp.ClientSession | None = None,
sample_rate: int = 24000,
) -> None:
"""Initialize the Hume TTS client.
See https://dev.hume.ai/reference/text-to-speech-tts/synthesize-json-streaming for API doc
Args:
utterance_options (NotGivenOr[PostedUtterance]): Default options for utterances,
including description, voice, and delivery controls.
context (NotGivenOr[PostedContext]): Utterances to use as context for generating
consistent speech style and prosody across multiple requests.
format (NotGivenOr[Format]): Specifies the output audio file format (WAV, MP3 or PCM).
Defaults to WAV format.
split_utterances (bool): Controls how audio output is segmented in the response.
When enabled (True), input utterances are split into natural-sounding segments.
When disabled (False), maintains one-to-one mapping between input and output.
Defaults to False.
num_generations (int): Number of generations of the audio to produce.
Must be between 1 and 5. Defaults to 1.
instant_mode (bool): Enables ultra-low latency streaming, reducing time to first chunk.
Recommended for real-time applications. Only for streaming endpoints.
With this enabled, requests incur 10% higher cost. Defaults to False.
strip_headers (bool): If enabled, the audio for all the chunks of a generation.
Once concatenated together, will constitute a single audio file.
If disabled, each chunk’s audio will be its own audio file, each with its headers.
api_key (NotGivenOr[str]): Hume API key for authentication. If not provided,
will attempt to read from HUME_API_KEY environment variable.
word_tokenizer (tokenize.WordTokenizer | None): Custom word tokenizer to use for text.
If None, a basic word tokenizer will be used.
http_session (aiohttp.ClientSession | None): Optional HTTP session for API requests.
If None, a new session will be created.
sample_rate (int): Audio sample rate in Hz. Defaults to 24000.
"""
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=sample_rate,
num_channels=DEFAULT_NUM_CHANNELS,
)
self._api_key = api_key if is_given(api_key) else os.environ.get("HUME_API_KEY")
if not self._api_key:
raise ValueError(
"Hume API key is required, either as argument or set HUME_API_KEY env variable"
)
if not word_tokenizer:
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
self._opts = _TTSOptions(
utterance_options=utterance_options
if is_given(utterance_options)
else DEFAULT_UTTERANCE,
context=context if is_given(context) else None,
format=format if is_given(format) else FormatWav(),
api_key=self._api_key,
sample_rate=self.sample_rate,
split_utterances=split_utterances,
num_generations=num_generations,
strip_headers=strip_headers,
instant_mode=instant_mode,
word_tokenizer=word_tokenizer,
)
self._client = AsyncHumeClient(api_key=self._api_key)
self._session = http_session
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def update_options(
self,
*,
utterance_options: NotGivenOr[PostedUtterance] = NOT_GIVEN,
context: NotGivenOr[PostedContext] = NOT_GIVEN,
format: NotGivenOr[Format] = NOT_GIVEN,
split_utterances: NotGivenOr[bool] = NOT_GIVEN,
num_generations: NotGivenOr[int] = NOT_GIVEN,
instant_mode: NotGivenOr[bool] = NOT_GIVEN,
strip_headers: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
"""Update TTS options for synthesizing speech.
Args:
utterance_options (NotGivenOr[PostedUtterance]): Options for utterances,
including text, description, voice, and additional controls.
context (Optional[PostedContext]): Utterances to use as context for generating
consistent speech style and prosody across multiple requests.
format (NotGivenOr[Format]): Specifies the output audio file format (WAV, MP3 or PCM).
split_utterances (NotGivenOr[bool]): Controls how audio output is segmented.
When True, utterances are split into natural-sounding segments.
When False, maintains one-to-one mapping between input and output.
num_generations (NotGivenOr[int]): Number of speech generations to produce (1-5).
instant_mode (NotGivenOr[bool]): Enables ultra-low latency streaming.
Reduces time to first audio chunk, recommended for real-time applications.
Note: Incurs 10% higher cost when enabled.
strip_headers (NotGivenOr[bool]): If enabled, the audio for the chunks of a generation.
Once concatenated together, will constitute a single audio file.
If disabled, each chunk’s audio will be its own audio file, each with its headers.
"""
if is_given(utterance_options):
# text is required in PostedUtterance but it is declared as an empty string
# it will be overwritten when input tokens are received
self._opts.utterance_options = PostedUtterance(
description=utterance_options.description if utterance_options.description else "",
voice=utterance_options.voice if utterance_options.voice else DEFAULT_VOICE,
speed=utterance_options.speed if utterance_options.speed else 1,
trailing_silence=utterance_options.trailing_silence
if utterance_options.trailing_silence
else 0.35,
text="",
)
if is_given(format):
self._opts.format = format
if is_given(context):
self._opts.context = context
if is_given(split_utterances):
self._opts.split_utterances = split_utterances
if is_given(num_generations):
self._opts.num_generations = num_generations
if is_given(instant_mode):
self._opts.instant_mode = instant_mode
if is_given(strip_headers):
self._opts.strip_headers = strip_headers
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
)
class ChunkedStream(tts.ChunkedStream):
"""Stream for Hume TTS JSON streaming API."""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts = opts
self._client = tts._client
async def _run(self) -> None:
request_id = utils.shortuuid()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=DEFAULT_NUM_CHANNELS,
)
decode_task: asyncio.Task | None = None
try:
async def _decode_loop():
try:
async for chunk in self._client.tts.synthesize_json_streaming(
utterances=[
PostedUtterance(
text=self._input_text,
description=self._opts.utterance_options.description,
voice=self._opts.utterance_options.voice,
speed=self._opts.utterance_options.speed,
trailing_silence=self._opts.utterance_options.trailing_silence,
)
],
context=self._opts.context,
format=self._opts.format,
num_generations=self._opts.num_generations,
split_utterances=self._opts.split_utterances,
instant_mode=self._opts.instant_mode,
strip_headers=self._opts.strip_headers,
):
decoder.push(base64.b64decode(chunk.audio))
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError:
raise APITimeoutError() from None
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-hume"
dynamic = ["version"]
description = "Hume TTS plugin for LiveKit agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [
{name = "LiveKit", email = "info@livekit.io"}
]
keywords = ["webrtc", "realtime", "audio", "livekit", "HumeAI", "Hume", "Octave"]
classifiers = [
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"aiohttp>=3.8.0",
"livekit-agents>=1.0.17",
"hume>=0.8.3"
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/hume/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Minimal
This is a minimal example of a LiveKit plugin for Agents.
### Developer note
When copying this directory over to create a new `livekit-plugins` package, make sure it's nested within the `livekit-plugins` folder and that the `"name"` field in `package.json` follows the proper naming convention for CI:
```json
{
"name": "livekit-plugins-<name>",
"private": true
}
## livekit-plugins/livekit-plugins-minimal/livekit/plugins/minimal/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from livekit.agents import Plugin
from .log import logger
from .version import __version__
class MinimalPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(MinimalPlugin())
import logging
logger = logging.getLogger("livekit.plugins.minimal")
# Copyright 2023 LiveKit, Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-minimal"
dynamic = ["version"]
description = "Minimal plugin template for LiveKit Agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/minimal/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Neuphonic
Agent Framework plugin for voice synthesis with [Neuphonic](https://neuphonic.com) API.
## Installation
```bash
pip install livekit-plugins-neuphonic
You’ll need an API key from Neuphonic. It can be set as an environment variable: NEUPHONIC_API_TOKEN
## livekit-plugins/livekit-plugins-neuphonic/livekit/plugins/neuphonic/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .tts import TTS, ChunkedStream
from .version import __version__
__all__ = ["TTS", "ChunkedStream", "__version__"]
from livekit.agents import Plugin
from .log import logger
class NeuphonicPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(NeuphonicPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.neuphonic")
from typing import Literal
TTSEncodings = Literal[
"pcm_linear",
"pcm_mulaw",
]
TTSModels = Literal["neu-fast", "neu-hq"]
TTSLangCodes = Literal["en", "nl", "es", "de", "hi", "en-hi", "ar"]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import json
import os
import weakref
from dataclasses import dataclass
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from .log import logger
from .models import TTSEncodings, TTSLangCodes, TTSModels
API_BASE_URL = "api.neuphonic.com"
AUTHORIZATION_HEADER = "X-API-KEY"
NUM_CHANNELS = 1
@dataclass
class _TTSOptions:
base_url: str
api_key: str
model: TTSModels | str
lang_code: TTSLangCodes | str
encoding: TTSEncodings | str
sampling_rate: int
speed: float
voice_id: NotGivenOr[str] = NOT_GIVEN
@property
def model_params(self) -> dict:
"""Returns a dictionary of model parameters for API requests."""
params = {
"voice_id": self.voice_id,
"model": self.model,
"lang_code": self.lang_code,
"encoding": self.encoding,
"sampling_rate": self.sampling_rate,
"speed": self.speed,
}
return {k: v for k, v in params.items() if is_given(v) and v is not None}
def get_query_param_string(self):
"""Forms the query parameter string from all model parameters."""
queries = []
for key, value in self.model_params.items():
queries.append(f"{key}={value}")
return "?" + "&".join(queries)
def _parse_sse_message(message: str) -> dict:
"""
Parse each response from the SSE endpoint.
The message will either be a string reading:
- `event: error`
- `event: message`
- `data: { "status_code": 200, "data": {"audio": ... } }`
"""
message = message.strip()
if not message or "data" not in message:
return None
_, value = message.split(": ", 1)
message = json.loads(value)
if message.get("errors") is not None:
raise Exception(f"Status {message.status_code} error received: {message.errors}.")
return message
class TTS(tts.TTS):
def __init__(
self,
*,
model: TTSModels | str = "neu_hq",
voice_id: NotGivenOr[str] = NOT_GIVEN,
lang_code: TTSLangCodes | str = "en",
encoding: TTSEncodings | str = "pcm_linear",
speed: float = 1.0,
sample_rate: int = 22050,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
base_url: str = API_BASE_URL,
) -> None:
"""
Create a new instance of the Neuphonic TTS.
See https://docs.neuphonic.com for more documentation on all of these options, or go to https://app.neuphonic.com/ to test out different options.
Args:
model (TTSModels | str, optional): The Neuphonic model to use. See Defaults to "neu_hq".
voice_id (str, optional): The voice ID for the desired voice. Defaults to None.
lang_code (TTSLanguages | str, optional): The language code for synthesis. Defaults to "en".
encoding (TTSEncodings | str, optional): The audio encoding format. Defaults to "pcm_mulaw".
speed (float, optional): The audio playback speed. Defaults to 1.0.
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 22050.
api_key (str | None, optional): The Neuphonic API key. If not provided, it will be read from the NEUPHONIC_API_TOKEN environment variable.
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
base_url (str, optional): The base URL for the Neuphonic API. Defaults to "api.neuphonic.com".
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
neuphonic_api_key = api_key if is_given(api_key) else os.environ.get("NEUPHONIC_API_TOKEN")
if not neuphonic_api_key:
raise ValueError("API key must be provided or set in NEUPHONIC_API_TOKEN")
self._opts = _TTSOptions(
model=model,
voice_id=voice_id,
lang_code=lang_code,
encoding=encoding,
speed=speed,
sampling_rate=sample_rate,
api_key=neuphonic_api_key,
base_url=base_url,
)
self._session = http_session
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=90,
mark_refreshed_on_get=True,
)
self._streams = weakref.WeakSet[SynthesizeStream]()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
url = f"wss://{self._opts.base_url}/speak/{self._opts.lang_code}{self._opts.get_query_param_string()}"
return await asyncio.wait_for(
session.ws_connect(url, headers={AUTHORIZATION_HEADER: self._opts.api_key}),
self._conn_options.timeout,
)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def prewarm(self) -> None:
self._pool.prewarm()
def update_options(
self,
*,
model: NotGivenOr[TTSModels] = NOT_GIVEN,
voice_id: NotGivenOr[str] = NOT_GIVEN,
lang_code: NotGivenOr[TTSLangCodes] = NOT_GIVEN,
encoding: NotGivenOr[TTSEncodings] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
) -> None:
"""
Update the Text-to-Speech (TTS) configuration options.
This method allows updating the TTS settings, including model type, voice_id, lang_code,
encoding, speed and sample_rate. If any parameter is not provided, the existing value will be
retained.
Args:
model (TTSModels | str, optional): The Neuphonic model to use.
voice_id (str, optional): The voice ID for the desired voice.
lang_code (TTSLanguages | str, optional): The language code for synthesis..
encoding (TTSEncodings | str, optional): The audio encoding format.
speed (float, optional): The audio playback speed.
sample_rate (int, optional): The audio sample rate in Hz.
""" # noqa: E501
if is_given(model):
self._opts.model = model
if is_given(voice_id):
self._opts.voice_id = voice_id
if is_given(lang_code):
self._opts.lang_code = lang_code
if is_given(encoding):
self._opts.encoding = encoding
if is_given(speed):
self._opts.speed = speed
if is_given(sample_rate):
self._opts.sampling_rate = sample_rate
self._pool.invalidate()
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
stream = SynthesizeStream(
tts=self,
pool=self._pool,
opts=self._opts,
)
self._streams.add(stream)
return stream
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
await super().aclose()
class ChunkedStream(tts.ChunkedStream):
"""Synthesize chunked text using the SSE endpoint"""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
session: aiohttp.ClientSession,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._session = opts, session
async def _run(self) -> None:
request_id = utils.shortuuid()
bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sampling_rate, num_channels=NUM_CHANNELS
)
json_data = {
"text": self._input_text,
**self._opts.model_params,
}
headers = {
AUTHORIZATION_HEADER: self._opts.api_key,
}
try:
async with self._session.post(
f"https://{self._opts.base_url}/sse/speak/{self._opts.lang_code}",
headers=headers,
json=json_data,
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=self._conn_options.timeout,
),
read_bufsize=10
* 1024
* 1024, # large read_bufsize to avoid `ValueError: Chunk too big`
) as response:
response.raise_for_status()
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for line in response.content:
message = line.decode("utf-8").strip()
if message:
parsed_message = _parse_sse_message(message)
if (
parsed_message is not None
and parsed_message.get("data", {}).get("audio") is not None
):
audio_bytes = base64.b64decode(parsed_message["data"]["audio"])
for frame in bstream.write(audio_bytes):
emitter.push(frame)
for frame in bstream.flush():
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
class SynthesizeStream(tts.SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
opts: _TTSOptions,
pool: utils.ConnectionPool[aiohttp.ClientWebSocketResponse],
):
super().__init__(tts=tts)
self._opts, self._pool = opts, pool
async def _run(self) -> None:
request_id = utils.shortuuid()
async def _send_task(ws: aiohttp.ClientWebSocketResponse):
"""Stream text to the websocket."""
async for data in self._input_ch:
self._mark_started()
if isinstance(data, self._FlushSentinel):
await ws.send_str(json.dumps({"text": "<STOP>"}))
continue
await ws.send_str(json.dumps({"text": data}))
async def _recv_task(ws: aiohttp.ClientWebSocketResponse):
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sampling_rate,
num_channels=NUM_CHANNELS,
)
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"Neuphonic connection closed unexpectedly",
request_id=request_id,
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("Unexpected Neuphonic message type %s", msg.type)
continue
data = json.loads(msg.data)
if data.get("data"):
b64data = base64.b64decode(data["data"]["audio"])
for frame in audio_bstream.write(b64data):
emitter.push(frame)
if data["data"].get("stop"): # A bool flag, is True when audio reaches "<STOP>"
for frame in audio_bstream.flush():
emitter.push(frame)
emitter.flush()
break # we are not going to receive any more audio
else:
logger.error("Unexpected Neuphonic message %s", data)
async with self._pool.connection() as ws:
tasks = [
asyncio.create_task(_send_task(ws)),
asyncio.create_task(_recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.gracefully_cancel(*tasks)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-neuphonic"
dynamic = ["version"]
description = "Neuphonic inference plugin for LiveKit Agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "livekit", "neuphonic"]
classifiers = [
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/neuphonic/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins NLTK
Agent Framework plugin for [NLTK](https://www.nltk.org/)-based text processing. Currently featuring a `SentenceTokenizer`.
## Installation
```bash
pip install livekit-plugins-nltk
## livekit-plugins/livekit-plugins-nltk/livekit/plugins/nltk/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .sentence_tokenizer import SentenceTokenizer
from .version import __version__
__all__ = ["SentenceTokenizer", "__version__"]
import nltk # type: ignore
from livekit.agents import Plugin
from .log import logger
class NltkPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
def download_files(self):
try:
_ = nltk.data.find("tokenizers/punkt_tab")
except LookupError:
nltk.download("punkt_tab")
Plugin.register_plugin(NltkPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.nltk")
from __future__ import annotations
import dataclasses
import functools
from dataclasses import dataclass
import nltk # type: ignore
from livekit import agents
# nltk is using the punkt tokenizer
# https://www.nltk.org/_modules/nltk/tokenize/punkt.html
# this code is using a whitespace to concatenate small sentences together
# (languages such as Chinese and Japanese are not yet supported)
@dataclass
class _TokenizerOptions:
language: str
min_sentence_len: int
stream_context_len: int
class SentenceTokenizer(agents.tokenize.SentenceTokenizer):
def __init__(
self,
*,
language: str = "english",
min_sentence_len: int = 20,
stream_context_len: int = 10,
) -> None:
super().__init__()
self._config = _TokenizerOptions(
language=language,
min_sentence_len=min_sentence_len,
stream_context_len=stream_context_len,
)
def _sanitize_options(self, language: str | None = None) -> _TokenizerOptions:
config = dataclasses.replace(self._config)
if language:
config.language = language
return config
def tokenize(self, text: str, *, language: str | None = None) -> list[str]:
config = self._sanitize_options(language=language)
sentences = nltk.tokenize.sent_tokenize(text, config.language)
new_sentences = []
buff = ""
for sentence in sentences:
buff += sentence + " "
if len(buff) - 1 >= config.min_sentence_len:
new_sentences.append(buff.rstrip())
buff = ""
if buff:
new_sentences.append(buff.rstrip())
return new_sentences
def stream(self, *, language: str | None = None) -> agents.tokenize.SentenceStream:
config = self._sanitize_options(language=language)
return agents.tokenize.BufferedSentenceStream(
tokenizer=functools.partial(nltk.tokenize.sent_tokenize, language=config.language),
min_token_len=self._config.min_sentence_len,
min_ctx_len=self._config.stream_context_len,
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-nltk"
dynamic = ["version"]
description = "Agent Framework plugin for NLTK-based text processing."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17", "nltk >= 3.9.1, < 4"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/nltk/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins OpenAI
Agent Framework plugin for services from OpenAI. Currently supports STT, TTS, and Dalle 3.
## Installation
```bash
pip install livekit-plugins-openai
You’ll need an API key from OpenAI. It can be set as an environment variable: OPENAI_API_KEY
In addition to LLM, STT, and TTS, this package also supports using OpenAI’s Assistants API as a LLM.
The Assistants API is a stateful API that holds the conversation state on the server-side.
The AssistantLLM
class gives you a LLM-like interface to interact with the Assistant API.
For examples of using Assistants API with VoicePipelineAssistant, see the openai assistants API example
## livekit-plugins/livekit-plugins-openai/livekit/plugins/openai/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from . import realtime
from .embeddings import EmbeddingData, create_embeddings
from .llm import LLM, LLMStream
from .models import STTModels, TTSModels, TTSVoices
from .stt import STT
from .tts import TTS
from .version import __version__
__all__ = [
"STT",
"TTS",
"LLM",
"LLMStream",
"STTModels",
# "beta",
"TTSModels",
"TTSVoices",
"create_embeddings",
"EmbeddingData",
"realtime",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class OpenAIPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(OpenAIPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
from __future__ import annotations
import base64
import os
import struct
from dataclasses import dataclass
import aiohttp
from livekit.agents import utils
from . import models
@dataclass
class EmbeddingData:
index: int
embedding: list[float]
async def create_embeddings(
*,
input: list[str],
model: models.EmbeddingModels = "text-embedding-3-small",
dimensions: int | None = None,
api_key: str | None = None,
http_session: aiohttp.ClientSession | None = None,
) -> list[EmbeddingData]:
http_session = http_session or utils.http_context.http_session()
api_key = api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY must be set")
async with http_session.post(
"https://api.openai.com/v1/embeddings",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model,
"input": input,
"encoding_format": "base64",
"dimensions": dimensions,
},
) as resp:
json = await resp.json()
data = json["data"]
list_data = []
for d in data:
bytes = base64.b64decode(d["embedding"])
num_floats = len(bytes) // 4
floats = list(struct.unpack("f" * num_floats, bytes))
list_data.append(EmbeddingData(index=d["index"], embedding=floats))
return list_data
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
import httpx
import openai
from livekit.agents import APIConnectionError, APIStatusError, APITimeoutError, llm
from livekit.agents.llm import ToolChoice, utils as llm_utils
from livekit.agents.llm.chat_context import ChatContext
from livekit.agents.llm.tool_context import FunctionTool
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
from openai.types.chat import (
ChatCompletionChunk,
ChatCompletionToolChoiceOptionParam,
completion_create_params,
)
from openai.types.chat.chat_completion_chunk import Choice
from .models import (
CerebrasChatModels,
ChatModels,
DeepSeekChatModels,
OctoChatModels,
PerplexityChatModels,
TelnyxChatModels,
TogetherChatModels,
XAIChatModels,
)
from .utils import AsyncAzureADTokenProvider, to_chat_ctx, to_fnc_ctx
@dataclass
class _LLMOptions:
model: str | ChatModels
user: NotGivenOr[str]
temperature: NotGivenOr[float]
parallel_tool_calls: NotGivenOr[bool]
tool_choice: NotGivenOr[ToolChoice]
store: NotGivenOr[bool]
metadata: NotGivenOr[dict[str, str]]
class LLM(llm.LLM):
def __init__(
self,
*,
model: str | ChatModels = "gpt-4o",
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
store: NotGivenOr[bool] = NOT_GIVEN,
metadata: NotGivenOr[dict[str, str]] = NOT_GIVEN,
timeout: httpx.Timeout | None = None,
) -> None:
"""
Create a new instance of OpenAI LLM.
``api_key`` must be set to your OpenAI API key, either using the argument or by setting the
``OPENAI_API_KEY`` environmental variable.
"""
super().__init__()
self._opts = _LLMOptions(
model=model,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
store=store,
metadata=metadata,
)
self._client = client or openai.AsyncClient(
api_key=api_key if is_given(api_key) else None,
base_url=base_url if is_given(base_url) else None,
max_retries=0,
http_client=httpx.AsyncClient(
timeout=timeout
if timeout
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
follow_redirects=True,
limits=httpx.Limits(
max_connections=50,
max_keepalive_connections=50,
keepalive_expiry=120,
),
),
)
@staticmethod
def with_azure(
*,
model: str | ChatModels = "gpt-4o",
azure_endpoint: str | None = None,
azure_deployment: str | None = None,
api_version: str | None = None,
api_key: str | None = None,
azure_ad_token: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
organization: str | None = None,
project: str | None = None,
base_url: str | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
timeout: httpx.Timeout | None = None,
) -> LLM:
"""
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `api_key` from `AZURE_OPENAI_API_KEY`
- `organization` from `OPENAI_ORG_ID`
- `project` from `OPENAI_PROJECT_ID`
- `azure_ad_token` from `AZURE_OPENAI_AD_TOKEN`
- `api_version` from `OPENAI_API_VERSION`
- `azure_endpoint` from `AZURE_OPENAI_ENDPOINT`
""" # noqa: E501
azure_client = openai.AsyncAzureOpenAI(
max_retries=0,
azure_endpoint=azure_endpoint,
azure_deployment=azure_deployment,
api_version=api_version,
api_key=api_key,
azure_ad_token=azure_ad_token,
azure_ad_token_provider=azure_ad_token_provider,
organization=organization,
project=project,
base_url=base_url,
timeout=timeout
if timeout
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
) # type: ignore
return LLM(
model=model,
client=azure_client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_cerebras(
*,
model: str | CerebrasChatModels = "llama3.1-8b",
api_key: str | None = None,
base_url: str = "https://api.cerebras.ai/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
) -> LLM:
"""
Create a new instance of Cerebras LLM.
``api_key`` must be set to your Cerebras API key, either using the argument or by setting
the ``CEREBRAS_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("CEREBRAS_API_KEY")
if api_key is None:
raise ValueError(
"Cerebras API key is required, either as argument or set CEREBAAS_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_fireworks(
*,
model: str = "accounts/fireworks/models/llama-v3p3-70b-instruct",
api_key: str | None = None,
base_url: str = "https://api.fireworks.ai/inference/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of Fireworks LLM.
``api_key`` must be set to your Fireworks API key, either using the argument or by setting
the ``FIREWORKS_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("FIREWORKS_API_KEY")
if api_key is None:
raise ValueError(
"Fireworks API key is required, either as argument or set FIREWORKS_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_x_ai(
*,
model: str | XAIChatModels = "grok-2-public",
api_key: str | None = None,
base_url: str = "https://api.x.ai/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
):
"""
Create a new instance of XAI LLM.
``api_key`` must be set to your XAI API key, either using the argument or by setting
the ``XAI_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("XAI_API_KEY")
if api_key is None:
raise ValueError(
"XAI API key is required, either as argument or set XAI_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_deepseek(
*,
model: str | DeepSeekChatModels = "deepseek-chat",
api_key: str | None = None,
base_url: str = "https://api.deepseek.com/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of DeepSeek LLM.
``api_key`` must be set to your DeepSeek API key, either using the argument or by setting
the ``DEEPSEEK_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("DEEPSEEK_API_KEY")
if api_key is None:
raise ValueError(
"DeepSeek API key is required, either as argument or set DEEPSEEK_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_octo(
*,
model: str | OctoChatModels = "llama-2-13b-chat",
api_key: str | None = None,
base_url: str = "https://text.octoai.run/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of OctoAI LLM.
``api_key`` must be set to your OctoAI API key, either using the argument or by setting
the ``OCTOAI_TOKEN`` environmental variable.
"""
api_key = api_key or os.environ.get("OCTOAI_TOKEN")
if api_key is None:
raise ValueError(
"OctoAI API key is required, either as argument or set OCTOAI_TOKEN environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_ollama(
*,
model: str = "llama3.1",
base_url: str = "http://localhost:11434/v1",
client: openai.AsyncClient | None = None,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of Ollama LLM.
"""
return LLM(
model=model,
api_key="ollama",
base_url=base_url,
client=client,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_perplexity(
*,
model: str | PerplexityChatModels = "llama-3.1-sonar-small-128k-chat",
api_key: str | None = None,
base_url: str = "https://api.perplexity.ai",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of PerplexityAI LLM.
``api_key`` must be set to your TogetherAI API key, either using the argument or by setting
the ``PERPLEXITY_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("PERPLEXITY_API_KEY")
if api_key is None:
raise ValueError(
"Perplexity AI API key is required, either as argument or set PERPLEXITY_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_together(
*,
model: str | TogetherChatModels = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
api_key: str | None = None,
base_url: str = "https://api.together.xyz/v1",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of TogetherAI LLM.
``api_key`` must be set to your TogetherAI API key, either using the argument or by setting
the ``TOGETHER_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("TOGETHER_API_KEY")
if api_key is None:
raise ValueError(
"Together AI API key is required, either as argument or set TOGETHER_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
@staticmethod
def with_telnyx(
*,
model: str | TelnyxChatModels = "meta-llama/Meta-Llama-3.1-70B-Instruct",
api_key: str | None = None,
base_url: str = "https://api.telnyx.com/v2/ai",
client: openai.AsyncClient | None = None,
user: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: ToolChoice = "auto",
) -> LLM:
"""
Create a new instance of Telnyx LLM.
``api_key`` must be set to your Telnyx API key, either using the argument or by setting
the ``TELNYX_API_KEY`` environmental variable.
"""
api_key = api_key or os.environ.get("TELNYX_API_KEY")
if api_key is None:
raise ValueError(
"Telnyx AI API key is required, either as argument or set TELNYX_API_KEY environmental variable" # noqa: E501
)
return LLM(
model=model,
api_key=api_key,
base_url=base_url,
client=client,
user=user,
temperature=temperature,
parallel_tool_calls=parallel_tool_calls,
tool_choice=tool_choice,
)
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[FunctionTool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
response_format: NotGivenOr[
completion_create_params.ResponseFormat | type[llm_utils.ResponseFormatT]
] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LLMStream:
extra = {}
if is_given(extra_kwargs):
extra.update(extra_kwargs)
if is_given(self._opts.metadata):
extra["metadata"] = self._opts.metadata
if is_given(self._opts.user):
extra["user"] = self._opts.user
parallel_tool_calls = (
parallel_tool_calls if is_given(parallel_tool_calls) else self._opts.parallel_tool_calls
)
if is_given(parallel_tool_calls):
extra["parallel_tool_calls"] = parallel_tool_calls
tool_choice = tool_choice if is_given(tool_choice) else self._opts.tool_choice # type: ignore
if is_given(tool_choice):
oai_tool_choice: ChatCompletionToolChoiceOptionParam
if isinstance(tool_choice, dict):
oai_tool_choice = {
"type": "function",
"function": {"name": tool_choice["function"]["name"]},
}
extra["tool_choice"] = oai_tool_choice
elif tool_choice in ("auto", "required", "none"):
oai_tool_choice = tool_choice
extra["tool_choice"] = oai_tool_choice
if is_given(response_format):
extra["response_format"] = llm_utils.to_openai_response_format(response_format)
return LLMStream(
self,
model=self._opts.model,
client=self._client,
chat_ctx=chat_ctx,
tools=tools or [],
conn_options=conn_options,
extra_kwargs=extra,
)
class LLMStream(llm.LLMStream):
def __init__(
self,
llm: LLM,
*,
model: str | ChatModels,
client: openai.AsyncClient,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool],
conn_options: APIConnectOptions,
extra_kwargs: dict[str, Any],
) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._model = model
self._client = client
self._llm = llm
self._extra_kwargs = extra_kwargs
async def _run(self) -> None:
# current function call that we're waiting for full completion (args are streamed)
# (defined inside the _run method to make sure the state is reset for each run/attempt)
self._oai_stream: openai.AsyncStream[ChatCompletionChunk] | None = None
self._tool_call_id: str | None = None
self._fnc_name: str | None = None
self._fnc_raw_arguments: str | None = None
self._tool_index: int | None = None
retryable = True
try:
self._oai_stream = stream = await self._client.chat.completions.create(
messages=to_chat_ctx(self._chat_ctx, id(self._llm)),
tools=to_fnc_ctx(self._tools) if self._tools else openai.NOT_GIVEN,
model=self._model,
stream_options={"include_usage": True},
stream=True,
**self._extra_kwargs,
)
async with stream:
async for chunk in stream:
for choice in chunk.choices:
chat_chunk = self._parse_choice(chunk.id, choice)
if chat_chunk is not None:
retryable = False
self._event_ch.send_nowait(chat_chunk)
if chunk.usage is not None:
retryable = False
tokens_details = chunk.usage.prompt_tokens_details
cached_tokens = tokens_details.cached_tokens if tokens_details else 0
chunk = llm.ChatChunk(
id=chunk.id,
usage=llm.CompletionUsage(
completion_tokens=chunk.usage.completion_tokens,
prompt_tokens=chunk.usage.prompt_tokens,
prompt_cached_tokens=cached_tokens or 0,
total_tokens=chunk.usage.total_tokens,
),
)
self._event_ch.send_nowait(chunk)
except openai.APITimeoutError:
raise APITimeoutError(retryable=retryable) from None
except openai.APIStatusError as e:
raise APIStatusError(
e.message,
status_code=e.status_code,
request_id=e.request_id,
body=e.body,
retryable=retryable,
) from None
except Exception as e:
raise APIConnectionError(retryable=retryable) from e
def _parse_choice(self, id: str, choice: Choice) -> llm.ChatChunk | None:
delta = choice.delta
# https://github.com/livekit/agents/issues/688
# the delta can be None when using Azure OpenAI (content filtering)
if delta is None:
return None
if delta.tool_calls:
for tool in delta.tool_calls:
if not tool.function:
continue
call_chunk = None
if self._tool_call_id and tool.id and tool.index != self._tool_index:
call_chunk = llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
role="assistant",
content=delta.content,
tool_calls=[
llm.FunctionToolCall(
arguments=self._fnc_raw_arguments or "",
name=self._fnc_name or "",
call_id=self._tool_call_id or "",
)
],
),
)
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
if tool.function.name:
self._tool_index = tool.index
self._tool_call_id = tool.id
self._fnc_name = tool.function.name
self._fnc_raw_arguments = tool.function.arguments or ""
elif tool.function.arguments:
self._fnc_raw_arguments += tool.function.arguments # type: ignore
if call_chunk is not None:
return call_chunk
if choice.finish_reason in ("tool_calls", "stop") and self._tool_call_id:
call_chunk = llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(
role="assistant",
content=delta.content,
tool_calls=[
llm.FunctionToolCall(
arguments=self._fnc_raw_arguments or "",
name=self._fnc_name or "",
call_id=self._tool_call_id or "",
)
],
),
)
self._tool_call_id = self._fnc_name = self._fnc_raw_arguments = None
return call_chunk
return llm.ChatChunk(
id=id,
delta=llm.ChoiceDelta(content=delta.content, role="assistant"),
)
import logging
logger = logging.getLogger("livekit.plugins.openai")
from typing import Literal
from openai.types import AudioModel
STTModels = AudioModel
TTSModels = Literal["tts-1", "tts-1-hd", "gpt-4o-mini-tts"]
TTSVoices = Literal[
"alloy",
"ash",
"ballad",
"coral",
"echo",
"fable",
"onyx",
"nova",
"sage",
"shimmer",
]
DalleModels = Literal["dall-e-2", "dall-e-3"]
ChatModels = Literal[
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"gpt-4-turbo",
"gpt-4-turbo-2024-04-09",
"gpt-4-turbo-preview",
"gpt-4-0125-preview",
"gpt-4-1106-preview",
"gpt-4-vision-preview",
"gpt-4-1106-vision-preview",
"gpt-4",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"gpt-3.5-turbo",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-16k-0613",
]
EmbeddingModels = Literal[
"text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large"
]
AssistantTools = Literal["code_interpreter", "file_search", "function"]
# adapters for OpenAI-compatible LLMs
TelnyxChatModels = Literal[
"meta-llama/Meta-Llama-3.1-8B-Instruct",
"meta-llama/Meta-Llama-3.1-70B-Instruct",
]
CerebrasChatModels = Literal[
"llama3.1-8b",
"llama-3.3-70b",
"llama-4-scout-17b-16e-instruct",
"deepseek-r1-distill-llama-70b",
]
PerplexityChatModels = Literal[
"llama-3.1-sonar-small-128k-online",
"llama-3.1-sonar-small-128k-chat",
"llama-3.1-sonar-large-128k-online",
"llama-3.1-sonar-large-128k-chat",
"llama-3.1-8b-instruct",
"llama-3.1-70b-instruct",
]
GroqChatModels = Literal[
"llama-3.1-405b-reasoning",
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"llama3-groq-70b-8192-tool-use-preview",
"llama3-groq-8b-8192-tool-use-preview",
"llama-guard-3-8b",
"llama3-70b-8192",
"llama3-8b-8192",
"mixtral-8x7b-32768",
"gemma-7b-it",
"gemma2-9b-it",
]
GroqAudioModels = Literal[
"whisper-large-v3", "distil-whisper-large-v3-en", "whisper-large-v3-turbo"
]
DeepSeekChatModels = Literal[
"deepseek-coder",
"deepseek-chat",
]
VertexModels = Literal[
"google/gemini-2.0-flash-exp",
"google/gemini-1.5-flash",
"google/gemini-1.5-pro",
"google/gemini-1.0-pro-vision",
"google/gemini-1.0-pro-vision-001",
"google/gemini-1.0-pro-002",
"google/gemini-1.0-pro-001",
"google/gemini-1.0-pro",
]
TogetherChatModels = Literal[
"Austism/chronos-hermes-13b",
"Gryphe/MythoMax-L2-13b",
"NousResearch/Nous-Capybara-7B-V1p9",
"NousResearch/Nous-Hermes-2-Mistral-7B-DPO",
"NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO",
"NousResearch/Nous-Hermes-2-Mixtral-8x7B-SFT",
"NousResearch/Nous-Hermes-2-Yi-34B",
"NousResearch/Nous-Hermes-Llama2-13b",
"NousResearch/Nous-Hermes-llama-2-7b",
"Open-Orca/Mistral-7B-OpenOrca",
"Qwen/Qwen1.5-0.5B-Chat",
"Qwen/Qwen1.5-1.8B-Chat",
"Qwen/Qwen1.5-110B-Chat",
"Qwen/Qwen1.5-14B-Chat",
"Qwen/Qwen1.5-32B-Chat",
"Qwen/Qwen1.5-4B-Chat",
"Qwen/Qwen1.5-72B-Chat",
"Qwen/Qwen1.5-7B-Chat",
"Qwen/Qwen2-72B-Instruct",
"Snowflake/snowflake-arctic-instruct",
"Undi95/ReMM-SLERP-L2-13B",
"Undi95/Toppy-M-7B",
"WizardLM/WizardLM-13B-V1.2",
"allenai/OLMo-7B",
"allenai/OLMo-7B-Instruct",
"allenai/OLMo-7B-Twin-2T",
"codellama/CodeLlama-13b-Instruct-hf",
"codellama/CodeLlama-34b-Instruct-hf",
"codellama/CodeLlama-70b-Instruct-hf",
"codellama/CodeLlama-7b-Instruct-hf",
"cognitivecomputations/dolphin-2.5-mixtral-8x7b",
"databricks/dbrx-instruct",
"deepseek-ai/deepseek-coder-33b-instruct",
"deepseek-ai/deepseek-llm-67b-chat",
"garage-bAInd/Platypus2-70B-instruct",
"google/gemma-2-27b-it",
"google/gemma-2-9b-it",
"google/gemma-2b-it",
"google/gemma-7b-it",
"lmsys/vicuna-13b-v1.5",
"lmsys/vicuna-7b-v1.5",
"meta-llama/Llama-2-13b-chat-hf",
"meta-llama/Llama-2-70b-chat-hf",
"meta-llama/Llama-2-7b-chat-hf",
"meta-llama/Llama-3-70b-chat-hf",
"meta-llama/Llama-3-8b-chat-hf",
"meta-llama/Meta-Llama-3-70B-Instruct-Lite",
"meta-llama/Meta-Llama-3-70B-Instruct-Turbo",
"meta-llama/Meta-Llama-3-8B-Instruct-Lite",
"meta-llama/Meta-Llama-3-8B-Instruct-Turbo",
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
"mistralai/Mistral-7B-Instruct-v0.1",
"mistralai/Mistral-7B-Instruct-v0.2",
"mistralai/Mistral-7B-Instruct-v0.3",
"mistralai/Mixtral-8x22B-Instruct-v0.1",
"mistralai/Mixtral-8x7B-Instruct-v0.1",
"openchat/openchat-3.5-1210",
"snorkelai/Snorkel-Mistral-PairRM-DPO",
"teknium/OpenHermes-2-Mistral-7B",
"teknium/OpenHermes-2p5-Mistral-7B",
"togethercomputer/Llama-2-7B-32K-Instruct",
"togethercomputer/RedPajama-INCITE-7B-Chat",
"togethercomputer/RedPajama-INCITE-Chat-3B-v1",
"togethercomputer/StripedHyena-Nous-7B",
"togethercomputer/alpaca-7b",
"upstage/SOLAR-10.7B-Instruct-v1.0",
"zero-one-ai/Yi-34B-Chat",
]
OctoChatModels = Literal[
"meta-llama-3-70b-instruct",
"meta-llama-3.1-405b-instruct",
"meta-llama-3.1-70b-instruct",
"meta-llama-3.1-8b-instruct",
"mistral-7b-instruct",
"mixtral-8x7b-instruct",
"wizardlm-2-8x22bllamaguard-2-7b",
]
XAIChatModels = Literal[
"grok-2",
"grok-2-mini",
"grok-2-mini-public",
"grok-2-public",
]
from .realtime_model import RealtimeModel, RealtimeSession
__all__ = [
"RealtimeSession",
"RealtimeModel",
]
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import os
import time
import weakref
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Literal, Union, overload
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import aiohttp
from pydantic import BaseModel, ValidationError
from livekit import rtc
from livekit.agents import APIConnectionError, APIError, io, llm, utils
from livekit.agents.llm.tool_context import (
get_raw_function_info,
is_function_tool,
is_raw_function_tool,
)
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from openai.types.beta.realtime import (
ConversationItem,
ConversationItemContent,
ConversationItemCreatedEvent,
ConversationItemCreateEvent,
ConversationItemDeletedEvent,
ConversationItemDeleteEvent,
ConversationItemInputAudioTranscriptionCompletedEvent,
ConversationItemInputAudioTranscriptionFailedEvent,
ConversationItemTruncateEvent,
ErrorEvent,
InputAudioBufferAppendEvent,
InputAudioBufferClearEvent,
InputAudioBufferCommitEvent,
InputAudioBufferSpeechStartedEvent,
InputAudioBufferSpeechStoppedEvent,
RealtimeClientEvent,
ResponseAudioDeltaEvent,
ResponseAudioDoneEvent,
ResponseAudioTranscriptDoneEvent,
ResponseCancelEvent,
ResponseContentPartAddedEvent,
ResponseContentPartDoneEvent,
ResponseCreatedEvent,
ResponseCreateEvent,
ResponseDoneEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
SessionUpdateEvent,
session_update_event,
)
from openai.types.beta.realtime.response_create_event import Response
from openai.types.beta.realtime.session import InputAudioTranscription, TurnDetection
from ..log import logger
# When a response is created with the OpenAI Realtime API, those events are sent in this order:
# 1. response.created (contains resp_id)
# 2. response.output_item.added (contains item_id)
# 3. conversation.item.created
# 4. response.content_part.added (type audio/text)
# 5. response.audio_transcript.delta (x2, x3, x4, etc)
# 6. response.audio.delta (x2, x3, x4, etc)
# 7. response.content_part.done
# 8. response.output_item.done (contains item_status: "completed/incomplete")
# 9. response.done (contains status_details for cancelled/failed/turn_detected/content_filter)
#
# Ourcode assumes a response will generate only one item with type "message"
SAMPLE_RATE = 24000
NUM_CHANNELS = 1
OPENAI_BASE_URL = "https://api.openai.com/v1"
_log_oai_events = int(os.getenv("LOG_OAI_EVENTS", 0))
@dataclass
class _RealtimeOptions:
model: str
voice: str
temperature: float
tool_choice: llm.ToolChoice | None
input_audio_transcription: InputAudioTranscription | None
turn_detection: TurnDetection | None
api_key: str
base_url: str
is_azure: bool
azure_deployment: str | None
entra_token: str | None
api_version: str | None
@dataclass
class _MessageGeneration:
message_id: str
text_ch: utils.aio.Chan[str]
audio_ch: utils.aio.Chan[rtc.AudioFrame]
@dataclass
class _ResponseGeneration:
message_ch: utils.aio.Chan[llm.MessageGeneration]
function_ch: utils.aio.Chan[llm.FunctionCall]
messages: dict[str, _MessageGeneration]
@dataclass
class _CreateResponseHandle:
instructions: NotGivenOr[str]
done_fut: asyncio.Future[llm.GenerationCreatedEvent]
timeout: asyncio.TimerHandle | None = None
def timeout_start(self) -> None:
if self.timeout or self.done_fut is None or self.done_fut.done():
return
def _on_timeout() -> None:
if not self.done_fut.done():
self.done_fut.set_exception(llm.RealtimeError("generate_reply timed out."))
self.timeout = asyncio.get_event_loop().call_later(5.0, _on_timeout)
self.done_fut.add_done_callback(lambda _: self.timeout.cancel())
_MOCK_AUDIO_ID_PREFIX = "lk_mock_audio_item_"
# default values got from a "default" session from their API
DEFAULT_TEMPERATURE = 0.8
DEFAULT_TURN_DETECTION = TurnDetection(
type="server_vad",
threshold=0.5,
prefix_padding_ms=300,
silence_duration_ms=200,
create_response=True,
interrupt_response=True,
)
DEFAULT_INPUT_AUDIO_TRANSCRIPTION = InputAudioTranscription(
model="gpt-4o-mini-transcribe",
)
DEFAULT_TOOL_CHOICE = "auto"
AZURE_DEFAULT_TURN_DETECTION = TurnDetection(
type="server_vad",
threshold=0.5,
prefix_padding_ms=300,
silence_duration_ms=200,
create_response=True,
)
AZURE_DEFAULT_INPUT_AUDIO_TRANSCRIPTION = InputAudioTranscription(
model="whisper-1",
)
class RealtimeModel(llm.RealtimeModel):
@overload
def __init__(
self,
*,
model: str = "gpt-4o-realtime-preview",
voice: str = "alloy",
input_audio_transcription: NotGivenOr[InputAudioTranscription | None] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
api_key: str | None = None,
base_url: str | None = None,
http_session: aiohttp.ClientSession | None = None,
) -> None: ...
@overload
def __init__(
self,
*,
azure_deployment: str | None = None,
entra_token: str | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
voice: str = "alloy",
input_audio_transcription: NotGivenOr[InputAudioTranscription | None] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None: ...
def __init__(
self,
*,
model: str = "gpt-4o-realtime-preview",
voice: str = "alloy",
temperature: NotGivenOr[float] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[InputAudioTranscription | None] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
api_key: str | None = None,
http_session: aiohttp.ClientSession | None = None,
azure_deployment: str | None = None,
entra_token: str | None = None,
api_version: str | None = None,
) -> None:
super().__init__(
capabilities=llm.RealtimeCapabilities(
message_truncation=True,
turn_detection=turn_detection is not None,
user_transcription=input_audio_transcription is not None,
)
)
is_azure = (
api_version is not None or entra_token is not None or azure_deployment is not None
)
api_key = api_key or os.environ.get("OPENAI_API_KEY")
if api_key is None and not is_azure:
raise ValueError(
"The api_key client option must be set either by passing api_key "
"to the client or by setting the OPENAI_API_KEY environment variable"
)
if is_given(base_url):
base_url_val = base_url
else:
if is_azure:
azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"Missing Azure endpoint. Please pass base_url "
"or set AZURE_OPENAI_ENDPOINT environment variable."
)
base_url_val = f"{azure_endpoint.rstrip('/')}/openai"
else:
base_url_val = OPENAI_BASE_URL
self._opts = _RealtimeOptions(
model=model,
voice=voice,
temperature=temperature if is_given(temperature) else DEFAULT_TEMPERATURE,
tool_choice=tool_choice or None,
input_audio_transcription=input_audio_transcription
if is_given(input_audio_transcription)
else DEFAULT_INPUT_AUDIO_TRANSCRIPTION,
turn_detection=turn_detection if is_given(turn_detection) else DEFAULT_TURN_DETECTION,
api_key=api_key,
base_url=base_url_val,
is_azure=is_azure,
azure_deployment=azure_deployment,
entra_token=entra_token,
api_version=api_version,
)
self._http_session = http_session
self._sessions = weakref.WeakSet[RealtimeSession]()
@classmethod
def with_azure(
cls,
*,
azure_deployment: str,
azure_endpoint: str | None = None,
api_version: str | None = None,
api_key: str | None = None,
entra_token: str | None = None,
base_url: str | None = None,
voice: str = "alloy",
input_audio_transcription: NotGivenOr[InputAudioTranscription | None] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
temperature: float = 0.8,
http_session: aiohttp.ClientSession | None = None,
):
"""
Create a RealtimeClient instance configured for Azure OpenAI Service.
Args:
azure_deployment (str): The name of your Azure OpenAI deployment.
azure_endpoint (str or None, optional): The endpoint URL for your Azure OpenAI resource. If None, will attempt to read from the environment variable AZURE_OPENAI_ENDPOINT.
api_version (str or None, optional): API version to use with Azure OpenAI Service. If None, will attempt to read from the environment variable OPENAI_API_VERSION.
api_key (str or None, optional): Azure OpenAI API key. If None, will attempt to read from the environment variable AZURE_OPENAI_API_KEY.
entra_token (str or None, optional): Azure Entra authentication token. Required if not using API key authentication.
base_url (str or None, optional): Base URL for the API endpoint. If None, constructed from the azure_endpoint.
voice (api_proto.Voice, optional): Voice setting for audio outputs. Defaults to "alloy".
input_audio_transcription (InputTranscriptionOptions, optional): Options for transcribing input audio. Defaults to DEFAULT_INPUT_AUDIO_TRANSCRIPTION.
turn_detection (ServerVadOptions, optional): Options for server-based voice activity detection (VAD). Defaults to DEFAULT_SERVER_VAD_OPTIONS.
temperature (float, optional): Sampling temperature for response generation. Defaults to 0.8.
max_response_output_tokens (int or Literal["inf"], optional): Maximum number of tokens in the response. Defaults to "inf".
http_session (aiohttp.ClientSession or None, optional): Async HTTP session to use for requests. If None, a new session will be created.
Returns:
RealtimeClient: An instance of RealtimeClient configured for Azure OpenAI Service.
Raises:
ValueError: If required Azure parameters are missing or invalid.
""" # noqa: E501
api_key = api_key or os.getenv("AZURE_OPENAI_API_KEY")
if api_key is None and entra_token is None:
raise ValueError(
"Missing credentials. Please pass one of `api_key`, `entra_token`, "
"or the `AZURE_OPENAI_API_KEY` environment variable."
)
api_version = api_version or os.getenv("OPENAI_API_VERSION")
if api_version is None:
raise ValueError(
"Must provide either the `api_version` argument or the "
"`OPENAI_API_VERSION` environment variable"
)
if base_url is None:
azure_endpoint = azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"Missing Azure endpoint. Please pass the `azure_endpoint` "
"parameter or set the `AZURE_OPENAI_ENDPOINT` environment variable."
)
base_url = f"{azure_endpoint.rstrip('/')}/openai"
elif azure_endpoint is not None:
raise ValueError("base_url and azure_endpoint are mutually exclusive")
if not is_given(input_audio_transcription):
input_audio_transcription = AZURE_DEFAULT_INPUT_AUDIO_TRANSCRIPTION
if not is_given(turn_detection):
turn_detection = AZURE_DEFAULT_TURN_DETECTION
return cls(
voice=voice,
input_audio_transcription=input_audio_transcription,
turn_detection=turn_detection,
temperature=temperature,
api_key=api_key,
http_session=http_session,
azure_deployment=azure_deployment,
api_version=api_version,
entra_token=entra_token,
base_url=base_url,
)
def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
) -> None:
if is_given(voice):
self._opts.voice = voice
if is_given(temperature):
self._opts.temperature = temperature
if is_given(turn_detection):
self._opts.turn_detection = turn_detection
if is_given(tool_choice):
self._opts.tool_choice = tool_choice
for sess in self._sessions:
sess.update_options(
voice=voice,
temperature=temperature,
turn_detection=turn_detection,
tool_choice=tool_choice,
)
def _ensure_http_session(self) -> aiohttp.ClientSession:
if not self._http_session:
self._http_session = utils.http_context.http_session()
return self._http_session
def session(self) -> RealtimeSession:
sess = RealtimeSession(self)
self._sessions.add(sess)
return sess
async def aclose(self) -> None: ...
def process_base_url(
url: str,
model: str,
is_azure: bool = False,
azure_deployment: str | None = None,
api_version: str | None = None,
) -> str:
if url.startswith("http"):
url = url.replace("http", "ws", 1)
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
# ensure "/realtime" is added if the path is empty OR "/v1"
if not parsed_url.path or parsed_url.path.rstrip("/") in ["", "/v1", "/openai"]:
path = parsed_url.path.rstrip("/") + "/realtime"
else:
path = parsed_url.path
if is_azure:
if api_version:
query_params["api-version"] = [api_version]
if azure_deployment:
query_params["deployment"] = [azure_deployment]
else:
if "model" not in query_params:
query_params["model"] = [model]
new_query = urlencode(query_params, doseq=True)
new_url = urlunparse((parsed_url.scheme, parsed_url.netloc, path, "", new_query, ""))
return new_url
class RealtimeSession(
llm.RealtimeSession[Literal["openai_server_event_received", "openai_client_event_queued"]]
):
"""
A session for the OpenAI Realtime API.
This class is used to interact with the OpenAI Realtime API.
It is responsible for sending events to the OpenAI Realtime API and receiving events from it.
It exposes two more events:
- openai_server_event_received: expose the raw server events from the OpenAI Realtime API
- openai_client_event_queued: expose the raw client events sent to the OpenAI Realtime API
"""
def __init__(self, realtime_model: RealtimeModel) -> None:
super().__init__(realtime_model)
self._realtime_model = realtime_model
self._tools = llm.ToolContext.empty()
self._msg_ch = utils.aio.Chan[Union[RealtimeClientEvent, dict]]()
self._input_resampler: rtc.AudioResampler | None = None
self._main_atask = asyncio.create_task(self._main_task(), name="RealtimeSession._main_task")
self._initial_session_update()
self._response_created_futures: dict[str, _CreateResponseHandle] = {}
self._text_mode_recovery_atask: asyncio.Task | None = None
self._text_mode_recovery_retries: int = 0
self._item_delete_future: dict[str, asyncio.Future] = {}
self._item_create_future: dict[str, asyncio.Future] = {}
self._current_generation: _ResponseGeneration | None = None
self._remote_chat_ctx = llm.remote_chat_context.RemoteChatContext()
self._update_chat_ctx_lock = asyncio.Lock()
self._update_fnc_ctx_lock = asyncio.Lock()
# 100ms chunks
self._bstream = utils.audio.AudioByteStream(
SAMPLE_RATE, NUM_CHANNELS, samples_per_channel=SAMPLE_RATE // 10
)
self._pushed_duration_s = 0 # duration of audio pushed to the OpenAI Realtime API
def send_event(self, event: RealtimeClientEvent | dict) -> None:
with contextlib.suppress(utils.aio.channel.ChanClosed):
self._msg_ch.send_nowait(event)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
headers = {"User-Agent": "LiveKit Agents"}
if self._realtime_model._opts.is_azure:
if self._realtime_model._opts.entra_token:
headers["Authorization"] = f"Bearer {self._realtime_model._opts.entra_token}"
if self._realtime_model._opts.api_key:
headers["api-key"] = self._realtime_model._opts.api_key
else:
headers["Authorization"] = f"Bearer {self._realtime_model._opts.api_key}"
headers["OpenAI-Beta"] = "realtime=v1"
url = process_base_url(
self._realtime_model._opts.base_url,
self._realtime_model._opts.model,
is_azure=self._realtime_model._opts.is_azure,
api_version=self._realtime_model._opts.api_version,
azure_deployment=self._realtime_model._opts.azure_deployment,
)
if _log_oai_events:
logger.debug(f"connecting to Realtime API: {url}")
ws_conn = await self._realtime_model._ensure_http_session().ws_connect(
url=url, headers=headers
)
closing = False
@utils.log_exceptions(logger=logger)
async def _send_task() -> None:
nonlocal closing
async for msg in self._msg_ch:
try:
if isinstance(msg, BaseModel):
msg = msg.model_dump(
by_alias=True, exclude_unset=True, exclude_defaults=False
)
self.emit("openai_client_event_queued", msg)
await ws_conn.send_str(json.dumps(msg))
if _log_oai_events:
msg_copy = msg.copy()
if msg_copy["type"] == "input_audio_buffer.append":
msg_copy = {**msg_copy, "audio": "..."}
logger.debug(f">>> {msg_copy}")
except Exception:
break
closing = True
await ws_conn.close()
@utils.log_exceptions(logger=logger)
async def _recv_task() -> None:
while True:
msg = await ws_conn.receive()
if msg.type == aiohttp.WSMsgType.CLOSED:
if not closing:
error = Exception("OpenAI S2S connection closed unexpectedly")
self.emit(
"error",
llm.RealtimeModelError(
timestamp=time.time(),
label=self._realtime_model._label,
error=APIConnectionError(
message="OpenAI S2S connection closed unexpectedly",
),
recoverable=False,
),
)
raise error
return
elif msg.type != aiohttp.WSMsgType.TEXT:
continue
event = json.loads(msg.data)
# emit the raw json dictionary instead of the BaseModel because different
# providers can have different event types that are not part of the OpenAI Realtime API # noqa: E501
self.emit("openai_server_event_received", event)
try:
if _log_oai_events:
event_copy = event.copy()
if event_copy["type"] == "response.audio.delta":
event_copy = {**event_copy, "delta": "..."}
logger.debug(f"<<< {event_copy}")
if event["type"] == "input_audio_buffer.speech_started":
self._handle_input_audio_buffer_speech_started(
InputAudioBufferSpeechStartedEvent.construct(**event)
)
elif event["type"] == "input_audio_buffer.speech_stopped":
self._handle_input_audio_buffer_speech_stopped(
InputAudioBufferSpeechStoppedEvent.construct(**event)
)
elif event["type"] == "response.created":
self._handle_response_created(ResponseCreatedEvent.construct(**event))
elif event["type"] == "response.output_item.added":
self._handle_response_output_item_added(
ResponseOutputItemAddedEvent.construct(**event)
)
elif event["type"] == "conversation.item.created":
self._handle_conversion_item_created(
ConversationItemCreatedEvent.construct(**event)
)
elif event["type"] == "conversation.item.deleted":
self._handle_conversion_item_deleted(
ConversationItemDeletedEvent.construct(**event)
)
elif event["type"] == "conversation.item.input_audio_transcription.completed":
self._handle_conversion_item_input_audio_transcription_completed(
ConversationItemInputAudioTranscriptionCompletedEvent.construct(**event)
)
elif event["type"] == "conversation.item.input_audio_transcription.failed":
self._handle_conversion_item_input_audio_transcription_failed(
ConversationItemInputAudioTranscriptionFailedEvent.construct(**event)
)
elif event["type"] == "response.content_part.added":
self._handle_response_content_part_added(
ResponseContentPartAddedEvent.construct(**event)
)
elif event["type"] == "response.content_part.done":
self._handle_response_content_part_done(
ResponseContentPartDoneEvent.construct(**event)
)
elif event["type"] == "response.audio_transcript.delta":
self._handle_response_audio_transcript_delta(event)
elif event["type"] == "response.audio.delta":
self._handle_response_audio_delta(
ResponseAudioDeltaEvent.construct(**event)
)
elif event["type"] == "response.audio_transcript.done":
self._handle_response_audio_transcript_done(
ResponseAudioTranscriptDoneEvent.construct(**event)
)
elif event["type"] == "response.audio.done":
self._handle_response_audio_done(ResponseAudioDoneEvent.construct(**event))
elif event["type"] == "response.output_item.done":
self._handle_response_output_item_done(
ResponseOutputItemDoneEvent.construct(**event)
)
elif event["type"] == "response.done":
self._handle_response_done(ResponseDoneEvent.construct(**event))
elif event["type"] == "error":
self._handle_error(ErrorEvent.construct(**event))
except Exception:
logger.exception("failed to handle event", extra={"event": event})
tasks = [
asyncio.create_task(_recv_task(), name="_recv_task"),
asyncio.create_task(_send_task(), name="_send_task"),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
await ws_conn.close()
def _initial_session_update(self) -> None:
input_audio_transcription = self._realtime_model._opts.input_audio_transcription
input_audio_transcription = (
session_update_event.SessionInputAudioTranscription.model_validate(
input_audio_transcription.model_dump(
by_alias=True,
exclude_unset=True,
exclude_defaults=True,
)
)
if input_audio_transcription
else None
)
turn_detection = self._realtime_model._opts.turn_detection
turn_detection = (
session_update_event.SessionTurnDetection.model_validate(
turn_detection.model_dump(
by_alias=True,
exclude_unset=True,
exclude_defaults=True,
)
)
if turn_detection
else None
)
# initial session update
self.send_event(
SessionUpdateEvent(
type="session.update",
# Using model_construct since OpenAI restricts voices to those defined in the BaseModel. # noqa: E501
# Other providers support different voices, so we need to accommodate that.
session=session_update_event.Session.model_construct(
model=self._realtime_model._opts.model,
voice=self._realtime_model._opts.voice,
input_audio_format="pcm16",
output_audio_format="pcm16",
modalities=["text", "audio"],
turn_detection=turn_detection,
input_audio_transcription=input_audio_transcription,
temperature=self._realtime_model._opts.temperature,
),
event_id=utils.shortuuid("session_update_"),
)
)
@property
def chat_ctx(self) -> llm.ChatContext:
return self._remote_chat_ctx.to_chat_ctx()
@property
def tools(self) -> llm.ToolContext:
return self._tools.copy()
def update_options(
self,
*,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
voice: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetection | None] = NOT_GIVEN,
) -> None:
kwargs = {}
if is_given(tool_choice):
oai_tool_choice = tool_choice
if isinstance(tool_choice, dict) and tool_choice["type"] == "function":
oai_tool_choice = tool_choice["function"]
if oai_tool_choice is None:
oai_tool_choice = DEFAULT_TOOL_CHOICE
kwargs["tool_choice"] = oai_tool_choice
if is_given(voice):
kwargs["voice"] = voice
if is_given(temperature):
kwargs["temperature"] = temperature
if is_given(turn_detection):
kwargs["turn_detection"] = turn_detection
if kwargs:
self.send_event(
SessionUpdateEvent(
type="session.update",
session=session_update_event.Session.model_construct(**kwargs),
event_id=utils.shortuuid("options_update_"),
)
)
async def update_chat_ctx(
self, chat_ctx: llm.ChatContext, *, _add_mock_audio: bool = False
) -> None:
chat_ctx = chat_ctx.copy()
if _add_mock_audio:
chat_ctx.items.append(_create_mock_audio_item())
else:
# clean up existing mock audio items
chat_ctx.items[:] = [
item for item in chat_ctx.items if not item.id.startswith(_MOCK_AUDIO_ID_PREFIX)
]
async with self._update_chat_ctx_lock:
diff_ops = llm.utils.compute_chat_ctx_diff(
self._remote_chat_ctx.to_chat_ctx(), chat_ctx
)
futs = []
for msg_id in diff_ops.to_remove:
event_id = utils.shortuuid("chat_ctx_delete_")
self.send_event(
ConversationItemDeleteEvent(
type="conversation.item.delete",
item_id=msg_id,
event_id=event_id,
)
)
futs.append(f := asyncio.Future())
self._item_delete_future[msg_id] = f
for previous_msg_id, msg_id in diff_ops.to_create:
event_id = utils.shortuuid("chat_ctx_create_")
chat_item = chat_ctx.get_by_id(msg_id)
assert chat_item is not None
self.send_event(
ConversationItemCreateEvent(
type="conversation.item.create",
item=_livekit_item_to_openai_item(chat_item),
previous_item_id=("root" if previous_msg_id is None else previous_msg_id),
event_id=event_id,
)
)
futs.append(f := asyncio.Future())
self._item_create_future[msg_id] = f
try:
await asyncio.wait_for(asyncio.gather(*futs, return_exceptions=True), timeout=5.0)
except asyncio.TimeoutError:
raise llm.RealtimeError("update_chat_ctx timed out.") from None
async def update_tools(self, tools: list[llm.FunctionTool | llm.RawFunctionTool]) -> None:
async with self._update_fnc_ctx_lock:
oai_tools: list[session_update_event.SessionTool] = []
retained_tools: list[llm.FunctionTool | llm.RawFunctionTool] = []
for tool in tools:
if is_function_tool(tool):
tool_desc = llm.utils.build_legacy_openai_schema(tool, internally_tagged=True)
elif is_raw_function_tool(tool):
tool_info = get_raw_function_info(tool)
tool_desc = tool_info.raw_schema
tool_desc["type"] = "function" # internally tagged
else:
logger.error(
"OpenAI Realtime API doesn't support this tool type", extra={"tool": tool}
)
continue
try:
session_tool = session_update_event.SessionTool.model_validate(tool_desc)
oai_tools.append(session_tool)
retained_tools.append(tool)
except ValidationError:
logger.error(
"OpenAI Realtime API doesn't support this tool",
extra={"tool": tool_desc},
)
continue
event_id = utils.shortuuid("tools_update_")
# f = asyncio.Future()
# self._response_futures[event_id] = f
self.send_event(
SessionUpdateEvent(
type="session.update",
session=session_update_event.Session.model_construct(
model=self._realtime_model._opts.model,
tools=oai_tools,
),
event_id=event_id,
)
)
self._tools = llm.ToolContext(retained_tools)
async def update_instructions(self, instructions: str) -> None:
event_id = utils.shortuuid("instructions_update_")
# f = asyncio.Future()
# self._response_futures[event_id] = f
self.send_event(
SessionUpdateEvent(
type="session.update",
session=session_update_event.Session.model_construct(instructions=instructions),
event_id=event_id,
)
)
def push_audio(self, frame: rtc.AudioFrame) -> None:
for f in self._resample_audio(frame):
data = f.data.tobytes()
for nf in self._bstream.write(data):
self.send_event(
InputAudioBufferAppendEvent(
type="input_audio_buffer.append",
audio=base64.b64encode(nf.data).decode("utf-8"),
)
)
self._pushed_duration_s += nf.duration
def push_video(self, frame: rtc.VideoFrame) -> None:
pass
def commit_audio(self) -> None:
if self._pushed_duration_s > 0.1: # OpenAI requires at least 100ms of audio
self.send_event(InputAudioBufferCommitEvent(type="input_audio_buffer.commit"))
self._pushed_duration_s = 0
def clear_audio(self) -> None:
self.send_event(InputAudioBufferClearEvent(type="input_audio_buffer.clear"))
self._pushed_duration_s = 0
def generate_reply(
self, *, instructions: NotGivenOr[str] = NOT_GIVEN
) -> asyncio.Future[llm.GenerationCreatedEvent]:
handle = self._create_response(instructions=instructions, user_initiated=True)
self._text_mode_recovery_retries = 0 # reset the counter
return handle.done_fut
def interrupt(self) -> None:
self.send_event(ResponseCancelEvent(type="response.cancel"))
def truncate(self, *, message_id: str, audio_end_ms: int) -> None:
self.send_event(
ConversationItemTruncateEvent(
type="conversation.item.truncate",
content_index=0,
item_id=message_id,
audio_end_ms=audio_end_ms,
)
)
async def aclose(self) -> None:
self._msg_ch.close()
await self._main_atask
def _resample_audio(self, frame: rtc.AudioFrame) -> Iterator[rtc.AudioFrame]:
if self._input_resampler:
if frame.sample_rate != self._input_resampler._input_rate:
# input audio changed to a different sample rate
self._input_resampler = None
if self._input_resampler is None and (
frame.sample_rate != SAMPLE_RATE or frame.num_channels != NUM_CHANNELS
):
self._input_resampler = rtc.AudioResampler(
input_rate=frame.sample_rate,
output_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
)
if self._input_resampler:
# TODO(long): flush the resampler when the input source is changed
yield from self._input_resampler.push(frame)
else:
yield frame
def _create_response(
self,
*,
user_initiated: bool,
instructions: NotGivenOr[str] = NOT_GIVEN,
old_handle: _CreateResponseHandle | None = None,
) -> _CreateResponseHandle:
handle = old_handle or _CreateResponseHandle(
instructions=instructions, done_fut=asyncio.Future()
)
if old_handle and utils.is_given(instructions):
handle.instructions = instructions
event_id = utils.shortuuid("response_create_")
if user_initiated:
self._response_created_futures[event_id] = handle
self.send_event(
ResponseCreateEvent(
type="response.create",
event_id=event_id,
response=Response(
instructions=handle.instructions or None,
metadata={"client_event_id": event_id} if user_initiated else None,
),
)
)
if user_initiated:
handle.timeout_start()
return handle
def _emit_generation_event(self, response_id: str) -> None:
# called when the generation is a function call or a audio message
generation_ev = llm.GenerationCreatedEvent(
message_stream=self._current_generation.message_ch,
function_stream=self._current_generation.function_ch,
user_initiated=False,
)
if handle := self._response_created_futures.pop(response_id, None):
generation_ev.user_initiated = True
handle.done_fut.set_result(generation_ev)
self.emit("generation_created", generation_ev)
def _handle_input_audio_buffer_speech_started(
self, _: InputAudioBufferSpeechStartedEvent
) -> None:
self.emit("input_speech_started", llm.InputSpeechStartedEvent())
def _handle_input_audio_buffer_speech_stopped(
self, _: InputAudioBufferSpeechStoppedEvent
) -> None:
user_transcription_enabled = (
self._realtime_model._opts.input_audio_transcription is not None
)
self.emit(
"input_speech_stopped",
llm.InputSpeechStoppedEvent(user_transcription_enabled=user_transcription_enabled),
)
def _handle_response_created(self, event: ResponseCreatedEvent) -> None:
assert event.response.id is not None, "response.id is None"
self._current_generation = _ResponseGeneration(
message_ch=utils.aio.Chan(),
function_ch=utils.aio.Chan(),
messages={},
)
if (
isinstance(event.response.metadata, dict)
and (client_event_id := event.response.metadata.get("client_event_id"))
and (handle := self._response_created_futures.pop(client_event_id, None))
):
# set key to the response id
self._response_created_futures[event.response.id] = handle
# the generation_created event is emitted when
# 1. the response is not a message on response.output_item.added event
# 2. the content is audio on response.content_part.added event
# will try to recover from text response on response.content_part.done event
def _handle_response_output_item_added(self, event: ResponseOutputItemAddedEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
assert (item_type := event.item.type) is not None, "item.type is None"
assert (response_id := event.response_id) is not None, "response_id is None"
if item_type != "message":
# emit immediately if it's not a message, otherwise wait response.content_part.added
self._emit_generation_event(response_id)
self._text_mode_recovery_retries = 0
def _handle_response_content_part_added(self, event: ResponseContentPartAddedEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item_id) is not None, "item_id is None"
assert (item_type := event.part.type) is not None, "part.type is None"
assert (response_id := event.response_id) is not None, "response_id is None"
if item_type == "audio":
self._emit_generation_event(response_id)
if self._text_mode_recovery_retries > 0:
logger.info(
"recovered from text-only response",
extra={"retried_times": self._text_mode_recovery_retries},
)
self._text_mode_recovery_retries = 0
item_generation = _MessageGeneration(
message_id=item_id,
text_ch=utils.aio.Chan(),
audio_ch=utils.aio.Chan(),
)
self._current_generation.message_ch.send_nowait(
llm.MessageGeneration(
message_id=item_id,
text_stream=item_generation.text_ch,
audio_stream=item_generation.audio_ch,
)
)
self._current_generation.messages[item_id] = item_generation
else:
self.interrupt()
if self._text_mode_recovery_retries == 0:
logger.warning("received text-only response from realtime API")
def _handle_response_content_part_done(self, event: ResponseContentPartDoneEvent) -> None:
if event.part.type != "text":
return
# try to recover from text-only response on response.content_part_done event
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item_id) is not None, "item_id is None"
assert (response_id := event.response_id) is not None, "response_id is None"
async def _retry_generation(
item_id: str, response_handle: _CreateResponseHandle | None
) -> None:
"""Recover from text-only response to audio mode.
When chat history is loaded, OpenAI Realtime API may respond with text only.
This method recovers by:
1. Deleting the text response
2. Creating an empty user audio message
3. Requesting a new response to trigger audio mode
"""
# remove the text item
chat_ctx = self.chat_ctx
idx = chat_ctx.index_by_id(item_id)
if idx is not None:
chat_ctx.items.pop(idx)
await self.update_chat_ctx(chat_ctx, _add_mock_audio=True)
if response_handle and response_handle.done_fut.done():
if response_handle.done_fut.exception() is not None:
logger.error("generate_reply timed out, cancel recovery")
return
self._create_response(
old_handle=response_handle,
user_initiated=response_handle is not None,
)
if self._text_mode_recovery_retries >= 5:
logger.error(
"failed to recover from text-only response",
extra={"retried_times": self._text_mode_recovery_retries},
)
self._text_mode_recovery_retries = 0
return
handle = self._response_created_futures.pop(response_id, None)
if handle and handle.done_fut.done():
if handle.done_fut.exception() is not None:
logger.error("generate_reply timed out, cancel recovery")
self._text_mode_recovery_retries = 0
return
self._text_mode_recovery_retries += 1
logger.warning(
"trying to recover from text-only response",
extra={"retries": self._text_mode_recovery_retries},
)
if self._text_mode_recovery_atask and not self._text_mode_recovery_atask.done():
self._text_mode_recovery_atask.cancel()
self._text_mode_recovery_atask = asyncio.create_task(
_retry_generation(item_id=item_id, response_handle=handle)
)
def _handle_conversion_item_created(self, event: ConversationItemCreatedEvent) -> None:
assert event.item.id is not None, "item.id is None"
try:
self._remote_chat_ctx.insert(
event.previous_item_id, _openai_item_to_livekit_item(event.item)
)
except ValueError as e:
logger.warning(
f"failed to insert item `{event.item.id}`: {str(e)}",
)
if fut := self._item_create_future.pop(event.item.id, None):
fut.set_result(None)
def _handle_conversion_item_deleted(self, event: ConversationItemDeletedEvent) -> None:
assert event.item_id is not None, "item_id is None"
try:
self._remote_chat_ctx.delete(event.item_id)
except ValueError as e:
logger.warning(
f"failed to delete item `{event.item_id}`: {str(e)}",
)
if fut := self._item_delete_future.pop(event.item_id, None):
fut.set_result(None)
def _handle_conversion_item_input_audio_transcription_completed(
self, event: ConversationItemInputAudioTranscriptionCompletedEvent
) -> None:
if remote_item := self._remote_chat_ctx.get(event.item_id):
assert isinstance(remote_item.item, llm.ChatMessage)
remote_item.item.content.append(event.transcript)
self.emit(
"input_audio_transcription_completed",
llm.InputTranscriptionCompleted(item_id=event.item_id, transcript=event.transcript),
)
def _handle_conversion_item_input_audio_transcription_failed(
self, event: ConversationItemInputAudioTranscriptionFailedEvent
) -> None:
logger.error(
"OpenAI Realtime API failed to transcribe input audio",
extra={"error": event.error},
)
def _handle_response_audio_transcript_delta(self, event: dict) -> None:
assert self._current_generation is not None, "current_generation is None"
item_id = event["item_id"]
delta = event["delta"]
if (start_time := event.get("start_time")) is not None:
delta = io.TimedString(delta, start_time=start_time)
item_generation = self._current_generation.messages[item_id]
item_generation.text_ch.send_nowait(delta)
def _handle_response_audio_delta(self, event: ResponseAudioDeltaEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
item_generation = self._current_generation.messages[event.item_id]
data = base64.b64decode(event.delta)
item_generation.audio_ch.send_nowait(
rtc.AudioFrame(
data=data,
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=len(data) // 2,
)
)
def _handle_response_audio_transcript_done(self, _: ResponseAudioTranscriptDoneEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
def _handle_response_audio_done(self, _: ResponseAudioDoneEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
def _handle_response_output_item_done(self, event: ResponseOutputItemDoneEvent) -> None:
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item.id) is not None, "item.id is None"
assert (item_type := event.item.type) is not None, "item.type is None"
if item_type == "function_call":
item = event.item
assert item.call_id is not None, "call_id is None"
assert item.name is not None, "name is None"
assert item.arguments is not None, "arguments is None"
self._current_generation.function_ch.send_nowait(
llm.FunctionCall(
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
)
)
elif item_type == "message" and (
item_generation := self._current_generation.messages.get(item_id)
):
# text response doesn't have item_generation
item_generation.text_ch.close()
item_generation.audio_ch.close()
def _handle_response_done(self, _: ResponseDoneEvent) -> None:
if self._current_generation is None:
return # OpenAI has a race condition where we could receive response.done without any previous response.created (This happens generally during interruption) # noqa: E501
assert self._current_generation is not None, "current_generation is None"
for generation in self._current_generation.messages.values():
# close all messages that haven't been closed yet
if not generation.text_ch.closed:
generation.text_ch.close()
if not generation.audio_ch.closed:
generation.audio_ch.close()
self._current_generation.function_ch.close()
self._current_generation.message_ch.close()
self._current_generation = None
def _handle_error(self, event: ErrorEvent) -> None:
if event.error.message.startswith("Cancellation failed"):
return
logger.error(
"OpenAI Realtime API returned an error",
extra={"error": event.error},
)
self.emit(
"error",
llm.RealtimeModelError(
timestamp=time.time(),
label=self._realtime_model._label,
error=APIError(
message="OpenAI Realtime API returned an error",
body=event.error,
retryable=True,
),
recoverable=True,
),
)
# if event.error.event_id:
# fut = self._response_futures.pop(event.error.event_id, None)
# if fut is not None and not fut.done():
# fut.set_exception(multimodal.RealtimeError(event.error.message))
def _livekit_item_to_openai_item(item: llm.ChatItem) -> ConversationItem:
conversation_item = ConversationItem(
id=item.id,
)
if item.type == "function_call":
conversation_item.type = "function_call"
conversation_item.call_id = item.call_id
conversation_item.name = item.name
conversation_item.arguments = item.arguments
elif item.type == "function_call_output":
conversation_item.type = "function_call_output"
conversation_item.call_id = item.call_id
conversation_item.output = item.output
elif item.type == "message":
role = "system" if item.role == "developer" else item.role
conversation_item.type = "message"
conversation_item.role = role
content_list: list[ConversationItemContent] = []
for c in item.content:
if isinstance(c, str):
content_list.append(
ConversationItemContent(
type=("text" if role == "assistant" else "input_text"),
text=c,
)
)
elif isinstance(c, llm.ImageContent):
continue # not supported for now
elif isinstance(c, llm.AudioContent):
if conversation_item.role == "user":
encoded_audio = base64.b64encode(rtc.combine_audio_frames(c.frame).data).decode(
"utf-8"
)
content_list.append(
ConversationItemContent(
type="input_audio",
audio=encoded_audio,
transcript=c.transcript,
)
)
conversation_item.content = content_list
return conversation_item
def _openai_item_to_livekit_item(item: ConversationItem) -> llm.ChatItem:
assert item.id is not None, "id is None"
if item.type == "function_call":
assert item.call_id is not None, "call_id is None"
assert item.name is not None, "name is None"
assert item.arguments is not None, "arguments is None"
return llm.FunctionCall(
id=item.id,
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
)
if item.type == "function_call_output":
assert item.call_id is not None, "call_id is None"
assert item.output is not None, "output is None"
return llm.FunctionCallOutput(
id=item.id,
call_id=item.call_id,
output=item.output,
is_error=False,
)
if item.type == "message":
assert item.role is not None, "role is None"
assert item.content is not None, "content is None"
content: list[llm.ChatContent] = []
for c in item.content:
if c.type == "text" or c.type == "input_text":
assert c.text is not None, "text is None"
content.append(c.text)
return llm.ChatMessage(
id=item.id,
role=item.role,
content=content,
)
raise ValueError(f"unsupported item type: {item.type}")
def _create_mock_audio_item(duration: float = 2) -> llm.ChatMessage:
audio_data = b"\x00\x00" * (SAMPLE_RATE * duration)
return llm.ChatMessage(
id=utils.shortuuid(_MOCK_AUDIO_ID_PREFIX),
role="user",
content=[
llm.AudioContent(
frame=[
rtc.AudioFrame(
data=audio_data,
sample_rate=SAMPLE_RATE,
num_channels=1,
samples_per_channel=len(audio_data) // 2,
)
]
)
],
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import json
import os
import time
import weakref
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import aiohttp
import httpx
import openai
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from openai.types.audio import TranscriptionVerbose
from openai.types.beta.realtime.transcription_session_update_param import (
SessionTurnDetection,
)
from .log import logger
from .models import GroqAudioModels, STTModels
from .utils import AsyncAzureADTokenProvider
# OpenAI Realtime API has a timeout of 15 mins, we'll attempt to restart the session
# before that timeout is reached
_max_session_duration = 10 * 60
# emit interim transcriptions every 0.5 seconds
_delta_transcript_interval = 0.5
SAMPLE_RATE = 24000
NUM_CHANNELS = 1
@dataclass
class _STTOptions:
model: STTModels | str
language: str
detect_language: bool
turn_detection: SessionTurnDetection
prompt: NotGivenOr[str] = NOT_GIVEN
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN
class STT(stt.STT):
def __init__(
self,
*,
language: str = "en",
detect_language: bool = False,
model: STTModels | str = "gpt-4o-mini-transcribe",
prompt: NotGivenOr[str] = NOT_GIVEN,
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
client: openai.AsyncClient | None = None,
use_realtime: bool = False,
):
"""
Create a new instance of OpenAI STT.
Args:
language: The language code to use for transcription (e.g., "en" for English).
detect_language: Whether to automatically detect the language.
model: The OpenAI model to use for transcription.
prompt: Optional text prompt to guide the transcription. Only supported for whisper-1.
turn_detection: When using realtime transcription, this controls how model detects the user is done speaking.
Final transcripts are generated only after the turn is over. See: https://platform.openai.com/docs/guides/realtime-vad
noise_reduction_type: Type of noise reduction to apply. "near_field" or "far_field"
This isn't needed when using LiveKit's noise cancellation.
base_url: Custom base URL for OpenAI API.
api_key: Your OpenAI API key. If not provided, will use the OPENAI_API_KEY environment variable.
client: Optional pre-configured OpenAI AsyncClient instance.
use_realtime: Whether to use the realtime transcription API. (default: False)
""" # noqa: E501
super().__init__(
capabilities=stt.STTCapabilities(streaming=use_realtime, interim_results=use_realtime)
)
if detect_language:
language = ""
if not is_given(turn_detection):
turn_detection = {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 600,
"silence_duration_ms": 350,
}
self._opts = _STTOptions(
language=language,
detect_language=detect_language,
model=model,
prompt=prompt,
turn_detection=turn_detection,
)
if is_given(noise_reduction_type):
self._opts.noise_reduction_type = noise_reduction_type
self._client = client or openai.AsyncClient(
max_retries=0,
api_key=api_key if is_given(api_key) else None,
base_url=base_url if is_given(base_url) else None,
http_client=httpx.AsyncClient(
timeout=httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
follow_redirects=True,
limits=httpx.Limits(
max_connections=50,
max_keepalive_connections=50,
keepalive_expiry=120,
),
),
)
self._streams = weakref.WeakSet[SpeechStream]()
self._session: aiohttp.ClientSession | None = None
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
max_session_duration=_max_session_duration,
connect_cb=self._connect_ws,
close_cb=self._close_ws,
)
@staticmethod
def with_azure(
*,
language: str = "en",
detect_language: bool = False,
model: STTModels | str = "gpt-4o-mini-transcribe",
prompt: NotGivenOr[str] = NOT_GIVEN,
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
azure_endpoint: str | None = None,
azure_deployment: str | None = None,
api_version: str | None = None,
api_key: str | None = None,
azure_ad_token: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
organization: str | None = None,
project: str | None = None,
base_url: str | None = None,
use_realtime: bool = False,
timeout: httpx.Timeout | None = None,
) -> STT:
"""
Create a new instance of Azure OpenAI STT.
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `api_key` from `AZURE_OPENAI_API_KEY`
- `organization` from `OPENAI_ORG_ID`
- `project` from `OPENAI_PROJECT_ID`
- `azure_ad_token` from `AZURE_OPENAI_AD_TOKEN`
- `api_version` from `OPENAI_API_VERSION`
- `azure_endpoint` from `AZURE_OPENAI_ENDPOINT`
""" # noqa: E501
azure_client = openai.AsyncAzureOpenAI(
max_retries=0,
azure_endpoint=azure_endpoint,
azure_deployment=azure_deployment,
api_version=api_version,
api_key=api_key,
azure_ad_token=azure_ad_token,
azure_ad_token_provider=azure_ad_token_provider,
organization=organization,
project=project,
base_url=base_url,
timeout=timeout
if timeout
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
) # type: ignore
return STT(
language=language,
detect_language=detect_language,
model=model,
prompt=prompt,
turn_detection=turn_detection,
noise_reduction_type=noise_reduction_type,
client=azure_client,
use_realtime=use_realtime,
)
@staticmethod
def with_groq(
*,
model: GroqAudioModels | str = "whisper-large-v3-turbo",
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
client: openai.AsyncClient | None = None,
language: str = "en",
detect_language: bool = False,
prompt: NotGivenOr[str] = NOT_GIVEN,
) -> STT:
"""
Create a new instance of Groq STT.
``api_key`` must be set to your Groq API key, either using the argument or by setting
the ``GROQ_API_KEY`` environmental variable.
"""
groq_api_key = api_key if is_given(api_key) else os.environ.get("GROQ_API_KEY")
if not groq_api_key:
raise ValueError("Groq API key is required")
if not is_given(base_url):
base_url = "https://api.groq.com/openai/v1"
return STT(
model=model,
api_key=groq_api_key,
base_url=base_url,
client=client,
language=language,
detect_language=detect_language,
prompt=prompt,
use_realtime=False,
)
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
if is_given(language):
self._opts.language = language
stream = SpeechStream(
stt=self,
pool=self._pool,
conn_options=conn_options,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
model: NotGivenOr[STTModels | GroqAudioModels | str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
detect_language: NotGivenOr[bool] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
) -> None:
"""
Update the options for the speech stream. Most options are updated at the
connection level. SpeechStreams will be recreated when options are updated.
Args:
language: The language to transcribe in.
detect_language: Whether to automatically detect the language.
model: The model to use for transcription.
prompt: Optional text prompt to guide the transcription. Only supported for whisper-1.
turn_detection: When using realtime, this controls how model detects the user is done speaking.
noise_reduction_type: Type of noise reduction to apply. "near_field" or "far_field"
""" # noqa: E501
if is_given(model):
self._opts.model = model
if is_given(language):
self._opts.language = language
if is_given(detect_language):
self._opts.detect_language = detect_language
self._opts.language = ""
if is_given(prompt):
self._opts.prompt = prompt
if is_given(turn_detection):
self._opts.turn_detection = turn_detection
if is_given(noise_reduction_type):
self._opts.noise_reduction_type = noise_reduction_type
for stream in self._streams:
if is_given(language):
stream.update_options(language=language)
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
prompt = self._opts.prompt if is_given(self._opts.prompt) else ""
realtime_config: dict[str, Any] = {
"type": "transcription_session.update",
"session": {
"input_audio_format": "pcm16",
"input_audio_transcription": {
"model": self._opts.model,
"prompt": prompt,
},
"turn_detection": self._opts.turn_detection,
},
}
if self._opts.language:
realtime_config["session"]["input_audio_transcription"]["language"] = (
self._opts.language
)
if self._opts.noise_reduction_type:
realtime_config["session"]["input_audio_noise_reduction"] = {
"type": self._opts.noise_reduction_type
}
query_params: dict[str, str] = {
"intent": "transcription",
}
headers = {
"User-Agent": "LiveKit Agents",
"Authorization": f"Bearer {self._client.api_key}",
"OpenAI-Beta": "realtime=v1",
}
url = f"{str(self._client.base_url).rstrip('/')}/realtime?{urlencode(query_params)}"
if url.startswith("http"):
url = url.replace("http", "ws", 1)
session = self._ensure_session()
ws = await asyncio.wait_for(
session.ws_connect(url, headers=headers),
DEFAULT_API_CONNECT_OPTIONS.timeout,
)
await ws.send_json(realtime_config)
return ws
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
try:
if is_given(language):
self._opts.language = language
data = rtc.combine_audio_frames(buffer).to_wav_bytes()
prompt = self._opts.prompt if is_given(self._opts.prompt) else openai.NOT_GIVEN
format = "json"
if self._opts.model == "whisper-1":
# verbose_json returns language and other details, only supported for whisper-1
format = "verbose_json"
resp = await self._client.audio.transcriptions.create(
file=(
"file.wav",
data,
"audio/wav",
),
model=self._opts.model, # type: ignore
language=self._opts.language,
prompt=prompt,
response_format=format,
timeout=httpx.Timeout(30, connect=conn_options.timeout),
)
sd = stt.SpeechData(text=resp.text, language=self._opts.language)
if isinstance(resp, TranscriptionVerbose) and resp.language:
sd.language = resp.language
return stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[sd],
)
except openai.APITimeoutError:
raise APITimeoutError() from None
except openai.APIStatusError as e:
raise APIStatusError(
e.message, status_code=e.status_code, request_id=e.request_id, body=e.body
) from None
except Exception as e:
raise APIConnectionError() from e
class SpeechStream(stt.SpeechStream):
def __init__(
self,
*,
stt: STT,
conn_options: APIConnectOptions,
pool: utils.ConnectionPool[aiohttp.ClientWebSocketResponse],
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
self._pool = pool
self._language = stt._opts.language
self._request_id = ""
self._reconnect_event = asyncio.Event()
def update_options(
self,
*,
language: str,
):
self._language = language
self._pool.invalidate()
self._reconnect_event.set()
@utils.log_exceptions(logger=logger)
async def _run(self) -> None:
closing_ws = False
@utils.log_exceptions(logger=logger)
async def send_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
# forward audio to OAI in chunks of 50ms
audio_bstream = utils.audio.AudioByteStream(
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=SAMPLE_RATE // 20,
)
async for data in self._input_ch:
frames: list[rtc.AudioFrame] = []
if isinstance(data, rtc.AudioFrame):
frames.extend(audio_bstream.write(data.data.tobytes()))
elif isinstance(data, self._FlushSentinel):
frames.extend(audio_bstream.flush())
for frame in frames:
encoded_frame = {
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame.data.tobytes()).decode("utf-8"),
}
await ws.send_json(encoded_frame)
closing_ws = True
@utils.log_exceptions(logger=logger)
async def recv_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
current_text = ""
last_interim_at: float = 0
connected_at = time.time()
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws: # close is expected, see SpeechStream.aclose
return
# this will trigger a reconnection, see the _run loop
raise APIStatusError(
message="OpenAI Realtime STT connection closed unexpectedly"
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected OpenAI message type %s", msg.type)
continue
try:
data = json.loads(msg.data)
msg_type = data.get("type")
if msg_type == "conversation.item.input_audio_transcription.delta":
delta = data.get("delta", "")
if delta:
current_text += delta
if time.time() - last_interim_at > _delta_transcript_interval:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[
stt.SpeechData(
text=current_text,
language=self._language,
)
],
)
)
last_interim_at = time.time()
elif msg_type == "conversation.item.input_audio_transcription.completed":
current_text = ""
transcript = data.get("transcript", "")
if transcript:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
text=transcript,
language=self._language,
)
],
)
)
# restart session if needed
if time.time() - connected_at > _max_session_duration:
logger.info("resetting Realtime STT session due to timeout")
self._pool.remove(ws)
self._reconnect_event.set()
return
except Exception:
logger.exception("failed to process OpenAI message")
while True:
async with self._pool.connection() as ws:
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[asyncio.gather(*tasks), wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
) # type: ignore
# propagate exceptions from completed tasks
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Literal, Union
import httpx
import openai
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import TTSModels, TTSVoices
from .utils import AsyncAzureADTokenProvider
OPENAI_TTS_SAMPLE_RATE = 48000
OPENAI_TTS_CHANNELS = 1
DEFAULT_MODEL = "gpt-4o-mini-tts"
DEFAULT_VOICE = "ash"
_RESPONSE_FORMATS = Union[Literal["mp3", "opus", "aac", "flac", "wav", "pcm"], str]
@dataclass
class _TTSOptions:
model: TTSModels | str
voice: TTSVoices | str
speed: float
instructions: str | None
response_format: _RESPONSE_FORMATS
class TTS(tts.TTS):
def __init__(
self,
*,
model: TTSModels | str = DEFAULT_MODEL,
voice: TTSVoices | str = DEFAULT_VOICE,
speed: float = 1.0,
instructions: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
client: openai.AsyncClient | None = None,
response_format: NotGivenOr[_RESPONSE_FORMATS] = NOT_GIVEN,
) -> None:
"""
Create a new instance of OpenAI TTS.
``api_key`` must be set to your OpenAI API key, either using the argument or by setting the
``OPENAI_API_KEY`` environmental variable.
"""
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=OPENAI_TTS_SAMPLE_RATE,
num_channels=OPENAI_TTS_CHANNELS,
)
self._opts = _TTSOptions(
model=model,
voice=voice,
speed=speed,
instructions=instructions if is_given(instructions) else None,
response_format=response_format if is_given(response_format) else "opus",
)
self._client = client or openai.AsyncClient(
max_retries=0,
api_key=api_key if is_given(api_key) else None,
base_url=base_url if is_given(base_url) else None,
http_client=httpx.AsyncClient(
timeout=httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
follow_redirects=True,
limits=httpx.Limits(
max_connections=50,
max_keepalive_connections=50,
keepalive_expiry=120,
),
),
)
def update_options(
self,
*,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
voice: NotGivenOr[TTSVoices | str] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
instructions: NotGivenOr[str] = NOT_GIVEN,
) -> None:
if is_given(model):
self._opts.model = model
if is_given(voice):
self._opts.voice = voice
if is_given(speed):
self._opts.speed = speed
if is_given(instructions):
self._opts.instructions = instructions
@staticmethod
def with_azure(
*,
model: TTSModels | str = DEFAULT_MODEL,
voice: TTSVoices | str = DEFAULT_VOICE,
speed: float = 1.0,
instructions: NotGivenOr[str] = NOT_GIVEN,
azure_endpoint: str | None = None,
azure_deployment: str | None = None,
api_version: str | None = None,
api_key: str | None = None,
azure_ad_token: str | None = None,
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
organization: str | None = None,
project: str | None = None,
base_url: str | None = None,
response_format: NotGivenOr[_RESPONSE_FORMATS] = NOT_GIVEN,
timeout: httpx.Timeout | None = None,
) -> TTS:
"""
Create a new instance of Azure OpenAI TTS.
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
- `api_key` from `AZURE_OPENAI_API_KEY`
- `organization` from `OPENAI_ORG_ID`
- `project` from `OPENAI_PROJECT_ID`
- `azure_ad_token` from `AZURE_OPENAI_AD_TOKEN`
- `api_version` from `OPENAI_API_VERSION`
- `azure_endpoint` from `AZURE_OPENAI_ENDPOINT`
""" # noqa: E501
azure_client = openai.AsyncAzureOpenAI(
max_retries=0,
azure_endpoint=azure_endpoint,
azure_deployment=azure_deployment,
api_version=api_version,
api_key=api_key,
azure_ad_token=azure_ad_token,
azure_ad_token_provider=azure_ad_token_provider,
organization=organization,
project=project,
base_url=base_url,
timeout=timeout
if timeout
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
) # type: ignore
return TTS(
model=model,
voice=voice,
speed=speed,
instructions=instructions,
client=azure_client,
response_format=response_format,
)
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
client=self._client,
)
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
input_text: str,
conn_options: APIConnectOptions,
opts: _TTSOptions,
client: openai.AsyncClient,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._client = client
self._opts = opts
async def _run(self):
oai_stream = self._client.audio.speech.with_streaming_response.create(
input=self.input_text,
model=self._opts.model,
voice=self._opts.voice,
response_format=self._opts.response_format,
speed=self._opts.speed,
instructions=self._opts.instructions or openai.NOT_GIVEN,
timeout=httpx.Timeout(30, connect=self._conn_options.timeout),
)
request_id = utils.shortuuid()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=OPENAI_TTS_SAMPLE_RATE,
num_channels=OPENAI_TTS_CHANNELS,
)
@utils.log_exceptions(logger=logger)
async def _decode_loop():
try:
async with oai_stream as stream:
async for data in stream.iter_bytes():
decoder.push(data)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
try:
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
await decode_task # await here to raise the error if any
except openai.APITimeoutError:
raise APITimeoutError() from None
except openai.APIStatusError as e:
raise APIStatusError(
e.message, status_code=e.status_code, request_id=e.request_id, body=e.body
) from None
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.cancel_and_wait(decode_task)
await decoder.aclose()
from __future__ import annotations
import base64
import os
from collections import OrderedDict
from collections.abc import Awaitable
from dataclasses import dataclass, field
from typing import Any, Callable, Union
from livekit.agents import llm
from livekit.agents.llm.tool_context import (
get_raw_function_info,
is_function_tool,
is_raw_function_tool,
)
from livekit.agents.log import logger
from openai.types.chat import (
ChatCompletionContentPartParam,
ChatCompletionMessageParam,
ChatCompletionToolParam,
)
AsyncAzureADTokenProvider = Callable[[], Union[str, Awaitable[str]]]
def get_base_url(base_url: str | None) -> str:
if not base_url:
base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
return base_url
def to_fnc_ctx(
fnc_ctx: list[llm.FunctionTool | llm.RawFunctionTool],
) -> list[ChatCompletionToolParam]:
tools: list[ChatCompletionToolParam] = []
for fnc in fnc_ctx:
if is_raw_function_tool(fnc):
info = get_raw_function_info(fnc)
tools.append(
{
"type": "function",
"function": info.raw_schema, # type: ignore
}
)
elif is_function_tool(fnc):
tools.append(llm.utils.build_strict_openai_schema(fnc)) # type: ignore
return tools
@dataclass
class _ChatItemGroup:
message: llm.ChatMessage | None = None
tool_calls: list[llm.FunctionCall] = field(default_factory=list)
tool_outputs: list[llm.FunctionCallOutput] = field(default_factory=list)
def add(self, item: llm.ChatItem) -> _ChatItemGroup:
if item.type == "message":
assert self.message is None, "only one message is allowed in a group"
self.message = item
elif item.type == "function_call":
self.tool_calls.append(item)
elif item.type == "function_call_output":
self.tool_outputs.append(item)
return self
def to_chat_items(self, cache_key: Any) -> list[ChatCompletionMessageParam]:
tool_calls = {tool_call.call_id: tool_call for tool_call in self.tool_calls}
tool_outputs = {tool_output.call_id: tool_output for tool_output in self.tool_outputs}
valid_tools = set(tool_calls.keys()) & set(tool_outputs.keys())
# remove invalid tool calls and tool outputs
if len(tool_calls) != len(valid_tools) or len(tool_outputs) != len(valid_tools):
for tool_call in self.tool_calls:
if tool_call.call_id not in valid_tools:
logger.warning(
"function call missing the corresponding function output, ignoring",
extra={"call_id": tool_call.call_id, "tool_name": tool_call.name},
)
tool_calls.pop(tool_call.call_id)
for tool_output in self.tool_outputs:
if tool_output.call_id not in valid_tools:
logger.warning(
"function output missing the corresponding function call, ignoring",
extra={"call_id": tool_output.call_id, "tool_name": tool_output.name},
)
tool_outputs.pop(tool_output.call_id)
if not self.message and not tool_calls and not tool_outputs:
return []
msg = (
_to_chat_item(self.message, cache_key)
if self.message
else {"role": "assistant", "tool_calls": []}
)
if tool_calls:
msg.setdefault("tool_calls", [])
for tool_call in tool_calls.values():
msg["tool_calls"].append(
{
"id": tool_call.call_id,
"type": "function",
"function": {"name": tool_call.name, "arguments": tool_call.arguments},
}
)
items = [msg]
for tool_output in tool_outputs.values():
items.append(_to_chat_item(tool_output, cache_key))
return items
def to_chat_ctx(chat_ctx: llm.ChatContext, cache_key: Any) -> list[ChatCompletionMessageParam]:
# OAI requires the tool calls to be followed by the corresponding tool outputs
# we group them first and remove invalid tool calls and outputs before converting
item_groups: dict[str, _ChatItemGroup] = OrderedDict() # item_id to group of items
tool_outputs: list[llm.FunctionCallOutput] = []
for item in chat_ctx.items:
if (item.type == "message" and item.role == "assistant") or item.type == "function_call":
# only assistant messages and function calls can be grouped
group_id = item.id.split("/")[0]
if group_id not in item_groups:
item_groups[group_id] = _ChatItemGroup().add(item)
else:
item_groups[group_id].add(item)
elif item.type == "function_call_output":
tool_outputs.append(item)
else:
item_groups[item.id] = _ChatItemGroup().add(item)
# add tool outputs to their corresponding groups
call_id_to_group: dict[str, _ChatItemGroup] = {
tool_call.call_id: group for group in item_groups.values() for tool_call in group.tool_calls
}
for tool_output in tool_outputs:
if tool_output.call_id not in call_id_to_group:
logger.warning(
"function output missing the corresponding function call, ignoring",
extra={"call_id": tool_output.call_id, "tool_name": tool_output.name},
)
continue
call_id_to_group[tool_output.call_id].add(tool_output)
messages = []
for group in item_groups.values():
messages.extend(group.to_chat_items(cache_key))
return messages
def _to_chat_item(msg: llm.ChatItem, cache_key: Any) -> ChatCompletionMessageParam:
if msg.type == "message":
list_content: list[ChatCompletionContentPartParam] = []
text_content = ""
for content in msg.content:
if isinstance(content, str):
if text_content:
text_content += "\n"
text_content += content
elif isinstance(content, llm.ImageContent):
list_content.append(_to_image_content(content, cache_key))
if not list_content:
# certain providers require text-only content in a string vs a list.
# for max-compatibility, we will combine all text content into a single string.
return {
"role": msg.role, # type: ignore
"content": text_content,
}
if text_content:
list_content.append({"type": "text", "text": text_content})
return {
"role": msg.role, # type: ignore
"content": list_content,
}
elif msg.type == "function_call":
return {
"role": "assistant",
"tool_calls": [
{
"id": msg.call_id,
"type": "function",
"function": {
"name": msg.name,
"arguments": msg.arguments,
},
}
],
}
elif msg.type == "function_call_output":
return {
"role": "tool",
"tool_call_id": msg.call_id,
"content": msg.output,
}
def _to_image_content(image: llm.ImageContent, cache_key: Any) -> ChatCompletionContentPartParam:
img = llm.utils.serialize_image(image)
if img.external_url:
return {
"type": "image_url",
"image_url": {
"url": img.external_url,
"detail": img.inference_detail,
},
}
if cache_key not in image._cache:
image._cache[cache_key] = img.data_bytes
b64_data = base64.b64encode(image._cache[cache_key]).decode("utf-8")
return {
"type": "image_url",
"image_url": {
"url": f"data:{img.mime_type};base64,{b64_data}",
"detail": img.inference_detail,
},
}
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-openai"
dynamic = ["version"]
description = "Agent Framework plugin for services from OpenAI"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents[codecs, images]>=1.0.17",
"openai[realtime]>=1.68.2",
]
[project.optional-dependencies]
vertex = ["google-auth>=2.0.0"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/openai/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins PlayAI/PlayHT
Agent Framework plugin for voice synthesis with [PlayAI](https://play.ai/) API.
## Installation
```bash
pip install livekit-plugins-playai
You’ll need USER ID and API Secret KEY from PlayHT. It can be set as an environment variable: PLAYHT_USER_ID
, PLAYHT_API_KEY
get it from here
## livekit-plugins/livekit-plugins-playai/livekit/plugins/playai/__init__.py
```py
from .tts import TTS
from .version import __version__
__all__ = [
"TTS",
"__version__",
]
from livekit.agents import Plugin
class PlayAIPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(PlayAIPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.playai")
from typing import Literal
from pyht.client import Format # type: ignore
TTSModel = Literal["Play3.0-mini", "PlayDialog", "PlayDialog-turbo"]
FORMAT = Literal["mp3"]
format_mapping = {
"mp3": Format.FORMAT_MP3,
}
from __future__ import annotations
import asyncio
import os
import weakref
from dataclasses import dataclass, fields
from pyht import AsyncClient as PlayHTAsyncClient # type: ignore
from pyht.client import (
Format, # type: ignore
Language, # type: ignore
TTSOptions, # type: ignore
)
from livekit.agents import APIConnectionError, APIConnectOptions, tokenize, tts, utils
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import TTSModel
NUM_CHANNELS = 1
@dataclass
class _Options:
model: TTSModel | str
tts_options: TTSOptions
word_tokenizer: tokenize.WordTokenizer
class TTS(tts.TTS):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
user_id: NotGivenOr[str] = NOT_GIVEN,
voice: str = "s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json",
language: str = "english",
sample_rate: int = 24000,
model: TTSModel | str = "Play3.0-mini",
word_tokenizer: tokenize.WordTokenizer | None = None,
**kwargs,
) -> None:
"""
Initialize the PlayAI TTS engine.
Args:
api_key (str): PlayAI API key.
user_id (str): PlayAI user ID.
voice (str): Voice manifest URL.
model (TTSModel): TTS model, defaults to "Play3.0-mini".
language (str): language, defaults to "english".
sample_rate (int): sample rate (Hz), A number greater than or equal to 8000, and must be less than or equal to 48000
word_tokenizer (tokenize.WordTokenizer): Tokenizer for processing text. Defaults to basic WordTokenizer.
**kwargs: Additional options.
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=True,
),
sample_rate=sample_rate,
num_channels=1,
)
if not word_tokenizer:
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
pyht_api_key = api_key if is_given(api_key) else os.environ.get("PLAYHT_API_KEY")
pyht_user_id = user_id if is_given(user_id) else os.environ.get("PLAYHT_USER_ID")
if not pyht_api_key or not pyht_user_id:
raise ValueError(
"PlayHT API key and user ID are required. Set environment variables PLAYHT_API_KEY and PLAYHT_USER_ID or pass them explicitly." # noqa: E501
)
_validate_kwargs(kwargs)
self._config = TTSOptions(
voice=voice,
format=Format.FORMAT_OGG, # Using OGG format for AudioDecoder
sample_rate=sample_rate,
language=Language(language),
**kwargs,
)
self._opts = _Options(
model=model,
tts_options=self._config,
word_tokenizer=word_tokenizer,
)
self._client = PlayHTAsyncClient(
user_id=pyht_user_id,
api_key=pyht_api_key,
)
self._streams = weakref.WeakSet[SynthesizeStream]()
def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[TTSModel | str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
**kwargs,
) -> None:
"""
Update the TTS options.
"""
updates = {}
if is_given(voice):
updates["voice"] = voice
if is_given(language):
updates["language"] = Language(language)
updates.update(kwargs)
_validate_kwargs(updates)
for key, value in updates.items():
if value is not None:
setattr(self._config, key, value)
if is_given(model):
self._opts.model = model
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SynthesizeStream:
stream = SynthesizeStream(
tts=self,
conn_options=conn_options,
opts=self._opts,
)
self._streams.add(stream)
return stream
class ChunkedStream(tts.ChunkedStream):
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _Options,
conn_options: APIConnectOptions,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._client = tts._client
self._opts = opts
self._config = self._opts.tts_options
async def _run(self) -> None:
request_id = utils.shortuuid()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._config.sample_rate,
num_channels=NUM_CHANNELS,
)
decode_task: asyncio.Task | None = None
try:
# Create a task to push data to the decoder
async def _decode_loop():
try:
async for chunk in self._client.tts(
text=self._input_text,
options=self._config,
voice_engine=self._opts.model,
protocol="http",
streaming=True,
):
decoder.push(chunk)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
class SynthesizeStream(tts.SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
opts: _Options,
conn_options: APIConnectOptions,
):
super().__init__(tts=tts, conn_options=conn_options)
self._client = tts._client
self._opts = opts
self._config = self._opts.tts_options
self._segments_ch = utils.aio.Chan[tokenize.WordStream]()
async def _run(self) -> None:
request_id = utils.shortuuid()
segment_id = utils.shortuuid()
input_task = asyncio.create_task(self._tokenize_input())
if self._opts.model == "PlayDialog-turbo":
protocol = "http"
else:
protocol = "ws"
try:
text_stream = await self._create_text_stream()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._config.sample_rate,
num_channels=NUM_CHANNELS,
)
# Create tasks for pushing data to decoder and generating events
async def decode_loop():
try:
async for chunk in self._client.stream_tts_input(
text_stream=text_stream,
options=self._config,
voice_engine=self._opts.model,
protocol=protocol,
):
decoder.push(chunk)
finally:
decoder.end_input()
decode_task = asyncio.create_task(decode_loop())
try:
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
finally:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(input_task)
@utils.log_exceptions(logger=logger)
async def _tokenize_input(self):
# Converts incoming text into WordStreams and sends them into _segments_ch
word_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if word_stream is None:
word_stream = self._opts.word_tokenizer.stream()
self._segments_ch.send_nowait(word_stream)
word_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if word_stream:
word_stream.end_input()
word_stream = None
self._segments_ch.close()
@utils.log_exceptions(logger=logger)
async def _create_text_stream(self):
async def text_stream():
async for word_stream in self._segments_ch:
async for word in word_stream:
self._mark_started()
yield word.token
return text_stream()
def _validate_kwargs(kwargs: dict) -> None:
valid_keys = {field.name for field in fields(TTSOptions)}
invalid_keys = set(kwargs.keys()) - valid_keys
if invalid_keys:
raise ValueError(f"Invalid parameters: {invalid_keys}. Allowed parameters: {valid_keys}")
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-playai"
dynamic = ["version"]
description = "Agent Framework plugin for voice synthesis with PlayAI's API."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "livekit", "playHT", "playAI"]
classifiers = [
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents[codecs]>=1.0.17",
"pyht>=0.1.14",
"aiohttp",
"livekit",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/playai/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Resemble
Agent Framework plugin for voice synthesis with the [Resemble AI](https://www.resemble.ai/) API, using both their REST API and WebSocket streaming interface.
## Installation
```bash
pip install livekit-plugins-resemble
You’ll need an API key from Resemble AI. It can be set as an environment variable: RESEMBLE_API_KEY
Additionally, you’ll need the voice UUID from your Resemble AI account.
import asyncio
from livekit.plugins.resemble import TTS
async def run_tts_example():
# Use TTS with async context manager for automatic resource cleanup
async with TTS(
api_key="your_api_key", # or set RESEMBLE_API_KEY environment variable
voice_uuid="your_voice_uuid",
# Optional parameters
sample_rate=44100, # Sample rate in Hz (default: 44100)
precision="PCM_16", # Audio precision (PCM_32, PCM_24, PCM_16, MULAW)
output_format="wav", # Output format (wav or mp3)
) as tts:
# One-off synthesis (uses REST API)
audio_stream = tts.synthesize("Hello, world!")
# Process chunks as they arrive
async for chunk in audio_stream:
# Audio data is in the 'frame.data' attribute of SynthesizedAudio objects
audio_data = chunk.frame.data
print(f"Received chunk: {len(audio_data)} bytes")
# Alternative: collect all audio at once into a single AudioFrame
audio_stream = tts.synthesize("Another example sentence.")
audio_frame = await audio_stream.collect()
print(f"Collected complete audio: {len(audio_frame.data)} bytes")
# Real-time streaming synthesis (uses WebSocket API)
# Only available for Business plan users in Resemble AI
stream = tts.stream()
await stream.synthesize_text("Hello, world!")
# Run the example
asyncio.run(run_tts_example())
If you prefer to manage resources manually, make sure to properly clean up:
import asyncio
from livekit.plugins.resemble import TTS
async def run_tts_example():
# Initialize TTS with your credentials
tts = TTS(
api_key="your_api_key",
voice_uuid="your_voice_uuid",
)
try:
# TTS operations
audio_stream = tts.synthesize("Hello, world!")
async for chunk in audio_stream:
# Access audio data correctly
process_audio(chunk.frame.data)
finally:
# Always clean up resources when done
await tts.aclose()
# Run the example
asyncio.run(run_tts_example())
When using this plugin outside of the LiveKit agent framework, it’s important to properly manage the TTS instance lifecycle:
async with TTS(...) as tts:
)await tts.aclose()
in a finally blockhttp_session
parameter:import aiohttp
async def with_custom_session():
async with aiohttp.ClientSession() as session:
async with TTS(
api_key="your_api_key",
voice_uuid="your_voice_uuid",
http_session=session
) as tts:
# Use TTS...
# No need to manually close anything - context managers handle it all
This plugin uses two different approaches to generate speech:
The WebSocket streaming API is only available for Resemble AI Business plan users.
## livekit-plugins/livekit-plugins-resemble/livekit/plugins/resemble/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .tts import TTS, ChunkedStream, SynthesizeStream
from .version import __version__
__all__ = ["TTS", "ChunkedStream", "SynthesizeStream", "__version__"]
from livekit.agents import Plugin
class ResemblePlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(ResemblePlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.resemble")
from enum import Enum
class Precision(str, Enum):
PCM_16 = "PCM_16"
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import json
import os
import weakref
from dataclasses import dataclass
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
from .log import logger
RESEMBLE_WEBSOCKET_URL = "wss://websocket.cluster.resemble.ai/stream"
RESEMBLE_REST_API_URL = "https://f.cluster.resemble.ai/synthesize"
NUM_CHANNELS = 1
DEFAULT_VOICE_UUID = "55592656"
BUFFERED_WORDS_COUNT = 3
@dataclass
class _TTSOptions:
voice_uuid: str
sample_rate: int
tokenizer: tokenize.SentenceTokenizer
class TTS(tts.TTS):
def __init__(
self,
*,
api_key: str | None = None,
voice_uuid: str | None = None,
tokenizer: tokenize.SentenceTokenizer | None = None,
sample_rate: int = 44100,
http_session: aiohttp.ClientSession | None = None,
use_streaming: bool = True,
) -> None:
"""
Create a new instance of the Resemble TTS.
See https://docs.app.resemble.ai/docs/text_to_speech/ for more documentation on all of these options.
Args:
voice_uuid (str, optional): The voice UUID for the desired voice. Defaults to None.
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 44100.
api_key (str | None, optional): The Resemble API key. If not provided, it will be read from the RESEMBLE_API_KEY environment variable.
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
tokenizer (tokenize.SentenceTokenizer, optional): The tokenizer to use. Defaults to tokenize.SentenceTokenizer().
use_streaming (bool, optional): Whether to use streaming or not. Defaults to True.
""" # noqa: E501
super().__init__(
capabilities=tts.TTSCapabilities(streaming=use_streaming),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
api_key = api_key or os.environ.get("RESEMBLE_API_KEY")
if not api_key:
raise ValueError(
"Resemble API key is required, either as argument or set RESEMBLE_API_KEY"
" environment variable"
)
self._api_key = api_key
if tokenizer is None:
tokenizer = tokenize.basic.SentenceTokenizer(min_sentence_len=BUFFERED_WORDS_COUNT)
if voice_uuid is None:
voice_uuid = DEFAULT_VOICE_UUID
self._opts = _TTSOptions(
voice_uuid=voice_uuid,
sample_rate=sample_rate,
tokenizer=tokenizer,
)
self._session = http_session
self._streams = weakref.WeakSet[SynthesizeStream]()
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
)
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
return await asyncio.wait_for(
session.ws_connect(
RESEMBLE_WEBSOCKET_URL,
headers={"Authorization": f"Bearer {self._api_key}"},
),
self._conn_options.timeout,
)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse):
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def prewarm(self) -> None:
self._pool.prewarm()
def update_options(
self,
*,
voice_uuid: str | None = None,
sample_rate: int | None = None,
) -> None:
"""
Update the Text-to-Speech (TTS) configuration options.
Args:
voice_uuid (str, optional): The voice UUID for the desired voice.
sample_rate (int, optional): The audio sample rate in Hz.
""" # noqa: E501
self._opts.voice_uuid = voice_uuid or self._opts.voice_uuid
self._opts.sample_rate = sample_rate or self._opts.sample_rate
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions | None = None,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options or DEFAULT_API_CONNECT_OPTIONS,
opts=self._opts,
api_key=self._api_key,
session=self._ensure_session(),
)
def stream(self, *, conn_options: APIConnectOptions | None = None) -> SynthesizeStream:
stream = SynthesizeStream(
tts=self,
pool=self._pool,
opts=self._opts,
api_key=self._api_key,
)
self._streams.add(stream)
return stream
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
await super().aclose()
class ChunkedStream(tts.ChunkedStream):
"""Synthesize text into speech in one go using Resemble AI's REST API."""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
conn_options: APIConnectOptions,
api_key: str,
session: aiohttp.ClientSession,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._session, self._api_key = opts, session, api_key
async def _run(self) -> None:
request_id = utils.shortuuid()
# Create request headers
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json", # Expect JSON response
}
# Create request payload
payload = {
"voice_uuid": self._opts.voice_uuid,
"data": self._input_text,
"sample_rate": self._opts.sample_rate,
"precision": "PCM_16",
}
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
)
try:
async with self._session.post(
RESEMBLE_REST_API_URL,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=self._conn_options.timeout,
),
) as response:
response.raise_for_status()
response_json = await response.json()
# Check for success
if not response_json.get("success", False):
issues = response_json.get("issues", ["Unknown error"])
error_msg = "; ".join(issues)
raise APIStatusError(
message=f"Resemble API returned failure: {error_msg}",
status_code=response.status,
request_id=request_id,
body=json.dumps(response_json),
)
# Extract base64-encoded audio content
audio_content_b64 = response_json.get("audio_content")
if not audio_content_b64:
raise APIStatusError(
message="No audio content in response",
status_code=response.status,
request_id=request_id,
body=json.dumps(response_json),
)
# Decode base64 to get raw audio bytes
audio_bytes = base64.b64decode(audio_content_b64)
decoder.push(audio_bytes)
decoder.end_input()
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=f"resemble api error: {str(e)}",
) from e
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientError as e:
raise APIConnectionError(
message=f"Resemble API connection error: {str(e)}",
) from e
except Exception as e:
raise APIConnectionError(f"Error during synthesis: {str(e)}") from e
finally:
await decoder.aclose()
class SynthesizeStream(tts.SynthesizeStream):
"""Stream-based text-to-speech synthesis using Resemble AI WebSocket API.
This implementation connects to Resemble's WebSocket API for real-time streaming
synthesis. Note that this requires a Business plan subscription with Resemble AI.
"""
def __init__(
self,
*,
tts: TTS,
opts: _TTSOptions,
pool: utils.ConnectionPool[aiohttp.ClientWebSocketResponse],
api_key: str,
):
super().__init__(tts=tts)
self._opts, self._pool, self._api_key = opts, pool, api_key
async def _run(self) -> None:
request_id = utils.shortuuid()
self._segments_ch = utils.aio.Chan[tokenize.SentenceStream]()
@utils.log_exceptions(logger=logger)
async def _tokenize_input():
"""tokenize text from the input_ch to words"""
input_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if input_stream is None:
# new segment (after flush for e.g)
input_stream = self._opts.tokenizer.stream()
self._segments_ch.send_nowait(input_stream)
input_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if input_stream is not None:
input_stream.end_input()
input_stream = None
if input_stream is not None:
input_stream.end_input()
self._segments_ch.close()
@utils.log_exceptions(logger=logger)
async def _process_segments():
async for input_stream in self._segments_ch:
await self._run_ws(input_stream)
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_process_segments()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
async def _run_ws(
self,
input_stream: tokenize.SentenceStream,
) -> None:
async with self._pool.connection() as ws:
segment_id = utils.shortuuid()
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
)
index_lock = asyncio.Lock()
current_index = 0
pending_requests = set()
@utils.log_exceptions(logger=logger)
async def _send_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal current_index
index = 0
async for data in input_stream:
payload = {
"voice_uuid": self._opts.voice_uuid,
"data": data.token,
"request_id": index,
"sample_rate": self._opts.sample_rate,
"precision": "PCM_16",
"output_format": "mp3",
}
async with index_lock:
pending_requests.add(index)
index += 1
current_index = index
await ws.send_str(json.dumps(payload))
@utils.log_exceptions(logger=logger)
async def _emit_task():
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=str(current_index),
segment_id=segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
@utils.log_exceptions(logger=logger)
async def _recv_task(ws: aiohttp.ClientWebSocketResponse):
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"Resemble connection closed unexpectedly",
request_id=str(current_index),
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("Unexpected Resemble message type %s", msg.type)
continue
data = json.loads(msg.data)
if data.get("type") == "audio":
if data.get("audio_content", None):
b64data = base64.b64decode(data["audio_content"])
decoder.push(b64data)
elif data.get("type") == "audio_end":
async with index_lock:
index = data["request_id"]
pending_requests.remove(index)
if not pending_requests:
decoder.end_input()
break # we are not going to receive any more audio
else:
logger.error("Unexpected Resemble message %s", data)
tasks = [
asyncio.create_task(_send_task(ws)),
asyncio.create_task(_recv_task(ws)),
asyncio.create_task(_emit_task()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=str(current_index),
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-resemble"
dynamic = ["version"]
description = "LiveKit Agents Plugin for Resemble AI"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/resemble/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Rime
Agent Framework plugin for voice synthesis with the [Rime](https://rime.ai/) API ([documentation](https://rimelabs.mintlify.app/api-reference/quickstart)).
## Installation
```bash
pip install livekit-plugins-rime
You’ll need an API key from Rime. It can be set as an environment variable: RIME_API_KEY
## livekit-plugins/livekit-plugins-rime/livekit/plugins/rime/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .tts import TTS, ChunkedStream
from .version import __version__
__all__ = ["TTS", "ChunkedStream", "__version__"]
from livekit.agents import Plugin
class RimePlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(RimePlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
from typing import Literal
TTSLangs = Literal["eng", "spa", "fra", "ger"]
import logging
logger = logging.getLogger("livekit.plugins.rime")
from typing import Literal
TTSModels = Literal["mistv2", "arcana"]
# https://docs.rime.ai/api-reference/voices
ArcanaVoices = Literal[
"luna", "celeste", "orion", "ursa", "astra", "esther", "estelle", "andromeda"
]
DefaultMistv2Voice = "cove"
# Copyright 202 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .langs import TTSLangs
from .log import logger
from .models import ArcanaVoices, TTSModels
@dataclass
class _TTSOptions:
model: TTSModels | str
speaker: str
arcana_options: _ArcanaOptions | None = None
mistv2_options: _Mistv2Options | None = None
@dataclass
class _ArcanaOptions:
repetition_penalty: NotGivenOr[float] = NOT_GIVEN
temperature: NotGivenOr[float] = NOT_GIVEN
top_p: NotGivenOr[float] = NOT_GIVEN
max_tokens: NotGivenOr[int] = NOT_GIVEN
@dataclass
class _Mistv2Options:
lang: NotGivenOr[TTSLangs | str] = NOT_GIVEN
sample_rate: NotGivenOr[int] = NOT_GIVEN
speed_alpha: NotGivenOr[float] = NOT_GIVEN
reduce_latency: NotGivenOr[bool] = NOT_GIVEN
pause_between_brackets: NotGivenOr[bool] = NOT_GIVEN
phonemize_between_brackets: NotGivenOr[bool] = NOT_GIVEN
DEFAULT_API_URL = "https://users.rime.ai/v1/rime-tts"
NUM_CHANNELS = 1
class TTS(tts.TTS):
def __init__(
self,
*,
model: TTSModels | str = "arcana",
speaker: NotGivenOr[ArcanaVoices | str] = NOT_GIVEN,
# Arcana options
repetition_penalty: NotGivenOr[float] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
top_p: NotGivenOr[float] = NOT_GIVEN,
max_tokens: NotGivenOr[int] = NOT_GIVEN,
# Mistv2 options
lang: TTSLangs | str = "eng",
sample_rate: int = 22050,
speed_alpha: NotGivenOr[float] = NOT_GIVEN,
reduce_latency: NotGivenOr[bool] = NOT_GIVEN,
pause_between_brackets: NotGivenOr[bool] = NOT_GIVEN,
phonemize_between_brackets: NotGivenOr[bool] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None:
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
self._api_key = api_key if is_given(api_key) else os.environ.get("RIME_API_KEY")
if not self._api_key:
raise ValueError(
"Rime API key is required, either as argument or set RIME_API_KEY environmental variable" # noqa: E501
)
if not is_given(speaker):
if model == "mistv2":
speaker = "cove"
else:
speaker = "astra"
self._opts = _TTSOptions(
model=model,
speaker=speaker,
)
if model == "arcana":
self._opts.arcana_options = _ArcanaOptions(
repetition_penalty=repetition_penalty,
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
)
elif model == "mistv2":
self._opts.mistv2_options = _Mistv2Options(
lang=lang,
sample_rate=sample_rate,
speed_alpha=speed_alpha,
reduce_latency=reduce_latency,
pause_between_brackets=pause_between_brackets,
phonemize_between_brackets=phonemize_between_brackets,
)
self._session = http_session
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
segment_id: NotGivenOr[str] = NOT_GIVEN,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
segment_id=segment_id if is_given(segment_id) else None,
api_key=self._api_key,
)
def update_options(
self,
*,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
speaker: NotGivenOr[str] = NOT_GIVEN,
) -> None:
if is_given(model):
self._opts.model = model
if is_given(speaker):
self._opts.speaker = speaker
class ChunkedStream(tts.ChunkedStream):
"""Synthesize using the chunked api endpoint"""
def __init__(
self,
tts: TTS,
input_text: str,
opts: _TTSOptions,
api_key: str,
session: aiohttp.ClientSession,
conn_options: APIConnectOptions,
segment_id: NotGivenOr[str] = NOT_GIVEN,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts = opts
self._session = session
self._segment_id = segment_id if is_given(segment_id) else utils.shortuuid()
self._api_key = api_key
async def _run(self) -> None:
request_id = utils.shortuuid()
payload = {
"speaker": self._opts.speaker,
"text": self._input_text,
"modelId": self._opts.model,
}
format = "mp3"
if self._opts.model == "arcana":
arcana_opts = self._opts.arcana_options
if is_given(arcana_opts.repetition_penalty):
payload["repetition_penalty"] = arcana_opts.repetition_penalty
if is_given(arcana_opts.temperature):
payload["temperature"] = arcana_opts.temperature
if is_given(arcana_opts.top_p):
payload["top_p"] = arcana_opts.top_p
if is_given(arcana_opts.max_tokens):
payload["max_tokens"] = arcana_opts.max_tokens
format = "wav"
elif self._opts.model == "mistv2":
mistv2_opts = self._opts.mistv2_options
if is_given(mistv2_opts.lang):
payload["lang"] = mistv2_opts.lang
if is_given(mistv2_opts.sample_rate):
payload["samplingRate"] = mistv2_opts.sample_rate
if is_given(mistv2_opts.speed_alpha):
payload["speedAlpha"] = mistv2_opts.speed_alpha
if is_given(mistv2_opts.reduce_latency):
payload["reduceLatency"] = mistv2_opts.reduce_latency
if is_given(mistv2_opts.pause_between_brackets):
payload["pauseBetweenBrackets"] = mistv2_opts.pause_between_brackets
if is_given(mistv2_opts.phonemize_between_brackets):
payload["phonemizeBetweenBrackets"] = mistv2_opts.phonemize_between_brackets
headers = {
"accept": f"audio/{format}",
"Authorization": f"Bearer {self._api_key}",
"content-type": "application/json",
}
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._tts.sample_rate,
num_channels=NUM_CHANNELS,
format=format,
)
decode_task: asyncio.Task | None = None
try:
async with self._session.post(
DEFAULT_API_URL,
headers=headers,
json=payload,
timeout=self._conn_options.timeout,
) as response:
if not response.content_type.startswith("audio"):
content = await response.text()
logger.error("Rime returned non-audio data: %s", content)
return
async def _decode_loop():
try:
async for bytes_data, _ in response.content.iter_chunks():
decoder.push(bytes_data)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
segment_id=self._segment_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-rime"
dynamic = ["version"]
description = "LiveKit Agents Plugin for Rime"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit", "rime"]
classifiers = [
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents[codecs]>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/rime/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Silero
Agent Framework Plugin for Silero. Currently supports Voice Activity Detection.
## Installation
```bash
pip install livekit-plugins-silero
This plugin contains model files that would need to be downloaded prior to use.
## livekit-plugins/livekit-plugins-silero/livekit/plugins/silero/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .vad import VAD, VADStream
from .version import __version__
__all__ = ["VAD", "VADStream", "__version__"]
from livekit.agents import Plugin
from .log import logger
class SileroPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(SileroPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
import logging
logger = logging.getLogger("livekit.plugins.silero")
import atexit
import importlib.resources
from contextlib import ExitStack
import numpy as np
import onnxruntime # type: ignore
_resource_files = ExitStack()
atexit.register(_resource_files.close)
SUPPORTED_SAMPLE_RATES = [8000, 16000]
def new_inference_session(force_cpu: bool) -> onnxruntime.InferenceSession:
res = importlib.resources.files("livekit.plugins.silero.resources") / "silero_vad.onnx"
ctx = importlib.resources.as_file(res)
path = str(_resource_files.enter_context(ctx))
opts = onnxruntime.SessionOptions()
opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
opts.add_session_config_entry("session.inter_op.allow_spinning", "0")
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
opts.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
if force_cpu and "CPUExecutionProvider" in onnxruntime.get_available_providers():
session = onnxruntime.InferenceSession(
path, providers=["CPUExecutionProvider"], sess_options=opts
)
else:
session = onnxruntime.InferenceSession(path, sess_options=opts)
return session
class OnnxModel:
def __init__(self, *, onnx_session: onnxruntime.InferenceSession, sample_rate: int) -> None:
self._sess = onnx_session
self._sample_rate = sample_rate
if sample_rate not in SUPPORTED_SAMPLE_RATES:
raise ValueError("Silero VAD only supports 8KHz and 16KHz sample rates")
if sample_rate == 8000:
self._window_size_samples = 256
self._context_size = 32
elif sample_rate == 16000:
self._window_size_samples = 512
self._context_size = 64
self._sample_rate_nd = np.array(sample_rate, dtype=np.int64)
self._context = np.zeros((1, self._context_size), dtype=np.float32)
self._rnn_state = np.zeros((2, 1, 128), dtype=np.float32)
self._input_buffer = np.zeros(
(1, self._context_size + self._window_size_samples), dtype=np.float32
)
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def window_size_samples(self) -> int:
return self._window_size_samples
@property
def context_size(self) -> int:
return self._context_size
def __call__(self, x: np.ndarray) -> float:
self._input_buffer[:, : self._context_size] = self._context
self._input_buffer[:, self._context_size :] = x
ort_inputs = {
"input": self._input_buffer,
"state": self._rnn_state,
"sr": self._sample_rate_nd,
}
out, self._state = self._sess.run(None, ort_inputs)
self._context = self._input_buffer[:, -self._context_size :]
return out.item()
"""Used by importlib.resources and setuptools"""
version https://git-lfs.github.com/spec/v1
oid sha256:6b99cbfd39246b6706f98ec13c7c50c6b299181f2474fa05cbc8046acc274396
size 2313101
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import time
import weakref
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Literal
import numpy as np
import onnxruntime # type: ignore
from livekit import agents, rtc
from livekit.agents import utils
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from . import onnx_model
from .log import logger
SLOW_INFERENCE_THRESHOLD = 0.2 # late by 200ms
@dataclass
class _VADOptions:
min_speech_duration: float
min_silence_duration: float
prefix_padding_duration: float
max_buffered_speech: float
activation_threshold: float
sample_rate: int
class VAD(agents.vad.VAD):
"""
Silero Voice Activity Detection (VAD) class.
This class provides functionality to detect speech segments within audio data using the Silero VAD model.
""" # noqa: E501
@classmethod
def load(
cls,
*,
min_speech_duration: float = 0.05,
min_silence_duration: float = 0.55,
prefix_padding_duration: float = 0.5,
max_buffered_speech: float = 60.0,
activation_threshold: float = 0.5,
sample_rate: Literal[8000, 16000] = 16000,
force_cpu: bool = True,
# deprecated
padding_duration: NotGivenOr[float] = NOT_GIVEN,
) -> VAD:
"""
Load and initialize the Silero VAD model.
This method loads the ONNX model and prepares it for inference. When options are not provided,
sane defaults are used.
**Note:**
This method is blocking and may take time to load the model into memory.
It is recommended to call this method inside your prewarm mechanism.
**Example:**
```python
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
async def entrypoint(ctx: JobContext):
vad = (ctx.proc.userdata["vad"],)
# your agent logic...
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
```
Args:
min_speech_duration (float): Minimum duration of speech to start a new speech chunk.
min_silence_duration (float): At the end of each speech, wait this duration before ending the speech.
prefix_padding_duration (float): Duration of padding to add to the beginning of each speech chunk.
max_buffered_speech (float): Maximum duration of speech to keep in the buffer (in seconds).
activation_threshold (float): Threshold to consider a frame as speech.
sample_rate (Literal[8000, 16000]): Sample rate for the inference (only 8KHz and 16KHz are supported).
force_cpu (bool): Force the use of CPU for inference.
padding_duration (float | None): **Deprecated**. Use `prefix_padding_duration` instead.
Returns:
VAD: An instance of the VAD class ready for streaming.
Raises:
ValueError: If an unsupported sample rate is provided.
""" # noqa: E501
if sample_rate not in onnx_model.SUPPORTED_SAMPLE_RATES:
raise ValueError("Silero VAD only supports 8KHz and 16KHz sample rates")
if is_given(padding_duration):
logger.warning(
"padding_duration is deprecated and will be removed in 1.5.0, use prefix_padding_duration instead", # noqa: E501
)
prefix_padding_duration = padding_duration
session = onnx_model.new_inference_session(force_cpu)
opts = _VADOptions(
min_speech_duration=min_speech_duration,
min_silence_duration=min_silence_duration,
prefix_padding_duration=prefix_padding_duration,
max_buffered_speech=max_buffered_speech,
activation_threshold=activation_threshold,
sample_rate=sample_rate,
)
return cls(session=session, opts=opts)
def __init__(
self,
*,
session: onnxruntime.InferenceSession,
opts: _VADOptions,
) -> None:
super().__init__(capabilities=agents.vad.VADCapabilities(update_interval=0.032))
self._onnx_session = session
self._opts = opts
self._streams = weakref.WeakSet[VADStream]()
def stream(self) -> VADStream:
"""
Create a new VADStream for processing audio data.
Returns:
VADStream: A stream object for processing audio input and detecting speech.
"""
stream = VADStream(
self,
self._opts,
onnx_model.OnnxModel(
onnx_session=self._onnx_session, sample_rate=self._opts.sample_rate
),
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
min_speech_duration: NotGivenOr[float] = NOT_GIVEN,
min_silence_duration: NotGivenOr[float] = NOT_GIVEN,
prefix_padding_duration: NotGivenOr[float] = NOT_GIVEN,
max_buffered_speech: NotGivenOr[float] = NOT_GIVEN,
activation_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Update the VAD options.
This method allows you to update the VAD options after the VAD object has been created.
Args:
min_speech_duration (float): Minimum duration of speech to start a new speech chunk.
min_silence_duration (float): At the end of each speech, wait this duration before ending the speech.
prefix_padding_duration (float): Duration of padding to add to the beginning of each speech chunk.
max_buffered_speech (float): Maximum duration of speech to keep in the buffer (in seconds).
activation_threshold (float): Threshold to consider a frame as speech.
""" # noqa: E501
if is_given(min_speech_duration):
self._opts.min_speech_duration = min_speech_duration
if is_given(min_silence_duration):
self._opts.min_silence_duration = min_silence_duration
if is_given(prefix_padding_duration):
self._opts.prefix_padding_duration = prefix_padding_duration
if is_given(max_buffered_speech):
self._opts.max_buffered_speech = max_buffered_speech
if is_given(activation_threshold):
self._opts.activation_threshold = activation_threshold
for stream in self._streams:
stream.update_options(
min_speech_duration=min_speech_duration,
min_silence_duration=min_silence_duration,
prefix_padding_duration=prefix_padding_duration,
max_buffered_speech=max_buffered_speech,
activation_threshold=activation_threshold,
)
class VADStream(agents.vad.VADStream):
def __init__(self, vad: VAD, opts: _VADOptions, model: onnx_model.OnnxModel) -> None:
super().__init__(vad)
self._opts, self._model = opts, model
self._loop = asyncio.get_event_loop()
self._executor = ThreadPoolExecutor(max_workers=1)
self._task.add_done_callback(lambda _: self._executor.shutdown(wait=False))
self._exp_filter = utils.ExpFilter(alpha=0.35)
self._input_sample_rate = 0
self._speech_buffer: np.ndarray | None = None
self._speech_buffer_max_reached = False
self._prefix_padding_samples = 0 # (input_sample_rate)
def update_options(
self,
*,
min_speech_duration: NotGivenOr[float] = NOT_GIVEN,
min_silence_duration: NotGivenOr[float] = NOT_GIVEN,
prefix_padding_duration: NotGivenOr[float] = NOT_GIVEN,
max_buffered_speech: NotGivenOr[float] = NOT_GIVEN,
activation_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Update the VAD options.
This method allows you to update the VAD options after the VAD object has been created.
Args:
min_speech_duration (float): Minimum duration of speech to start a new speech chunk.
min_silence_duration (float): At the end of each speech, wait this duration before ending the speech.
prefix_padding_duration (float): Duration of padding to add to the beginning of each speech chunk.
max_buffered_speech (float): Maximum duration of speech to keep in the buffer (in seconds).
activation_threshold (float): Threshold to consider a frame as speech.
""" # noqa: E501
old_max_buffered_speech = self._opts.max_buffered_speech
if is_given(min_speech_duration):
self._opts.min_speech_duration = min_speech_duration
if is_given(min_silence_duration):
self._opts.min_silence_duration = min_silence_duration
if is_given(prefix_padding_duration):
self._opts.prefix_padding_duration = prefix_padding_duration
if is_given(max_buffered_speech):
self._opts.max_buffered_speech = max_buffered_speech
if is_given(activation_threshold):
self._opts.activation_threshold = activation_threshold
if self._input_sample_rate:
assert self._speech_buffer is not None
self._prefix_padding_samples = int(
self._opts.prefix_padding_duration * self._input_sample_rate
)
self._speech_buffer.resize(
int(self._opts.max_buffered_speech * self._input_sample_rate)
+ self._prefix_padding_samples
)
if self._opts.max_buffered_speech > old_max_buffered_speech:
self._speech_buffer_max_reached = False
@agents.utils.log_exceptions(logger=logger)
async def _main_task(self):
inference_f32_data = np.empty(self._model.window_size_samples, dtype=np.float32)
speech_buffer_index: int = 0
# "pub_" means public, these values are exposed to the users through events
pub_speaking = False
pub_speech_duration = 0.0
pub_silence_duration = 0.0
pub_current_sample = 0
pub_timestamp = 0.0
speech_threshold_duration = 0.0
silence_threshold_duration = 0.0
input_frames = []
inference_frames = []
resampler: rtc.AudioResampler | None = None
# used to avoid drift when the sample_rate ratio is not an integer
input_copy_remaining_fract = 0.0
extra_inference_time = 0.0
async for input_frame in self._input_ch:
if not isinstance(input_frame, rtc.AudioFrame):
continue # ignore flush sentinel for now
if not self._input_sample_rate:
self._input_sample_rate = input_frame.sample_rate
# alloc the buffers now that we know the input sample rate
self._prefix_padding_samples = int(
self._opts.prefix_padding_duration * self._input_sample_rate
)
self._speech_buffer = np.empty(
int(self._opts.max_buffered_speech * self._input_sample_rate)
+ self._prefix_padding_samples,
dtype=np.int16,
)
if self._input_sample_rate != self._opts.sample_rate:
# resampling needed: the input sample rate isn't the same as the model's
# sample rate used for inference
resampler = rtc.AudioResampler(
input_rate=self._input_sample_rate,
output_rate=self._opts.sample_rate,
quality=rtc.AudioResamplerQuality.QUICK, # VAD doesn't need high quality
)
elif self._input_sample_rate != input_frame.sample_rate:
logger.error("a frame with another sample rate was already pushed")
continue
assert self._speech_buffer is not None
input_frames.append(input_frame)
if resampler is not None:
# the resampler may have a bit of latency, but it is OK to ignore since it should be
# negligible
inference_frames.extend(resampler.push(input_frame))
else:
inference_frames.append(input_frame)
while True:
start_time = time.perf_counter()
available_inference_samples = sum(
[frame.samples_per_channel for frame in inference_frames]
)
if available_inference_samples < self._model.window_size_samples:
break # not enough samples to run inference
input_frame = utils.combine_frames(input_frames)
inference_frame = utils.combine_frames(inference_frames)
# convert data to f32
np.divide(
inference_frame.data[: self._model.window_size_samples],
np.iinfo(np.int16).max,
out=inference_f32_data,
dtype=np.float32,
)
# run the inference
p = await self._loop.run_in_executor(
self._executor, self._model, inference_f32_data
)
p = self._exp_filter.apply(exp=1.0, sample=p)
window_duration = self._model.window_size_samples / self._opts.sample_rate
pub_current_sample += self._model.window_size_samples
pub_timestamp += window_duration
resampling_ratio = self._input_sample_rate / self._model.sample_rate
to_copy = (
self._model.window_size_samples * resampling_ratio + input_copy_remaining_fract
)
to_copy_int = int(to_copy)
input_copy_remaining_fract = to_copy - to_copy_int
# copy the inference window to the speech buffer
available_space = len(self._speech_buffer) - speech_buffer_index
to_copy_buffer = min(to_copy_int, available_space)
if to_copy_buffer > 0:
self._speech_buffer[
speech_buffer_index : speech_buffer_index + to_copy_buffer
] = input_frame.data[:to_copy_buffer]
speech_buffer_index += to_copy_buffer
elif not self._speech_buffer_max_reached:
# reached self._opts.max_buffered_speech (padding is included)
speech_buffer_max_reached = True
logger.warning(
"max_buffered_speech reached, ignoring further data for the current speech input" # noqa: E501
)
inference_duration = time.perf_counter() - start_time
extra_inference_time = max(
0.0,
extra_inference_time + inference_duration - window_duration,
)
if inference_duration > SLOW_INFERENCE_THRESHOLD:
logger.warning(
"inference is slower than realtime",
extra={"delay": extra_inference_time},
)
def _reset_write_cursor():
nonlocal speech_buffer_index, speech_buffer_max_reached
assert self._speech_buffer is not None
if speech_buffer_index <= self._prefix_padding_samples:
return
padding_data = self._speech_buffer[
speech_buffer_index - self._prefix_padding_samples : speech_buffer_index
]
self._speech_buffer_max_reached = False
self._speech_buffer[: self._prefix_padding_samples] = padding_data
speech_buffer_index = self._prefix_padding_samples
def _copy_speech_buffer() -> rtc.AudioFrame:
# copy the data from speech_buffer
assert self._speech_buffer is not None
speech_data = self._speech_buffer[:speech_buffer_index].tobytes() # noqa: B023
return rtc.AudioFrame(
sample_rate=self._input_sample_rate,
num_channels=1,
samples_per_channel=speech_buffer_index, # noqa: B023
data=speech_data,
)
if pub_speaking:
pub_speech_duration += window_duration
else:
pub_silence_duration += window_duration
self._event_ch.send_nowait(
agents.vad.VADEvent(
type=agents.vad.VADEventType.INFERENCE_DONE,
samples_index=pub_current_sample,
timestamp=pub_timestamp,
silence_duration=pub_silence_duration,
speech_duration=pub_speech_duration,
probability=p,
inference_duration=inference_duration,
frames=[
rtc.AudioFrame(
data=input_frame.data[:to_copy_int].tobytes(),
sample_rate=self._input_sample_rate,
num_channels=1,
samples_per_channel=to_copy_int,
)
],
speaking=pub_speaking,
raw_accumulated_silence=silence_threshold_duration,
raw_accumulated_speech=speech_threshold_duration,
)
)
if p >= self._opts.activation_threshold:
speech_threshold_duration += window_duration
silence_threshold_duration = 0.0
if not pub_speaking:
if speech_threshold_duration >= self._opts.min_speech_duration:
pub_speaking = True
pub_silence_duration = 0.0
pub_speech_duration = speech_threshold_duration
self._event_ch.send_nowait(
agents.vad.VADEvent(
type=agents.vad.VADEventType.START_OF_SPEECH,
samples_index=pub_current_sample,
timestamp=pub_timestamp,
silence_duration=pub_silence_duration,
speech_duration=pub_speech_duration,
frames=[_copy_speech_buffer()],
speaking=True,
)
)
else:
silence_threshold_duration += window_duration
speech_threshold_duration = 0.0
if not pub_speaking:
_reset_write_cursor()
if (
pub_speaking
and silence_threshold_duration >= self._opts.min_silence_duration
):
pub_speaking = False
pub_speech_duration = 0.0
pub_silence_duration = silence_threshold_duration
self._event_ch.send_nowait(
agents.vad.VADEvent(
type=agents.vad.VADEventType.END_OF_SPEECH,
samples_index=pub_current_sample,
timestamp=pub_timestamp,
silence_duration=pub_silence_duration,
speech_duration=pub_speech_duration,
frames=[_copy_speech_buffer()],
speaking=False,
)
)
_reset_write_cursor()
# remove the frames that were used for inference from the input and inference frames
input_frames = []
inference_frames = []
# add the remaining data
if len(input_frame.data) - to_copy_int > 0:
data = input_frame.data[to_copy_int:].tobytes()
input_frames.append(
rtc.AudioFrame(
data=data,
sample_rate=self._input_sample_rate,
num_channels=1,
samples_per_channel=len(data) // 2,
)
)
if len(inference_frame.data) - self._model.window_size_samples > 0:
data = inference_frame.data[self._model.window_size_samples :].tobytes()
inference_frames.append(
rtc.AudioFrame(
data=data,
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=len(data) // 2,
)
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-silero"
dynamic = ["version"]
description = "Agent Framework Plugin for Silero"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
"onnxruntime>=1.18",
"numpy>=1.26",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/silero/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
[tool.hatch.build.targets.wheel.shared-data]
"livekit/plugins/silero/resources/silero_vad.onnx" = "livekit/plugins/silero/resources/silero_vad.onnx"
# LiveKit Plugins Speechify
Agent Framework plugin for voice synthesis with [Speechify](https://www.speechify.ai/) API.
## Installation
```bash
pip install livekit-plugins-speechify
You’ll need an API key from Speechify. It can be set as an environment variable: SPEECHIFY_API_KEY
## livekit-plugins/livekit-plugins-speechify/livekit/plugins/speechify/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .models import TTSEncoding, TTSModels
from .tts import DEFAULT_VOICE_ID, TTS, Voice
from .version import __version__
__all__ = [
"TTS",
"Voice",
"TTSEncoding",
"TTSModels",
"DEFAULT_VOICE_ID",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class SpeechifyPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(SpeechifyPlugin())
# Cleanup docs of unexported modules
_module = dir()
NOT_IN_ALL = [m for m in _module if m not in __all__]
__pdoc__ = {}
for n in NOT_IN_ALL:
__pdoc__[n] = False
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
logger = logging.getLogger("livekit.plugins.speechify")
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Literal
TTSModels = Literal[
"simba-english",
"simba-multilingual",
]
TTSEncoding = Literal[
"mp3_24000",
"wav_48000",
"ogg_24000",
"aac_24000",
]
VoiceType = Literal["shared", "personal"]
Gender = Literal["male", "female", "neutral"]
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .log import logger
from .models import Gender, TTSEncoding, TTSModels, VoiceType
_DefaultEncoding: TTSEncoding = "ogg_24000"
def _sample_rate_from_encoding(output_encoding: TTSEncoding) -> int:
split = output_encoding.split("_")
return int(split[1])
def _audio_format_from_encoding(encoding: TTSEncoding) -> str:
split = encoding.split("_")
return split[0]
DEFAULT_VOICE_ID = "jack"
API_BASE_URL_V1 = "https://api.sws.speechify.com/v1"
AUTHORIZATION_HEADER = "Authorization"
CALLER_HEADER = "x-caller"
@dataclass
class Voice:
id: str
type: VoiceType
display_name: str
gender: Gender
avatar_image: str | None
models: list[TTSModels]
locale: str
@dataclass
class _TTSOptions:
base_url: NotGivenOr[str]
token: NotGivenOr[str]
voice_id: str
encoding: TTSEncoding
language: NotGivenOr[str]
model: NotGivenOr[TTSModels]
loudness_normalization: NotGivenOr[bool]
text_normalization: NotGivenOr[bool]
follow_redirects: bool
sample_rate: int
class TTS(tts.TTS):
def __init__(
self,
*,
voice_id: NotGivenOr[str] = DEFAULT_VOICE_ID,
encoding: NotGivenOr[TTSEncoding] = NOT_GIVEN,
model: NotGivenOr[TTSModels] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
loudness_normalization: NotGivenOr[bool] = NOT_GIVEN,
text_normalization: NotGivenOr[bool] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
follow_redirects: bool = True,
) -> None:
"""
Create a new instance of Speechify TTS.
Args:
voice_id (NotGivenOr[str]): Voice ID. Defaults to `cliff`.
encoding (NotGivenOr[TTSEncoding]): Audio encoding to use. Optional. Defaults to `wav_48000`.
model (NotGivenOr[TTSModels]): TTS model to use. Optional.
base_url (NotGivenOr[str]): Custom base URL for the API. Optional.
api_key (NotGivenOr[str]): Speechify API key. Can be set via argument or `SPEECHIFY_API_KEY` environment variable
language (NotGivenOr[str]): Language code for the TTS model. Optional.
loudness_normalization (NotGivenOr[bool]): Whether to normalize the loudness of the audio. Optional.
text_normalization (NotGivenOr[bool]): Whether to normalize the text. Optional.
http_session (aiohttp.ClientSession | None): Custom HTTP session for API requests. Optional.
follow_redirects (bool): Whether to follow redirects in HTTP requests. Defaults to True.
""" # noqa: E501
if not is_given(encoding):
encoding = _DefaultEncoding
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=False,
),
sample_rate=_sample_rate_from_encoding(encoding),
num_channels=1,
)
speechify_token = api_key if is_given(api_key) else os.environ.get("SPEECHIFY_API_KEY")
if not (speechify_token):
raise ValueError(
"Speechify API key is required, either as argument or set SPEECHIFY_API_KEY environment variable" # noqa: E501
)
self._opts = _TTSOptions(
model=model,
voice_id=voice_id,
language=language,
base_url=base_url if is_given(base_url) else API_BASE_URL_V1,
token=speechify_token,
follow_redirects=follow_redirects,
encoding=encoding,
sample_rate=_sample_rate_from_encoding(encoding),
loudness_normalization=loudness_normalization,
text_normalization=text_normalization,
)
self._session = http_session
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def list_voices(self) -> list[Voice]:
async with self._ensure_session().get(
f"{self._opts.base_url}/voices",
headers=_get_headers(self._opts.token),
) as resp:
return await resp.json()
def update_options(
self,
*,
voice_id: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[TTSModels] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
loudness_normalization: NotGivenOr[bool] = NOT_GIVEN,
text_normalization: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
"""
Args:
voice_id (NotGivenOr[str]): Voice ID.
model (NotGivenOr[TTSModels | str]): TTS model to use.
language (NotGivenOr[str]): Language code for the TTS model.
"""
if is_given(model):
self._opts.model = model
if is_given(voice_id):
self._opts.voice_id = voice_id
if is_given(language):
self._opts.language = language
if is_given(loudness_normalization):
self._opts.loudness_normalization = loudness_normalization
if is_given(text_normalization):
self._opts.text_normalization = text_normalization
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(
tts=self,
input_text=text,
conn_options=conn_options,
opts=self._opts,
session=self._ensure_session(),
)
class ChunkedStream(tts.ChunkedStream):
"""Synthesize using the chunked api endpoint"""
def __init__(
self,
*,
tts: TTS,
input_text: str,
opts: _TTSOptions,
conn_options: APIConnectOptions,
session: aiohttp.ClientSession,
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._opts, self._session = opts, session
async def _run(self) -> None:
request_id = utils.shortuuid()
data = {
"input": self._input_text,
"voice_id": self._opts.voice_id,
"language": self._opts.language if is_given(self._opts.language) else None,
"model": self._opts.model if is_given(self._opts.model) else None,
"audio_format": _audio_format_from_encoding(self._opts.encoding),
"options": {
"loudness_normalization": self._opts.loudness_normalization
if is_given(self._opts.loudness_normalization)
else None,
"text_normalization": self._opts.text_normalization
if is_given(self._opts.text_normalization)
else None,
},
}
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=self._opts.sample_rate,
num_channels=1,
)
decode_task: asyncio.Task | None = None
try:
async with self._session.post(
_synthesize_url(self._opts),
headers=_get_headers(self._opts.token, encoding=self._opts.encoding),
json=data,
timeout=self._conn_options.timeout,
) as resp:
if not resp.content_type.startswith("audio/"):
content = await resp.text()
logger.error("speechify returned non-audio data: %s", content)
return
async def _decode_loop():
try:
async for bytes_data, _ in resp.content.iter_chunks():
decoder.push(bytes_data)
finally:
decoder.end_input()
decode_task = asyncio.create_task(_decode_loop())
emitter = tts.SynthesizedAudioEmitter(
event_ch=self._event_ch,
request_id=request_id,
)
async for frame in decoder:
emitter.push(frame)
emitter.flush()
await decode_task
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
finally:
if decode_task:
await utils.aio.gracefully_cancel(decode_task)
await decoder.aclose()
def _synthesize_url(opts: _TTSOptions) -> str:
"""Construct the Speechify stream URL."""
return f"{opts.base_url}/audio/stream"
def _get_headers(token: str, *, encoding: TTSEncoding | None = None) -> dict[str, str]:
"""Construct the headers for the Speechify API."""
headers = {
AUTHORIZATION_HEADER: f"Bearer {token}" if not token.startswith("Bearer ") else token
}
if encoding:
accept = ""
format = _audio_format_from_encoding(encoding)
if format == "ogg":
accept = "audio/ogg"
elif format == "mp3":
accept = "audio/mpeg"
elif format == "aac":
accept = "audio/aac"
# docs does not specify mime type for wav
# https://docs.sws.speechify.com/v1/api-reference/api-reference/tts/audio/stream
if accept:
headers["Accept"] = accept
headers[CALLER_HEADER] = "livekit"
return headers
# Copyright 2024 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-speechify"
dynamic = ["version"]
description = "Agent Framework plugin for voice synthesis with Speechify's API."
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit", "speechify"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents[codecs]>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/speechify/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Speechmatics
Agent Framework plugin for Speechmatics.
## Installation
```bash
pip install livekit-plugins-speechmatics
Usage:
from livekit.agents import AgentSession
from livekit.plugins.turn_detector.english import EnglishModel
from livekit.plugins import speechmatics
agent = AgentSession(
stt=speechmatics.STT(),
turn_detector=EnglishModel(),
min_endpointing_delay=0.5,
max_endpointing_delay=5.0,
...
)
Note: The plugin was built with
LiveKit’s end-of-turn detection feature in mind,
and it doesn’t implement phrase endpointing. AddTranscript
and AddPartialTranscript
events are emitted as soon
as they’re received from the Speechmatics STT engine.
You’ll need to specify a Speechmatics API Key. It can be set as environment variable SPEECHMATICS_API_KEY
or
.env.local
file.
## livekit-plugins/livekit-plugins-speechmatics/livekit/plugins/speechmatics/__init__.py
```py
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .log import logger
from .stt import STT, SpeechStream
from .version import __version__
__all__ = [
"STT",
"SpeechStream",
"logger",
"__version__",
]
from livekit.agents import Plugin
class SpeechmaticsPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__)
Plugin.register_plugin(SpeechmaticsPlugin())
import logging
logger = logging.getLogger("livekit.plugins.speechmatics")
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import json
import os
import weakref
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectOptions,
APIStatusError,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from .log import logger
from .types import (
AudioSettings,
ClientMessageType,
ConnectionSettings,
ServerMessageType,
TranscriptionConfig,
)
from .utils import get_access_token, sanitize_url
class STT(stt.STT):
def __init__(
self,
*,
transcription_config: NotGivenOr[TranscriptionConfig] = NOT_GIVEN,
connection_settings: NotGivenOr[ConnectionSettings] = NOT_GIVEN,
audio_settings: NotGivenOr[AudioSettings] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
extra_headers: NotGivenOr[dict] = NOT_GIVEN,
):
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
),
)
if not is_given(transcription_config):
transcription_config = TranscriptionConfig( # noqa: B008
language="en",
operating_point="enhanced",
enable_partials=True,
max_delay=0.7,
)
if not is_given(connection_settings):
connection_settings = ConnectionSettings( # noqa: B008
url="wss://eu2.rt.speechmatics.com/v2",
)
if not is_given(audio_settings):
audio_settings = AudioSettings() # noqa: B008
self._transcription_config = transcription_config
self._audio_settings = audio_settings
self._connection_settings = connection_settings
self._extra_headers = extra_headers or {}
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
@property
def session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
raise NotImplementedError("Not implemented")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = dataclasses.replace(self._audio_settings)
if is_given(language):
config.language = language
stream = SpeechStream(
stt=self,
transcription_config=self._transcription_config,
audio_settings=config,
connection_settings=self._connection_settings,
conn_options=conn_options,
http_session=self.session,
extra_headers=self._extra_headers,
)
self._streams.add(stream)
return stream
class SpeechStream(stt.SpeechStream):
def __init__(
self,
*,
stt: STT,
transcription_config: TranscriptionConfig,
audio_settings: AudioSettings,
connection_settings: ConnectionSettings,
conn_options: APIConnectOptions,
http_session: aiohttp.ClientSession,
extra_headers: dict,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=audio_settings.sample_rate)
self._transcription_config = transcription_config
self._audio_settings = audio_settings
self._connection_settings = connection_settings
self._session = http_session
self._extra_headers = extra_headers
self._speech_duration: float = 0
self._reconnect_event = asyncio.Event()
self._recognition_started = asyncio.Event()
self._seq_no = 0
async def _run(self):
closing_ws = False
async def send_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
start_recognition_msg = {
"message": ClientMessageType.StartRecognition,
"audio_format": self._audio_settings.asdict(),
"transcription_config": self._transcription_config.asdict(),
}
await ws.send_str(json.dumps(start_recognition_msg))
await self._recognition_started.wait()
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._audio_settings.sample_rate,
num_channels=1,
)
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
frames = audio_bstream.flush()
else:
frames = audio_bstream.write(data.data.tobytes())
for frame in frames:
self._seq_no += 1
self._speech_duration += frame.duration
await ws.send_bytes(frame.data.tobytes())
closing_ws = True
await ws.send_str(
json.dumps(
{
"message": ClientMessageType.EndOfStream,
"last_seq_no": self._seq_no,
}
)
)
async def recv_task(ws: aiohttp.ClientWebSocketResponse):
nonlocal closing_ws
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws: # close is expected, see SpeechStream.aclose
return
# this will trigger a reconnection, see the _run loop
raise APIStatusError(message="Speechmatics connection closed unexpectedly")
try:
data = json.loads(msg.data)
self._process_stream_event(data, closing_ws)
except Exception:
logger.exception("failed to process Speechmatics message")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
ws = await self._connect_ws()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
try:
done, _ = await asyncio.wait(
[asyncio.gather(*tasks), wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
) # type: ignore
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
finally:
if ws is not None:
await ws.close()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
api_key = self._connection_settings.api_key or os.environ.get("SPEECHMATICS_API_KEY")
if api_key is None:
raise ValueError(
"Speechmatics API key is required. "
"Pass one in via ConnectionSettings.api_key parameter, "
"or set `SPEECHMATICS_API_KEY` environment variable"
)
if self._connection_settings.get_access_token:
api_key = await get_access_token(api_key)
headers = {
"Authorization": f"Bearer {api_key}",
**self._extra_headers,
}
url = sanitize_url(self._connection_settings.url, self._transcription_config.language)
return await self._session.ws_connect(
url,
ssl=self._connection_settings.ssl_context,
headers=headers,
)
def _process_stream_event(self, data: dict, closing_ws: bool) -> None:
message_type = data["message"]
if message_type == ServerMessageType.RecognitionStarted:
self._recognition_started.set()
elif message_type == ServerMessageType.AddPartialTranscript:
alts = live_transcription_to_speech_data(data)
if len(alts) > 0 and alts[0].text:
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=alts,
)
self._event_ch.send_nowait(interim_event)
elif message_type == ServerMessageType.AddTranscript:
alts = live_transcription_to_speech_data(data)
if len(alts) > 0 and alts[0].text:
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=alts,
)
self._event_ch.send_nowait(final_event)
if self._speech_duration > 0:
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=self._speech_duration),
)
self._event_ch.send_nowait(usage_event)
self._speech_duration = 0
elif message_type == ServerMessageType.EndOfTranscript:
if closing_ws:
pass
else:
raise Exception("Speechmatics connection closed unexpectedly")
def live_transcription_to_speech_data(data: dict) -> list[stt.SpeechData]:
speech_data: list[stt.SpeechData] = []
for result in data.get("results", []):
start_time, end_time, is_eos = (
result.get("start_time", 0),
result.get("end_time", 0),
result.get("is_eos", False),
)
for alt in result.get("alternatives", []):
content, confidence, language = (
alt.get("content", "").strip(),
alt.get("confidence", 1.0),
alt.get("language", "en"),
)
if not content:
continue
# append punctuation to the previous result
if is_eos and speech_data:
speech_data[-1].text += content
elif speech_data and start_time == speech_data[-1].end_time:
speech_data[-1].text += " " + content
else:
speech_data.append(
stt.SpeechData(language, content, start_time, end_time, confidence)
)
return speech_data
import ssl
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any, Optional
@dataclass
class TranscriptionConfig:
"""Real-time: Defines transcription parameters. See https://docs.speechmatics.com/rt-api-ref#transcription-config"""
language: str = "en"
"""ISO 639-1 language code. eg. `en`"""
operating_point: Optional[str] = None
"""Specifies which acoustic model to use."""
output_locale: Optional[str] = None
"""RFC-5646 language code for transcript output. eg. `en-AU`"""
diarization: Optional[str] = None
"""Indicates type of diarization to use, if any."""
additional_vocab: Optional[dict] = None
"""Additional vocabulary that is not part of the standard language."""
punctuation_overrides: Optional[dict] = None
"""Permitted puctuation marks for advanced punctuation."""
enable_entities: Optional[bool] = None
"""Indicates if inverse text normalization entity output is enabled."""
max_delay: Optional[float] = None
"""Maximum acceptable delay."""
max_delay_mode: Optional[str] = None
"""Determines whether the threshold specified in max_delay can be exceeded
if a potential entity is detected. Flexible means if a potential entity
is detected, then the max_delay can be overriden until the end of that
entity. Fixed means that max_delay specified ignores any potential
entity that would not be completed within that threshold."""
enable_partials: Optional[bool] = None
"""Indicates if partials for transcription, where words are produced
immediately, is enabled."""
audio_filtering_config: Optional[dict] = None
"""Puts a lower limit on the volume of processed audio by using the volume_threshold setting."""
transcript_filtering_config: Optional[dict] = None
"""Removes disfluencies with the remove_disfluencies setting."""
def asdict(self) -> dict[Any, Any]:
"""Returns model as a dict while excluding None values recursively."""
return asdict(self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None})
@dataclass
class AudioSettings:
"""Real-time: Defines audio parameters."""
encoding: str = "pcm_s16le"
"""Encoding format when raw audio is used. Allowed values are
`pcm_f32le`, `pcm_s16le` and `mulaw`."""
sample_rate: int = 16000
"""Sampling rate in hertz."""
def asdict(self):
return {
"type": "raw",
"encoding": self.encoding,
"sample_rate": self.sample_rate,
}
@dataclass
class ConnectionSettings:
"""Defines connection parameters."""
url: str
"""Websocket server endpoint."""
ssl_context: ssl.SSLContext = field(default_factory=ssl.create_default_context)
"""SSL context."""
api_key: Optional[str] = None
"""api key to authenticate a customer."""
get_access_token: Optional[bool] = True
"""Automatically generate a temporary token for authentication."""
class ClientMessageType(str, Enum):
# pylint: disable=invalid-name
"""Real-time: Defines various messages sent from client to server."""
StartRecognition = "StartRecognition"
"""Initiates a recognition job based on configuration set previously."""
AddAudio = "AddAudio"
"""Adds more audio data to the recognition job. The server confirms
receipt by sending an :py:attr:`ServerMessageType.AudioAdded` message."""
EndOfStream = "EndOfStream"
"""Indicates that the client has no more audio to send."""
SetRecognitionConfig = "SetRecognitionConfig"
"""Allows the client to re-configure the recognition session."""
class ServerMessageType(str, Enum):
"""Real-time: Defines various message types sent from server to client."""
RecognitionStarted = "RecognitionStarted"
"""Server response to :py:attr:`ClientMessageType.StartRecognition`,
acknowledging that a recognition session has started."""
AudioAdded = "AudioAdded"
"""Server response to :py:attr:`ClientMessageType.AddAudio`, indicating
that audio has been added successfully."""
AddPartialTranscript = "AddPartialTranscript"
"""Indicates a partial transcript, which is an incomplete transcript that
is immediately produced and may change as more context becomes available.
"""
AddTranscript = "AddTranscript"
"""Indicates the final transcript of a part of the audio."""
EndOfTranscript = "EndOfTranscript"
"""Server response to :py:attr:`ClientMessageType.EndOfStream`,
after the server has finished sending all :py:attr:`AddTranscript`
messages."""
Info = "Info"
"""Indicates a generic info message."""
Warning = "Warning"
"""Indicates a generic warning message."""
Error = "Error"
"""Indicates n generic error message."""
import importlib.metadata
import os
import aiohttp
async def get_access_token(api_key: str) -> str:
mp_api_url = os.getenv("SPEECHMATICS_MANAGEMENT_PLATFORM_URL", "https://mp.speechmatics.com")
endpoint = f"{mp_api_url}/v1/api_keys"
params = {"type": "rt", "sm-sdk": get_sdk_version()}
json_body = {"ttl": 60}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, params=params, json=json_body, headers=headers) as resp:
if resp.status == 201:
try:
data = await resp.json()
return data["key_value"]
except (ValueError, KeyError) as e:
raise Exception(
f"Failed to parse Speechmatics access token response: {e}"
) from None
else:
error_message = await resp.text()
raise Exception(
f"Failed to get Speechmatics access token. "
f"Status: {resp.status}, Error: {error_message}"
)
def get_sdk_version():
version = importlib.metadata.version("livekit-plugins-speechmatics")
return f"livekit-plugins-{version}"
def sanitize_url(url, language):
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
parsed_url = urlparse(url)
query_params = dict(parse_qsl(parsed_url.query))
query_params["sm-sdk"] = get_sdk_version()
updated_query = urlencode(query_params)
url_path = parsed_url.path
if not url_path.endswith(language):
if url_path.endswith("/"):
url_path += language
else:
url_path += f"/{language}"
return urlunparse(parsed_url._replace(path=url_path, query=updated_query))
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-speechmatics"
dynamic = ["version"]
description = "Agent Framework plugin for Speechmatics"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/speechmatics/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pathlib
import setuptools.command.build_py
here = pathlib.Path(__file__).parent.resolve()
about = {}
with open(os.path.join(here, "livekit", "plugins", "speechmatics", "version.py")) as f:
exec(f.read(), about)
setuptools.setup(
name="livekit-plugins-speechmatics",
version=about["__version__"],
description="Agent Framework plugin for Speechmatics",
long_description=(here / "README.md").read_text(encoding="utf-8"),
long_description_content_type="text/markdown",
url="https://github.com/livekit/agents",
cmdclass={},
classifiers=[
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
],
keywords=["webrtc", "realtime", "audio", "video", "livekit"],
license="Apache-2.0",
packages=setuptools.find_namespace_packages(include=["livekit.*"]),
python_requires=">=3.9.0",
install_requires=[
"livekit-agents>=0.12.16,<1.0.0",
],
package_data={},
project_urls={
"Documentation": "https://docs.livekit.io",
"Website": "https://livekit.io/",
"Source": "https://github.com/livekit/agents",
},
)
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .avatar import AvatarSession, TavusException
from .version import __version__
__all__ = [
"TavusException",
"AvatarSession",
"__version__",
]
from livekit.agents import Plugin
from .log import logger
class TavusPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)
Plugin.register_plugin(TavusPlugin())
import asyncio
import os
from typing import Any, cast
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectionError,
APIConnectOptions,
APIStatusError,
NotGivenOr,
utils,
)
from .log import logger
class TavusException(Exception):
"""Exception for Tavus errors"""
DEFAULT_API_URL = "https://tavusapi.com/v2"
class TavusAPI:
def __init__(
self,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
session: aiohttp.ClientSession | None = None,
) -> None:
self._api_key = api_key or os.getenv("TAVUS_API_KEY")
if self._api_key is None:
raise TavusException("TAVUS_API_KEY must be set")
self._api_key = cast(str, self._api_key)
self._api_url = api_url or DEFAULT_API_URL
self._conn_options = conn_options
self._session = session or aiohttp.ClientSession()
async def create_conversation(
self,
*,
replica_id: NotGivenOr[str] = NOT_GIVEN,
persona_id: NotGivenOr[str] = NOT_GIVEN,
properties: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> str:
replica_id = replica_id or os.getenv("TAVUS_REPLICA_ID")
if not replica_id:
raise TavusException("TAVUS_REPLICA_ID must be set")
persona_id = persona_id or os.getenv("TAVUS_PERSONA_ID")
if not persona_id:
# create a persona if not provided
persona_id = await self.create_persona()
properties = properties or {}
payload = {
"replica_id": replica_id,
"persona_id": persona_id,
"properties": properties,
}
if utils.is_given(extra_payload):
payload.update(extra_payload)
if "conversation_name" not in payload:
payload["conversation_name"] = utils.shortuuid("lk_conversation_")
response_data = await self._post("conversations", payload)
return response_data["conversation_id"]
async def create_persona(
self,
name: NotGivenOr[str] = NOT_GIVEN,
*,
extra_payload: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> str:
name = name or utils.shortuuid("lk_persona_")
payload = {
"persona_name": name,
"pipeline_mode": "echo",
"layers": {
"transport": {"transport_type": "livekit"},
},
}
if utils.is_given(extra_payload):
payload.update(extra_payload)
response_data = await self._post("personas", payload)
return response_data["persona_id"]
async def _post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
"""
Make a POST request to the Tavus API with retry logic.
Args:
endpoint: API endpoint path (without leading slash)
payload: JSON payload for the request
Returns:
Response data as a dictionary
Raises:
APIConnectionError: If the request fails after all retries
"""
for i in range(self._conn_options.max_retry):
try:
async with self._session.post(
f"{self._api_url}/{endpoint}",
headers={
"Content-Type": "application/json",
"x-api-key": self._api_key,
},
json=payload,
timeout=self._conn_options.timeout,
) as response:
if not response.ok:
text = await response.text()
raise APIStatusError(
"Server returned an error", status_code=response.status, body=text
)
return await response.json()
except Exception as e:
if isinstance(e, APIConnectionError):
logger.warning("failed to call tavus api", extra={"error": str(e)})
else:
logger.exception("failed to call tavus api")
if i < self._conn_options.max_retry - 1:
await asyncio.sleep(self._conn_options.retry_interval)
raise APIConnectionError("Failed to call Tavus API after all retries")
from __future__ import annotations
import os
import aiohttp
from livekit import api, rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
AgentSession,
APIConnectOptions,
NotGivenOr,
utils,
)
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
from .api import TavusAPI, TavusException
from .log import logger
SAMPLE_RATE = 24000
_AVATAR_AGENT_IDENTITY = "tavus-avatar-agent"
_AVATAR_AGENT_NAME = "tavus-avatar-agent"
class AvatarSession:
"""A Tavus avatar session"""
def __init__(
self,
*,
replica_id: NotGivenOr[str] = NOT_GIVEN,
persona_id: NotGivenOr[str] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
self._http_session: aiohttp.ClientSession | None = None
self._conn_options = conn_options
self._persona_id = persona_id
self._replica_id = replica_id
self._api = TavusAPI(
api_url=api_url,
api_key=api_key,
conn_options=conn_options,
session=self._ensure_http_session(),
)
self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY
self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME
def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is None:
self._http_session = utils.http_context.http_session()
return self._http_session
async def start(
self,
agent_session: AgentSession,
room: rtc.Room,
*,
livekit_url: NotGivenOr[str] = NOT_GIVEN,
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
) -> None:
livekit_url = livekit_url or os.getenv("LIVEKIT_URL")
livekit_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
livekit_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
if not livekit_url or not livekit_api_key or not livekit_api_secret:
raise TavusException(
"livekit_url, livekit_api_key, and livekit_api_secret must be set "
"by arguments or environment variables"
)
livekit_token = (
api.AccessToken()
.with_kind("agent")
.with_identity(self._avatar_participant_identity)
.with_name(self._avatar_participant_name)
.with_grants(api.VideoGrants(room_join=True, room=room.name))
# allow the avatar agent to publish audio and video on behalf of your local agent
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: room.local_participant.identity})
.to_jwt()
)
logger.debug("starting avatar session")
await self._api.create_conversation(
persona_id=self._persona_id,
replica_id=self._replica_id,
properties={"livekit_ws_url": livekit_url, "livekit_room_token": livekit_token},
)
logger.debug("waiting for avatar agent to join the room")
await utils.wait_for_participant(room=room, identity=self._avatar_participant_identity)
agent_session.output.audio = DataStreamAudioOutput(
room=room,
destination_identity=self._avatar_participant_identity,
sample_rate=SAMPLE_RATE,
)
import logging
logger = logging.getLogger("livekit.plugins.tavus")
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-tavus"
dynamic = ["version"]
description = "Agent Framework plugin for Tavus"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "support@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.0.17"]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/tavus/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
# LiveKit Plugins Turn Detector
This plugin introduces end-of-turn detection for LiveKit Agents using a custom open-weight model to determine when a user has finished speaking.
Traditional voice agents use VAD (voice activity detection) for end-of-turn detection. However, VAD models lack language understanding, often causing false positives where the agent interrupts the user before they finish speaking.
By leveraging a language model specifically trained for this task, this plugin offers a more accurate and robust method for detecting end-of-turns.
## Installation
```bash
pip install livekit-plugins-turn-detector
The English model is the smaller of the two models. It requires 200MB of RAM and completes inference in ~10ms
from livekit.plugins.turn_detector.english import EnglishModel
session = AgentSession(
...
turn_detection=EnglishModel(),
)
We’ve trained a separate multilingual model that supports the following languages: English, French, Spanish, German, Italian, Portuguese, Dutch, Chinese, Japanese, Korean, Indonesian, Russian, Turkish
The multilingual model requires ~400MB of RAM and completes inferences in ~25ms.
from livekit.plugins.turn_detector.multilingual import MultilingualModel
session = AgentSession(
...
turn_detection=MultilingualModel(),
)
The turn detector can be used even with speech-to-speech models such as OpenAI’s Realtime API. You’ll need to provide a separate STT to ensure our model has access to the text content.
session = AgentSession(
...
stt=deepgram.STT(model="nova-3", language="multi"),
llm=openai.realtime.RealtimeModel(),
turn_detection=MultilingualModel(),
)
This plugin requires model files. Before starting your agent for the first time, or when building Docker images for deployment, run the following command to download the model files:
python my_agent.py download-files
The end-of-turn model is optimized to run on CPUs with modest system requirements. It is designed to run on the same server hosting your agents.
The model requires <500MB of RAM and runs within a shared inference server, supporting multiple concurrent sessions.
The plugin source code is licensed under the Apache-2.0 license.
The end-of-turn model is licensed under the LiveKit Model License.
## livekit-plugins/livekit-plugins-turn-detector/livekit/plugins/turn_detector/__init__.py
```py
# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from livekit.agents import Plugin
from .log import logger
from .version import __version__
__all__ = ["english", "multilingual", "__version__"]
class EOUPlugin(Plugin):
def __init__(self):
super().__init__(__name__, __version__, __package__, logger)
def download_files(self) -> None:
from transformers import AutoTokenizer
from .base import _download_from_hf_hub
from .models import HG_MODEL, MODEL_REVISIONS, ONNX_FILENAME
for revision in MODEL_REVISIONS.values():
AutoTokenizer.from_pretrained(HG_MODEL, revision=revision)
_download_from_hf_hub(HG_MODEL, ONNX_FILENAME, subfolder="onnx", revision=revision)
_download_from_hf_hub(HG_MODEL, "languages.json", revision=revision)
Plugin.register_plugin(EOUPlugin())
from __future__ import annotations
import asyncio
import json
import time
from abc import ABC, abstractmethod
from livekit.agents import llm
from livekit.agents.inference_runner import _InferenceRunner
from livekit.agents.ipc.inference_executor import InferenceExecutor
from livekit.agents.job import get_job_context
from .log import logger
from .models import HG_MODEL, MODEL_REVISIONS, ONNX_FILENAME, EOUModelType
MAX_HISTORY_TOKENS = 256
MAX_HISTORY_TURNS = 6
def _download_from_hf_hub(repo_id, filename, **kwargs):
from huggingface_hub import hf_hub_download
local_path = hf_hub_download(repo_id=repo_id, filename=filename, **kwargs)
return local_path
class _EUORunnerBase(_InferenceRunner):
def __init__(self, model_type: EOUModelType):
super().__init__()
self._model_revision = MODEL_REVISIONS[model_type]
def _format_chat_ctx(self, chat_ctx: dict):
new_chat_ctx = []
for msg in chat_ctx:
content = msg["content"]
if not content:
continue
msg["content"] = content
new_chat_ctx.append(msg)
convo_text = self._tokenizer.apply_chat_template(
new_chat_ctx,
add_generation_prompt=False,
add_special_tokens=False,
tokenize=False,
)
# remove the EOU token from current utterance
ix = convo_text.rfind("<|im_end|>")
text = convo_text[:ix]
return text
def initialize(self) -> None:
import onnxruntime as ort
from huggingface_hub import errors
from transformers import AutoTokenizer
try:
local_path_onnx = _download_from_hf_hub(
HG_MODEL,
ONNX_FILENAME,
subfolder="onnx",
revision=self._model_revision,
local_files_only=True,
)
self._session = ort.InferenceSession(
local_path_onnx, providers=["CPUExecutionProvider"]
)
self._tokenizer = AutoTokenizer.from_pretrained(
HG_MODEL,
revision=self._model_revision,
local_files_only=True,
truncation_side="left",
)
except (errors.LocalEntryNotFoundError, OSError):
logger.error(
f"Could not find model {HG_MODEL} with revision {self._model_revision}. "
"Make sure you have downloaded the model before running the agent. "
"Use `python3 your_agent.py download-files` to download the models."
)
raise RuntimeError(
"livekit-plugins-turn-detector initialization failed. "
f"Could not find model {HG_MODEL} with revision {self._model_revision}."
) from None
def run(self, data: bytes) -> bytes | None:
data_json = json.loads(data)
chat_ctx = data_json.get("chat_ctx", None)
if not chat_ctx:
raise ValueError("chat_ctx is required on the inference input data")
start_time = time.perf_counter()
text = self._format_chat_ctx(chat_ctx)
inputs = self._tokenizer(
text,
add_special_tokens=False,
return_tensors="np",
max_length=MAX_HISTORY_TOKENS,
truncation=True,
)
# Run inference
outputs = self._session.run(None, {"input_ids": inputs["input_ids"].astype("int64")})
eou_probability = outputs[0][0]
end_time = time.perf_counter()
data = {
"eou_probability": float(eou_probability),
"input": text,
"duration": round(end_time - start_time, 3),
}
return json.dumps(data).encode()
class EOUModelBase(ABC):
def __init__(
self,
model_type: EOUModelType = "en", # default to smaller, english-only model
inference_executor: InferenceExecutor | None = None,
# if set, overrides the per-language threshold tuned for accuracy.
# not recommended unless you're confident in the impact.
unlikely_threshold: float | None = None,
) -> None:
self._model_type = model_type
self._executor = inference_executor or get_job_context().inference_executor
config_fname = _download_from_hf_hub(
HG_MODEL,
"languages.json",
revision=MODEL_REVISIONS[self._model_type],
local_files_only=True,
)
with open(config_fname) as f:
self._languages = json.load(f)
self._unlikely_threshold = unlikely_threshold
@abstractmethod
def _inference_method(self): ...
def unlikely_threshold(self, language: str | None) -> float | None:
if language is None:
return None
lang = language.lower()
# try the full language code first
lang_data = self._languages.get(lang)
# try the base language if the full language code is not found
if lang_data is None and "-" in lang:
base_lang = lang.split("-")[0]
lang_data = self._languages.get(base_lang)
if not lang_data:
logger.warning(f"Language {language} not supported by EOU model")
return None
# if a custom threshold is provided, use it
if self._unlikely_threshold is not None:
return self._unlikely_threshold
else:
return lang_data["threshold"]
def supports_language(self, language: str | None) -> bool:
return self.unlikely_threshold(language) is not None
async def predict_eou(self, chat_ctx: llm.ChatContext) -> float:
return await self.predict_end_of_turn(chat_ctx)
# our EOU model inference should be fast, 3 seconds is more than enough
async def predict_end_of_turn(
self, chat_ctx: llm.ChatContext, *, timeout: float | None = 3
) -> float:
messages = []
for item in chat_ctx.items:
if item.type != "message":
continue
if item.role not in ("user", "assistant"):
continue
for cnt in item.content:
if isinstance(cnt, str):
messages.append(
{
"role": item.role,
"content": cnt,
}
)
break
messages = messages[-MAX_HISTORY_TURNS:]
json_data = json.dumps({"chat_ctx": messages}).encode()
result = await asyncio.wait_for(
self._executor.do_inference(self._inference_method(), json_data),
timeout=timeout,
)
assert result is not None, "end_of_utterance prediction should always returns a result"
result_json = json.loads(result.decode())
logger.debug(
"eou prediction",
extra=result_json,
)
return result_json["eou_probability"]
from __future__ import annotations
from livekit.agents.inference_runner import _InferenceRunner
from .base import EOUModelBase, _EUORunnerBase
class _EUORunnerEn(_EUORunnerBase):
INFERENCE_METHOD = "lk_end_of_utterance_en"
def __init__(self):
super().__init__("en")
class EnglishModel(EOUModelBase):
def __init__(self, *, unlikely_threshold: float | None = None):
super().__init__(model_type="en", unlikely_threshold=unlikely_threshold)
def _inference_method(self) -> str:
return _EUORunnerEn.INFERENCE_METHOD
_InferenceRunner.register_runner(_EUORunnerEn)
import logging
logger = logging.getLogger("livekit.plugins.turn_detector")
from typing import Literal
EOUModelType = Literal["en", "multilingual"]
MODEL_REVISIONS: dict[EOUModelType, str] = {
"en": "v1.2.2-en",
"multilingual": "v0.1.0-intl",
}
HG_MODEL = "livekit/turn-detector"
ONNX_FILENAME = "model_q8.onnx"
from __future__ import annotations
from livekit.agents.inference_runner import _InferenceRunner
from .base import EOUModelBase, _EUORunnerBase
class _EUORunnerMultilingual(_EUORunnerBase):
INFERENCE_METHOD = "lk_end_of_utterance_multilingual"
def __init__(self):
super().__init__("multilingual")
class MultilingualModel(EOUModelBase):
def __init__(self, *, unlikely_threshold: float | None = None):
super().__init__(model_type="multilingual", unlikely_threshold=unlikely_threshold)
def _inference_method(self) -> str:
return _EUORunnerMultilingual.INFERENCE_METHOD
_InferenceRunner.register_runner(_EUORunnerMultilingual)
# Copyright 2023 LiveKit, Inc.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "1.0.17"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "livekit-plugins-turn-detector"
dynamic = ["version"]
description = "End of utterance detection for LiveKit Agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = [
"livekit-agents>=1.0.17",
"transformers>=4.47.1",
"numpy>=1.26",
"onnxruntime>=1.18",
"jinja2",
]
[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"
[tool.hatch.version]
path = "livekit/plugins/turn_detector/version.py"
[tool.hatch.build.targets.wheel]
packages = ["livekit"]
[tool.hatch.build.targets.sdist]
include = ["/livekit"]
[tool.uv]
constraint-dependencies = ['onnxruntime<1.20.0; python_version == "3.9"']
[tool.uv.sources]
livekit-agents = { workspace = true }
livekit-plugins-anthropic = { workspace = true }
livekit-plugins-assemblyai = { workspace = true }
livekit-plugins-aws = { workspace = true }
livekit-plugins-azure = { workspace = true }
livekit-plugins-cartesia = { workspace = true }
livekit-plugins-clova = { workspace = true }
livekit-plugins-deepgram = { workspace = true }
livekit-plugins-elevenlabs = { workspace = true }
livekit-plugins-fal = { workspace = true }
livekit-plugins-google = { workspace = true }
livekit-plugins-nltk = { workspace = true }
livekit-plugins-openai = { workspace = true }
livekit-plugins-rime = { workspace = true }
livekit-plugins-silero = { workspace = true }
livekit-plugins-speechmatics = { workspace = true }
livekit-plugins-turn-detector = { workspace = true }
livekit-plugins-neuphonic = { workspace = true }
livekit-plugins-playai = { workspace = true }
livekit-plugins-groq = { workspace = true }
livekit-plugins-gladia = { workspace = true }
livekit-plugins-resemble = { workspace = true }
livekit-plugins-bey = { workspace = true }
livekit-plugins-bithuman = { workspace = true }
livekit-plugins-speechify = { workspace = true }
livekit-plugins-tavus = { workspace = true }
livekit-plugins-hume = { workspace = true }
[tool.uv.workspace]
members = ["livekit-plugins/*", "livekit-agents"]
exclude = ["livekit-plugins/livekit-plugins-browser"]
[dependency-groups]
dev = [
"python-dotenv>=1.0.1",
"mypy",
"pytest",
"ruff",
"pytest-asyncio>=0.25.3",
"jiwer>=3.1.0",
]
[tool.ruff]
line-length = 100
target-version = "py39"
exclude = [".github"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
[tool.ruff.lint.isort]
combine-as-imports = true
known-first-party = ["livekit"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = ["--import-mode=importlib", "--ignore=examples"]
[tool.mypy]
strict = true
[tool.mypy-google.genai]
follow_imports = "normal"
follow_untyped_imports = true
[tool.mypy-aiobotocore]
follow_untyped_imports = true
[tool.mypy-boto3]
follow_untyped_imports = true
FROM python:3.9-slim
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y curl strace procps
RUN pip install --no-cache-dir uv
WORKDIR /app
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /build
RUN git clone https://github.com/Shopify/toxiproxy.git .
RUN make build
RUN ls -al dist
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache curl
COPY --from=builder /build/dist/toxiproxy-server /usr/local/bin/toxiproxy-server
COPY --from=builder /build/dist/toxiproxy-cli /usr/local/bin/toxiproxy-cli
PLUGIN ?=
.PHONY: test up down
up:
@if [ -f ../.env ]; then \
echo "Found .env file. Using it..."; \
docker compose --env-file ../.env build; \
docker compose --env-file ../.env up -d; \
else \
echo "No .env file found. Running without it..."; \
docker compose build; \
docker compose up -d; \
fi
down:
docker compose down
test: up
@docker compose exec app bash -c "\
until curl -sf http://toxiproxy:8474/proxies; do \
echo 'Waiting for toxiproxy...'; \
sleep 1; \
done"
echo 'Toxiproxy is ready'
docker compose exec app uv sync --all-extras --dev
docker compose exec -e PLUGIN="$(PLUGIN)" app uv run pytest -s --color=yes --tb=short tests/test_tts.py --show-capture=all
$(MAKE) down
import asyncio
import dataclasses
import gc
import inspect
import logging
import types
import pytest
from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, utils
from livekit.agents.cli import log
from .toxic_proxy import Toxiproxy
TEST_CONNECT_OPTIONS = dataclasses.replace(DEFAULT_API_CONNECT_OPTIONS, retry_interval=0.0)
@pytest.fixture
def job_process(event_loop):
utils.http_context._new_session_ctx()
yield
event_loop.run_until_complete(utils.http_context._close_http_ctx())
@pytest.fixture(autouse=True)
def configure_test():
log._silence_noisy_loggers()
@pytest.fixture
def toxiproxy():
toxiproxy = Toxiproxy()
yield toxiproxy
if toxiproxy.running():
toxiproxy.destroy_all()
@pytest.fixture()
def logger():
logger = logging.getLogger("livekit.tests")
logger.setLevel(logging.DEBUG)
return logger
def safe_is_async_generator(obj):
# For whatever reason, OpenAI complains about this.
# .venv/lib/python3.9/site-packages/openai/_extras/pandas_proxy.py:20: in __load__
# import pandas
# ModuleNotFoundError: No module named 'pandas'
try:
return isinstance(obj, types.AsyncGeneratorType)
except Exception:
return False
def live_async_generators_ids() -> set:
return {
id(obj)
for obj in gc.get_objects()
if safe_is_async_generator(obj) and getattr(obj, "ag_frame", None) is not None
}
def is_pytest_asyncio_task(task) -> bool:
try:
coro = task.get_coro()
mod = getattr(coro, "__module__", "")
if "pytest" in mod or "pytest_asyncio" in mod:
return True
for frame in task.get_stack():
if "pytest" in frame.f_code.co_filename or "pytest_asyncio" in frame.f_code.co_filename:
return True
except Exception:
pass
return False
def format_task(task) -> str:
try:
name = task.get_name() if hasattr(task, "get_name") else "<unknown name>"
coro = task.get_coro()
coro_name = getattr(coro, "__qualname__", None) or type(coro).__name__
frame = getattr(coro, "cr_frame", None)
if frame:
location = f"{frame.f_code.co_filename}:{frame.f_lineno}"
else:
location = "no frame available"
return (
f"Task Name : {name}\n"
f"Coroutine : {coro_name}\n"
f"Location : {location}\n"
f"State : {'pending' if not task.done() else 'done'}"
)
except Exception:
return repr(task)
def format_async_generator_by_id(gen_id: int) -> str:
for obj in gc.get_objects():
if id(obj) == gen_id and safe_is_async_generator(obj):
try:
frame = getattr(obj, "ag_frame", None)
if frame:
filename = frame.f_code.co_filename # type: ignore[attr-defined]
lineno = frame.f_lineno # type: ignore[attr-defined]
func_name = frame.f_code.co_name # type: ignore[attr-defined]
stack_summary = "\n".join(
f' File "{frm.filename}", line {frm.lineno}, in {frm.function}'
for frm in inspect.getouterframes(frame)
)
return (
f"AsyncGenerator id: {gen_id}\n"
f" Created/paused in: {func_name}\n"
f" Location: {filename}:{lineno}\n"
f" Frame stack:\n{stack_summary}"
)
else:
return f"AsyncGenerator id: {gen_id} (closed)"
except Exception as e:
return f"AsyncGenerator id: {gen_id} (failed to introspect: {e})"
return f"AsyncGenerator id: {gen_id} (object not found)"
@pytest.fixture(autouse=True)
async def fail_on_leaked_tasks():
tasks_before = set(asyncio.all_tasks())
async_gens_before = live_async_generators_ids()
yield
# gc.collect()
tasks_after = set(asyncio.all_tasks())
async_gens_after = live_async_generators_ids()
leaked_tasks = [
task
for task in tasks_after - tasks_before
if not task.done() and not is_pytest_asyncio_task(task)
]
leaked_async_gens = async_gens_after - async_gens_before
error_messages = []
if leaked_tasks:
tasks_msg = "\n\n".join(format_task(task) for task in leaked_tasks)
error_messages.append("Leaked tasks detected:\n" + tasks_msg)
if leaked_async_gens:
gens_msg = "\n\n".join(format_async_generator_by_id(gen_id) for gen_id in leaked_async_gens)
error_messages.append("Leaked async generators detected:\n" + gens_msg)
if error_messages:
final_msg = "Test leaked resources:\n\n" + "\n\n".join(error_messages)
pytest.fail(final_msg)
services:
toxiproxy:
build:
context: ..
dockerfile: tests/Dockerfile.toxiproxy
command: ["toxiproxy-server", "-host", "0.0.0.0", "-port", "8474"]
environment:
- LOG_LEVEL=info
# ports:
# - "8474:8474"
# - "443:443"
networks:
toxinet:
ipv4_address: 172.30.0.10
app:
build:
context: ..
dockerfile: tests/Dockerfile.tests
command: tail -f /dev/null
volumes:
- ../tests:/app/tests
- ../livekit-agents:/app/livekit-agents
- ../livekit-plugins:/app/livekit-plugins
- ../pyproject.toml:/app/pyproject.toml
- ../uv.lock:/app/uv.lock
environment:
- LIVEKIT_URL
- LIVEKIT_API_KEY
- LIVEKIT_API_SECRET
- DEEPGRAM_API_KEY
- OPENAI_API_KEY
- ELEVEN_API_KEY
- CARTESIA_API_KEY
- AZURE_SPEECH_KEY
- AZURE_SPEECH_REGION
- GOOGLE_CREDENTIALS_JSON
- ANTHROPIC_API_KEY
- GROQ_API_KEY
- ASSEMBLYAI_API_KEY
- FAL_KEY
- PLAYHT_API_KEY
- GOOGLE_API_KEY
- PLAYHT_USER_ID
- RIME_API_KEY
- SPEECHMATICS_API_KEY
- GOOGLE_APPLICATION_CREDENTIALS
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- NEUPHONIC_API_KEY
- RESEMBLE_API_KEY
- SPEECHIFY_API_KEY
extra_hosts:
- "polly.us-west-2.amazonaws.com:172.30.0.10"
- "westus.tts.speech.microsoft.com:172.30.0.10"
- "api.cartesia.ai:172.30.0.10"
- "api.deepgram.com:172.30.0.10"
- "api.elevenlabs.io:172.30.0.10"
- "api.sws.speechify.com:172.30.0.10"
- "texttospeech.googleapis.com:172.30.0.10"
- "api.groq.com:172.30.0.10"
- "api.neuphonic.com:172.30.0.10"
- "api.openai.com:172.30.0.10"
- "api.play.ht:172.30.0.10"
- "f.cluster.resemble.ai:172.30.0.10"
- "users.rime.ai:172.30.0.10"
networks:
- toxinet
networks:
toxinet:
driver: bridge
ipam:
config:
- subnet: "172.30.0.0/16"
from __future__ import annotations
import asyncio
from livekit.agents import NOT_GIVEN, NotGivenOr, utils
from livekit.agents.stt import (
STT,
RecognizeStream,
SpeechData,
SpeechEvent,
SpeechEventType,
STTCapabilities,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
from livekit.agents.utils.audio import AudioBuffer
class RecognizeSentinel:
pass
class FakeSTT(STT):
def __init__(
self,
*,
fake_exception: Exception | None = None,
fake_transcript: str | None = None,
fake_timeout: float | None = None,
) -> None:
super().__init__(
capabilities=STTCapabilities(streaming=True, interim_results=False),
)
self._fake_exception = fake_exception
self._fake_transcript = fake_transcript
self._fake_timeout = fake_timeout
self._recognize_ch = utils.aio.Chan[RecognizeSentinel]()
self._stream_ch = utils.aio.Chan[FakeRecognizeStream]()
def update_options(
self,
*,
fake_exception: NotGivenOr[Exception | None] = NOT_GIVEN,
fake_transcript: NotGivenOr[str | None] = NOT_GIVEN,
fake_timeout: NotGivenOr[float | None] = NOT_GIVEN,
) -> None:
if utils.is_given(fake_exception):
self._fake_exception = fake_exception
if utils.is_given(fake_transcript):
self._fake_transcript = fake_transcript
if utils.is_given(fake_timeout):
self._fake_timeout = fake_timeout
@property
def recognize_ch(self) -> utils.aio.ChanReceiver[RecognizeSentinel]:
return self._recognize_ch
@property
def stream_ch(self) -> utils.aio.ChanReceiver[FakeRecognizeStream]:
return self._stream_ch
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: str | None,
conn_options: APIConnectOptions,
) -> SpeechEvent:
if self._fake_timeout is not None:
await asyncio.sleep(self._fake_timeout)
if self._fake_exception is not None:
raise self._fake_exception
return SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[SpeechData(text=self._fake_transcript or "", language=language or "")],
)
async def recognize(
self,
buffer: AudioBuffer,
*,
language: str | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
):
self._recognize_ch.send_nowait(RecognizeSentinel())
return await super().recognize(buffer, language=language, conn_options=conn_options)
def stream(
self,
*,
language: str | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> FakeRecognizeStream:
stream = FakeRecognizeStream(
stt=self,
conn_options=conn_options,
)
self._stream_ch.send_nowait(stream)
return stream
class FakeRecognizeStream(RecognizeStream):
def __init__(
self,
*,
stt: STT,
conn_options: APIConnectOptions,
):
super().__init__(stt=stt, conn_options=conn_options)
self._attempt = 0
@property
def attempt(self) -> int:
return self._attempt
def send_fake_transcript(self, transcript: str) -> None:
self._event_ch.send_nowait(
SpeechEvent(
type=SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[SpeechData(text=transcript, language="")],
)
)
async def _run(self) -> None:
self._attempt += 1
assert isinstance(self._stt, FakeSTT)
if self._stt._fake_timeout is not None:
await asyncio.sleep(self._stt._fake_timeout)
if self._stt._fake_transcript is not None:
self.send_fake_transcript(self._stt._fake_transcript)
async for _ in self._input_ch:
pass
if self._stt._fake_exception is not None:
raise self._stt._fake_exception
from __future__ import annotations
import asyncio
from livekit import rtc
from livekit.agents import NOT_GIVEN, NotGivenOr, utils
from livekit.agents.tts import (
TTS,
ChunkedStream,
SynthesizedAudio,
SynthesizeStream,
TTSCapabilities,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
class FakeTTS(TTS):
def __init__(
self,
*,
sample_rate: int = 24000,
num_channels: int = 1,
fake_timeout: float | None = None,
fake_audio_duration: float | None = None,
fake_exception: Exception | None = None,
) -> None:
super().__init__(
capabilities=TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=num_channels,
)
self._fake_timeout = fake_timeout
self._fake_audio_duration = fake_audio_duration
self._fake_exception = fake_exception
self._synthesize_ch = utils.aio.Chan[FakeChunkedStream]()
self._stream_ch = utils.aio.Chan[FakeSynthesizeStream]()
def update_options(
self,
*,
fake_timeout: NotGivenOr[float | None] = NOT_GIVEN,
fake_audio_duration: NotGivenOr[float | None] = NOT_GIVEN,
fake_exception: NotGivenOr[Exception | None] = NOT_GIVEN,
) -> None:
if utils.is_given(fake_timeout):
self._fake_timeout = fake_timeout
if utils.is_given(fake_audio_duration):
self._fake_audio_duration = fake_audio_duration
if utils.is_given(fake_exception):
self._fake_exception = fake_exception
@property
def synthesize_ch(self) -> utils.aio.ChanReceiver[FakeChunkedStream]:
return self._synthesize_ch
@property
def stream_ch(self) -> utils.aio.ChanReceiver[FakeSynthesizeStream]:
return self._stream_ch
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> FakeChunkedStream:
stream = FakeChunkedStream(tts=self, input_text=text, conn_options=conn_options)
self._synthesize_ch.send_nowait(stream)
return stream
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> FakeSynthesizeStream:
stream = FakeSynthesizeStream(
tts=self,
conn_options=conn_options,
)
self._stream_ch.send_nowait(stream)
return stream
class FakeChunkedStream(ChunkedStream):
def __init__(self, *, tts: FakeTTS, input_text: str, conn_options: APIConnectOptions) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._attempt = 0
@property
def attempt(self) -> int:
return self._attempt
async def _run(self) -> None:
self._attempt += 1
assert isinstance(self._tts, FakeTTS)
request_id = utils.shortuuid("fake_tts_")
if self._tts._fake_timeout is not None:
await asyncio.sleep(self._tts._fake_timeout)
if self._tts._fake_audio_duration is not None:
pushed_samples = 0
max_samples = (
int(self._tts.sample_rate * self._tts._fake_audio_duration + 0.5)
* self._tts.num_channels
)
while pushed_samples < max_samples:
num_samples = min(self._tts.sample_rate // 100, max_samples - pushed_samples)
self._event_ch.send_nowait(
SynthesizedAudio(
request_id=request_id,
frame=rtc.AudioFrame(
data=b"\x00\x00" * num_samples,
samples_per_channel=num_samples // self._tts.num_channels,
sample_rate=self._tts.sample_rate,
num_channels=self._tts.num_channels,
),
)
)
pushed_samples += num_samples
if self._tts._fake_exception is not None:
raise self._tts._fake_exception
class FakeSynthesizeStream(SynthesizeStream):
def __init__(
self,
*,
tts: TTS,
conn_options: APIConnectOptions,
):
super().__init__(tts=tts, conn_options=conn_options)
self._attempt = 0
@property
def attempt(self) -> int:
return self._attempt
async def _run(self) -> None:
self._attempt += 1
assert isinstance(self._tts, FakeTTS)
if self._tts._fake_timeout is not None:
await asyncio.sleep(self._tts._fake_timeout)
has_data = False
async for data in self._input_ch:
if isinstance(data, str):
has_data = True
continue
elif isinstance(data, SynthesizeStream._FlushSentinel) and not has_data:
continue
has_data = False
if self._tts._fake_audio_duration is None:
continue
request_id = utils.shortuuid("fake_tts_")
segment_id = utils.shortuuid("fake_segment_")
pushed_samples = 0
max_samples = (
int(self._tts.sample_rate * self._tts._fake_audio_duration + 0.5)
* self._tts.num_channels
)
while pushed_samples < max_samples:
num_samples = min(self._tts.sample_rate // 100, max_samples - pushed_samples)
self._event_ch.send_nowait(
SynthesizedAudio(
request_id=request_id,
segment_id=segment_id,
is_final=(pushed_samples + num_samples >= max_samples),
frame=rtc.AudioFrame(
data=b"\x00\x00" * num_samples,
samples_per_channel=num_samples // self._tts.num_channels,
sample_rate=self._tts.sample_rate,
num_channels=self._tts.num_channels,
),
)
)
pushed_samples += num_samples
if self._tts._fake_exception is not None:
raise self._tts._fake_exception
version https://git-lfs.github.com/spec/v1
oid sha256:a420326dbf4f37675bf14ae260fff776aee428ced887fc97e0936c36b96589f6
size 559968
The people who are crazy enough to think they can change the world are the ones who do.
The reasonable man adapts himself to the world; the unreasonable one persists in trying to adapt the world to himself. Therefore all progress depends on the unreasonable man.
Never doubt that a small group of thoughtful, committed citizens can change the world; indeed, it's the only thing that ever has.
Do not go where the path may lead, go instead where there is no path and leave a trail.
It could not have been ten seconds, and yet it seemed a long time that their hands were clasped together.
He had time to learn every detail of her hand.
He explored the long fingers, the shapely nails, the work-hardened palm with its row of callouses, the smooth flesh under the wrist.
Merely from feeling it he would have known it by sight.
In the same instant it occurred to him that he did not know what colour the girl's eyes were.
They were probably brown, but people with dark hair sometimes had blue eyes.
To turn his head and look at her would have been inconceivable folly.
With hands locked together, invisible among the press of bodies,
they stared steadily in front of them, and instead of the eyes of the girl, the eyes of the aged prisoner gazed mournfully at Winston out of nests of hair.
import asyncio
from livekit.agents.utils import aio
async def test_channel():
tx = rx = aio.Chan[int]()
sum = 0
async def test_task():
nonlocal sum
while True:
try:
sum = sum + await rx.recv()
except aio.ChanClosed:
break
t = asyncio.create_task(test_task())
for _ in range(10):
await tx.send(1)
tx.close()
await t
assert sum == 10
async def test_interval():
interval = aio.interval(0.1)
_ = asyncio.get_event_loop()
async for i in interval:
if i == 3:
break
async def test_sleep():
await aio.sleep(0)
sleep = aio.sleep(5)
sleep.reset(0.1)
await sleep
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import aiohttp
import pytest
from livekit.agents.stt import SpeechEventType
from livekit.agents.utils.codecs import AudioStreamDecoder, StreamBuffer
from livekit.plugins import deepgram
from .utils import wer
TEST_AUDIO_FILEPATH = os.path.join(os.path.dirname(__file__), "change-sophie.opus")
@pytest.mark.asyncio
async def test_decode_and_transcribe():
# Skip if test file doesn't exist
if not os.path.exists(TEST_AUDIO_FILEPATH):
pytest.skip(f"Test file not found: {TEST_AUDIO_FILEPATH}")
decoder = AudioStreamDecoder()
with open(TEST_AUDIO_FILEPATH, "rb") as f:
opus_data = f.read()
decoder.push(opus_data)
decoder.end_input()
session = aiohttp.ClientSession()
stt = deepgram.STT(http_session=session)
stream = stt.stream()
# Push frames to STT
async for frame in decoder:
stream.push_frame(frame)
# Mark end of input
stream.end_input()
# Collect results
final_text = ""
async for event in stream:
if event.type == SpeechEventType.FINAL_TRANSCRIPT:
if event.alternatives:
if final_text:
final_text += " "
final_text += event.alternatives[0].text
await decoder.aclose()
await stream.aclose()
await session.close()
# Verify the transcription
expected_text = (
"the people that are crazy enough to think they can change the world are the ones who do"
)
assert wer(final_text, expected_text) < 0.2
def test_stream_buffer():
buffer = StreamBuffer()
data_chunks = [b"hello", b"world", b"test", b"data"]
received_data = bytearray()
write_completed = threading.Event()
def writer():
for chunk in data_chunks:
buffer.write(chunk)
time.sleep(0.01) # Simulate some processing time
buffer.end_input()
write_completed.set()
def reader():
while True:
data = buffer.read(4) # Read in small chunks
if not data: # EOF
break
received_data.extend(data)
# Run writer and reader in separate threads
with ThreadPoolExecutor(max_workers=2) as executor:
reader_future = executor.submit(reader)
writer_future = executor.submit(writer)
# Wait for both threads to complete
writer_future.result()
reader_future.result()
# Verify that all data was received correctly
expected_data = b"".join(data_chunks)
assert bytes(received_data) == expected_data
def test_stream_buffer_large_chunks():
import hashlib
buffer = StreamBuffer()
large_chunk = os.urandom(1024 * 1024) # 1MB of random bytes
num_chunks = 5
total_size = 0
write_completed = threading.Event()
input_hasher = hashlib.sha256()
def writer():
nonlocal total_size
for _ in range(num_chunks):
buffer.write(large_chunk)
total_size += len(large_chunk)
input_hasher.update(large_chunk)
buffer.end_input()
write_completed.set()
received_size = 0
output_hasher = hashlib.sha256()
def reader():
nonlocal received_size
# allow writer to start first
time.sleep(1)
while True:
chunk = buffer.read(8192) # Read in 8KB chunks
if not chunk:
break
received_size += len(chunk)
output_hasher.update(chunk)
# Run writer and reader in separate threads
with ThreadPoolExecutor(max_workers=2) as executor:
reader_future = executor.submit(reader)
writer_future = executor.submit(writer)
# Wait for both threads to complete
writer_future.result()
reader_future.result()
assert received_size == total_size
assert total_size == num_chunks * len(large_chunk)
assert input_hasher.hexdigest() == output_hasher.hexdigest()
def test_stream_buffer_early_close():
buffer = StreamBuffer()
# Write some data
buffer.write(b"test data")
# Close the buffer
buffer.close()
# Reading from closed buffer should return empty bytes
assert buffer.read() == b""
from livekit.agents.llm import utils
# function_arguments_to_pydantic_model
def ai_function1(a: int, b: str = "default") -> None:
"""
This is a test function
Args:
a: First argument
b: Second argument
"""
pass
def test_args_model():
from docstring_parser import parse_from_object
docstring = parse_from_object(ai_function1)
print(docstring.description)
model = utils.function_arguments_to_pydantic_model(ai_function1)
print(model.model_json_schema())
def test_dict():
from livekit import rtc
from livekit.agents.llm import ChatContext, ImageContent
chat_ctx = ChatContext()
chat_ctx.add_message(
role="user",
content="Hello, world!",
)
chat_ctx.add_message(
role="assistant",
content="Hello, world!",
)
chat_ctx.add_message(
role="user",
content=[
ImageContent(
image=rtc.VideoFrame(64, 64, rtc.VideoBufferType.RGB24, b"0" * 64 * 64 * 3)
)
],
)
print(chat_ctx.to_dict())
print(chat_ctx.items)
print(ChatContext.from_dict(chat_ctx.to_dict()).items)
from livekit.plugins.openai.realtime.realtime_model import process_base_url
def test_process_base_url():
assert (
process_base_url("https://api.openai.com/v1", "gpt-4")
== "wss://api.openai.com/v1/realtime?model=gpt-4"
)
assert (
process_base_url("http://example.com", "gpt-4") == "ws://example.com/realtime?model=gpt-4"
)
assert ( # noqa: F631
process_base_url(
"wss://livekit.ai/voice/v1/chat/voice?client=oai&enable_noise_suppression=true",
"gpt-4",
)
== "wss://livekit.ai/voice/v1/chat/voice?client=oai&enable_noise_suppression=true",
)
assert (
process_base_url(
"https://test.azure.com/openai",
"gpt-4",
)
== "wss://test.azure.com/openai/realtime?model=gpt-4"
)
assert (
process_base_url(
"https://test.azure.com/openai",
"gpt-4",
is_azure=True,
azure_deployment="my-deployment",
api_version="2025-04-12",
)
== "wss://test.azure.com/openai/realtime?api-version=2025-04-12&deployment=my-deployment"
)
assert (
process_base_url(
"https://test.azure.com/custom/path",
"gpt-4",
api_version="2025-04-12",
)
== "wss://test.azure.com/custom/path?model=gpt-4"
)
import time
import pytest
from livekit.agents.utils import ConnectionPool
class DummyConnection:
def __init__(self, id):
self.id = id
def __repr__(self):
return f"DummyConnection({self.id})"
def dummy_connect_factory():
counter = 0
async def dummy_connect():
nonlocal counter
counter += 1
return DummyConnection(counter)
return dummy_connect
@pytest.mark.asyncio
async def test_get_reuses_connection():
dummy_connect = dummy_connect_factory()
pool = ConnectionPool(max_session_duration=60, connect_cb=dummy_connect)
conn1 = await pool.get()
# Return the connection to the pool
pool.put(conn1)
async with pool.connection() as conn:
assert conn is conn1, "Expected conn to be the same connection as conn1"
conn2 = await pool.get()
assert conn1 is conn2, "Expected the same connection to be reused when it hasn't expired."
@pytest.mark.asyncio
async def test_get_creates_new_connection_when_none_available():
dummy_connect = dummy_connect_factory()
pool = ConnectionPool(max_session_duration=60, connect_cb=dummy_connect)
conn1 = await pool.get()
# Not putting conn1 back means the available pool is empty,
# so calling get() again should create a new connection.
conn2 = await pool.get()
assert conn1 is not conn2, "Expected a new connection when no available connection exists."
@pytest.mark.asyncio
async def test_remove_connection():
dummy_connect = dummy_connect_factory()
pool = ConnectionPool(max_session_duration=60, connect_cb=dummy_connect)
conn = await pool.get()
pool.put(conn)
# Reset the connection which should remove it from the pool.
pool.remove(conn)
# Even if we try to put it back, it won't be added because it's not tracked anymore.
pool.put(conn)
new_conn = await pool.get()
assert new_conn is not conn, "Expected a removed connection to not be reused."
@pytest.mark.asyncio
async def test_get_expired():
# Use a short max duration to simulate expiration.
dummy_connect = dummy_connect_factory()
pool = ConnectionPool(max_session_duration=1, connect_cb=dummy_connect)
conn = await pool.get()
pool.put(conn)
# Artificially set the connection's timestamp in the past to simulate expiration.
pool._connections[conn] = time.time() - 2 # 2 seconds ago (max_session_duration is 1)
conn2 = await pool.get()
assert conn2 is not conn, "Expected a new connection to be returned."
from __future__ import annotations
import asyncio
import ctypes
import io
import multiprocessing as mp
import socket
import time
import uuid
from dataclasses import dataclass
from multiprocessing.context import BaseContext
from typing import ClassVar
import psutil
from livekit.agents import JobContext, JobProcess, ipc, job, utils
from livekit.protocol import agent
@dataclass
class EmptyMessage:
MSG_ID: ClassVar[int] = 0
@dataclass
class SomeDataMessage:
MSG_ID: ClassVar[int] = 1
string: str = ""
number: int = 0
double: float = 0.0
data: bytes = b""
def write(self, b: io.BytesIO) -> None:
ipc.channel.write_string(b, self.string)
ipc.channel.write_int(b, self.number)
ipc.channel.write_double(b, self.double)
ipc.channel.write_bytes(b, self.data)
def read(self, b: io.BytesIO) -> None:
self.string = ipc.channel.read_string(b)
self.number = ipc.channel.read_int(b)
self.double = ipc.channel.read_double(b)
self.data = ipc.channel.read_bytes(b)
IPC_MESSAGES = {
EmptyMessage.MSG_ID: EmptyMessage,
SomeDataMessage.MSG_ID: SomeDataMessage,
}
def _echo_main(mp_cch):
async def _pong():
cch = await utils.aio.duplex_unix._AsyncDuplex.open(mp_cch)
while True:
try:
msg = await ipc.channel.arecv_message(cch, IPC_MESSAGES)
await ipc.channel.asend_message(cch, msg)
except utils.aio.duplex_unix.DuplexClosed:
print("_echo_main, duplex closed..")
break
asyncio.run(_pong())
async def test_async_channel():
mp_pch, mp_cch = socket.socketpair()
pch = await utils.aio.duplex_unix._AsyncDuplex.open(mp_pch)
proc = mp.get_context("spawn").Process(target=_echo_main, args=(mp_cch,))
proc.start()
mp_cch.close()
await ipc.channel.asend_message(pch, EmptyMessage())
assert await ipc.channel.arecv_message(pch, IPC_MESSAGES) == EmptyMessage()
await ipc.channel.asend_message(
pch, SomeDataMessage(string="hello", number=42, double=3.14, data=b"world")
)
assert await ipc.channel.arecv_message(pch, IPC_MESSAGES) == SomeDataMessage(
string="hello", number=42, double=3.14, data=b"world"
)
await pch.aclose()
await asyncio.sleep(0.5)
proc.terminate()
proc.join()
def test_sync_channel():
mp_pch, mp_cch = socket.socketpair()
pch = utils.aio.duplex_unix._Duplex.open(mp_pch)
proc = mp.get_context("spawn").Process(target=_echo_main, args=(mp_cch,))
proc.start()
mp_cch.close()
ipc.channel.send_message(pch, EmptyMessage())
assert ipc.channel.recv_message(pch, IPC_MESSAGES) == EmptyMessage()
ipc.channel.send_message(
pch, SomeDataMessage(string="hello", number=42, double=3.14, data=b"world")
)
assert ipc.channel.recv_message(pch, IPC_MESSAGES) == SomeDataMessage(
string="hello", number=42, double=3.14, data=b"world"
)
pch.close()
def _generate_fake_job() -> job.RunningJobInfo:
return job.RunningJobInfo(
job=agent.Job(id="fake_job_" + str(uuid.uuid4().hex), type=agent.JobType.JT_ROOM),
url="fake_url",
token="fake_token",
accept_arguments=job.JobAcceptArguments(name="", identity="", metadata=""),
worker_id="fake_id",
)
@dataclass
class _StartArgs:
initialize_counter: mp.Value
entrypoint_counter: mp.Value
shutdown_counter: mp.Value
initialize_simulate_work_time: float
entrypoint_simulate_work_time: float
shutdown_simulate_work_time: float
update_ev: mp.Condition
def _new_start_args(mp_ctx: BaseContext) -> _StartArgs:
return _StartArgs(
initialize_counter=mp_ctx.Value(ctypes.c_uint),
entrypoint_counter=mp_ctx.Value(ctypes.c_uint),
shutdown_counter=mp_ctx.Value(ctypes.c_uint),
initialize_simulate_work_time=0.0,
entrypoint_simulate_work_time=0.0,
shutdown_simulate_work_time=0.0,
update_ev=mp_ctx.Condition(),
)
def _initialize_proc(proc: JobProcess) -> None:
start_args: _StartArgs = proc.user_arguments
# incrementing isn't atomic (the lock should be reentrant by default)
with start_args.initialize_counter.get_lock():
start_args.initialize_counter.value += 1
time.sleep(start_args.initialize_simulate_work_time)
with start_args.update_ev:
start_args.update_ev.notify()
async def _job_entrypoint(job_ctx: JobContext) -> None:
start_args: _StartArgs = job_ctx.proc.user_arguments
async def _job_shutdown() -> None:
with start_args.shutdown_counter.get_lock():
start_args.shutdown_counter.value += 1
await asyncio.sleep(start_args.shutdown_simulate_work_time)
with start_args.update_ev:
start_args.update_ev.notify()
job_ctx.add_shutdown_callback(_job_shutdown)
with start_args.entrypoint_counter.get_lock():
start_args.entrypoint_counter.value += 1
await asyncio.sleep(start_args.entrypoint_simulate_work_time)
job_ctx.shutdown(
"calling shutdown inside the test to avoid a warning when neither shutdown nor connect is called." # noqa: E501
)
with start_args.update_ev:
start_args.update_ev.notify()
async def _wait_for_elements(q: asyncio.Queue, num_elements: int) -> None:
for _ in range(num_elements):
await q.get()
async def test_proc_pool():
mp_ctx = mp.get_context("spawn")
loop = asyncio.get_running_loop()
num_idle_processes = 3
pool = ipc.proc_pool.ProcPool(
initialize_process_fnc=_initialize_proc,
job_entrypoint_fnc=_job_entrypoint,
num_idle_processes=num_idle_processes,
job_executor_type=job.JobExecutorType.PROCESS,
initialize_timeout=20.0,
close_timeout=20.0,
inference_executor=None,
memory_warn_mb=0,
memory_limit_mb=0,
mp_ctx=mp_ctx,
loop=loop,
)
start_args = _new_start_args(mp_ctx)
created_q = asyncio.Queue()
start_q = asyncio.Queue()
ready_q = asyncio.Queue()
close_q = asyncio.Queue()
pids = []
exitcodes = []
@pool.on("process_created")
def _process_created(proc: ipc.job_proc_executor.ProcJobExecutor):
created_q.put_nowait(None)
proc.user_arguments = start_args
@pool.on("process_started")
def _process_started(proc: ipc.job_proc_executor.ProcJobExecutor):
start_q.put_nowait(None)
pids.append(proc.pid)
@pool.on("process_ready")
def _process_ready(proc: ipc.job_proc_executor.ProcJobExecutor):
ready_q.put_nowait(None)
@pool.on("process_closed")
def _process_closed(proc: ipc.job_proc_executor.ProcJobExecutor):
close_q.put_nowait(None)
exitcodes.append(proc.exitcode)
pool.start()
await _wait_for_elements(created_q, num_idle_processes)
await _wait_for_elements(start_q, num_idle_processes)
await _wait_for_elements(ready_q, num_idle_processes)
assert start_args.initialize_counter.value == num_idle_processes
jobs_to_start = 2
for _ in range(jobs_to_start):
await pool.launch_job(_generate_fake_job())
await _wait_for_elements(created_q, jobs_to_start)
await _wait_for_elements(start_q, jobs_to_start)
await _wait_for_elements(ready_q, jobs_to_start)
await pool.aclose()
assert start_args.entrypoint_counter.value == jobs_to_start
assert start_args.shutdown_counter.value == jobs_to_start
await _wait_for_elements(close_q, num_idle_processes + jobs_to_start)
# the way we check that a process doesn't exist anymore isn't technically reliable (pid recycle could happen) # noqa: E501
for pid in pids:
assert not psutil.pid_exists(pid)
for exitcode in exitcodes:
# this test expects graceful shutdown, kill is tested on another test
assert exitcode == 0, f"process did not exit cleanly: {exitcode}"
async def test_slow_initialization():
mp_ctx = mp.get_context("spawn")
loop = asyncio.get_running_loop()
num_idle_processes = 2
pool = ipc.proc_pool.ProcPool(
job_executor_type=job.JobExecutorType.PROCESS,
initialize_process_fnc=_initialize_proc,
job_entrypoint_fnc=_job_entrypoint,
num_idle_processes=num_idle_processes,
initialize_timeout=1.0,
close_timeout=20.0,
inference_executor=None,
memory_warn_mb=0,
memory_limit_mb=0,
mp_ctx=mp_ctx,
loop=loop,
)
start_args = _new_start_args(mp_ctx)
start_args.initialize_simulate_work_time = 2.0
start_q = asyncio.Queue()
close_q = asyncio.Queue()
pids = []
exitcodes = []
@pool.on("process_created")
def _process_created(proc: ipc.job_proc_executor.ProcJobExecutor):
proc.user_arguments = start_args
start_q.put_nowait(None)
@pool.on("process_closed")
def _process_closed(proc: ipc.job_proc_executor.ProcJobExecutor):
close_q.put_nowait(None)
pids.append(proc.pid)
exitcodes.append(proc.exitcode)
pool.start()
await _wait_for_elements(start_q, num_idle_processes)
await _wait_for_elements(close_q, num_idle_processes)
# after initialization failure, warmup should be retried
await _wait_for_elements(start_q, num_idle_processes)
await pool.aclose()
for pid in pids:
assert not psutil.pid_exists(pid)
for exitcode in exitcodes:
assert exitcode != 0, "process should have been killed"
def _create_proc(
*,
close_timeout: float,
mp_ctx: BaseContext,
initialize_timeout: float = 20.0,
) -> tuple[ipc.job_proc_executor.ProcJobExecutor, _StartArgs]:
start_args = _new_start_args(mp_ctx)
loop = asyncio.get_running_loop()
proc = ipc.job_proc_executor.ProcJobExecutor(
initialize_process_fnc=_initialize_proc,
job_entrypoint_fnc=_job_entrypoint,
initialize_timeout=initialize_timeout,
close_timeout=close_timeout,
memory_warn_mb=0,
memory_limit_mb=0,
ping_interval=2.5,
ping_timeout=10.0,
high_ping_threshold=1.0,
inference_executor=None,
mp_ctx=mp_ctx,
loop=loop,
)
proc.user_arguments = start_args
return proc, start_args
async def test_shutdown_no_job():
mp_ctx = mp.get_context("spawn")
proc, start_args = _create_proc(close_timeout=10.0, mp_ctx=mp_ctx)
await proc.start()
await proc.initialize()
await asyncio.sleep(1.0)
await proc.aclose()
assert proc.exitcode == 0
assert not proc.killed
assert start_args.shutdown_counter.value == 0, "shutdown_cb isn't called when there is no job"
async def test_job_slow_shutdown():
mp_ctx = mp.get_context("spawn")
proc, start_args = _create_proc(close_timeout=1.0, mp_ctx=mp_ctx)
start_args.shutdown_simulate_work_time = 10.0
await proc.start()
await proc.initialize()
await asyncio.sleep(1.0)
fake_job = _generate_fake_job()
await proc.launch_job(fake_job)
await asyncio.sleep(1.0)
await proc.aclose()
# process is killed when there is a job with slow timeout
assert proc.exitcode != 0, "process should have been killed"
assert proc.killed
async def test_job_graceful_shutdown():
mp_ctx = mp.get_context("spawn")
proc, start_args = _create_proc(close_timeout=10.0, mp_ctx=mp_ctx)
start_args.shutdown_simulate_work_time = 1.0
await proc.start()
await proc.initialize()
await asyncio.sleep(1.0)
fake_job = _generate_fake_job()
await proc.launch_job(fake_job)
await asyncio.sleep(1.0)
await proc.aclose()
assert proc.exitcode == 0, "process should have exited cleanly"
assert not proc.killed
assert start_args.shutdown_counter.value == 1
from __future__ import annotations
import asyncio
import base64
from enum import Enum
from pathlib import Path
from typing import Annotated, Callable
import pytest
from livekit.agents import APIConnectionError, llm
from livekit.agents.llm import ChatContext, FunctionContext, TypeInfo, ai_callable
from livekit.plugins import anthropic, aws, google, openai
from livekit.rtc import VideoBufferType, VideoFrame
class Unit(Enum):
FAHRENHEIT = "fahrenheit"
CELSIUS = "celsius"
class FncCtx(FunctionContext):
@ai_callable(description="Get the current weather in a given location", auto_retry=True)
def get_weather(
self,
location: Annotated[
str, TypeInfo(description="The city and state, e.g. San Francisco, CA")
],
unit: Annotated[Unit, TypeInfo(description="The temperature unit to use.")] = Unit.CELSIUS,
) -> None: ...
@ai_callable(description="Play a music")
def play_music(
self,
name: Annotated[str, TypeInfo(description="the name of the Artist")],
) -> None: ...
# test for cancelled calls
@ai_callable(description="Turn on/off the lights in a room")
async def toggle_light(
self,
room: Annotated[str, TypeInfo(description="The room to control")],
on: bool = True,
) -> None:
await asyncio.sleep(60)
# used to test arrays as arguments
@ai_callable(description="Select currencies of a specific area")
def select_currencies(
self,
currencies: Annotated[
list[str],
TypeInfo(
description="The currencies to select",
choices=["usd", "eur", "gbp", "jpy", "sek"],
),
],
) -> None: ...
@ai_callable(description="Update user info")
def update_user_info(
self,
email: Annotated[str | None, TypeInfo(description="The user address email")] = None,
name: Annotated[str | None, TypeInfo(description="The user name")] = None,
address: Annotated[str, TypeInfo(description="The user address")] | None = None,
) -> None: ...
def test_hashable_typeinfo():
typeinfo = TypeInfo(description="testing", choices=[1, 2, 3])
# TypeInfo must be hashable when used in combination of typing.Annotated
hash(typeinfo)
LLMS: list[Callable[[], llm.LLM]] = [
pytest.param(lambda: openai.LLM(), id="openai"),
# lambda: openai.beta.AssistantLLM(
# assistant_opts=openai.beta.AssistantOptions(
# create_options=openai.beta.AssistantCreateOptions(
# name=f"test-{uuid.uuid4()}",
# instructions="You are a basic assistant",
# model="gpt-4o",
# )
# )
# ),
pytest.param(lambda: anthropic.LLM(), id="anthropic"),
pytest.param(lambda: google.LLM(), id="google"),
pytest.param(lambda: google.LLM(vertexai=True), id="google-vertexai"),
pytest.param(lambda: aws.LLM(), id="aws"),
]
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_chat(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
chat_ctx = ChatContext().append(
text='You are an assistant at a drive-thru restaurant "Live-Burger". Ask the customer what they would like to order.', # noqa: E501
)
# Anthropic and vertex requires at least one message (system messages don't count)
chat_ctx.append(
text="Hello",
role="user",
)
stream = input_llm.chat(chat_ctx=chat_ctx)
text = ""
async for chunk in stream:
if not chunk.choices:
continue
content = chunk.choices[0].delta.content
if content:
text += content
assert len(text) > 0
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_llm_chat_with_consecutive_messages(
llm_factory: callable,
):
input_llm = llm_factory()
chat_ctx = ChatContext()
chat_ctx.append(
text="Hello, How can I help you today?",
role="assistant",
)
chat_ctx.append(text="I see that you have a busy day ahead.", role="assistant")
chat_ctx.append(text="Actually, I need some help with my recent order.", role="user")
chat_ctx.append(text="I want to cancel my order.", role="user")
stream = input_llm.chat(chat_ctx=chat_ctx)
collected_text = ""
async for chunk in stream:
if not chunk.choices:
continue
content = chunk.choices[0].delta.content
if content:
collected_text += content
assert len(collected_text) > 0, "Expected a non-empty response from the LLM chat"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_basic_fnc_calls(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
stream = await _request_fnc_call(
input_llm,
"What's the weather in San Francisco and what's the weather Paris?",
fnc_ctx,
)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls])
await stream.aclose()
assert len(calls) == 2, "get_weather should be called twice"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_function_call_exception_handling(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
@fnc_ctx.ai_callable(description="Simulate a failure")
async def failing_function():
raise RuntimeError("Simulated failure")
stream = await _request_fnc_call(input_llm, "Call the failing function", fnc_ctx)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls], return_exceptions=True)
await stream.aclose()
assert len(calls) == 1
assert isinstance(calls[0].exception, RuntimeError)
assert str(calls[0].exception) == "Simulated failure"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_runtime_addition(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
called_msg = ""
@fnc_ctx.ai_callable(description="Show a message on the screen")
async def show_message(
message: Annotated[str, TypeInfo(description="The message to show")],
):
nonlocal called_msg
called_msg = message
stream = await _request_fnc_call(
input_llm, "Can you show 'Hello LiveKit!' on the screen?", fnc_ctx
)
fns = stream.execute_functions()
await asyncio.gather(*[f.task for f in fns])
await stream.aclose()
assert called_msg == "Hello LiveKit!", "send_message should be called"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_cancelled_calls(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
stream = await _request_fnc_call(input_llm, "Turn off the lights in the bedroom", fnc_ctx)
calls = stream.execute_functions()
await asyncio.sleep(0.2) # wait for the loop executor to start the task
# don't wait for gather_function_results and directly close (this should cancel the
# ongoing calls)
await stream.aclose()
assert len(calls) == 1
assert isinstance(calls[0].exception, asyncio.CancelledError), (
"toggle_light should have been cancelled"
)
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_calls_arrays(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
stream = await _request_fnc_call(
input_llm,
"Can you select all currencies in Europe at once from given choices using function call `select_currencies`?", # noqa: E501
fnc_ctx,
temperature=0.2,
)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls])
await stream.aclose()
assert len(calls) == 1, "select_currencies should have been called only once"
call = calls[0]
currencies = call.call_info.arguments["currencies"]
assert len(currencies) == 3, "select_currencies should have 3 currencies"
assert "eur" in currencies and "gbp" in currencies and "sek" in currencies, (
"select_currencies should have eur, gbp, sek"
)
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_calls_choices(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
# test choices on int
@fnc_ctx.ai_callable(description="Change the volume")
def change_volume(
volume: Annotated[
int, TypeInfo(description="The volume level", choices=[0, 11, 30, 83, 99])
],
) -> None: ...
if not input_llm.capabilities.supports_choices_on_int:
with pytest.raises(APIConnectionError):
stream = await _request_fnc_call(input_llm, "Set the volume to 30", fnc_ctx)
else:
stream = await _request_fnc_call(input_llm, "Set the volume to 30", fnc_ctx)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls])
await stream.aclose()
assert len(calls) == 1, "change_volume should have been called only once"
call = calls[0]
volume = call.call_info.arguments["volume"]
assert volume == 30, "change_volume should have been called with volume 30"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_optional_args(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
fnc_ctx = FncCtx()
stream = await _request_fnc_call(
input_llm, "Using a tool call update the user info to name Theo", fnc_ctx
)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls])
await stream.aclose()
assert len(calls) == 1, "update_user_info should have been called only once"
call = calls[0]
name = call.call_info.arguments.get("name", None)
email = call.call_info.arguments.get("email", None)
address = call.call_info.arguments.get("address", None)
assert name == "Theo", "update_user_info should have been called with name 'Theo'"
assert email is None, "update_user_info should have been called with email None"
assert address is None, "update_user_info should have been called with address None"
test_tool_choice_cases = [
pytest.param(
"Default tool_choice (auto)",
"Get the weather for New York and play some music from the artist 'The Beatles'.",
None,
{"get_weather", "play_music"},
id="Default tool_choice (auto)",
),
pytest.param(
"Tool_choice set to 'required'",
"Get the weather for Chicago and play some music from the artist 'Eminem'.",
"required",
{"get_weather", "play_music"},
id="Tool_choice set to 'required'",
),
pytest.param(
"Tool_choice set to a specific tool ('get_weather')",
"Get the weather for Miami.",
llm.ToolChoice(type="function", name="get_weather"),
{"get_weather"},
id="Tool_choice set to a specific tool ('get_weather')",
),
pytest.param(
"Tool_choice set to 'none'",
"Get the weather for Seattle and play some music from the artist 'Frank Sinatra'.",
"none",
set(), # No tool calls expected
id="Tool_choice set to 'none'",
),
]
@pytest.mark.parametrize(
"description, user_request, tool_choice, expected_calls", test_tool_choice_cases
)
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_tool_choice_options(
description: str,
user_request: str,
tool_choice: dict | str | None,
expected_calls: set,
llm_factory: Callable[[], llm.LLM],
):
input_llm = llm_factory()
fnc_ctx = FncCtx()
stream = await _request_fnc_call(
input_llm,
user_request,
fnc_ctx,
tool_choice=tool_choice,
parallel_tool_calls=True,
)
calls = stream.execute_functions()
await asyncio.gather(*[f.task for f in calls], return_exceptions=True)
await stream.aclose()
print(calls)
call_names = {call.call_info.function_info.name for call in calls}
if tool_choice == "none":
assert call_names == expected_calls, (
f"Test '{description}' failed: Expected calls {expected_calls}, but got {call_names}"
)
async def _request_fnc_call(
model: llm.LLM,
request: str,
fnc_ctx: FncCtx,
temperature: float | None = None,
parallel_tool_calls: bool | None = None,
tool_choice: llm.ToolChoice | None = None,
) -> llm.LLMStream:
stream = model.chat(
chat_ctx=ChatContext()
.append(
text="You are an helpful assistant. Follow the instructions provided by the user. You can use multiple tool calls at once.", # noqa: E501
role="system",
)
.append(text=request, role="user"),
fnc_ctx=fnc_ctx,
temperature=temperature,
tool_choice=tool_choice,
parallel_tool_calls=parallel_tool_calls,
)
async for _ in stream:
pass
return stream
_HEARTS_RGBA_PATH = Path(__file__).parent / "hearts.rgba"
with open(_HEARTS_RGBA_PATH, "rb") as f:
image_data = f.read()
_HEARTS_IMAGE_VIDEO_FRAME = VideoFrame(
width=512, height=512, type=VideoBufferType.RGBA, data=image_data
)
_HEARTS_JPEG_PATH = Path(__file__).parent / "hearts.jpg"
with open(_HEARTS_JPEG_PATH, "rb") as f:
_HEARTS_IMAGE_DATA_URL = f"data:image/jpeg;base64,{base64.b64encode(f.read()).decode()}"
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_chat_with_image_data_url(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
chat_ctx = (
ChatContext()
.append(
text="You are an AI assistant that describes images in detail upon request.",
role="system",
)
.append(
text="Describe this image",
images=[llm.ChatImage(image=_HEARTS_IMAGE_DATA_URL, inference_detail="low")],
role="user",
)
)
stream = input_llm.chat(chat_ctx=chat_ctx)
text = ""
async for chunk in stream:
if not chunk.choices:
continue
content = chunk.choices[0].delta.content
if content:
text += content
assert "heart" in text.lower()
@pytest.mark.parametrize("llm_factory", LLMS)
async def test_chat_with_image_frame(llm_factory: Callable[[], llm.LLM]):
input_llm = llm_factory()
chat_ctx = (
ChatContext()
.append(
text="You are an AI assistant that describes images in detail upon request.",
role="system",
)
.append(
text="Describe this image",
images=[llm.ChatImage(image=_HEARTS_IMAGE_VIDEO_FRAME, inference_detail="low")],
role="user",
)
)
stream = input_llm.chat(chat_ctx=chat_ctx)
text = ""
async for chunk in stream:
if not chunk.choices:
continue
content = chunk.choices[0].delta.content
if content:
text += content
assert "heart" in text.lower()
import datetime
from typing import Optional
import pytest
from google.genai import types
from pydantic import BaseModel, Field
from livekit.plugins.google import utils
# Gemini Schema Tests
# Test for inlining $ref definitions
async def test_json_def_replaced():
class Location(BaseModel):
lat: float
lng: float = 1.1
class Locations(BaseModel):
locations: list[Location]
json_schema = Locations.model_json_schema()
# Original schema with $defs as produced by Pydantic.
expected_schema = {
"$defs": {
"Location": {
"properties": {
"lat": {"title": "Lat", "type": "number"},
"lng": {"default": 1.1, "title": "Lng", "type": "number"},
},
"required": ["lat"],
"title": "Location",
"type": "object",
}
},
"properties": {
"locations": {
"items": {"$ref": "#/$defs/Location"},
"title": "Locations",
"type": "array",
}
},
"required": ["locations"],
"title": "Locations",
"type": "object",
}
assert json_schema == expected_schema
gemini_schema = utils._GeminiJsonSchema(json_schema).simplify()
expected_gemini_schema = {
"properties": {
"locations": {
"items": {
"properties": {
"lat": {"type": types.Type.NUMBER},
"lng": {"type": types.Type.NUMBER},
},
"required": ["lat"],
"type": types.Type.OBJECT,
},
"type": types.Type.ARRAY,
}
},
"required": ["locations"],
"type": types.Type.OBJECT,
}
assert gemini_schema == expected_gemini_schema
# Test for handling anyOf (optional field)
async def test_json_def_replaced_any_of():
class Location(BaseModel):
lat: float
lng: float
class Locations(BaseModel):
op_location: Optional[Location] = None
json_schema = Locations.model_json_schema()
gemini_schema = utils._GeminiJsonSchema(json_schema).simplify()
# The anyOf containing the Location ref and {"type": "null"} is merged,
# so op_location becomes the inlined Location with "nullable": True.
expected_gemini_schema = {
"properties": {
"op_location": {
"properties": {
"lat": {"type": types.Type.NUMBER},
"lng": {"type": types.Type.NUMBER},
},
"required": ["lat", "lng"],
"type": types.Type.OBJECT,
"nullable": True,
}
},
"type": types.Type.OBJECT,
}
assert gemini_schema == expected_gemini_schema
# Test for recursive $ref – should raise ValueError
async def test_json_def_recursive():
class Location(BaseModel):
lat: float
lng: float
nested_locations: list["Location"]
Location.model_rebuild()
json_schema = Location.model_json_schema()
expected_schema = {
"$defs": {
"Location": {
"properties": {
"lat": {"title": "Lat", "type": "number"},
"lng": {"title": "Lng", "type": "number"},
"nested_locations": {
"items": {"$ref": "#/$defs/Location"},
"title": "Nested Locations",
"type": "array",
},
},
"required": ["lat", "lng", "nested_locations"],
"title": "Location",
"type": "object",
}
},
"$ref": "#/$defs/Location",
}
assert json_schema == expected_schema
with pytest.raises(
ValueError,
match=r"Recursive `\$ref`s in JSON Schema are not supported by Gemini",
):
utils._GeminiJsonSchema(json_schema).simplify()
# Test for preserving format, and description on string fields
async def test_json_def_date():
class FormattedStringFields(BaseModel):
d: datetime.date
dt: datetime.datetime
t: datetime.time = Field(description="")
td: datetime.timedelta = Field(description="my timedelta")
json_schema = FormattedStringFields.model_json_schema()
expected_schema = {
"properties": {
"d": {"format": "date", "title": "D", "type": "string"},
"dt": {"format": "date-time", "title": "Dt", "type": "string"},
"t": {"format": "time", "title": "T", "type": "string", "description": ""},
"td": {
"format": "duration",
"title": "Td",
"type": "string",
"description": "my timedelta",
},
},
"required": ["d", "dt", "t", "td"],
"title": "FormattedStringFields",
"type": "object",
}
assert json_schema == expected_schema
gemini_schema = utils._GeminiJsonSchema(json_schema).simplify()
expected_gemini_schema = {
"properties": {
"d": {"format": "date", "type": types.Type.STRING},
"dt": {"format": "date-time", "type": types.Type.STRING},
"t": {
"format": "time",
"type": types.Type.STRING,
"description": "",
},
"td": {
"format": "duration",
"type": types.Type.STRING,
"description": "my timedelta",
},
},
"required": ["d", "dt", "t", "td"],
"type": types.Type.OBJECT,
}
assert gemini_schema == expected_gemini_schema
"""
Do speech recognition on a long audio file and compare the result with the expected transcript
"""
import asyncio
import time
from typing import Callable
import pytest
from livekit import agents
from livekit.agents import stt
from livekit.plugins import (
assemblyai,
aws,
azure,
deepgram,
fal,
google,
openai,
silero,
speechmatics,
)
from .utils import make_test_speech, wer
SAMPLE_RATES = [24000, 44100] # test multiple input sample rates
WER_THRESHOLD = 0.25
RECOGNIZE_STT: list[Callable[[], stt.STT]] = [
pytest.param(lambda: deepgram.STT(), id="deepgram"),
# pytest.param(lambda: google.STT(), id="google"),
# pytest.param(
# lambda: google.STT(
# languages=["en-AU"],
# model="chirp_2",
# spoken_punctuation=False,
# location="us-central1",
# ),
# id="google.chirp_2",
# ),
pytest.param(lambda: openai.STT(), id="openai"),
pytest.param(lambda: fal.WizperSTT(), id="fal"),
]
@pytest.mark.usefixtures("job_process")
@pytest.mark.parametrize("stt_factory", RECOGNIZE_STT)
@pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
async def test_recognize(stt_factory, sample_rate):
async with stt_factory() as stt:
frames, transcript = await make_test_speech(sample_rate=sample_rate)
start_time = time.time()
event = await stt.recognize(buffer=frames)
text = event.alternatives[0].text
dt = time.time() - start_time
print(f"WER: {wer(text, transcript)} for {stt} in {dt:.2f}s")
assert wer(text, transcript) <= WER_THRESHOLD
assert event.type == agents.stt.SpeechEventType.FINAL_TRANSCRIPT
STREAM_VAD = silero.VAD.load(min_silence_duration=0.75)
STREAM_STT: list[Callable[[], stt.STT]] = [
pytest.param(lambda: aws.STT(), id="aws"),
pytest.param(lambda: assemblyai.STT(), id="assemblyai"),
pytest.param(lambda: deepgram.STT(), id="deepgram"),
pytest.param(lambda: google.STT(), id="google"),
pytest.param(
lambda: agents.stt.StreamAdapter(stt=openai.STT(), vad=STREAM_VAD),
id="openai.stream",
),
pytest.param(
lambda: agents.stt.StreamAdapter(stt=openai.STT.with_groq(), vad=STREAM_VAD),
id="openai.with_groq.stream",
),
pytest.param(
lambda: google.STT(
languages=["en-US"],
model="chirp_2",
spoken_punctuation=False,
location="us-central1",
),
id="google.chirp_2",
),
pytest.param(lambda: azure.STT(), id="azure"),
pytest.param(lambda: speechmatics.STT(), id="speechmatics"),
]
@pytest.mark.usefixtures("job_process")
@pytest.mark.parametrize("stt_factory", STREAM_STT)
@pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
async def test_stream(stt_factory, sample_rate):
stt = stt_factory()
frames, transcript = await make_test_speech(chunk_duration_ms=10, sample_rate=sample_rate)
stream = stt.stream()
async def _stream_input():
for frame in frames:
stream.push_frame(frame)
await asyncio.sleep(0.005)
stream.end_input()
async def _stream_output():
text = ""
# make sure the events are sent in the right order
recv_start, recv_end = False, True
start_time = time.time()
async for event in stream:
if event.type == agents.stt.SpeechEventType.START_OF_SPEECH:
assert recv_end, "START_OF_SPEECH recv but no END_OF_SPEECH has been sent before"
assert not recv_start
recv_end = False
recv_start = True
continue
if event.type == agents.stt.SpeechEventType.FINAL_TRANSCRIPT:
if text != "":
text += " "
text += event.alternatives[0].text
# ensure STT is tagging languages correctly
language = event.alternatives[0].language
assert language is not None
assert language.lower().startswith("en")
if event.type == agents.stt.SpeechEventType.END_OF_SPEECH:
recv_start = False
recv_end = True
dt = time.time() - start_time
print(f"WER: {wer(text, transcript)} for streamed {stt} in {dt:.2f}s")
assert wer(text, transcript) <= WER_THRESHOLD
await asyncio.wait_for(asyncio.gather(_stream_input(), _stream_output()), timeout=120)
await stream.aclose()
from __future__ import annotations
import asyncio
import pytest
from livekit.agents import APIConnectionError, utils
from livekit.agents.stt import STT, AvailabilityChangedEvent, FallbackAdapter
from livekit.agents.utils.aio.channel import ChanEmpty
from .fake_stt import FakeSTT
class FallbackAdapterTester(FallbackAdapter):
def __init__(
self,
stt: list[STT],
*,
attempt_timeout: float = 10.0,
max_retry_per_stt: int = 1,
retry_interval: float = 5,
) -> None:
super().__init__(
stt,
attempt_timeout=attempt_timeout,
max_retry_per_stt=max_retry_per_stt,
retry_interval=retry_interval,
)
self.on("stt_availability_changed", self._on_stt_availability_changed)
self._availability_changed_ch: dict[int, utils.aio.Chan[AvailabilityChangedEvent]] = {
id(t): utils.aio.Chan[AvailabilityChangedEvent]() for t in stt
}
def _on_stt_availability_changed(self, ev: AvailabilityChangedEvent) -> None:
self._availability_changed_ch[id(ev.stt)].send_nowait(ev)
def availability_changed_ch(
self,
tts: STT,
) -> utils.aio.ChanReceiver[AvailabilityChangedEvent]:
return self._availability_changed_ch[id(tts)]
async def test_stt_fallback() -> None:
fake1 = FakeSTT(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeSTT(fake_transcript="hello world")
fallback_adapter = FallbackAdapterTester([fake1, fake2])
ev = await fallback_adapter.recognize([])
assert ev.alternatives[0].text == "hello world"
assert fake1.recognize_ch.recv_nowait()
assert fake2.recognize_ch.recv_nowait()
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
fake2.update_options(fake_exception=APIConnectionError("fake2 failed"))
with pytest.raises(APIConnectionError):
await fallback_adapter.recognize([])
assert not fallback_adapter.availability_changed_ch(fake2).recv_nowait().available
await fallback_adapter.aclose()
# stream
fake1 = FakeSTT(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeSTT(fake_transcript="hello world")
fallback_adapter = FallbackAdapterTester([fake1, fake2])
async with fallback_adapter.stream() as stream:
stream.end_input()
last_alt = ""
async for ev in stream:
last_alt = ev.alternatives[0].text
assert last_alt == "hello world"
await fallback_adapter.aclose()
async def test_stt_stream_fallback() -> None:
fake1 = FakeSTT(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeSTT(fake_transcript="hello world")
fallback_adapter = FallbackAdapterTester([fake1, fake2])
async with fallback_adapter.stream() as stream:
stream.end_input()
async for _ in stream:
pass
assert fake1.stream_ch.recv_nowait()
assert fake2.stream_ch.recv_nowait()
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
await fallback_adapter.aclose()
async def test_stt_recover() -> None:
fake1 = FakeSTT(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeSTT(fake_exception=APIConnectionError("fake2 failed"), fake_timeout=0.5)
fallback_adapter = FallbackAdapterTester([fake1, fake2])
with pytest.raises(APIConnectionError):
await fallback_adapter.recognize([])
fake2.update_options(fake_exception=None, fake_transcript="hello world")
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
assert not fallback_adapter.availability_changed_ch(fake2).recv_nowait().available
assert (
await asyncio.wait_for(fallback_adapter.availability_changed_ch(fake2).recv(), 1.0)
).available, "fake2 should have recovered"
await fallback_adapter.recognize([])
assert fake1.recognize_ch.recv_nowait()
assert fake2.recognize_ch.recv_nowait()
with pytest.raises(ChanEmpty):
fallback_adapter.availability_changed_ch(fake1).recv_nowait()
with pytest.raises(ChanEmpty):
fallback_adapter.availability_changed_ch(fake2).recv_nowait()
await fallback_adapter.aclose()
import pytest
from livekit.agents import tokenize
from livekit.agents.tokenize import basic
from livekit.agents.tokenize._basic_paragraph import split_paragraphs
from livekit.plugins import nltk
# Download the punkt tokenizer, will only download if not already present
nltk.NltkPlugin().download_files()
TEXT = (
"Hi! "
"LiveKit is a platform for live audio and video applications and services. \n\n"
"R.T.C stands for Real-Time Communication... again R.T.C. "
"Mr. Theo is testing the sentence tokenizer. "
"\nThis is a test. Another test. "
"A short sentence.\n"
"A longer sentence that is longer than the previous sentence. "
"f(x) = x * 2.54 + 42. "
"Hey!\n Hi! Hello! "
"\n\n"
)
EXPECTED_MIN_20 = [
"Hi! LiveKit is a platform for live audio and video applications and services.",
"R.T.C stands for Real-Time Communication... again R.T.C.",
"Mr. Theo is testing the sentence tokenizer.",
"This is a test. Another test.",
"A short sentence. A longer sentence that is longer than the previous sentence.",
"f(x) = x * 2.54 + 42.",
"Hey! Hi! Hello!",
]
EXPECTED_MIN_20_RETAIN_FORMAT = [
"Hi! LiveKit is a platform for live audio and video applications and services.",
" \n\nR.T.C stands for Real-Time Communication... again R.T.C.",
" Mr. Theo is testing the sentence tokenizer.",
" \nThis is a test. Another test.",
" A short sentence.\nA longer sentence that is longer than the previous sentence.",
" f(x) = x * 2.54 + 42.",
" Hey!\n Hi! Hello! \n\n",
]
SENT_TOKENIZERS = [
(nltk.SentenceTokenizer(min_sentence_len=20), EXPECTED_MIN_20),
(basic.SentenceTokenizer(min_sentence_len=20), EXPECTED_MIN_20),
(
basic.SentenceTokenizer(min_sentence_len=20, retain_format=True),
EXPECTED_MIN_20_RETAIN_FORMAT,
),
]
@pytest.mark.parametrize("tokenizer, expected", SENT_TOKENIZERS)
def test_sent_tokenizer(tokenizer: tokenize.SentenceTokenizer, expected: list[str]):
segmented = tokenizer.tokenize(text=TEXT)
for i, segment in enumerate(expected):
assert segment == segmented[i]
@pytest.mark.parametrize("tokenizer, expected", SENT_TOKENIZERS)
async def test_streamed_sent_tokenizer(tokenizer: tokenize.SentenceTokenizer, expected: list[str]):
# divide text by chunks of arbitrary length (1-4)
pattern = [1, 2, 4]
text = TEXT
chunks = []
pattern_iter = iter(pattern * (len(text) // sum(pattern) + 1))
for chunk_size in pattern_iter:
if not text:
break
chunks.append(text[:chunk_size])
text = text[chunk_size:]
stream = tokenizer.stream()
for chunk in chunks:
stream.push_text(chunk)
stream.end_input()
for i in range(len(expected)):
ev = await stream.__anext__()
assert ev.token == expected[i]
WORDS_TEXT = "This is a test. Blabla another test! multiple consecutive spaces: done"
WORDS_EXPECTED = [
"This",
"is",
"a",
"test",
"Blabla",
"another",
"test",
"multiple",
"consecutive",
"spaces",
"done",
]
WORD_TOKENIZERS = [basic.WordTokenizer()]
@pytest.mark.parametrize("tokenizer", WORD_TOKENIZERS)
def test_word_tokenizer(tokenizer: tokenize.WordTokenizer):
tokens = tokenizer.tokenize(text=WORDS_TEXT)
for i, token in enumerate(WORDS_EXPECTED):
assert token == tokens[i]
@pytest.mark.parametrize("tokenizer", WORD_TOKENIZERS)
async def test_streamed_word_tokenizer(tokenizer: tokenize.WordTokenizer):
# divide text by chunks of arbitrary length (1-4)
pattern = [1, 2, 4]
text = WORDS_TEXT
chunks = []
pattern_iter = iter(pattern * (len(text) // sum(pattern) + 1))
for chunk_size in pattern_iter:
if not text:
break
chunks.append(text[:chunk_size])
text = text[chunk_size:]
stream = tokenizer.stream()
for chunk in chunks:
stream.push_text(chunk)
stream.end_input()
for i in range(len(WORDS_EXPECTED)):
ev = await stream.__anext__()
assert ev.token == WORDS_EXPECTED[i]
WORDS_PUNCT_TEXT = 'This is <phoneme alphabet="cmu-arpabet" ph="AE K CH UW AH L IY">actually</phoneme> tricky to handle.' # noqa: E501
WORDS_PUNCT_EXPECTED = [
"This",
"is",
"<phoneme",
'alphabet="cmu-arpabet"',
'ph="AE',
"K",
"CH",
"UW",
"AH",
"L",
'IY">actually</phoneme>',
"tricky",
"to",
"handle.",
]
WORD_PUNCT_TOKENIZERS = [basic.WordTokenizer(ignore_punctuation=False)]
@pytest.mark.parametrize("tokenizer", WORD_PUNCT_TOKENIZERS)
def test_punct_word_tokenizer(tokenizer: tokenize.WordTokenizer):
tokens = tokenizer.tokenize(text=WORDS_PUNCT_TEXT)
for i, token in enumerate(WORDS_PUNCT_EXPECTED):
assert token == tokens[i]
@pytest.mark.parametrize("tokenizer", WORD_PUNCT_TOKENIZERS)
async def test_streamed_punct_word_tokenizer(tokenizer: tokenize.WordTokenizer):
# divide text by chunks of arbitrary length (1-4)
pattern = [1, 2, 4]
text = WORDS_PUNCT_TEXT
chunks = []
pattern_iter = iter(pattern * (len(text) // sum(pattern) + 1))
for chunk_size in pattern_iter:
if not text:
break
chunks.append(text[:chunk_size])
text = text[chunk_size:]
stream = tokenizer.stream()
for chunk in chunks:
stream.push_text(chunk)
stream.end_input()
for i in range(len(WORDS_PUNCT_EXPECTED)):
ev = await stream.__anext__()
assert ev.token == WORDS_PUNCT_EXPECTED[i]
HYPHENATOR_TEXT = [
"Segment",
"expected",
"communication",
"window",
"welcome",
"bedroom",
]
HYPHENATOR_EXPECTED = [
["Seg", "ment"],
["ex", "pect", "ed"],
["com", "mu", "ni", "ca", "tion"],
["win", "dow"],
["wel", "come"],
["bed", "room"],
]
def test_hyphenate_word():
for i, word in enumerate(HYPHENATOR_TEXT):
hyphenated = basic.hyphenate_word(word)
assert hyphenated == HYPHENATOR_EXPECTED[i]
REPLACE_TEXT = (
"This is a test. Hello world, I'm creating this agents.. framework. Once again "
"framework. A.B.C"
)
REPLACE_EXPECTED = (
"This is a test. Hello universe, I'm creating this assistants.. library. twice again "
"library. A.B.C.D"
)
REPLACE_REPLACEMENTS = {
"world": "universe",
"framework": "library",
"a.b.c": "A.B.C.D",
"once": "twice",
"agents": "assistants",
}
def test_replace_words():
replaced = tokenize.utils.replace_words(text=REPLACE_TEXT, replacements=REPLACE_REPLACEMENTS)
assert replaced == REPLACE_EXPECTED
async def test_replace_words_async():
pattern = [1, 2, 4]
text = REPLACE_TEXT
chunks = []
pattern_iter = iter(pattern * (len(text) // sum(pattern) + 1))
for chunk_size in pattern_iter:
if not text:
break
chunks.append(text[:chunk_size])
text = text[chunk_size:]
async def _replace_words_async():
for chunk in chunks:
yield chunk
replaced_chunks = []
async for chunk in tokenize.utils.replace_words(
text=_replace_words_async(), replacements=REPLACE_REPLACEMENTS
):
replaced_chunks.append(chunk)
replaced = "".join(replaced_chunks)
assert replaced == REPLACE_EXPECTED
PARAGRAPH_TEST_CASES = [
("Single paragraph.", [("Single paragraph.", 0, 17)]),
(
"Paragraph 1.\n\nParagraph 2.",
[("Paragraph 1.", 0, 12), ("Paragraph 2.", 14, 26)],
),
(
"Para 1.\n\nPara 2.\n\nPara 3.",
[("Para 1.", 0, 7), ("Para 2.", 9, 16), ("Para 3.", 18, 25)],
),
(
"\n\nParagraph with leading newlines.",
[("Paragraph with leading newlines.", 2, 34)],
),
(
"Paragraph with trailing newlines.\n\n",
[("Paragraph with trailing newlines.", 0, 33)],
),
(
"\n\n Paragraph with leading and trailing spaces. \n\n",
[("Paragraph with leading and trailing spaces.", 4, 47)],
),
(
"Para 1.\n\n\n\nPara 2.", # Multiple newlines between paragraphs
[("Para 1.", 0, 7), ("Para 2.", 11, 18)],
),
(
"Para 1.\n \n \nPara 2.", # Newlines with spaces between paragraphs
[("Para 1.", 0, 7), ("Para 2.", 12, 19)],
),
(
"", # Empty string
[],
),
(
"\n\n\n", # Only newlines
[],
),
(
"Line 1\nLine 2\nLine 3", # Single paragraph with newlines
[("Line 1\nLine 2\nLine 3", 0, 20)],
),
]
@pytest.mark.parametrize(
"test_case",
PARAGRAPH_TEST_CASES,
)
def test_split_paragraphs(test_case):
input_text, expected_output = test_case
result = split_paragraphs(input_text)
assert result == expected_output, f"Failed for input: {input_text}"
from __future__ import annotations
import asyncio
import os
import pathlib
import ssl
import time
import aiohttp
import pytest
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import APIConnectOptions, APITimeoutError
from livekit.agents.utils import AudioBuffer
from livekit.plugins import (
aws,
azure,
cartesia,
deepgram,
elevenlabs,
google,
groq,
neuphonic,
openai,
playai,
resemble,
rime,
speechify,
)
from .toxic_proxy import Proxy, Toxiproxy
from .utils import wer
load_dotenv(override=True)
WER_THRESHOLD = 0.2
TEST_AUDIO_SYNTHESIZE = pathlib.Path(os.path.dirname(__file__), "long_synthesize.txt").read_text()
PROXY_LISTEN = "0.0.0.0:443"
OAI_LISTEN = "0.0.0.0:500"
def setup_oai_proxy(toxiproxy: Toxiproxy) -> Proxy:
return toxiproxy.create("api.openai.com:443", "oai-stt-proxy", listen=OAI_LISTEN, enabled=True)
async def assert_valid_synthesized_audio(frames: AudioBuffer, sample_rate: int, num_channels: int):
# use whisper as the source of truth to verify synthesized speech (smallest WER)
data = rtc.combine_audio_frames(frames).to_wav_bytes()
form = aiohttp.FormData()
form.add_field("file", data, filename="file.wav", content_type="audio/wav")
form.add_field("model", "whisper-1")
form.add_field("response_format", "verbose_json")
ssl_ctx = ssl.create_default_context()
connector = aiohttp.TCPConnector(ssl=ssl_ctx)
async with aiohttp.ClientSession(
connector=connector, timeout=aiohttp.ClientTimeout(total=30)
) as session:
async with session.post(
"https://toxiproxy:500/v1/audio/transcriptions",
data=form,
headers={
"Host": "api.openai.com",
"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
},
ssl=ssl_ctx,
server_hostname="api.openai.com",
) as resp:
result = await resp.json()
assert wer(result["text"], TEST_AUDIO_SYNTHESIZE) <= WER_THRESHOLD
combined_frame = rtc.combine_audio_frames(frames)
assert combined_frame.sample_rate == sample_rate, "sample rate should be the same"
assert combined_frame.num_channels == num_channels, "num channels should be the same"
SYNTHESIZE_TTS = [
pytest.param(
lambda: {
"tts": cartesia.TTS(),
"proxy-upstream": "api.cartesia.ai:443",
},
id="cartesia",
),
pytest.param(
lambda: {
"tts": aws.TTS(region="us-west-2"),
"proxy-upstream": "polly.us-west-2.amazonaws.com:443",
},
id="aws",
),
pytest.param(
lambda: {
"tts": azure.TTS(),
"proxy-upstream": "westus.tts.speech.microsoft.com:443",
},
id="azure",
),
pytest.param(
lambda: {
"tts": deepgram.TTS(),
"proxy-upstream": "api.deepgram.com:443",
},
id="deepgram",
),
pytest.param(
lambda: {
"tts": elevenlabs.TTS(),
"proxy-upstream": "api.elevenlabs.io:443",
},
id="elevenlabs",
),
pytest.param(
lambda: {
"tts": google.TTS(),
"proxy-upstream": "texttospeech.googleapis.com:443",
},
id="google",
),
pytest.param(
lambda: {
"tts": groq.TTS(),
"proxy-upstream": "api.groq.com:443",
},
id="groq",
),
pytest.param(
lambda: {
"tts": neuphonic.TTS(),
"proxy-upstream": "api.neuphonic.com:443",
},
id="neuphonic",
),
pytest.param(
lambda: {
"tts": openai.TTS(),
"proxy-upstream": "api.openai.com:443",
},
id="openai",
),
pytest.param(
lambda: {
"tts": playai.TTS(),
"proxy-upstream": "api.play.ht:443",
},
id="playai",
),
pytest.param(
lambda: {
"tts": resemble.TTS(),
"proxy-upstream": "f.cluster.resemble.ai:443",
},
id="resemble",
),
pytest.param(
lambda: {
"tts": rime.TTS(
model="mistv2",
),
"proxy-upstream": "users.rime.ai:443",
},
id="rime",
),
pytest.param(
lambda: {
"tts": speechify.TTS(),
"proxy-upstream": "api.sws.speechify.com:443",
},
id="speechify",
),
]
PLUGIN = os.getenv("PLUGIN", "").strip()
if PLUGIN:
SYNTHESIZE_TTS = [p for p in SYNTHESIZE_TTS if p.id.startswith(PLUGIN)] # type: ignore
@pytest.mark.usefixtures("job_process")
@pytest.mark.parametrize("tts_factory", SYNTHESIZE_TTS)
async def test_synthesize(tts_factory, toxiproxy: Toxiproxy):
setup_oai_proxy(toxiproxy)
tts_info: dict = tts_factory()
tts = tts_info["tts"]
proxy_upstream = tts_info["proxy-upstream"]
proxy_name = f"{tts.label}-proxy"
toxiproxy.create(proxy_upstream, proxy_name, listen=PROXY_LISTEN, enabled=True)
frames = []
try:
async def process_synthesis():
async with tts.synthesize(
text=TEST_AUDIO_SYNTHESIZE, conn_options=APIConnectOptions(max_retry=0, timeout=5)
) as stream:
async for audio in stream:
frames.append(audio.frame)
await asyncio.wait_for(process_synthesis(), timeout=30)
except asyncio.TimeoutError:
pytest.fail("test timed out after 30 seconds")
finally:
await tts.aclose()
await assert_valid_synthesized_audio(frames, tts.sample_rate, tts.num_channels)
@pytest.mark.usefixtures("job_process")
@pytest.mark.parametrize("tts_factory", SYNTHESIZE_TTS)
async def test_synthesize_timeout(tts_factory, toxiproxy: Toxiproxy):
setup_oai_proxy(toxiproxy)
tts_info: dict = tts_factory()
tts = tts_info["tts"]
proxy_upstream = tts_info["proxy-upstream"]
proxy_name = f"{tts.label}-proxy"
p = toxiproxy.create(proxy_upstream, proxy_name, listen=PROXY_LISTEN, enabled=True)
p.add_toxic(type="timeout", attributes={"timeout": 0})
start_time = time.time()
try:
async def test_timeout_process():
async with tts.synthesize(
text=TEST_AUDIO_SYNTHESIZE,
conn_options=APIConnectOptions(max_retry=0, timeout=5),
) as stream:
async for _ in stream:
pass
with pytest.raises(APITimeoutError):
await asyncio.wait_for(test_timeout_process(), timeout=10)
except asyncio.TimeoutError:
pytest.fail("test timed out after 10 seconds")
finally:
await tts.aclose()
end_time = time.time()
elapsed_time = end_time - start_time
assert 4 <= elapsed_time <= 6, f"Expected timeout around 5 seconds, got {elapsed_time:.2f}s"
from __future__ import annotations
import asyncio
import contextlib
import pytest
from livekit import rtc
from livekit.agents import APIConnectionError, utils
from livekit.agents.tts import TTS, AvailabilityChangedEvent, FallbackAdapter
from livekit.agents.tts.tts import SynthesizeStream
from livekit.agents.utils.aio.channel import ChanEmpty
from .fake_tts import FakeTTS
class FallbackAdapterTester(FallbackAdapter):
def __init__(
self,
tts: list[TTS],
*,
attempt_timeout: float = 10.0,
max_retry_per_tts: int = 1, # only retry once by default
no_fallback_after_audio_duration: float | None = 3.0,
sample_rate: int | None = None,
) -> None:
super().__init__(
tts,
attempt_timeout=attempt_timeout,
max_retry_per_tts=max_retry_per_tts,
no_fallback_after_audio_duration=no_fallback_after_audio_duration,
sample_rate=sample_rate,
)
self.on("tts_availability_changed", self._on_tts_availability_changed)
self._availability_changed_ch: dict[int, utils.aio.Chan[AvailabilityChangedEvent]] = {
id(t): utils.aio.Chan[AvailabilityChangedEvent]() for t in tts
}
def _on_tts_availability_changed(self, ev: AvailabilityChangedEvent) -> None:
self._availability_changed_ch[id(ev.tts)].send_nowait(ev)
def availability_changed_ch(
self,
tts: TTS,
) -> utils.aio.ChanReceiver[AvailabilityChangedEvent]:
return self._availability_changed_ch[id(tts)]
async def test_tts_fallback() -> None:
fake1 = FakeTTS(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeTTS(fake_audio_duration=5.0, sample_rate=48000)
fallback_adapter = FallbackAdapterTester([fake1, fake2])
async with fallback_adapter.synthesize("hello test") as stream:
frames = []
async for data in stream:
frames.append(data.frame)
assert fake1.synthesize_ch.recv_nowait()
assert fake2.synthesize_ch.recv_nowait()
assert rtc.combine_audio_frames(frames).duration == 5.0
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
fake2.update_options(fake_audio_duration=0.0)
with pytest.raises(APIConnectionError):
async with fallback_adapter.synthesize("hello test") as stream:
async for _ in stream:
pass
assert not fallback_adapter.availability_changed_ch(fake2).recv_nowait().available
await fallback_adapter.aclose()
async def test_no_audio() -> None:
fake1 = FakeTTS(fake_audio_duration=0.0)
fallback_adapter = FallbackAdapterTester([fake1])
with pytest.raises(APIConnectionError):
async with fallback_adapter.synthesize("hello test") as stream:
async for _ in stream:
pass
# stream
fake1.update_options(fake_audio_duration=5.0)
async def _input_task(stream: SynthesizeStream):
with contextlib.suppress(RuntimeError):
stream.push_text("hello test")
stream.flush()
await asyncio.sleep(1.0)
fake1.update_options(fake_timeout=0.5, fake_audio_duration=None)
stream.push_text("hello test")
stream.end_input()
with pytest.raises(APIConnectionError):
async with fallback_adapter.stream() as stream:
input_task = asyncio.create_task(_input_task(stream))
segments = set()
try:
async for ev in stream:
segments.add(ev.segment_id)
finally:
await input_task
assert len(segments) == 1
await fallback_adapter.aclose()
async def test_tts_stream_fallback() -> None:
fake1 = FakeTTS(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeTTS(fake_audio_duration=5.0)
fallback_adapter = FallbackAdapterTester([fake1, fake2])
async with fallback_adapter.stream() as stream:
stream.push_text("hello test")
stream.end_input()
async for _ in stream:
pass
assert fake1.stream_ch.recv_nowait()
assert fake2.stream_ch.recv_nowait()
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
await fallback_adapter.aclose()
async def test_tts_recover() -> None:
fake1 = FakeTTS(fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeTTS(fake_exception=APIConnectionError("fake2 failed"), fake_timeout=0.5)
fallback_adapter = FallbackAdapterTester([fake1, fake2])
with pytest.raises(APIConnectionError):
async for _ in fallback_adapter.synthesize("hello test"):
pass
assert fake1.synthesize_ch.recv_nowait()
assert fake2.synthesize_ch.recv_nowait()
fake2.update_options(fake_exception=None, fake_audio_duration=5.0)
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
assert not fallback_adapter.availability_changed_ch(fake2).recv_nowait().available
assert (
await asyncio.wait_for(fallback_adapter.availability_changed_ch(fake2).recv(), 1.0)
).available, "fake2 should have recovered"
async for _ in fallback_adapter.synthesize("hello test"):
pass
assert fake1.synthesize_ch.recv_nowait()
assert fake2.synthesize_ch.recv_nowait()
with pytest.raises(ChanEmpty):
fallback_adapter.availability_changed_ch(fake1).recv_nowait()
with pytest.raises(ChanEmpty):
fallback_adapter.availability_changed_ch(fake2).recv_nowait()
await fallback_adapter.aclose()
async def test_audio_resampled() -> None:
fake1 = FakeTTS(sample_rate=48000, fake_exception=APIConnectionError("fake1 failed"))
fake2 = FakeTTS(fake_audio_duration=5.0, sample_rate=16000)
fallback_adapter = FallbackAdapterTester([fake1, fake2])
async with fallback_adapter.synthesize("hello test") as stream:
frames = []
async for data in stream:
frames.append(data.frame)
assert fake1.synthesize_ch.recv_nowait()
assert fake2.synthesize_ch.recv_nowait()
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
combined_frame = rtc.combine_audio_frames(frames)
assert combined_frame.duration == 5.0
assert combined_frame.sample_rate == 48000
assert await asyncio.wait_for(fake1.synthesize_ch.recv(), 1.0)
async with fallback_adapter.stream() as stream:
stream.push_text("hello test")
stream.end_input()
frames = []
async for data in stream:
frames.append(data.frame)
print(frames)
assert fake2.stream_ch.recv_nowait()
combined_frame = rtc.combine_audio_frames(frames)
assert combined_frame.duration == 5.0
assert combined_frame.sample_rate == 48000
await fallback_adapter.aclose()
async def test_timeout():
fake1 = FakeTTS(fake_timeout=0.5, sample_rate=48000)
fake2 = FakeTTS(fake_timeout=0.5, sample_rate=48000)
fallback_adapter = FallbackAdapterTester([fake1, fake2], attempt_timeout=0.1)
with pytest.raises(APIConnectionError):
async for _ in fallback_adapter.synthesize("hello test"):
pass
assert fake1.synthesize_ch.recv_nowait()
assert fake2.synthesize_ch.recv_nowait()
assert not fallback_adapter.availability_changed_ch(fake1).recv_nowait().available
assert not fallback_adapter.availability_changed_ch(fake2).recv_nowait().available
assert await asyncio.wait_for(fake1.synthesize_ch.recv(), 1.0)
assert await asyncio.wait_for(fake2.synthesize_ch.recv(), 1.0)
# stream
with pytest.raises(APIConnectionError):
async with fallback_adapter.stream() as stream:
stream.end_input()
async for _ in stream:
pass
assert fake1.stream_ch.recv_nowait()
assert fake2.stream_ch.recv_nowait()
assert await asyncio.wait_for(fake1.stream_ch.recv(), 1.0)
assert await asyncio.wait_for(fake2.stream_ch.recv(), 1.0)
await fallback_adapter.aclose()
# consecutive push must not timeout
fake1.update_options(fake_timeout=None, fake_audio_duration=5.0)
fallback_adapter = FallbackAdapterTester([fake1], attempt_timeout=0.25)
async def _input_task1(stream: SynthesizeStream):
stream.push_text("hello world")
stream.flush()
await asyncio.sleep(1.0)
stream.push_text("bye world")
stream.end_input()
async with fallback_adapter.stream() as stream:
input_task = asyncio.create_task(_input_task1(stream))
segments = set()
final_count = 0
async for ev in stream:
segments.add(ev.segment_id)
if ev.is_final:
final_count += 1
assert len(segments) == 2
assert final_count == 2
await input_task
async def _input_task2(stream: SynthesizeStream):
with contextlib.suppress(RuntimeError):
stream.push_text("hello test")
stream.flush()
await asyncio.sleep(1.0)
fake1.update_options(fake_timeout=0.5, fake_audio_duration=None)
stream.push_text("hello test")
stream.flush()
await asyncio.sleep(1.0)
stream.end_input()
with pytest.raises(APIConnectionError):
async with fallback_adapter.stream() as stream:
input_task = asyncio.create_task(_input_task2(stream))
try:
async for _ in stream:
pass
finally:
await input_task
await fallback_adapter.aclose()
import pytest
from livekit.agents import vad
from livekit.plugins import silero
from . import utils
SAMPLE_RATES = [16000, 44100] # test multiple input sample rates
VAD = silero.VAD.load(
min_speech_duration=0.5,
min_silence_duration=0.75,
)
@pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
async def test_chunks_vad(sample_rate) -> None:
frames, _ = await utils.make_test_speech(chunk_duration_ms=10, sample_rate=sample_rate)
assert len(frames) > 1, "frames aren't chunked"
stream = VAD.stream()
for frame in frames:
stream.push_frame(frame)
stream.end_input()
start_of_speech_i = 0
end_of_speech_i = 0
inference_frames = []
async for ev in stream:
if ev.type == vad.VADEventType.START_OF_SPEECH:
with open(
f"test_vad.{sample_rate}.start_of_speech_frames_{start_of_speech_i}.wav",
"wb",
) as f:
f.write(utils.make_wav_file(ev.frames))
start_of_speech_i += 1
if ev.type == vad.VADEventType.INFERENCE_DONE:
inference_frames.extend(ev.frames)
if ev.type == vad.VADEventType.END_OF_SPEECH:
with open(
f"test_vad.{sample_rate}.end_of_speech_frames_{end_of_speech_i}.wav",
"wb",
) as f:
f.write(utils.make_wav_file(ev.frames))
end_of_speech_i += 1
assert start_of_speech_i > 0, "no start of speech detected"
assert start_of_speech_i == end_of_speech_i, "start and end of speech mismatch"
with open(f"test_vad.{sample_rate}.inference_frames.wav", "wb") as f:
f.write(utils.make_wav_file(inference_frames))
@pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
async def test_file_vad(sample_rate):
frames, _ = await utils.make_test_speech(sample_rate=sample_rate)
assert len(frames) == 1, "one frame should be the whole audio"
stream = VAD.stream()
for frame in frames:
stream.push_frame(frame)
stream.end_input()
start_of_speech_i = 0
end_of_speech_i = 0
async for ev in stream:
if ev.type == vad.VADEventType.START_OF_SPEECH:
start_of_speech_i += 1
if ev.type == vad.VADEventType.END_OF_SPEECH:
end_of_speech_i += 1
assert start_of_speech_i > 0, "no start of speech detected"
assert start_of_speech_i == end_of_speech_i, "start and end of speech mismatch"
# based on https://github.com/douglas/toxiproxy-python.
import socket
from collections.abc import Iterator
from contextlib import closing, contextmanager
from dataclasses import dataclass, field
from typing import Any, Optional
import requests
class ProxyExists(Exception):
pass
class NotFound(Exception):
pass
class InvalidToxic(Exception):
pass
def can_connect_to(host: str, port: int) -> bool:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
return sock.connect_ex((host, port)) == 0
def validate_response(response: requests.Response) -> requests.Response:
if response.status_code == 409:
raise ProxyExists(response.content)
elif response.status_code == 404:
raise NotFound(response.content)
elif response.status_code == 400:
raise InvalidToxic(response.content)
return response
class APIConsumer:
host: str = "toxiproxy"
port: int = 8474
@classmethod
def get_base_url(cls) -> str:
return f"http://{cls.host}:{cls.port}"
@classmethod
def get(cls, url: str, params: Optional[dict[str, Any]] = None, **kwargs) -> requests.Response:
endpoint = cls.get_base_url() + url
return validate_response(requests.get(url=endpoint, params=params, **kwargs))
@classmethod
def delete(cls, url: str, **kwargs) -> requests.Response:
endpoint = cls.get_base_url() + url
return validate_response(requests.delete(url=endpoint, **kwargs))
@classmethod
def post(cls, url: str, data: Any = None, json: Any = None, **kwargs) -> requests.Response:
endpoint = cls.get_base_url() + url
return validate_response(requests.post(url=endpoint, data=data, json=json, **kwargs))
@dataclass
class Toxic:
type: str
stream: str = "downstream"
name: Optional[str] = None
toxicity: float = 1.0
attributes: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.name is None:
self.name = f"{self.type}_{self.stream}"
@dataclass
class Proxy:
name: str
upstream: str
enabled: bool
listen: str
def __init__(self, name: str, upstream: str, enabled: bool, listen: str, **kwargs):
self.name = name
self.upstream = upstream
self.enabled = enabled
self.listen = listen
@contextmanager
def down(self) -> Iterator["Proxy"]:
try:
self.disable()
yield self
finally:
self.enable()
def toxics(self) -> dict[str, Toxic]:
response = APIConsumer.get(f"/proxies/{self.name}/toxics")
toxics_list = response.json()
toxics_dict: dict[str, Toxic] = {}
for toxic_data in toxics_list:
toxic_data["proxy"] = self.name # optionally add proxy info if needed elsewhere
toxic_name = toxic_data.get(
"name", f"{toxic_data.get('type')}_{toxic_data.get('stream', 'downstream')}"
)
toxics_dict[toxic_name] = Toxic(**toxic_data)
return toxics_dict
def get_toxic(self, toxic_name: str) -> Optional[Toxic]:
return self.toxics().get(toxic_name)
def add_toxic(
self,
*,
type: str,
stream: str = "downstream",
name: Optional[str] = None,
toxicity: float = 1.0,
attributes: Optional[dict[str, Any]] = None,
) -> None:
if name is None:
name = f"{type}_{stream}"
if attributes is None:
attributes = {}
json_payload = {
"name": name,
"type": type,
"stream": stream,
"toxicity": toxicity,
"attributes": attributes,
}
APIConsumer.post(f"/proxies/{self.name}/toxics", json=json_payload).json()
def destroy_toxic(self, toxic_name: str) -> bool:
delete_url = f"/proxies/{self.name}/toxics/{toxic_name}"
response = APIConsumer.delete(delete_url)
return response.ok
def destroy(self) -> bool:
return APIConsumer.delete(f"/proxies/{self.name}").ok
def disable(self) -> None:
self.__enable_proxy(False)
def enable(self) -> None:
self.__enable_proxy(True)
def __enable_proxy(self, enabled: bool) -> None:
json_payload = {"enabled": enabled}
APIConsumer.post(f"/proxies/{self.name}", json=json_payload).json()
self.enabled = enabled
class Toxiproxy:
def proxies(self) -> dict[str, Proxy]:
response = APIConsumer.get("/proxies")
proxies_data = response.json()
proxies_dict: dict[str, Proxy] = {}
for name, data in proxies_data.items():
proxies_dict[name] = Proxy(**data)
return proxies_dict
def destroy_all(self) -> None:
for proxy in list(self.proxies().values()):
self.destroy(proxy)
def get_proxy(self, proxy_name: str) -> Optional[Proxy]:
return self.proxies().get(proxy_name)
def running(self) -> bool:
return can_connect_to(APIConsumer.host, APIConsumer.port)
def version(self) -> Optional[bytes]:
if self.running():
return APIConsumer.get("/version").content
return None
def reset(self) -> bool:
response = APIConsumer.post("/reset")
return response.ok
def create(
self, upstream: str, name: str, listen: Optional[str] = None, enabled: Optional[bool] = None
) -> Proxy:
if name in self.proxies():
raise ProxyExists("This proxy already exists.")
listen_addr = listen or "127.0.0.1:0"
json_payload: dict = {"upstream": upstream, "name": name, "listen": listen_addr}
if enabled is not None:
json_payload["enabled"] = enabled
proxy_info = APIConsumer.post("/proxies", json=json_payload).json()
print(proxy_info)
return Proxy(**proxy_info)
def destroy(self, proxy: Proxy) -> bool:
return proxy.destroy()
def populate(self, proxies: list[dict[str, Any]]) -> list[Proxy]:
populated_proxies: list[Proxy] = []
for proxy_conf in proxies:
name = proxy_conf["name"]
existing = self.get_proxy(name)
# If an existing proxy is found and its configuration differs, destroy it first.
if existing and (
existing.upstream != proxy_conf["upstream"]
or existing.listen != proxy_conf["listen"]
):
self.destroy(existing)
existing = None
if existing is None:
proxy_instance = self.create(**proxy_conf)
populated_proxies.append(proxy_instance)
return populated_proxies
def update_api_consumer(self, host: str, port: int) -> None:
APIConsumer.host = host
APIConsumer.port = port
from __future__ import annotations
import os
import pathlib
import jiwer as tr
from livekit import rtc
from livekit.agents import utils
TEST_AUDIO_FILEPATH = os.path.join(os.path.dirname(__file__), "long.mp3")
TEST_AUDIO_TRANSCRIPT = pathlib.Path(os.path.dirname(__file__), "long_transcript.txt").read_text()
def wer(hypothesis: str, reference: str) -> float:
wer_standardize_contiguous = tr.Compose(
[
tr.ToLowerCase(),
tr.ExpandCommonEnglishContractions(),
tr.RemoveKaldiNonWords(),
tr.RemoveWhiteSpace(replace_by_space=True),
tr.RemoveMultipleSpaces(),
tr.Strip(),
tr.ReduceToSingleSentence(),
tr.ReduceToListOfListOfWords(),
]
)
return tr.wer(
reference,
hypothesis,
reference_transform=wer_standardize_contiguous,
hypothesis_transform=wer_standardize_contiguous,
)
async def read_mp3_file(path) -> rtc.AudioFrame:
decoder = utils.codecs.AudioStreamDecoder(
sample_rate=48000,
num_channels=1,
)
frames: list[rtc.AudioFrame] = []
with open(path, "rb") as file:
while True:
chunk = file.read(4096)
if not chunk:
break
decoder.push(chunk)
decoder.end_input()
async for frame in decoder:
frames.append(frame)
return rtc.combine_audio_frames(frames) # merging just for ease of use
async def make_test_speech(
*,
chunk_duration_ms: int | None = None,
sample_rate: int | None = None, # resample if not None
) -> tuple[list[rtc.AudioFrame], str]:
input_audio = await read_mp3_file(TEST_AUDIO_FILEPATH)
if sample_rate is not None and input_audio.sample_rate != sample_rate:
resampler = rtc.AudioResampler(
input_rate=input_audio.sample_rate,
output_rate=sample_rate,
num_channels=input_audio.num_channels,
)
frames = []
if resampler:
frames = resampler.push(input_audio)
frames.extend(resampler.flush())
input_audio = rtc.combine_audio_frames(frames)
if not chunk_duration_ms:
return [input_audio], TEST_AUDIO_TRANSCRIPT
chunk_size = int(input_audio.sample_rate / (1000 / chunk_duration_ms))
bstream = utils.audio.AudioByteStream(
sample_rate=input_audio.sample_rate,
num_channels=input_audio.num_channels,
samples_per_channel=chunk_size,
)
frames = bstream.write(input_audio.data.tobytes())
frames.extend(bstream.flush())
return frames, TEST_AUDIO_TRANSCRIPT
version = 1
revision = 1
requires-python = ">=3.9.0"
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version == '3.10.*'",
"python_full_version < '3.10'",
]
[manifest]
members = [
"livekit-agents",
"livekit-plugins-anthropic",
"livekit-plugins-assemblyai",
"livekit-plugins-aws",
"livekit-plugins-azure",
"livekit-plugins-bey",
"livekit-plugins-cartesia",
"livekit-plugins-clova",
"livekit-plugins-deepgram",
"livekit-plugins-elevenlabs",
"livekit-plugins-fal",
"livekit-plugins-gladia",
"livekit-plugins-google",
"livekit-plugins-groq",
"livekit-plugins-minimal",
"livekit-plugins-neuphonic",
"livekit-plugins-nltk",
"livekit-plugins-openai",
"livekit-plugins-playai",
"livekit-plugins-resemble",
"livekit-plugins-rime",
"livekit-plugins-silero",
"livekit-plugins-speechify",
"livekit-plugins-speechmatics",
"livekit-plugins-turn-detector",
]
constraints = [{ name = "onnxruntime", marker = "python_full_version == '3.9.*'", specifier = "<1.20.0" }]
[manifest.dependency-groups]
dev = [
{ name = "jiwer", specifier = ">=3.1.0" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio", specifier = ">=0.25.3" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "ruff" },
]
[[package]]
name = "aioboto3"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiobotocore", extra = ["boto3"] },
{ name = "aiofiles" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/2d/f33d891f5a2122288391a8ba91f7f418b2db96abdb0f92f71d59ac2e145d/aioboto3-14.1.0.tar.gz", hash = "sha256:9d59b536ae8a951b9413ce151bf77df9c7cfe2cbaa2c4c240c066f384305c932", size = 268254 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/85/04ba3a451a2aad4af64ddb7744620debc43ea9437eb9224186e31f0c984d/aioboto3-14.1.0-py3-none-any.whl", hash = "sha256:f8547032bc4f90966b22869c1295d890c161549f4e8919f32853571ceb6fd0c6", size = 35551 },
]
[[package]]
name = "aiobotocore"
version = "2.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "aioitertools" },
{ name = "botocore" },
{ name = "jmespath" },
{ name = "multidict" },
{ name = "python-dateutil" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/dc/f5f872fb01ce37c09525cedc7ecfad7002ffe2a8a23f77d7d2c234399b51/aiobotocore-2.21.1.tar.gz", hash = "sha256:010357f43004413e92a9d066bb0db1f241aeb29ffed306e9197061ffc94e6577", size = 108900 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/67/026598918f92145156f2feb7957f57daefda20375cc2ac1a0692a9b8010b/aiobotocore-2.21.1-py3-none-any.whl", hash = "sha256:bd7c49a6d6f8a3d9444b0a94417c8da13813b5c7eec1c4f0ec2db7e8ce8f23e7", size = 78313 },
]
[package.optional-dependencies]
boto3 = [
{ name = "boto3" },
]
[[package]]
name = "aiofiles"
version = "24.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 },
]
[[package]]
name = "aiohttp"
version = "3.11.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/3f/c4a667d184c69667b8f16e0704127efc5f1e60577df429382b4d95fd381e/aiohttp-3.11.13.tar.gz", hash = "sha256:8ce789231404ca8fff7f693cdce398abf6d90fd5dae2b1847477196c243b1fbb", size = 7674284 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/49/18bde4fbe1f98a12fb548741e65b27c5f0991c1af4ad15c86b537a4ce94a/aiohttp-3.11.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d", size = 708941 },
{ url = "https://files.pythonhosted.org/packages/99/24/417e5ab7074f5c97c9a794b6acdc59f47f2231d43e4d5cec06150035e61e/aiohttp-3.11.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef", size = 468823 },
{ url = "https://files.pythonhosted.org/packages/76/93/159d3a2561bc6d64d32f779d08b17570b1c5fe55b985da7e2df9b3a4ff8f/aiohttp-3.11.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9840be675de208d1f68f84d578eaa4d1a36eee70b16ae31ab933520c49ba1325", size = 455984 },
{ url = "https://files.pythonhosted.org/packages/18/bc/ed0dce45da90d4618ae14e677abbd704aec02e0f54820ea3815c156f0759/aiohttp-3.11.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28a772757c9067e2aee8a6b2b425d0efaa628c264d6416d283694c3d86da7689", size = 1585022 },
{ url = "https://files.pythonhosted.org/packages/75/10/c1e6d59030fcf04ccc253193607b5b7ced0caffd840353e109c51134e5e9/aiohttp-3.11.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b88aca5adbf4625e11118df45acac29616b425833c3be7a05ef63a6a4017bfdb", size = 1632761 },
{ url = "https://files.pythonhosted.org/packages/2d/8e/da1a20fbd2c961f824dc8efeb8d31c32ed4af761c87de83032ad4c4f5237/aiohttp-3.11.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce10ddfbe26ed5856d6902162f71b8fe08545380570a885b4ab56aecfdcb07f4", size = 1668720 },
{ url = "https://files.pythonhosted.org/packages/fa/9e/d0bbdc82236c3fe43b28b3338a13ef9b697b0f7a875b33b950b975cab1f6/aiohttp-3.11.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa48dac27f41b36735c807d1ab093a8386701bbf00eb6b89a0f69d9fa26b3671", size = 1589941 },
{ url = "https://files.pythonhosted.org/packages/ed/14/248ed0385baeee854e495ca7f33b48bb151d1b226ddbf1585bdeb2301fbf/aiohttp-3.11.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89ce611b1eac93ce2ade68f1470889e0173d606de20c85a012bfa24be96cf867", size = 1544978 },
{ url = "https://files.pythonhosted.org/packages/20/b0/b2ad9d24fe85db8330034ac45dde67799af40ca2363c0c9b30126e204ef3/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78e4dd9c34ec7b8b121854eb5342bac8b02aa03075ae8618b6210a06bbb8a115", size = 1529641 },
{ url = "https://files.pythonhosted.org/packages/11/c6/03bdcb73a67a380b9593d52613ea88edd21ddc4ff5aaf06d4f807dfa2220/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:66047eacbc73e6fe2462b77ce39fc170ab51235caf331e735eae91c95e6a11e4", size = 1558027 },
{ url = "https://files.pythonhosted.org/packages/0d/ae/e45491c8ca4d1e30ff031fb25b44842e16c326f8467026c3eb2a9c167608/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ad8f1c19fe277eeb8bc45741c6d60ddd11d705c12a4d8ee17546acff98e0802", size = 1536991 },
{ url = "https://files.pythonhosted.org/packages/19/89/10eb37351dd2b52928a54768a70a58171e43d7914685fe3feec8f681d905/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64815c6f02e8506b10113ddbc6b196f58dbef135751cc7c32136df27b736db09", size = 1607848 },
{ url = "https://files.pythonhosted.org/packages/a4/fd/492dec170df6ea57bef4bcd26374befdc170b10ba9ac7f51a0214943c20a/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:967b93f21b426f23ca37329230d5bd122f25516ae2f24a9cea95a30023ff8283", size = 1629208 },
{ url = "https://files.pythonhosted.org/packages/70/46/ef8a02cb171d4779ca1632bc8ac0c5bb89729b091e2a3f4b895d688146b5/aiohttp-3.11.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf1f31f83d16ec344136359001c5e871915c6ab685a3d8dee38e2961b4c81730", size = 1564684 },
{ url = "https://files.pythonhosted.org/packages/8a/03/b1b552d1112b72da94bd1f9f5efb8adbcbbafaa8d495fc0924cd80493f17/aiohttp-3.11.13-cp310-cp310-win32.whl", hash = "sha256:00c8ac69e259c60976aa2edae3f13d9991cf079aaa4d3cd5a49168ae3748dee3", size = 416982 },
{ url = "https://files.pythonhosted.org/packages/b0/2d/b6be8e7905ceba64121268ce28208bafe508a742c1467bf636a41d152284/aiohttp-3.11.13-cp310-cp310-win_amd64.whl", hash = "sha256:90d571c98d19a8b6e793b34aa4df4cee1e8fe2862d65cc49185a3a3d0a1a3996", size = 442389 },
{ url = "https://files.pythonhosted.org/packages/3b/93/8e012ae31ff1bda5d43565d6f9e0bad325ba6f3f2d78f298bd39645be8a3/aiohttp-3.11.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b35aab22419ba45f8fc290d0010898de7a6ad131e468ffa3922b1b0b24e9d2e", size = 709013 },
{ url = "https://files.pythonhosted.org/packages/d8/be/fc7c436678ffe547d038319add8e44fd5e33090158752e5c480aed51a8d0/aiohttp-3.11.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81cba651db8795f688c589dd11a4fbb834f2e59bbf9bb50908be36e416dc760", size = 468896 },
{ url = "https://files.pythonhosted.org/packages/d9/1c/56906111ac9d4dab4baab43c89d35d5de1dbb38085150257895005b08bef/aiohttp-3.11.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f55d0f242c2d1fcdf802c8fabcff25a9d85550a4cf3a9cf5f2a6b5742c992839", size = 455968 },
{ url = "https://files.pythonhosted.org/packages/ba/16/229d36ed27c2bb350320364efb56f906af194616cc15fc5d87f3ef21dbef/aiohttp-3.11.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4bea08a6aad9195ac9b1be6b0c7e8a702a9cec57ce6b713698b4a5afa9c2e33", size = 1686082 },
{ url = "https://files.pythonhosted.org/packages/3a/44/78fd174509c56028672e5dfef886569cfa1fced0c5fd5c4480426db19ac9/aiohttp-3.11.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6070bcf2173a7146bb9e4735b3c62b2accba459a6eae44deea0eb23e0035a23", size = 1744056 },
{ url = "https://files.pythonhosted.org/packages/a3/11/325145c6dce8124b5caadbf763e908f2779c14bb0bc5868744d1e5cb9cb7/aiohttp-3.11.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:718d5deb678bc4b9d575bfe83a59270861417da071ab44542d0fcb6faa686636", size = 1785810 },
{ url = "https://files.pythonhosted.org/packages/95/de/faba18a0af09969e10eb89fdbd4cb968bea95e75449a7fa944d4de7d1d2f/aiohttp-3.11.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f6b2c5b4a4d22b8fb2c92ac98e0747f5f195e8e9448bfb7404cd77e7bfa243f", size = 1675540 },
{ url = "https://files.pythonhosted.org/packages/ea/53/0437c46e960b79ae3b1ff74c1ec12f04bf4f425bd349c8807acb38aae3d7/aiohttp-3.11.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:747ec46290107a490d21fe1ff4183bef8022b848cf9516970cb31de6d9460088", size = 1620210 },
{ url = "https://files.pythonhosted.org/packages/04/2f/31769ed8e29cc22baaa4005bd2749a7fd0f61ad0f86024d38dff8e394cf6/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:01816f07c9cc9d80f858615b1365f8319d6a5fd079cd668cc58e15aafbc76a54", size = 1654399 },
{ url = "https://files.pythonhosted.org/packages/b0/24/acb24571815b9a86a8261577c920fd84f819178c02a75b05b1a0d7ab83fb/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a08ad95fcbd595803e0c4280671d808eb170a64ca3f2980dd38e7a72ed8d1fea", size = 1660424 },
{ url = "https://files.pythonhosted.org/packages/91/45/30ca0c3ba5bbf7592eee7489eae30437736f7ff912eaa04cfdcf74edca8c/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c97be90d70f7db3aa041d720bfb95f4869d6063fcdf2bb8333764d97e319b7d0", size = 1650415 },
{ url = "https://files.pythonhosted.org/packages/86/8d/4d887df5e732cc70349243c2c9784911979e7bd71c06f9e7717b8a896f75/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ab915a57c65f7a29353c8014ac4be685c8e4a19e792a79fe133a8e101111438e", size = 1733292 },
{ url = "https://files.pythonhosted.org/packages/40/c9/bd950dac0a4c84d44d8da8d6e0f9c9511d45e02cf908a4e1fca591f46a25/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:35cda4e07f5e058a723436c4d2b7ba2124ab4e0aa49e6325aed5896507a8a42e", size = 1755536 },
{ url = "https://files.pythonhosted.org/packages/32/04/aafeda6b4ed3693a44bb89eae002ebaa74f88b2265a7e68f8a31c33330f5/aiohttp-3.11.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af55314407714fe77a68a9ccaab90fdb5deb57342585fd4a3a8102b6d4370080", size = 1693126 },
{ url = "https://files.pythonhosted.org/packages/a1/4f/67729187e884b0f002a0317d2cc7962a5a0416cadc95ea88ba92477290d9/aiohttp-3.11.13-cp311-cp311-win32.whl", hash = "sha256:42d689a5c0a0c357018993e471893e939f555e302313d5c61dfc566c2cad6185", size = 416800 },
{ url = "https://files.pythonhosted.org/packages/29/23/d98d491ca073ee92cc6a741be97b6b097fb06dacc5f95c0c9350787db549/aiohttp-3.11.13-cp311-cp311-win_amd64.whl", hash = "sha256:b73a2b139782a07658fbf170fe4bcdf70fc597fae5ffe75e5b67674c27434a9f", size = 442891 },
{ url = "https://files.pythonhosted.org/packages/9a/a9/6657664a55f78db8767e396cc9723782ed3311eb57704b0a5dacfa731916/aiohttp-3.11.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2eabb269dc3852537d57589b36d7f7362e57d1ece308842ef44d9830d2dc3c90", size = 705054 },
{ url = "https://files.pythonhosted.org/packages/3b/06/f7df1fe062d16422f70af5065b76264f40b382605cf7477fa70553a9c9c1/aiohttp-3.11.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b77ee42addbb1c36d35aca55e8cc6d0958f8419e458bb70888d8c69a4ca833d", size = 464440 },
{ url = "https://files.pythonhosted.org/packages/22/3a/8773ea866735754004d9f79e501fe988bdd56cfac7fdecbc8de17fc093eb/aiohttp-3.11.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55789e93c5ed71832e7fac868167276beadf9877b85697020c46e9a75471f55f", size = 456394 },
{ url = "https://files.pythonhosted.org/packages/7f/61/8e2f2af2327e8e475a2b0890f15ef0bbfd117e321cce1e1ed210df81bbac/aiohttp-3.11.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c929f9a7249a11e4aa5c157091cfad7f49cc6b13f4eecf9b747104befd9f56f2", size = 1682752 },
{ url = "https://files.pythonhosted.org/packages/24/ed/84fce816bc8da39aa3f6c1196fe26e47065fea882b1a67a808282029c079/aiohttp-3.11.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d33851d85537bbf0f6291ddc97926a754c8f041af759e0aa0230fe939168852b", size = 1737375 },
{ url = "https://files.pythonhosted.org/packages/d9/de/35a5ba9e3d21ebfda1ebbe66f6cc5cbb4d3ff9bd6a03e5e8a788954f8f27/aiohttp-3.11.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9229d8613bd8401182868fe95688f7581673e1c18ff78855671a4b8284f47bcb", size = 1793660 },
{ url = "https://files.pythonhosted.org/packages/ff/fe/0f650a8c7c72c8a07edf8ab164786f936668acd71786dd5885fc4b1ca563/aiohttp-3.11.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669dd33f028e54fe4c96576f406ebb242ba534dd3a981ce009961bf49960f117", size = 1692233 },
{ url = "https://files.pythonhosted.org/packages/a8/20/185378b3483f968c6303aafe1e33b0da0d902db40731b2b2b2680a631131/aiohttp-3.11.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c1b20a1ace54af7db1f95af85da530fe97407d9063b7aaf9ce6a32f44730778", size = 1619708 },
{ url = "https://files.pythonhosted.org/packages/a4/f9/d9c181750980b17e1e13e522d7e82a8d08d3d28a2249f99207ef5d8d738f/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5724cc77f4e648362ebbb49bdecb9e2b86d9b172c68a295263fa072e679ee69d", size = 1641802 },
{ url = "https://files.pythonhosted.org/packages/50/c7/1cb46b72b1788710343b6e59eaab9642bd2422f2d87ede18b1996e0aed8f/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aa36c35e94ecdb478246dd60db12aba57cfcd0abcad43c927a8876f25734d496", size = 1684678 },
{ url = "https://files.pythonhosted.org/packages/71/87/89b979391de840c5d7c34e78e1148cc731b8aafa84b6a51d02f44b4c66e2/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b5b37c863ad5b0892cc7a4ceb1e435e5e6acd3f2f8d3e11fa56f08d3c67b820", size = 1646921 },
{ url = "https://files.pythonhosted.org/packages/a7/db/a463700ac85b72f8cf68093e988538faaf4e865e3150aa165cf80ee29d6e/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e06cf4852ce8c4442a59bae5a3ea01162b8fcb49ab438d8548b8dc79375dad8a", size = 1702493 },
{ url = "https://files.pythonhosted.org/packages/b8/32/1084e65da3adfb08c7e1b3e94f3e4ded8bd707dee265a412bc377b7cd000/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5194143927e494616e335d074e77a5dac7cd353a04755330c9adc984ac5a628e", size = 1735004 },
{ url = "https://files.pythonhosted.org/packages/a0/bb/a634cbdd97ce5d05c2054a9a35bfc32792d7e4f69d600ad7e820571d095b/aiohttp-3.11.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:afcb6b275c2d2ba5d8418bf30a9654fa978b4f819c2e8db6311b3525c86fe637", size = 1694964 },
{ url = "https://files.pythonhosted.org/packages/fd/cf/7d29db4e5c28ec316e5d2ac9ac9df0e2e278e9ea910e5c4205b9b64c2c42/aiohttp-3.11.13-cp312-cp312-win32.whl", hash = "sha256:7104d5b3943c6351d1ad7027d90bdd0ea002903e9f610735ac99df3b81f102ee", size = 411746 },
{ url = "https://files.pythonhosted.org/packages/65/a9/13e69ad4fd62104ebd94617f9f2be58231b50bb1e6bac114f024303ac23b/aiohttp-3.11.13-cp312-cp312-win_amd64.whl", hash = "sha256:47dc018b1b220c48089b5b9382fbab94db35bef2fa192995be22cbad3c5730c8", size = 438078 },
{ url = "https://files.pythonhosted.org/packages/87/dc/7d58d33cec693f1ddf407d4ab975445f5cb507af95600f137b81683a18d8/aiohttp-3.11.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9862d077b9ffa015dbe3ce6c081bdf35135948cb89116e26667dd183550833d1", size = 698372 },
{ url = "https://files.pythonhosted.org/packages/84/e7/5d88514c9e24fbc8dd6117350a8ec4a9314f4adae6e89fe32e3e639b0c37/aiohttp-3.11.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbfef0666ae9e07abfa2c54c212ac18a1f63e13e0760a769f70b5717742f3ece", size = 461057 },
{ url = "https://files.pythonhosted.org/packages/96/1a/8143c48a929fa00c6324f85660cb0f47a55ed9385f0c1b72d4b8043acf8e/aiohttp-3.11.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a1f7d857c4fcf7cabb1178058182c789b30d85de379e04f64c15b7e88d66fb", size = 453340 },
{ url = "https://files.pythonhosted.org/packages/2f/1c/b8010e4d65c5860d62681088e5376f3c0a940c5e3ca8989cae36ce8c3ea8/aiohttp-3.11.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba40b7ae0f81c7029583a338853f6607b6d83a341a3dcde8bed1ea58a3af1df9", size = 1665561 },
{ url = "https://files.pythonhosted.org/packages/19/ed/a68c3ab2f92fdc17dfc2096117d1cfaa7f7bdded2a57bacbf767b104165b/aiohttp-3.11.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5b95787335c483cd5f29577f42bbe027a412c5431f2f80a749c80d040f7ca9f", size = 1718335 },
{ url = "https://files.pythonhosted.org/packages/27/4f/3a0b6160ce663b8ebdb65d1eedff60900cd7108838c914d25952fe2b909f/aiohttp-3.11.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7d474c5c1f0b9405c1565fafdc4429fa7d986ccbec7ce55bc6a330f36409cad", size = 1775522 },
{ url = "https://files.pythonhosted.org/packages/0b/58/9da09291e19696c452e7224c1ce8c6d23a291fe8cd5c6b247b51bcda07db/aiohttp-3.11.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e83fb1991e9d8982b3b36aea1e7ad27ea0ce18c14d054c7a404d68b0319eebb", size = 1677566 },
{ url = "https://files.pythonhosted.org/packages/3d/18/6184f2bf8bbe397acbbbaa449937d61c20a6b85765f48e5eddc6d84957fe/aiohttp-3.11.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4586a68730bd2f2b04a83e83f79d271d8ed13763f64b75920f18a3a677b9a7f0", size = 1603590 },
{ url = "https://files.pythonhosted.org/packages/04/94/91e0d1ca0793012ccd927e835540aa38cca98bdce2389256ab813ebd64a3/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fe4eb0e7f50cdb99b26250d9328faef30b1175a5dbcfd6d0578d18456bac567", size = 1618688 },
{ url = "https://files.pythonhosted.org/packages/71/85/d13c3ea2e48a10b43668305d4903838834c3d4112e5229177fbcc23a56cd/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2a8a6bc19818ac3e5596310ace5aa50d918e1ebdcc204dc96e2f4d505d51740c", size = 1658053 },
{ url = "https://files.pythonhosted.org/packages/12/6a/3242a35100de23c1e8d9e05e8605e10f34268dee91b00d9d1e278c58eb80/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f27eec42f6c3c1df09cfc1f6786308f8b525b8efaaf6d6bd76c1f52c6511f6a", size = 1616917 },
{ url = "https://files.pythonhosted.org/packages/f5/b3/3f99b6f0a9a79590a7ba5655dbde8408c685aa462247378c977603464d0a/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a4a13dfbb23977a51853b419141cd0a9b9573ab8d3a1455c6e63561387b52ff", size = 1685872 },
{ url = "https://files.pythonhosted.org/packages/8a/2e/99672181751f280a85e24fcb9a2c2469e8b1a0de1746b7b5c45d1eb9a999/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:02876bf2f69b062584965507b07bc06903c2dc93c57a554b64e012d636952654", size = 1715719 },
{ url = "https://files.pythonhosted.org/packages/7a/cd/68030356eb9a7d57b3e2823c8a852709d437abb0fbff41a61ebc351b7625/aiohttp-3.11.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b992778d95b60a21c4d8d4a5f15aaab2bd3c3e16466a72d7f9bfd86e8cea0d4b", size = 1673166 },
{ url = "https://files.pythonhosted.org/packages/03/61/425397a9a2839c609d09fdb53d940472f316a2dbeaa77a35b2628dae6284/aiohttp-3.11.13-cp313-cp313-win32.whl", hash = "sha256:507ab05d90586dacb4f26a001c3abf912eb719d05635cbfad930bdbeb469b36c", size = 410615 },
{ url = "https://files.pythonhosted.org/packages/9c/54/ebb815bc0fe057d8e7a11c086c479e972e827082f39aeebc6019dd4f0862/aiohttp-3.11.13-cp313-cp313-win_amd64.whl", hash = "sha256:5ceb81a4db2decdfa087381b5fc5847aa448244f973e5da232610304e199e7b2", size = 436452 },
{ url = "https://files.pythonhosted.org/packages/86/88/c80c0972d35cdce2a62905a2053fc483685bf5f3930f1ab269ec006e1e98/aiohttp-3.11.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:51c3ff9c7a25f3cad5c09d9aacbc5aefb9267167c4652c1eb737989b554fe278", size = 709814 },
{ url = "https://files.pythonhosted.org/packages/ca/e6/d7ee65a814615fb6de79d124bb72be4e84f9d68485751c5279994554f061/aiohttp-3.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e271beb2b1dabec5cd84eb488bdabf9758d22ad13471e9c356be07ad139b3012", size = 469313 },
{ url = "https://files.pythonhosted.org/packages/8c/ab/d6257596cad471675419673d53f6e409d9eb7acfa7e36dfb77e8b65504b3/aiohttp-3.11.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e9eb7e5764abcb49f0e2bd8f5731849b8728efbf26d0cac8e81384c95acec3f", size = 456376 },
{ url = "https://files.pythonhosted.org/packages/1d/d5/ab9ad5242c7920e224cbdc1c9bec62a79f75884049ccb86edb64225e4c0f/aiohttp-3.11.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baae005092e3f200de02699314ac8933ec20abf998ec0be39448f6605bce93df", size = 1587792 },
{ url = "https://files.pythonhosted.org/packages/23/01/ef79aeb337702bbfd034b1d1a6357dca4a270ebe2b0ff80bb8ba90851ea0/aiohttp-3.11.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1982c98ac62c132d2b773d50e2fcc941eb0b8bad3ec078ce7e7877c4d5a2dce7", size = 1636636 },
{ url = "https://files.pythonhosted.org/packages/a6/ff/3bc33d6ab85046ecc3319817c1f473061cd97caba5a1cd154be181ab56ab/aiohttp-3.11.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2b25b2eeb35707113b2d570cadc7c612a57f1c5d3e7bb2b13870fe284e08fc0", size = 1672707 },
{ url = "https://files.pythonhosted.org/packages/f4/fd/2d1934d22b89de0d6b9dbb30c310996e440fffc08f95b083d91b6a7916c1/aiohttp-3.11.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b27961d65639128336b7a7c3f0046dcc62a9443d5ef962e3c84170ac620cec47", size = 1589919 },
{ url = "https://files.pythonhosted.org/packages/35/01/b13fe945b056a910fe98f659e6533b4a9e7f08f414f6c5447a9726df81e0/aiohttp-3.11.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe9f1e05025eacdd97590895e2737b9f851d0eb2e017ae9574d9a4f0b6252", size = 1544444 },
{ url = "https://files.pythonhosted.org/packages/73/9b/26da500b8de48a88b287936fae66d4f52306daedc6b6a273e97f479db685/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa1fb1b61881c8405829c50e9cc5c875bfdbf685edf57a76817dfb50643e4a1a", size = 1530616 },
{ url = "https://files.pythonhosted.org/packages/fc/27/5d1636c675f4f5ad0a8a68874d78fe6049041274d4d5da682f4ffee78097/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:25de43bb3cf83ad83efc8295af7310219af6dbe4c543c2e74988d8e9c8a2a917", size = 1559227 },
{ url = "https://files.pythonhosted.org/packages/32/cc/3ae7e23762b28fa9f794d89fde21111c5af85a2ec081a15812c312febfa7/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe7065e2215e4bba63dc00db9ae654c1ba3950a5fff691475a32f511142fcddb", size = 1536468 },
{ url = "https://files.pythonhosted.org/packages/cc/96/4ad817e79b0a3cc5089b818fccaf724d7d179f5840bc43fa538a2506f396/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7836587eef675a17d835ec3d98a8c9acdbeb2c1d72b0556f0edf4e855a25e9c1", size = 1607310 },
{ url = "https://files.pythonhosted.org/packages/3f/f3/c7e502478b8a181a85ac1524a6755dbb41959ee82edb681981733dcac87e/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:85fa0b18558eb1427090912bd456a01f71edab0872f4e0f9e4285571941e4090", size = 1629492 },
{ url = "https://files.pythonhosted.org/packages/3a/bb/0629e93af6317b277285a472d8e7aa92fa4e654dca00cf70f89f1788bd89/aiohttp-3.11.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a86dc177eb4c286c19d1823ac296299f59ed8106c9536d2b559f65836e0fb2c6", size = 1567741 },
{ url = "https://files.pythonhosted.org/packages/fc/40/427dafa3664413d29c5b3546aaacafb33e7725b1f6e15ce54cb857183c7b/aiohttp-3.11.13-cp39-cp39-win32.whl", hash = "sha256:684eea71ab6e8ade86b9021bb62af4bf0881f6be4e926b6b5455de74e420783a", size = 417303 },
{ url = "https://files.pythonhosted.org/packages/ca/a1/c7c0cdccbad4678dfb51f4d4f22dc6aacf8e3cdd6b99071170246106c364/aiohttp-3.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:82c249f2bfa5ecbe4a1a7902c81c0fba52ed9ebd0176ab3047395d02ad96cfcb", size = 442608 },
]
[[package]]
name = "aioitertools"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/de/38491a84ab323b47c7f86e94d2830e748780525f7a10c8600b67ead7e9ea/aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b", size = 19369 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/13/58b70a580de00893223d61de8fea167877a3aed97d4a5e1405c9159ef925/aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796", size = 24345 },
]
[[package]]
name = "aiosignal"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 },
]
[[package]]
name = "amazon-transcribe"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "awscrt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/1c/ca7de0b4735c63c455092b223191a4b31905a8e81b50aa906110a42528d5/amazon-transcribe-0.6.2.tar.gz", hash = "sha256:2d57e7590adc782a1f52b06be38e4e7d9e07e1fe8b22c53933bf99f625375109", size = 31042 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/7d/9424134095bbff76022725f789dc1e3cd28e70e7229ac3da6c89fdb02d16/amazon_transcribe-0.6.2-py3-none-any.whl", hash = "sha256:29c7cab0f84d642eed2468b276991ecd87fc1224d9c7fd158ea336f14ae66538", size = 38769 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anthropic"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/e3/a88c8494ce4d1a88252b9e053607e885f9b14d0a32273d47b727cbee4228/anthropic-0.49.0.tar.gz", hash = "sha256:c09e885b0f674b9119b4f296d8508907f6cff0009bc20d5cf6b35936c40b4398", size = 210016 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl", hash = "sha256:bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375", size = 243368 },
]
[[package]]
name = "anyio"
version = "4.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
]
[[package]]
name = "attrs"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 },
]
[[package]]
name = "av"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/b6/83129e0337376214b0304893cbf0ad0a54718bb47845517fa5870439ca0b/av-14.2.0.tar.gz", hash = "sha256:132b5d52ca262b97b0356e8f48cbbe54d0ac232107a722ab8cc8c0c19eafa17b", size = 4063022 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/bd/82d5508548ca8972bd40aa8161058df13453cdccf4a35dd21ec9ef2a64d0/av-14.2.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:a5be356aa3e63a0ab0a7b32a3544e7494fd3fc546bce3a353b39f8258b6d718f", size = 22074143 },
{ url = "https://files.pythonhosted.org/packages/03/a3/affde55bd7b9b4fd32d8b794a071ccc91aad19481929ffcafaad2a8eb446/av-14.2.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:f9e9a2bcb675916b1565dfe7dfad62d195c15a72dc4a56ac3b4006bac1d241d5", size = 27447923 },
{ url = "https://files.pythonhosted.org/packages/a2/bd/af5d2f7a06c77c20d9ed14a5707601a8b7135965922cdc5d6f3718aa1dfb/av-14.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e8b8d39a01c04fd8f8ce4633d3e9e5d7d794ea9f8d4a9de03b9bc224cbcc7", size = 36597115 },
{ url = "https://files.pythonhosted.org/packages/06/fc/55a97ebfda6a4639394c57ce78977b897d5ee04af1851401db1ed5a210d4/av-14.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e72d01513615a628ad08a5957e57ac23f6a43051fd87b87e2faa42cafd6ecb29", size = 34985675 },
{ url = "https://files.pythonhosted.org/packages/b9/dd/5eee0fa00134219051e9616786be19332823355f5ffbd2cbbf6d45e8be91/av-14.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512a8ceca26250f26fc28913d7a08f962f8e7704189c111e9688180f9b752458", size = 38805945 },
{ url = "https://files.pythonhosted.org/packages/8a/b5/eb11638a6eda0157fc3eeb43a9145ce772cd96776da031b63178917c1fc7/av-14.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:1b01e4c96ecc892aa3b7dc605e7403866a2bc0eaf83ce04a9a3aed7077c69a4a", size = 30851852 },
{ url = "https://files.pythonhosted.org/packages/75/d9/f93c06716ee45e5ec78814179f13ccef80593df69c2b8f48c6633a2157d0/av-14.2.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:42d0067654f3b05a86ddfaf4d82d4cb913d914024c5bbc8245dfe76357dfa350", size = 22066013 },
{ url = "https://files.pythonhosted.org/packages/f5/18/d4352b27f3c93efbea9950c151d93bed6f3d8bb18d9d6467e064749133b1/av-14.2.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:d8c58401c3cf38bff59e45aa6a1fc1c4cb2443b872d668b4a11e4a6d5e5b5ac0", size = 27441465 },
{ url = "https://files.pythonhosted.org/packages/18/68/a9398e36676721f335720173c856d26c4031203b8323ea43dd132c17cc34/av-14.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:707b3e9ec74d91a163b1b774b592cae32241f9df9b8f6c270ab7c7603e62359d", size = 37489187 },
{ url = "https://files.pythonhosted.org/packages/73/38/2d407d1775efa096fe1ec64bbe45eb85b2637245ab798979adf2b06cf4be/av-14.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c5443e0396adffa66ca75bcbac3607ebdd4e15fe17dd20cf0b5b2a95915f42b", size = 35784761 },
{ url = "https://files.pythonhosted.org/packages/af/3a/4156fa8234aa388c8aa6106f6356aad2e03682a4bca238c259caa4db7ecd/av-14.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7647d4a8d1855d05fe70784a962b15e103a2d4a0eba1dea7bfbfd95753dedb9", size = 39678470 },
{ url = "https://files.pythonhosted.org/packages/1a/ab/ddc797e2e99b84c674d7405ca3f99318d7bd7ff3ad13430911bc037ea3a9/av-14.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:530800028f1056be744bd002b4f60fe85395d94603627a2e0aa26acf90cd4521", size = 30853921 },
{ url = "https://files.pythonhosted.org/packages/5b/88/b56f5e5fa2486ee51413b043e08c7f5ed119c1e10b72725593da30adc28f/av-14.2.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a3da3e951148291d70f6cb3fb37bf81580b01992e915ef1030108e4076f62d38", size = 22070132 },
{ url = "https://files.pythonhosted.org/packages/89/36/787af232db9b3d5bbd5eb4d1d46c51b9669cba5b2273bb68a445cb281db8/av-14.2.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:6a6aae9e17aae4f2a97335825c0a701b763b72aaf89428f2a70bbdc83b64ad23", size = 27454954 },
{ url = "https://files.pythonhosted.org/packages/d3/c3/a174388d393f1564ad4c1b8300eb4f3e972851a4d392c1eba66a6848749e/av-14.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:897be9a665c365dfcf0c10a257fe223521ed4d3b478e6b258f55f7cd13fdedd3", size = 37748788 },
{ url = "https://files.pythonhosted.org/packages/f1/b4/96469f9e2b2763d49cd185be31a2512e52c9ff8526ee113cadfbab036850/av-14.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b5fc39524903c0bae26e856b7cff4b227f8472a9e8851b117a7711d3a01ac6", size = 36062884 },
{ url = "https://files.pythonhosted.org/packages/ed/e8/cf60f3fcde3d0eedee3e9ff66b674a9b85bffc907dccebbc56fb5ac4a954/av-14.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14c5f00b0b60d127ac0cde46a5bce9b67e905ba93033fdd48ae550c0c05d51b8", size = 40040294 },
{ url = "https://files.pythonhosted.org/packages/93/47/94b8fcfb8f102b45f2ca427b65a1243376d83d20c27f409170a4cc20e8ff/av-14.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:de04052374dbd36d9e8bcf2ead6501cc45e16bc13036d8cc17dacec96b7f6c51", size = 30857257 },
{ url = "https://files.pythonhosted.org/packages/09/5b/cd6c553af8385e590b5f816093ecb6e267e3f00c2669f8323be8f62b96c3/av-14.2.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e745ac7db026f4f68e4b5aebeda0d6188d2fb78a26825e628b97ee7ccaadc7e0", size = 22029217 },
{ url = "https://files.pythonhosted.org/packages/ce/bd/82c55b903fc1fc9428881742a10f5a4180a4f60ad2d75eb451acf85e7ceb/av-14.2.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:69e93ae8fd4e55247ebcc966a0bf1bcc7fcba2f6b9811eb622613c2615aec59f", size = 27412669 },
{ url = "https://files.pythonhosted.org/packages/a9/a5/39b9705e23b8b2369a45d00de24cbe080d4cd0ad2907c9a72bd5b5e42141/av-14.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01dfdd042a1077e37308a9c2538eb7cfb01588b916c9083f66fbf1b94432fb1a", size = 37392185 },
{ url = "https://files.pythonhosted.org/packages/56/4d/7b741803a88342d1e532d651be7a4a3f00a225dbc3a1648f8c447b64cc93/av-14.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c357421d4ec2f2eb919c0a4d48814328b93f456da12e8d751ca13be02920a82e", size = 35719211 },
{ url = "https://files.pythonhosted.org/packages/44/58/5f156af35eb58857f3a1c21b0d9b1bbfa535c2b4cecd6e0789c2202ead08/av-14.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeec3413822ffacc67a4832a0254cb67a3cfe6e3774ed80c0fa1b349dd1fe2b", size = 39691118 },
{ url = "https://files.pythonhosted.org/packages/5f/87/d7a5d6995f90b73b70554eea5ee9743ef1e2897be8117aa7a48e8c834239/av-14.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1c8b180cf339644f01b9a3c9a55aedbd1cf60ac60335f0254dcd6af3ba3fab4", size = 30827999 },
{ url = "https://files.pythonhosted.org/packages/6b/9b/eb3b48d2b42ae14856898ed996f512cec2655e8ebedc072c5bb658ca456a/av-14.2.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:2b114f2c4ad8ee051b62e330f2f8ebf4399646179c98dd2c9c58f5bd09a521c5", size = 22102151 },
{ url = "https://files.pythonhosted.org/packages/0f/c5/f0825d5a7aa2f9a6b299207ca98d136b82260cd345c915c624aaa9eb3b4b/av-14.2.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:d4358410ea04984acea15e4647f620a22bba9e12e4e632b4dc69c586bf896599", size = 27477304 },
{ url = "https://files.pythonhosted.org/packages/e4/3f/a4a82b72f9e89629a959da93cf671bbe7cd9c1366608c6cdbd80c05cabdc/av-14.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cd5a10b196b5f7a4b64e9c1b1c9eea87cadf4f1f0a8c00ade0ae8a223a5ba04", size = 36749313 },
{ url = "https://files.pythonhosted.org/packages/ae/68/c49a162ee995cbea153ce73cf6cd818cf54e4871f05a2d537a92c23d7cbb/av-14.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f1f06d6d51ca859f2ee2db25afc3871ecc2179af588e745f31e137fa7935b1c", size = 35131393 },
{ url = "https://files.pythonhosted.org/packages/88/f6/b01f6dd899f1f78471ed83be1ede15b0bf0f302aa3043ad44d772956e331/av-14.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a0ab52af7ce51e98aac17800d42ae2fdb6ffc05321a69458960558561f62c09", size = 38951392 },
{ url = "https://files.pythonhosted.org/packages/d0/ca/fc89c5d4d5adb69ab099aad3b8d8087ca2681ee3f25dfc7e97a1fc39bb82/av-14.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:bcd1711f0f1c00e56e26f9593e3e9efe3cf0c24a1d610a7d53a3df027bca0ebc", size = 30875988 },
]
[[package]]
name = "awscrt"
version = "0.16.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/97/5c7d6c78d9f40fe03f3ca04da85fe45da1566f4b48b157634dbcb46ecb6c/awscrt-0.16.26.tar.gz", hash = "sha256:1cc8cfa42cd16cb54d8b75417fec0fb038f7318c490209290bdb6c42ad3c2dff", size = 31262472 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/84/7ffc911450a86498362314b05e88ccf7d95d1c4e4077414369ac75b4f0ae/awscrt-0.16.26-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b83621f697b3aed064c82175845194b82e024184b1d4af30666b5087e93eb625", size = 1333709 },
{ url = "https://files.pythonhosted.org/packages/60/f7/2224d5cddfd0bac7d49c7cba89bf97ff7b05697e2ec9bea958c265cef85e/awscrt-0.16.26-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd6b8cdff1f6c4724b4422ff678334ab377b93230b1da1fe20a64acd80ff2121", size = 7402265 },
{ url = "https://files.pythonhosted.org/packages/ba/34/75508a2a8077a2945dc2e21809ea5fd1ddb3eede08d4f6da40dbd7b3dc45/awscrt-0.16.26-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b31d90066d3f44ab2be58c122b817131cc1c756411ad9a93efff6bff979c02b3", size = 7760720 },
{ url = "https://files.pythonhosted.org/packages/15/53/42835d813a4b9aa4aaaaba465373de912c75e532927d06e69b605787b902/awscrt-0.16.26-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:127cfe2e838c13b41620642e0a78d3cf38d70f7c2695a9fc873aff12306403aa", size = 7582650 },
{ url = "https://files.pythonhosted.org/packages/01/9a/59d111d6a2cb6781dbb393cc1ad08c700ce0688317ec322c3453dc9d8fab/awscrt-0.16.26-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8e86404c8dea31e57504c97a424de3cce486cb5abfdf319d7cec78f010befb8", size = 7993772 },
{ url = "https://files.pythonhosted.org/packages/94/d4/fbe87fbff8775663027c0e3a0afb69dd2687c7228c5425ec5873b8ec870e/awscrt-0.16.26-cp310-cp310-win32.whl", hash = "sha256:63253072ae3d8c24a1ce1b3296ec3eef523f08ab93411fc00cc88a976054ea32", size = 2309244 },
{ url = "https://files.pythonhosted.org/packages/7c/18/cfbc70fda74b0a3a83d19e66cf07e82cc11d2b25dad1c080a81b1854d233/awscrt-0.16.26-cp310-cp310-win_amd64.whl", hash = "sha256:3f301ec4d734948953458d98ea2ec360d41fa590f424a69d188a79560d76c7e3", size = 2392722 },
{ url = "https://files.pythonhosted.org/packages/3a/55/9780174797ff690a1d2a8d0d5382a7a6a0c7f4c840718069d14ea90febbe/awscrt-0.16.26-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:9f612ebac3084b09a2f4e6a1ded9a694ac6d07531fad61106f5ebf47c1e17de2", size = 1333856 },
{ url = "https://files.pythonhosted.org/packages/88/04/c9988e3cc5f56c435b992281818e1a3d37ad5d40c488e17ba83768055d82/awscrt-0.16.26-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e0e19992ba4ef0cf8d39e1ebc3e5f5db69f415c8401c843f258227c8947bc6", size = 7368241 },
{ url = "https://files.pythonhosted.org/packages/f9/c9/68c3c556cd9040bfeeae8bd9b75ae168c6cc5247263850aaabb8fab6c23f/awscrt-0.16.26-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85e40658a3ab0b84084500cb7b09ec56e944b47358a1320035fc675e532a418b", size = 7725715 },
{ url = "https://files.pythonhosted.org/packages/00/83/fb3878433dd8c37c8c8b704fd090854c7e8deacdf8e13988cecf3a32854e/awscrt-0.16.26-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:52a22dab188bd88d662f0f8643885e74e48b1027ea944388325e43f37a84ec63", size = 7540235 },
{ url = "https://files.pythonhosted.org/packages/f0/37/3a0a4591dad3a8590ea8c0b92a995cb82d0dcec12f244cbb739ecec18fa9/awscrt-0.16.26-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c5b51529ea1e4b32ad2ee6e68447c89a48cf1c533746ecba5261cb600592c371", size = 7952646 },
{ url = "https://files.pythonhosted.org/packages/a5/1b/d56d512e262634e3d413b57e3b3b5c625fc95fd2581a904528001fdaa6aa/awscrt-0.16.26-cp311-abi3-win32.whl", hash = "sha256:aa1a848d85dc19f6785f354230369d05f9bdfdab0bd3089333d2d2f9b720e68a", size = 2306526 },
{ url = "https://files.pythonhosted.org/packages/03/0e/77a0317e7e98be2ea91fbd63498728a2efabe52cbcb8a5919edeb2d25c92/awscrt-0.16.26-cp311-abi3-win_amd64.whl", hash = "sha256:6fe57aacb74505af490005584ba8ba8a7b637b90b0fb066950792f0d3205e2c4", size = 2389793 },
{ url = "https://files.pythonhosted.org/packages/0f/f2/60cc80ad4d9e2de4f427b34fd4f4f6ab1373176748fe5f4d1cffbd0d2570/awscrt-0.16.26-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f07791cdd1bcb1f18fec538f15a2597e98e61f1d02c000a575e56abfcc17d812", size = 1333665 },
{ url = "https://files.pythonhosted.org/packages/9c/f3/e09b435cecae97aaf2c45611938a1da94b2a8a0ba868d53e8665f494d936/awscrt-0.16.26-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29cfd4be93fc32328c898f472b4a54fa6d5202724ac797c1659c5487c4a74e0", size = 7399402 },
{ url = "https://files.pythonhosted.org/packages/74/ef/c09961644685ca2ef18151cdf1f1a0c20c40988bbcaf9720e723e4973f1e/awscrt-0.16.26-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29422396a58107e85153622754fe82956f7f962d803369879578f5963701b733", size = 7757803 },
{ url = "https://files.pythonhosted.org/packages/86/0b/27f6d2565cdb57f4c8506db798ac01adc08e1525d11cef34955b47acde54/awscrt-0.16.26-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ffed91a63012da12a43ce55ae3234088602d1f9eaec115e674cd31613c01383", size = 6739698 },
{ url = "https://files.pythonhosted.org/packages/5b/1c/90c3f3908e9529198d9ea5f7083c963462db57b030f3f2c5fc120b33bf75/awscrt-0.16.26-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d814efba00ce1dadc37f847c581427da3b3fd2113b880e8a0e6b8ac2d6697951", size = 7431569 },
{ url = "https://files.pythonhosted.org/packages/7f/cc/665430f1fb1cff2666a026bb8ba1434ffd15a2f37eca72e495553be68ee6/awscrt-0.16.26-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f0a1ad7d2b0a213bcb83ed9b17daca606b66bbf42daa52510681a8d0e831e10f", size = 7579795 },
{ url = "https://files.pythonhosted.org/packages/fe/fc/ad19337b078cfe65930f050798582c6b04cf232d060adc0b02073ba2e170/awscrt-0.16.26-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dbcbba9b322a2c42f5c9a7d5d2edfde913c2d3cfe68afa488ac2b747d53d78ed", size = 7992240 },
{ url = "https://files.pythonhosted.org/packages/3c/09/8c8bd2d54e12228439cd6c2460f76080ea43556b8a74dd031b7cb2d88cfa/awscrt-0.16.26-cp39-cp39-win32.whl", hash = "sha256:fb1cf73f0bd25dc76e1c9763c294d7e307421294b61f72305305682c9a3e4589", size = 2307628 },
{ url = "https://files.pythonhosted.org/packages/ab/c1/011311aba209cba079c5a2f1c3f67df31aa82952a7a42cbb4c3fcbc30e3e/awscrt-0.16.26-cp39-cp39-win_amd64.whl", hash = "sha256:a0fe0700ae7d84981be6897987da888958bd4c1579875318e92e92d05e59f1a5", size = 2391310 },
]
[[package]]
name = "azure-cognitiveservices-speech"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9f/7eb0d57fa4b9184b190cde12eda20f89ba7078c2d8f88a2cc9b8231af846/azure_cognitiveservices_speech-1.43.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:af81ef91103f9174095f4dcdc173fbce180c9d4d7956f03d79859c9a10e8b320", size = 7479901 },
{ url = "https://files.pythonhosted.org/packages/46/a5/0568509dfbd22b15a28a260f4b4c4f2d1a7bc212cdd09de7cff425b7c86b/azure_cognitiveservices_speech-1.43.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ac09cc81ab01d0db4e2d9a79a3894c6a8d09e1359d0eeb5070aa9194a0c84576", size = 7331196 },
{ url = "https://files.pythonhosted.org/packages/a8/b3/1db6c96520a3ec70f14328a373991f8e6ce36e9112b3e73f2a956d67f477/azure_cognitiveservices_speech-1.43.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:e12527746fc5bff040c66e20172544e9708e10b29d9f3acc365576d44ccb7c5c", size = 40935541 },
{ url = "https://files.pythonhosted.org/packages/ee/02/c73a10f5d8ecf83e54f02e6e24813ce7661ad50f532944da5c0ecaeaea54/azure_cognitiveservices_speech-1.43.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07bdedba8494edfb24306279d3b0500ece016fc811ec0b3366707a75d118a245", size = 40710733 },
{ url = "https://files.pythonhosted.org/packages/83/c5/b593f08f70b73b8a997b87673235f83ec42d9c9bf0fae7f348e889dfc00c/azure_cognitiveservices_speech-1.43.0-py3-none-win32.whl", hash = "sha256:36570806a6b8fe12696a0372193ecc623bc629e355fa1edc67c03ac71731066b", size = 2152884 },
{ url = "https://files.pythonhosted.org/packages/f5/b8/b1e7894cb4bcd721356eb1687e6f17112c2c659f4365827b8e7daac07c7d/azure_cognitiveservices_speech-1.43.0-py3-none-win_amd64.whl", hash = "sha256:50a50aabc69434d1311c09eaa640622c1d47d270e6cbcf5d192a04325cb7de4c", size = 2410492 },
{ url = "https://files.pythonhosted.org/packages/5e/79/8d16e2cdeb01459726818558f1b484106b89e8b54ad85a847e471a5c2659/azure_cognitiveservices_speech-1.43.0-py3-none-win_arm64.whl", hash = "sha256:29dab439a3789196c38b169a74fb4eefa4ede59e79f062541c08cc39a2d786a5", size = 2205218 },
]
[[package]]
name = "boto3"
version = "1.37.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/8c/c2af03daafaacea1db1823d23073facffa75818b61d376c3be77dd297ae8/boto3-1.37.1.tar.gz", hash = "sha256:96d18f7feb0c1fcb95f8837b74b6c8880e1b4e35ce5f8a8f8cb243a090c278ed", size = 111175 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/ec/e722c53c9dc41e8df094587c32e19409bace8b43b5eb31fe3536ca57a38b/boto3-1.37.1-py3-none-any.whl", hash = "sha256:4320441f904435a1b85e6ecb81793192e522c737cc9ed6566014e29f0a11cb22", size = 139338 },
]
[[package]]
name = "botocore"
version = "1.37.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "urllib3", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/01/3083bff25fd91193162298920cb093b9095609408416526d52b2826965b7/botocore-1.37.1.tar.gz", hash = "sha256:b194db8fb2a0ffba53568c364ae26166e7eec0445496b2ac86a6e142f3dd982f", size = 13578835 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/20/352b2bf99f93ba18986615841786cbd0d38f7856bd49d4e154a540f04afe/botocore-1.37.1-py3-none-any.whl", hash = "sha256:c1db1bfc5d8c6b3b6d1ca6794f605294b4264e82a7e727b88e0fef9c2b9fbb9c", size = 13359164 },
]
[[package]]
name = "cachetools"
version = "5.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 },
{ url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 },
{ url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 },
{ url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 },
{ url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 },
{ url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 },
{ url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 },
{ url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 },
{ url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 },
{ url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 },
{ url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 },
{ url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 },
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 },
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 },
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 },
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 },
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 },
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 },
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 },
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 },
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 },
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 },
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 },
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 },
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
{ url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 },
{ url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 },
{ url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 },
{ url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 },
{ url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 },
{ url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 },
{ url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 },
{ url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 },
{ url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 },
{ url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 },
{ url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 },
{ url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 },
{ url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 },
{ url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 },
{ url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 },
{ url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 },
{ url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 },
{ url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 },
{ url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 },
{ url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 },
{ url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 },
{ url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 },
{ url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 },
{ url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 },
{ url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 },
{ url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 },
{ url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 },
{ url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 },
{ url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 },
{ url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 },
{ url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 },
{ url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 },
{ url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 },
{ url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 },
{ url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 },
{ url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 },
{ url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 },
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
{ url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 },
{ url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 },
{ url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 },
{ url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 },
{ url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 },
{ url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 },
{ url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 },
{ url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 },
{ url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 },
{ url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 },
{ url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 },
{ url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 },
{ url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
]
[[package]]
name = "docstring-parser"
version = "0.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 },
]
[[package]]
name = "eval-type-backport"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830 },
]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
]
[[package]]
name = "fal-client"
version = "0.5.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "httpx-sse" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/c5710df43dd0a14e23fe86e8a6ed284679b9604ac9d09c6c8efede6056ae/fal_client-0.5.9.tar.gz", hash = "sha256:238a5300293d8d8da1204f4455dc78b1539f2ff20122f870e7280ccc29f28922", size = 13924 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/fe/82a277970bc4cd1711f526bea481e6a54c3e4036a25235deb30497529d41/fal_client-0.5.9-py3-none-any.whl", hash = "sha256:f45dae7553c5b85e00418957cc4c8531e24f64e5aa7c7dad862ed67e7cfb0f03", size = 10414 },
]
[[package]]
name = "filelock"
version = "3.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
]
[[package]]
name = "flatbuffers"
version = "25.2.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/30/eb5dce7994fc71a2f685d98ec33cc660c0a5887db5610137e60d8cbc4489/flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e", size = 22170 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/25/155f9f080d5e4bc0082edfda032ea2bc2b8fab3f4d25d46c1e9dd22a1a89/flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051", size = 30953 },
]
[[package]]
name = "frozenlist"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/79/29d44c4af36b2b240725dce566b20f63f9b36ef267aaaa64ee7466f4f2f8/frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", size = 94451 },
{ url = "https://files.pythonhosted.org/packages/47/47/0c999aeace6ead8a44441b4f4173e2261b18219e4ad1fe9a479871ca02fc/frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", size = 54301 },
{ url = "https://files.pythonhosted.org/packages/8d/60/107a38c1e54176d12e06e9d4b5d755b677d71d1219217cee063911b1384f/frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", size = 52213 },
{ url = "https://files.pythonhosted.org/packages/17/62/594a6829ac5679c25755362a9dc93486a8a45241394564309641425d3ff6/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", size = 240946 },
{ url = "https://files.pythonhosted.org/packages/7e/75/6c8419d8f92c80dd0ee3f63bdde2702ce6398b0ac8410ff459f9b6f2f9cb/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", size = 264608 },
{ url = "https://files.pythonhosted.org/packages/88/3e/82a6f0b84bc6fb7e0be240e52863c6d4ab6098cd62e4f5b972cd31e002e8/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", size = 261361 },
{ url = "https://files.pythonhosted.org/packages/fd/85/14e5f9ccac1b64ff2f10c927b3ffdf88772aea875882406f9ba0cec8ad84/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", size = 231649 },
{ url = "https://files.pythonhosted.org/packages/ee/59/928322800306f6529d1852323014ee9008551e9bb027cc38d276cbc0b0e7/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", size = 241853 },
{ url = "https://files.pythonhosted.org/packages/7d/bd/e01fa4f146a6f6c18c5d34cab8abdc4013774a26c4ff851128cd1bd3008e/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", size = 243652 },
{ url = "https://files.pythonhosted.org/packages/a5/bd/e4771fd18a8ec6757033f0fa903e447aecc3fbba54e3630397b61596acf0/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", size = 241734 },
{ url = "https://files.pythonhosted.org/packages/21/13/c83821fa5544af4f60c5d3a65d054af3213c26b14d3f5f48e43e5fb48556/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", size = 260959 },
{ url = "https://files.pythonhosted.org/packages/71/f3/1f91c9a9bf7ed0e8edcf52698d23f3c211d8d00291a53c9f115ceb977ab1/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", size = 262706 },
{ url = "https://files.pythonhosted.org/packages/4c/22/4a256fdf5d9bcb3ae32622c796ee5ff9451b3a13a68cfe3f68e2c95588ce/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", size = 250401 },
{ url = "https://files.pythonhosted.org/packages/af/89/c48ebe1f7991bd2be6d5f4ed202d94960c01b3017a03d6954dd5fa9ea1e8/frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", size = 45498 },
{ url = "https://files.pythonhosted.org/packages/28/2f/cc27d5f43e023d21fe5c19538e08894db3d7e081cbf582ad5ed366c24446/frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", size = 51622 },
{ url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 },
{ url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 },
{ url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 },
{ url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 },
{ url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 },
{ url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 },
{ url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 },
{ url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 },
{ url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 },
{ url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 },
{ url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 },
{ url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 },
{ url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 },
{ url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 },
{ url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 },
{ url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 },
{ url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 },
{ url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 },
{ url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 },
{ url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 },
{ url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 },
{ url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 },
{ url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 },
{ url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 },
{ url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 },
{ url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 },
{ url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 },
{ url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 },
{ url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 },
{ url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 },
{ url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 },
{ url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 },
{ url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 },
{ url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 },
{ url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 },
{ url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 },
{ url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 },
{ url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 },
{ url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 },
{ url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 },
{ url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 },
{ url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 },
{ url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 },
{ url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 },
{ url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 },
{ url = "https://files.pythonhosted.org/packages/da/4d/d94ff0fb0f5313902c132817c62d19cdc5bdcd0c195d392006ef4b779fc6/frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", size = 95319 },
{ url = "https://files.pythonhosted.org/packages/8c/1b/d90e554ca2b483d31cb2296e393f72c25bdc38d64526579e95576bfda587/frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", size = 54749 },
{ url = "https://files.pythonhosted.org/packages/f8/66/7fdecc9ef49f8db2aa4d9da916e4ecf357d867d87aea292efc11e1b2e932/frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", size = 52718 },
{ url = "https://files.pythonhosted.org/packages/08/04/e2fddc92135276e07addbc1cf413acffa0c2d848b3e54cacf684e146df49/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", size = 241756 },
{ url = "https://files.pythonhosted.org/packages/c6/52/be5ff200815d8a341aee5b16b6b707355e0ca3652953852238eb92b120c2/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", size = 267718 },
{ url = "https://files.pythonhosted.org/packages/88/be/4bd93a58be57a3722fc544c36debdf9dcc6758f761092e894d78f18b8f20/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", size = 263494 },
{ url = "https://files.pythonhosted.org/packages/32/ba/58348b90193caa096ce9e9befea6ae67f38dabfd3aacb47e46137a6250a8/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", size = 232838 },
{ url = "https://files.pythonhosted.org/packages/f6/33/9f152105227630246135188901373c4f322cc026565ca6215b063f4c82f4/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", size = 242912 },
{ url = "https://files.pythonhosted.org/packages/a0/10/3db38fb3ccbafadd80a1b0d6800c987b0e3fe3ef2d117c6ced0246eea17a/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", size = 244763 },
{ url = "https://files.pythonhosted.org/packages/e2/cd/1df468fdce2f66a4608dffe44c40cdc35eeaa67ef7fd1d813f99a9a37842/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", size = 242841 },
{ url = "https://files.pythonhosted.org/packages/ee/5f/16097a5ca0bb6b6779c02cc9379c72fe98d56115d4c54d059fb233168fb6/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", size = 263407 },
{ url = "https://files.pythonhosted.org/packages/0f/f7/58cd220ee1c2248ee65a32f5b4b93689e3fe1764d85537eee9fc392543bc/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", size = 265083 },
{ url = "https://files.pythonhosted.org/packages/62/b8/49768980caabf81ac4a2d156008f7cbd0107e6b36d08a313bb31035d9201/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", size = 251564 },
{ url = "https://files.pythonhosted.org/packages/cb/83/619327da3b86ef957ee7a0cbf3c166a09ed1e87a3f7f1ff487d7d0284683/frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", size = 45691 },
{ url = "https://files.pythonhosted.org/packages/8b/28/407bc34a745151ed2322c690b6e7d83d7101472e81ed76e1ebdac0b70a78/frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", size = 51767 },
{ url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 },
]
[[package]]
name = "fsspec"
version = "2025.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/f4/5721faf47b8c499e776bc34c6a8fc17efdf7fdef0b00f398128bc5dcb4ac/fsspec-2025.3.0.tar.gz", hash = "sha256:a935fd1ea872591f2b5148907d103488fc523295e6c64b835cfad8c3eca44972", size = 298491 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/53/eb690efa8513166adef3e0669afd31e95ffde69fb3c52ec2ac7223ed6018/fsspec-2025.3.0-py3-none-any.whl", hash = "sha256:efb87af3efa9103f94ca91a7f8cb7a4df91af9f74fc106c9c7ea0efd7277c1b3", size = 193615 },
]
[[package]]
name = "google-api-core"
version = "2.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "googleapis-common-protos" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/5c/085bcb872556934bb119e5e09de54daa07873f6866b8f0303c49e72287f7/google_api_core-2.24.2.tar.gz", hash = "sha256:81718493daf06d96d6bc76a91c23874dbf2fac0adbbf542831b805ee6e974696", size = 163516 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/95/f472d85adab6e538da2025dfca9e976a0d125cc0af2301f190e77b76e51c/google_api_core-2.24.2-py3-none-any.whl", hash = "sha256:810a63ac95f3c441b7c0e43d344e372887f62ce9071ba972eacf32672e072de9", size = 160061 },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
]
[[package]]
name = "google-auth"
version = "2.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/eb/d504ba1daf190af6b204a9d4714d457462b486043744901a6eeea711f913/google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4", size = 270866 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/47/603554949a37bca5b7f894d51896a9c534b9eab808e2520a748e081669d0/google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a", size = 210770 },
]
[[package]]
name = "google-cloud-speech"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/ad/e250054c22bf96057a80da91a6cf875565abb0a4820c017b09578b76397a/google_cloud_speech-2.31.0.tar.gz", hash = "sha256:d7998c26a945f58933c60e2d3803ed0593eb416bac9d4381c03059d10272ef03", size = 380753 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ad/a15be5c4f9637038989dea42721afed7829665f420d184c1ece310b98dd3/google_cloud_speech-2.31.0-py2.py3-none-any.whl", hash = "sha256:1e1d61db8110f3c2b9b2ab108deefc3f47d6ba7fe5ad44eb623a5305a68f3cd1", size = 330165 },
]
[[package]]
name = "google-cloud-texttospeech"
version = "2.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/14/a0f0a789c14eeca6b8c86ea8808b2169a9a623209b61b3b2118d30a71fc0/google_cloud_texttospeech-2.25.0.tar.gz", hash = "sha256:776e70b58385c8c34b8425c7adb27e946a858377c227b95153315d75263c1e23", size = 177989 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f9/2cc3831068747fadc9125ab5c92986834bc35b1c865c487bf802ef7c4b31/google_cloud_texttospeech-2.25.0-py2.py3-none-any.whl", hash = "sha256:5066b486111d450aedf49a84824c7699fd3ce7fbc56a416c358f9cde25e82c90", size = 186679 },
]
[[package]]
name = "google-genai"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/44/64c6c23724580add879cbcca81ffed500955c1c21850468cd4dcf9c62a03/google_genai-1.11.0.tar.gz", hash = "sha256:0643b2f5373fbeae945d0cd5a37d157eab0c172bb5e14e905f2f8d45aa51cabb", size = 160955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/55f97203720cbda5a1c8e0460793914980e41c6ca4859fea735dd66d2c3a/google_genai-1.11.0-py3-none-any.whl", hash = "sha256:34fbe3c85419adbcddcb8222f99514596b3a69c80ff1a4ae30a01a763da27acc", size = 159687 },
]
[[package]]
name = "googleapis-common-protos"
version = "1.69.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/4f/d8be74b88621131dfd1ed70e5aff2c47f2bdf2289a70736bbf3eb0e7bc70/googleapis_common_protos-1.69.1.tar.gz", hash = "sha256:e20d2d8dda87da6fe7340afbbdf4f0bcb4c8fae7e6cadf55926c31f946b0b9b1", size = 144514 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/cb/2f4aa605b16df1e031dd7c322c597613eef933e8dd5b6a4414330b21e791/googleapis_common_protos-1.69.1-py2.py3-none-any.whl", hash = "sha256:4077f27a6900d5946ee5a369fab9c8ded4c0ef1c6e880458ea2f70c14f7b70d5", size = 293229 },
]
[[package]]
name = "grpcio"
version = "1.71.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/95/aa11fc09a85d91fbc7dd405dcb2a1e0256989d67bf89fa65ae24b3ba105a/grpcio-1.71.0.tar.gz", hash = "sha256:2b85f7820475ad3edec209d3d89a7909ada16caab05d3f2e08a7e8ae3200a55c", size = 12549828 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/c5/ef610b3f988cc0cc67b765f72b8e2db06a1db14e65acb5ae7810a6b7042e/grpcio-1.71.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:c200cb6f2393468142eb50ab19613229dcc7829b5ccee8b658a36005f6669fdd", size = 5210643 },
{ url = "https://files.pythonhosted.org/packages/bf/de/c84293c961622df302c0d5d07ec6e2d4cd3874ea42f602be2df09c4ad44f/grpcio-1.71.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:b2266862c5ad664a380fbbcdbdb8289d71464c42a8c29053820ee78ba0119e5d", size = 11308962 },
{ url = "https://files.pythonhosted.org/packages/7c/38/04c9e0dc8c904570c80faa1f1349b190b63e45d6b2782ec8567b050efa9d/grpcio-1.71.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:0ab8b2864396663a5b0b0d6d79495657ae85fa37dcb6498a2669d067c65c11ea", size = 5699236 },
{ url = "https://files.pythonhosted.org/packages/95/96/e7be331d1298fa605ea7c9ceafc931490edd3d5b33c4f695f1a0667f3491/grpcio-1.71.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c30f393f9d5ff00a71bb56de4aa75b8fe91b161aeb61d39528db6b768d7eac69", size = 6339767 },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7e7b7bb6bb18baf156fd4f2f5b254150dcdd6cbf0def1ee427a2fb2bfc4d/grpcio-1.71.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f250ff44843d9a0615e350c77f890082102a0318d66a99540f54769c8766ab73", size = 5943028 },
{ url = "https://files.pythonhosted.org/packages/13/aa/5fb756175995aeb47238d706530772d9a7ac8e73bcca1b47dc145d02c95f/grpcio-1.71.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6d8de076528f7c43a2f576bc311799f89d795aa6c9b637377cc2b1616473804", size = 6031841 },
{ url = "https://files.pythonhosted.org/packages/54/93/172783e01eed61f7f180617b7fa4470f504e383e32af2587f664576a7101/grpcio-1.71.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9b91879d6da1605811ebc60d21ab6a7e4bae6c35f6b63a061d61eb818c8168f6", size = 6651039 },
{ url = "https://files.pythonhosted.org/packages/6f/99/62654b220a27ed46d3313252214f4bc66261143dc9b58004085cd0646753/grpcio-1.71.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f71574afdf944e6652203cd1badcda195b2a27d9c83e6d88dc1ce3cfb73b31a5", size = 6198465 },
{ url = "https://files.pythonhosted.org/packages/68/35/96116de833b330abe4412cc94edc68f99ed2fa3e39d8713ff307b3799e81/grpcio-1.71.0-cp310-cp310-win32.whl", hash = "sha256:8997d6785e93308f277884ee6899ba63baafa0dfb4729748200fcc537858a509", size = 3620382 },
{ url = "https://files.pythonhosted.org/packages/b7/09/f32ef637e386f3f2c02effac49699229fa560ce9007682d24e9e212d2eb4/grpcio-1.71.0-cp310-cp310-win_amd64.whl", hash = "sha256:7d6ac9481d9d0d129224f6d5934d5832c4b1cddb96b59e7eba8416868909786a", size = 4280302 },
{ url = "https://files.pythonhosted.org/packages/63/04/a085f3ad4133426f6da8c1becf0749872a49feb625a407a2e864ded3fb12/grpcio-1.71.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:d6aa986318c36508dc1d5001a3ff169a15b99b9f96ef5e98e13522c506b37eef", size = 5210453 },
{ url = "https://files.pythonhosted.org/packages/b4/d5/0bc53ed33ba458de95020970e2c22aa8027b26cc84f98bea7fcad5d695d1/grpcio-1.71.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:d2c170247315f2d7e5798a22358e982ad6eeb68fa20cf7a820bb74c11f0736e7", size = 11347567 },
{ url = "https://files.pythonhosted.org/packages/e3/6d/ce334f7e7a58572335ccd61154d808fe681a4c5e951f8a1ff68f5a6e47ce/grpcio-1.71.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:e6f83a583ed0a5b08c5bc7a3fe860bb3c2eac1f03f1f63e0bc2091325605d2b7", size = 5696067 },
{ url = "https://files.pythonhosted.org/packages/05/4a/80befd0b8b1dc2b9ac5337e57473354d81be938f87132e147c4a24a581bd/grpcio-1.71.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be74ddeeb92cc87190e0e376dbc8fc7736dbb6d3d454f2fa1f5be1dee26b9d7", size = 6348377 },
{ url = "https://files.pythonhosted.org/packages/c7/67/cbd63c485051eb78663355d9efd1b896cfb50d4a220581ec2cb9a15cd750/grpcio-1.71.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd0dfbe4d5eb1fcfec9490ca13f82b089a309dc3678e2edabc144051270a66e", size = 5940407 },
{ url = "https://files.pythonhosted.org/packages/98/4b/7a11aa4326d7faa499f764eaf8a9b5a0eb054ce0988ee7ca34897c2b02ae/grpcio-1.71.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a2242d6950dc892afdf9e951ed7ff89473aaf744b7d5727ad56bdaace363722b", size = 6030915 },
{ url = "https://files.pythonhosted.org/packages/eb/a2/cdae2d0e458b475213a011078b0090f7a1d87f9a68c678b76f6af7c6ac8c/grpcio-1.71.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0fa05ee31a20456b13ae49ad2e5d585265f71dd19fbd9ef983c28f926d45d0a7", size = 6648324 },
{ url = "https://files.pythonhosted.org/packages/27/df/f345c8daaa8d8574ce9869f9b36ca220c8845923eb3087e8f317eabfc2a8/grpcio-1.71.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3d081e859fb1ebe176de33fc3adb26c7d46b8812f906042705346b314bde32c3", size = 6197839 },
{ url = "https://files.pythonhosted.org/packages/f2/2c/cd488dc52a1d0ae1bad88b0d203bc302efbb88b82691039a6d85241c5781/grpcio-1.71.0-cp311-cp311-win32.whl", hash = "sha256:d6de81c9c00c8a23047136b11794b3584cdc1460ed7cbc10eada50614baa1444", size = 3619978 },
{ url = "https://files.pythonhosted.org/packages/ee/3f/cf92e7e62ccb8dbdf977499547dfc27133124d6467d3a7d23775bcecb0f9/grpcio-1.71.0-cp311-cp311-win_amd64.whl", hash = "sha256:24e867651fc67717b6f896d5f0cac0ec863a8b5fb7d6441c2ab428f52c651c6b", size = 4282279 },
{ url = "https://files.pythonhosted.org/packages/4c/83/bd4b6a9ba07825bd19c711d8b25874cd5de72c2a3fbf635c3c344ae65bd2/grpcio-1.71.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:0ff35c8d807c1c7531d3002be03221ff9ae15712b53ab46e2a0b4bb271f38537", size = 5184101 },
{ url = "https://files.pythonhosted.org/packages/31/ea/2e0d90c0853568bf714693447f5c73272ea95ee8dad107807fde740e595d/grpcio-1.71.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:b78a99cd1ece4be92ab7c07765a0b038194ded2e0a26fd654591ee136088d8d7", size = 11310927 },
{ url = "https://files.pythonhosted.org/packages/ac/bc/07a3fd8af80467390af491d7dc66882db43884128cdb3cc8524915e0023c/grpcio-1.71.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:dc1a1231ed23caac1de9f943d031f1bc38d0f69d2a3b243ea0d664fc1fbd7fec", size = 5654280 },
{ url = "https://files.pythonhosted.org/packages/16/af/21f22ea3eed3d0538b6ef7889fce1878a8ba4164497f9e07385733391e2b/grpcio-1.71.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6beeea5566092c5e3c4896c6d1d307fb46b1d4bdf3e70c8340b190a69198594", size = 6312051 },
{ url = "https://files.pythonhosted.org/packages/49/9d/e12ddc726dc8bd1aa6cba67c85ce42a12ba5b9dd75d5042214a59ccf28ce/grpcio-1.71.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5170929109450a2c031cfe87d6716f2fae39695ad5335d9106ae88cc32dc84c", size = 5910666 },
{ url = "https://files.pythonhosted.org/packages/d9/e9/38713d6d67aedef738b815763c25f092e0454dc58e77b1d2a51c9d5b3325/grpcio-1.71.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5b08d03ace7aca7b2fadd4baf291139b4a5f058805a8327bfe9aece7253b6d67", size = 6012019 },
{ url = "https://files.pythonhosted.org/packages/80/da/4813cd7adbae6467724fa46c952d7aeac5e82e550b1c62ed2aeb78d444ae/grpcio-1.71.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f903017db76bf9cc2b2d8bdd37bf04b505bbccad6be8a81e1542206875d0e9db", size = 6637043 },
{ url = "https://files.pythonhosted.org/packages/52/ca/c0d767082e39dccb7985c73ab4cf1d23ce8613387149e9978c70c3bf3b07/grpcio-1.71.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:469f42a0b410883185eab4689060a20488a1a0a00f8bbb3cbc1061197b4c5a79", size = 6186143 },
{ url = "https://files.pythonhosted.org/packages/00/61/7b2c8ec13303f8fe36832c13d91ad4d4ba57204b1c723ada709c346b2271/grpcio-1.71.0-cp312-cp312-win32.whl", hash = "sha256:ad9f30838550695b5eb302add33f21f7301b882937460dd24f24b3cc5a95067a", size = 3604083 },
{ url = "https://files.pythonhosted.org/packages/fd/7c/1e429c5fb26122055d10ff9a1d754790fb067d83c633ff69eddcf8e3614b/grpcio-1.71.0-cp312-cp312-win_amd64.whl", hash = "sha256:652350609332de6dac4ece254e5d7e1ff834e203d6afb769601f286886f6f3a8", size = 4272191 },
{ url = "https://files.pythonhosted.org/packages/04/dd/b00cbb45400d06b26126dcfdbdb34bb6c4f28c3ebbd7aea8228679103ef6/grpcio-1.71.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:cebc1b34ba40a312ab480ccdb396ff3c529377a2fce72c45a741f7215bfe8379", size = 5184138 },
{ url = "https://files.pythonhosted.org/packages/ed/0a/4651215983d590ef53aac40ba0e29dda941a02b097892c44fa3357e706e5/grpcio-1.71.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:85da336e3649a3d2171e82f696b5cad2c6231fdd5bad52616476235681bee5b3", size = 11310747 },
{ url = "https://files.pythonhosted.org/packages/57/a3/149615b247f321e13f60aa512d3509d4215173bdb982c9098d78484de216/grpcio-1.71.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f9a412f55bb6e8f3bb000e020dbc1e709627dcb3a56f6431fa7076b4c1aab0db", size = 5653991 },
{ url = "https://files.pythonhosted.org/packages/ca/56/29432a3e8d951b5e4e520a40cd93bebaa824a14033ea8e65b0ece1da6167/grpcio-1.71.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47be9584729534660416f6d2a3108aaeac1122f6b5bdbf9fd823e11fe6fbaa29", size = 6312781 },
{ url = "https://files.pythonhosted.org/packages/a3/f8/286e81a62964ceb6ac10b10925261d4871a762d2a763fbf354115f9afc98/grpcio-1.71.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9c80ac6091c916db81131d50926a93ab162a7e97e4428ffc186b6e80d6dda4", size = 5910479 },
{ url = "https://files.pythonhosted.org/packages/35/67/d1febb49ec0f599b9e6d4d0d44c2d4afdbed9c3e80deb7587ec788fcf252/grpcio-1.71.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:789d5e2a3a15419374b7b45cd680b1e83bbc1e52b9086e49308e2c0b5bbae6e3", size = 6013262 },
{ url = "https://files.pythonhosted.org/packages/a1/04/f9ceda11755f0104a075ad7163fc0d96e2e3a9fe25ef38adfc74c5790daf/grpcio-1.71.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1be857615e26a86d7363e8a163fade914595c81fec962b3d514a4b1e8760467b", size = 6643356 },
{ url = "https://files.pythonhosted.org/packages/fb/ce/236dbc3dc77cf9a9242adcf1f62538734ad64727fabf39e1346ad4bd5c75/grpcio-1.71.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a76d39b5fafd79ed604c4be0a869ec3581a172a707e2a8d7a4858cb05a5a7637", size = 6186564 },
{ url = "https://files.pythonhosted.org/packages/10/fd/b3348fce9dd4280e221f513dd54024e765b21c348bc475516672da4218e9/grpcio-1.71.0-cp313-cp313-win32.whl", hash = "sha256:74258dce215cb1995083daa17b379a1a5a87d275387b7ffe137f1d5131e2cfbb", size = 3601890 },
{ url = "https://files.pythonhosted.org/packages/be/f8/db5d5f3fc7e296166286c2a397836b8b042f7ad1e11028d82b061701f0f7/grpcio-1.71.0-cp313-cp313-win_amd64.whl", hash = "sha256:22c3bc8d488c039a199f7a003a38cb7635db6656fa96437a8accde8322ce2366", size = 4273308 },
{ url = "https://files.pythonhosted.org/packages/c8/e3/22cb31bbb42de95b35b8f0fb691d8da6e0579e658bb37b86efe2999c702b/grpcio-1.71.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:c6a0a28450c16809f94e0b5bfe52cabff63e7e4b97b44123ebf77f448534d07d", size = 5210667 },
{ url = "https://files.pythonhosted.org/packages/f6/5e/4970fb231e57aad8f41682292343551f58fec5c7a07e261294def3cb8bb6/grpcio-1.71.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:a371e6b6a5379d3692cc4ea1cb92754d2a47bdddeee755d3203d1f84ae08e03e", size = 11336193 },
{ url = "https://files.pythonhosted.org/packages/7f/a4/dd71a5540d5e86526b39c23060b7d3195f3144af3fe291947b30c3fcbdad/grpcio-1.71.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:39983a9245d37394fd59de71e88c4b295eb510a3555e0a847d9965088cdbd033", size = 5699572 },
{ url = "https://files.pythonhosted.org/packages/d0/69/3e3522d7c2c525a60f4bbf811891925ac7594b768b1ac8e6c9d955a72c45/grpcio-1.71.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9182e0063112e55e74ee7584769ec5a0b4f18252c35787f48738627e23a62b97", size = 6339648 },
{ url = "https://files.pythonhosted.org/packages/32/f2/9d864ca8f3949bf507db9c6a18532c150fc03910dd3d3e17fd4bc5d3e462/grpcio-1.71.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693bc706c031aeb848849b9d1c6b63ae6bcc64057984bb91a542332b75aa4c3d", size = 5943469 },
{ url = "https://files.pythonhosted.org/packages/9b/58/aec6ce541b7fb2a9efa15d968db5897c2700bd2da6fb159c1d27515f120c/grpcio-1.71.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:20e8f653abd5ec606be69540f57289274c9ca503ed38388481e98fa396ed0b41", size = 6030255 },
{ url = "https://files.pythonhosted.org/packages/f7/4f/7356b7edd1f622d49e72faaea75a5d6ac7bdde8f4c14dd19bcfbafd56f4c/grpcio-1.71.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8700a2a57771cc43ea295296330daaddc0d93c088f0a35cc969292b6db959bf3", size = 6651120 },
{ url = "https://files.pythonhosted.org/packages/54/10/c1bb13137dc8d1637e2373a85904aa57991e65ef429791bfb8a64a60d5bd/grpcio-1.71.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d35a95f05a8a2cbe8e02be137740138b3b2ea5f80bd004444e4f9a1ffc511e32", size = 6197989 },
{ url = "https://files.pythonhosted.org/packages/0e/dc/0fd537831501df786bc2f9ec5ac1724528a344cd146f6335f7991763eb2b/grpcio-1.71.0-cp39-cp39-win32.whl", hash = "sha256:f9c30c464cb2ddfbc2ddf9400287701270fdc0f14be5f08a1e3939f1e749b455", size = 3620173 },
{ url = "https://files.pythonhosted.org/packages/97/22/b1535291aaa9c046c79a9dc4db125f6b9974d41de154221b72da4e8a005c/grpcio-1.71.0-cp39-cp39-win_amd64.whl", hash = "sha256:63e41b91032f298b3e973b3fa4093cbbc620c875e2da7b93e249d4728b54559a", size = 4280941 },
]
[[package]]
name = "grpcio-status"
version = "1.71.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/53/a911467bece076020456401f55a27415d2d70d3bc2c37af06b44ea41fc5c/grpcio_status-1.71.0.tar.gz", hash = "sha256:11405fed67b68f406b3f3c7c5ae5104a79d2d309666d10d61b152e91d28fb968", size = 13669 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/d6/31fbc43ff097d8c4c9fc3df741431b8018f67bf8dfbe6553a555f6e5f675/grpcio_status-1.71.0-py3-none-any.whl", hash = "sha256:843934ef8c09e3e858952887467f8256aac3910c55f077a359a65b2b3cde3e68", size = 14424 },
]
[[package]]
name = "h11"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
[[package]]
name = "httpcore"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "httpx-sse"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
]
[[package]]
name = "huggingface-hub"
version = "0.29.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/f9/851f34b02970e8143d41d4001b2d49e54ef113f273902103823b8bc95ada/huggingface_hub-0.29.3.tar.gz", hash = "sha256:64519a25716e0ba382ba2d3fb3ca082e7c7eb4a2fc634d200e8380006e0760e5", size = 390123 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/0c/37d380846a2e5c9a3c6a73d26ffbcfdcad5fc3eacf42fdf7cff56f2af634/huggingface_hub-0.29.3-py3-none-any.whl", hash = "sha256:0b25710932ac649c08cdbefa6c6ccb8e88eef82927cacdb048efb726429453aa", size = 468997 },
]
[[package]]
name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "jiter"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/82/39f7c9e67b3b0121f02a0b90d433626caa95a565c3d2449fea6bcfa3f5f5/jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", size = 314540 },
{ url = "https://files.pythonhosted.org/packages/01/07/7bf6022c5a152fca767cf5c086bb41f7c28f70cf33ad259d023b53c0b858/jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", size = 321065 },
{ url = "https://files.pythonhosted.org/packages/6c/b2/de3f3446ecba7c48f317568e111cc112613da36c7b29a6de45a1df365556/jiter-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", size = 341664 },
{ url = "https://files.pythonhosted.org/packages/13/cf/6485a4012af5d407689c91296105fcdb080a3538e0658d2abf679619c72f/jiter-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538", size = 364635 },
{ url = "https://files.pythonhosted.org/packages/0d/f7/4a491c568f005553240b486f8e05c82547340572d5018ef79414b4449327/jiter-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", size = 406288 },
{ url = "https://files.pythonhosted.org/packages/d3/ca/f4263ecbce7f5e6bded8f52a9f1a66540b270c300b5c9f5353d163f9ac61/jiter-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", size = 397499 },
{ url = "https://files.pythonhosted.org/packages/ac/a2/522039e522a10bac2f2194f50e183a49a360d5f63ebf46f6d890ef8aa3f9/jiter-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", size = 352926 },
{ url = "https://files.pythonhosted.org/packages/b1/67/306a5c5abc82f2e32bd47333a1c9799499c1c3a415f8dde19dbf876f00cb/jiter-0.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", size = 384506 },
{ url = "https://files.pythonhosted.org/packages/0f/89/c12fe7b65a4fb74f6c0d7b5119576f1f16c79fc2953641f31b288fad8a04/jiter-0.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", size = 520621 },
{ url = "https://files.pythonhosted.org/packages/c4/2b/d57900c5c06e6273fbaa76a19efa74dbc6e70c7427ab421bf0095dfe5d4a/jiter-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", size = 512613 },
{ url = "https://files.pythonhosted.org/packages/89/05/d8b90bfb21e58097d5a4e0224f2940568366f68488a079ae77d4b2653500/jiter-0.9.0-cp310-cp310-win32.whl", hash = "sha256:cb12e6d65ebbefe5518de819f3eda53b73187b7089040b2d17f5b39001ff31c4", size = 206613 },
{ url = "https://files.pythonhosted.org/packages/2c/1d/5767f23f88e4f885090d74bbd2755518050a63040c0f59aa059947035711/jiter-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:c43ca669493626d8672be3b645dbb406ef25af3f4b6384cfd306da7eb2e70322", size = 208371 },
{ url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 },
{ url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 },
{ url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 },
{ url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097 },
{ url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603 },
{ url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625 },
{ url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832 },
{ url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590 },
{ url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690 },
{ url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649 },
{ url = "https://files.pythonhosted.org/packages/a6/e5/4e385945179bcf128fa10ad8dca9053d717cbe09e258110e39045c881fe5/jiter-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", size = 206920 },
{ url = "https://files.pythonhosted.org/packages/4c/47/5e0b94c603d8e54dd1faab439b40b832c277d3b90743e7835879ab663757/jiter-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", size = 210119 },
{ url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 },
{ url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 },
{ url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 },
{ url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 },
{ url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 },
{ url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 },
{ url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 },
{ url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 },
{ url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 },
{ url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 },
{ url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 },
{ url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 },
{ url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 },
{ url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 },
{ url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 },
{ url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 },
{ url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 },
{ url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 },
{ url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 },
{ url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 },
{ url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 },
{ url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 },
{ url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 },
{ url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 },
{ url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 },
{ url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 },
{ url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 },
{ url = "https://files.pythonhosted.org/packages/aa/2c/9bee940db68d8cefb84178f8b15220c836276db8c6e09cbd422071c01c33/jiter-0.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:9ef340fae98065071ccd5805fe81c99c8f80484e820e40043689cf97fb66b3e2", size = 315246 },
{ url = "https://files.pythonhosted.org/packages/d0/9b/42d5d59585d9af4fe207e96c6edac2a62bca26d76e2471e78c2f5da28bb8/jiter-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:efb767d92c63b2cd9ec9f24feeb48f49574a713870ec87e9ba0c2c6e9329c3e2", size = 312621 },
{ url = "https://files.pythonhosted.org/packages/2e/a5/a64de757516e5531f8d147a32251905f0e23641738d3520a0a0724fe9651/jiter-0.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:113f30f87fb1f412510c6d7ed13e91422cfd329436364a690c34c8b8bd880c42", size = 343006 },
{ url = "https://files.pythonhosted.org/packages/89/be/08d2bae711200d558ab8c5771f05f47cd09b82b2258a8d6fad0ee2c6a1f3/jiter-0.9.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8793b6df019b988526f5a633fdc7456ea75e4a79bd8396a3373c371fc59f5c9b", size = 365099 },
{ url = "https://files.pythonhosted.org/packages/03/9e/d137a0088be90ba5081f7d5d2383374bd77a1447158e44c3ec4e142f902c/jiter-0.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a9aaa5102dba4e079bb728076fadd5a2dca94c05c04ce68004cfd96f128ea34", size = 407834 },
{ url = "https://files.pythonhosted.org/packages/04/4c/b6bee52a5b327830abea13eba4222f33f88895a1055eff8870ab3ebbde41/jiter-0.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d838650f6ebaf4ccadfb04522463e74a4c378d7e667e0eb1865cfe3990bfac49", size = 399255 },
{ url = "https://files.pythonhosted.org/packages/12/b7/364b615a35f99d01cc27d3caea8c3a3ac5451bd5cadf8e5dc4355b102aba/jiter-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0194f813efdf4b8865ad5f5c5f50f8566df7d770a82c51ef593d09e0b347020", size = 354142 },
{ url = "https://files.pythonhosted.org/packages/65/cc/5156f75c496aac65080e2995910104d0e46644df1452c20d963cb904b4b1/jiter-0.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7954a401d0a8a0b8bc669199db78af435aae1e3569187c2939c477c53cb6a0a", size = 385142 },
{ url = "https://files.pythonhosted.org/packages/46/cf/370be59c38e56a6fed0308ca266b12d8178b8d6630284cc88ae5af110764/jiter-0.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4feafe787eb8a8d98168ab15637ca2577f6ddf77ac6c8c66242c2d028aa5420e", size = 522035 },
{ url = "https://files.pythonhosted.org/packages/ff/f5/c462d994dcbff43de8a3c953548d609c73a5db8138182408944fce2b68c1/jiter-0.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:27cd1f2e8bb377f31d3190b34e4328d280325ad7ef55c6ac9abde72f79e84d2e", size = 513844 },
{ url = "https://files.pythonhosted.org/packages/15/39/60d8f17de27586fa1e7c8215ead8222556d40a6b96b20f1ad70528961f99/jiter-0.9.0-cp39-cp39-win32.whl", hash = "sha256:161d461dcbe658cf0bd0aa375b30a968b087cdddc624fc585f3867c63c6eca95", size = 207147 },
{ url = "https://files.pythonhosted.org/packages/4b/13/c10f17dcddd1b4c1313418e64ace5e77cc4f7313246140fb09044516a62c/jiter-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e8b36d8a16a61993be33e75126ad3d8aa29cf450b09576f3c427d27647fcb4aa", size = 208879 },
]
[[package]]
name = "jiwer"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rapidfuzz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/3e/71b95cf0e2179fb5de8744a79fd36c8bd4e02e1803129a16d423884b6654/jiwer-3.1.0.tar.gz", hash = "sha256:dc492d09e570f1baba98c76aba09baf8e09c06e6808a4ba412dd4bde67fb79ac", size = 103187 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/f4/35634d9eeff3b0bab51f5b9474ee569b1186bf29cf0d9d67b84acc80c53d/jiwer-3.1.0-py3-none-any.whl", hash = "sha256:5a14b5bba4692e1946ca3c6946435f7d90b1b526076ccb6c12be763e2146237d", size = 22303 },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
]
[[package]]
name = "joblib"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/33/60135848598c076ce4b231e1b1895170f45fbcaeaa2c9d5e38b04db70c35/joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e", size = 2116621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 },
]
[[package]]
name = "livekit"
version = "1.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/56/79d5749a781c3a5b7abf585344abf6fcba454b6804240799e5f6b951f12a/livekit-1.0.6.tar.gz", hash = "sha256:782b6dd99e28354b0d67b2464338f5b925c00a427d34da7a7ade9d9798053188", size = 309379 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/43/e0e71ad63f8e8f4429a65b4e4709aac2c85a258addbc2dcdcbc9c318380a/livekit-1.0.6-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:938c4a62d08c5c9f4a577d28289193d20c79f3bb6e49ab866015297fc87c2925", size = 10772952 },
{ url = "https://files.pythonhosted.org/packages/8d/e6/d6b0e90c2b66c5745e5957e534971f6cbb1fbe4f5feb8870b9a63f477399/livekit-1.0.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba850e7ae8eff217176b3f8d35d772de65148e896b19921fc5ee0e09ee2cf637", size = 9449530 },
{ url = "https://files.pythonhosted.org/packages/27/a0/5a6b6dae2565a495008657f478fc22bfa2f9be375db66deb873979e504f3/livekit-1.0.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:295ba4b7252ed2c0322128586db6fe3a3c68ee799c95d8acfc9ea74990ca66a7", size = 10532248 },
{ url = "https://files.pythonhosted.org/packages/87/8c/52c2dad375f06a1487745ee75fbfe3e4dd2a616a641c938e71a5b15cdf1b/livekit-1.0.6-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0ffda3340d977435df2c1b03d5a512de29215a7144d2e282eb7aa764dfe1d54e", size = 12049921 },
{ url = "https://files.pythonhosted.org/packages/d0/c4/b05062a673a66664e4cc4c113ed1d110863babeb573eb0a47a55bb9fdf09/livekit-1.0.6-py3-none-win_amd64.whl", hash = "sha256:a0503846cdd5fd9bdbe707c0d248f1c4bcbb33355787f418d35cc734ec64c8e0", size = 11378084 },
]
[[package]]
name = "livekit-agents"
source = { editable = "livekit-agents" }
dependencies = [
{ name = "aiohttp" },
{ name = "av" },
{ name = "click" },
{ name = "colorama" },
{ name = "docstring-parser" },
{ name = "eval-type-backport" },
{ name = "livekit" },
{ name = "livekit-api" },
{ name = "livekit-protocol" },
{ name = "nest-asyncio" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "protobuf" },
{ name = "psutil" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "sounddevice" },
{ name = "types-protobuf" },
{ name = "typing-extensions" },
{ name = "watchfiles" },
]
[package.optional-dependencies]
anthropic = [
{ name = "livekit-plugins-anthropic" },
]
assemblyai = [
{ name = "livekit-plugins-assemblyai" },
]
aws = [
{ name = "livekit-plugins-aws" },
]
azure = [
{ name = "livekit-plugins-azure" },
]
bey = [
{ name = "livekit-plugins-bey" },
]
cartesia = [
{ name = "livekit-plugins-cartesia" },
]
clova = [
{ name = "livekit-plugins-clova" },
]
codecs = [
{ name = "av" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
deepgram = [
{ name = "livekit-plugins-deepgram" },
]
elevenlabs = [
{ name = "livekit-plugins-elevenlabs" },
]
fal = [
{ name = "livekit-plugins-fal" },
]
gladia = [
{ name = "livekit-plugins-gladia" },
]
google = [
{ name = "livekit-plugins-google" },
]
groq = [
{ name = "livekit-plugins-groq" },
]
images = [
{ name = "pillow" },
]
neuphonic = [
{ name = "livekit-plugins-neuphonic" },
]
nltk = [
{ name = "livekit-plugins-nltk" },
]
openai = [
{ name = "livekit-plugins-openai" },
]
playai = [
{ name = "livekit-plugins-playai" },
]
resemble = [
{ name = "livekit-plugins-resemble" },
]
rime = [
{ name = "livekit-plugins-rime" },
]
silero = [
{ name = "livekit-plugins-silero" },
]
speechify = [
{ name = "livekit-plugins-speechify" },
]
speechmatics = [
{ name = "livekit-plugins-speechmatics" },
]
turn-detector = [
{ name = "livekit-plugins-turn-detector" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "~=3.10" },
{ name = "av", specifier = ">=12.0.0" },
{ name = "av", marker = "extra == 'codecs'", specifier = ">=12.0.0" },
{ name = "click", specifier = "~=8.1" },
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "docstring-parser", specifier = ">=0.16" },
{ name = "eval-type-backport" },
{ name = "livekit", specifier = ">=1.0.6,<2" },
{ name = "livekit-api", specifier = ">=1.0.2,<2" },
{ name = "livekit-plugins-anthropic", marker = "extra == 'anthropic'", editable = "livekit-plugins/livekit-plugins-anthropic" },
{ name = "livekit-plugins-assemblyai", marker = "extra == 'assemblyai'", editable = "livekit-plugins/livekit-plugins-assemblyai" },
{ name = "livekit-plugins-aws", marker = "extra == 'aws'", editable = "livekit-plugins/livekit-plugins-aws" },
{ name = "livekit-plugins-azure", marker = "extra == 'azure'", editable = "livekit-plugins/livekit-plugins-azure" },
{ name = "livekit-plugins-bey", marker = "extra == 'bey'", editable = "livekit-plugins/livekit-plugins-bey" },
{ name = "livekit-plugins-cartesia", marker = "extra == 'cartesia'", editable = "livekit-plugins/livekit-plugins-cartesia" },
{ name = "livekit-plugins-clova", marker = "extra == 'clova'", editable = "livekit-plugins/livekit-plugins-clova" },
{ name = "livekit-plugins-deepgram", marker = "extra == 'deepgram'", editable = "livekit-plugins/livekit-plugins-deepgram" },
{ name = "livekit-plugins-elevenlabs", marker = "extra == 'elevenlabs'", editable = "livekit-plugins/livekit-plugins-elevenlabs" },
{ name = "livekit-plugins-fal", marker = "extra == 'fal'", editable = "livekit-plugins/livekit-plugins-fal" },
{ name = "livekit-plugins-gladia", marker = "extra == 'gladia'", editable = "livekit-plugins/livekit-plugins-gladia" },
{ name = "livekit-plugins-google", marker = "extra == 'google'", editable = "livekit-plugins/livekit-plugins-google" },
{ name = "livekit-plugins-groq", marker = "extra == 'groq'", editable = "livekit-plugins/livekit-plugins-groq" },
{ name = "livekit-plugins-neuphonic", marker = "extra == 'neuphonic'", editable = "livekit-plugins/livekit-plugins-neuphonic" },
{ name = "livekit-plugins-nltk", marker = "extra == 'nltk'", editable = "livekit-plugins/livekit-plugins-nltk" },
{ name = "livekit-plugins-openai", marker = "extra == 'openai'", editable = "livekit-plugins/livekit-plugins-openai" },
{ name = "livekit-plugins-playai", marker = "extra == 'playai'", editable = "livekit-plugins/livekit-plugins-playai" },
{ name = "livekit-plugins-resemble", marker = "extra == 'resemble'", editable = "livekit-plugins/livekit-plugins-resemble" },
{ name = "livekit-plugins-rime", marker = "extra == 'rime'", editable = "livekit-plugins/livekit-plugins-rime" },
{ name = "livekit-plugins-silero", marker = "extra == 'silero'", editable = "livekit-plugins/livekit-plugins-silero" },
{ name = "livekit-plugins-speechify", marker = "extra == 'speechify'", editable = "livekit-plugins/livekit-plugins-speechify" },
{ name = "livekit-plugins-speechmatics", marker = "extra == 'speechmatics'", editable = "livekit-plugins/livekit-plugins-speechmatics" },
{ name = "livekit-plugins-turn-detector", marker = "extra == 'turn-detector'", editable = "livekit-plugins/livekit-plugins-turn-detector" },
{ name = "livekit-protocol", specifier = "~=1.0" },
{ name = "nest-asyncio", specifier = ">=1.6.0" },
{ name = "numpy", specifier = ">=1.26.0" },
{ name = "numpy", marker = "extra == 'codecs'", specifier = ">=1.26.0" },
{ name = "pillow", marker = "extra == 'images'", specifier = ">=10.3.0" },
{ name = "protobuf", specifier = ">=3" },
{ name = "psutil", specifier = ">=7.0" },
{ name = "pydantic", specifier = ">=2.0,<3" },
{ name = "pyjwt", specifier = ">=2.0" },
{ name = "sounddevice", specifier = ">=0.5" },
{ name = "types-protobuf", specifier = ">=4,<5" },
{ name = "typing-extensions", specifier = ">=4.12" },
{ name = "watchfiles", specifier = ">=1.0" },
]
provides-extras = ["anthropic", "assemblyai", "aws", "azure", "bey", "cartesia", "clova", "codecs", "deepgram", "elevenlabs", "fal", "gladia", "google", "groq", "images", "neuphonic", "nltk", "openai", "playai", "resemble", "rime", "silero", "speechify", "speechmatics", "turn-detector"]
[[package]]
name = "livekit-api"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit-protocol" },
{ name = "protobuf" },
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/58/19b21a9bf9011a870be0aa7cccf9188f2ae644247d7da8f20935bbb2aa5b/livekit_api-1.0.2.tar.gz", hash = "sha256:5a6726a24761af046bdb4ae32b572d28df728a0e6824805d86783fa94c59574f", size = 14803 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/a3/d7eda9924316fcc0b9287ae671094f241c25fa13bc24f18e9c708e58d6b2/livekit_api-1.0.2-py3-none-any.whl", hash = "sha256:81ea2b330a7182c77f3cf307dfcff7df7d0d70524055bfb02be23ac8a45b8c6a", size = 17251 },
]
[[package]]
name = "livekit-plugins-anthropic"
source = { editable = "livekit-plugins/livekit-plugins-anthropic" }
dependencies = [
{ name = "anthropic" },
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.34" },
{ name = "livekit-agents", editable = "livekit-agents" },
]
[[package]]
name = "livekit-plugins-assemblyai"
source = { editable = "livekit-plugins/livekit-plugins-assemblyai" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-aws"
source = { editable = "livekit-plugins/livekit-plugins-aws" }
dependencies = [
{ name = "aioboto3" },
{ name = "amazon-transcribe" },
{ name = "boto3" },
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [
{ name = "aioboto3", specifier = "==14.1.0" },
{ name = "amazon-transcribe", specifier = "==0.6.2" },
{ name = "boto3", specifier = "==1.37.1" },
{ name = "livekit-agents", editable = "livekit-agents" },
]
[[package]]
name = "livekit-plugins-azure"
source = { editable = "livekit-plugins/livekit-plugins-azure" }
dependencies = [
{ name = "azure-cognitiveservices-speech" },
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [
{ name = "azure-cognitiveservices-speech", specifier = ">=1.43.0" },
{ name = "livekit-agents", editable = "livekit-agents" },
]
[[package]]
name = "livekit-plugins-bey"
source = { editable = "livekit-plugins/livekit-plugins-bey" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-cartesia"
source = { editable = "livekit-plugins/livekit-plugins-cartesia" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-clova"
source = { editable = "livekit-plugins/livekit-plugins-clova" }
dependencies = [
{ name = "livekit-agents" },
{ name = "pydub" },
]
[package.metadata]
requires-dist = [
{ name = "livekit-agents", editable = "livekit-agents" },
{ name = "pydub", specifier = "~=0.25.1" },
]
[[package]]
name = "livekit-plugins-deepgram"
source = { editable = "livekit-plugins/livekit-plugins-deepgram" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs"] },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" },
{ name = "numpy", specifier = ">=1.26" },
]
[[package]]
name = "livekit-plugins-elevenlabs"
source = { editable = "livekit-plugins/livekit-plugins-elevenlabs" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs"] },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-fal"
source = { editable = "livekit-plugins/livekit-plugins-fal" }
dependencies = [
{ name = "fal-client" },
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [
{ name = "fal-client" },
{ name = "livekit-agents", editable = "livekit-agents" },
]
[[package]]
name = "livekit-plugins-gladia"
source = { editable = "livekit-plugins/livekit-plugins-gladia" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit-agents", extra = ["codecs"] },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.8.0" },
{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" },
{ name = "numpy", specifier = ">=1.26" },
]
[[package]]
name = "livekit-plugins-google"
source = { editable = "livekit-plugins/livekit-plugins-google" }
dependencies = [
{ name = "google-auth" },
{ name = "google-cloud-speech" },
{ name = "google-cloud-texttospeech" },
{ name = "google-genai" },
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [
{ name = "google-auth", specifier = ">=2,<3" },
{ name = "google-cloud-speech", specifier = ">=2,<3" },
{ name = "google-cloud-texttospeech", specifier = ">=2,<3" },
{ name = "google-genai", specifier = ">=1.11.0" },
{ name = "livekit-agents", editable = "livekit-agents" },
]
[[package]]
name = "livekit-plugins-groq"
source = { editable = "livekit-plugins/livekit-plugins-groq" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit" },
{ name = "livekit-agents", extra = ["codecs"] },
{ name = "livekit-plugins-openai" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp" },
{ name = "livekit" },
{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" },
{ name = "livekit-plugins-openai", editable = "livekit-plugins/livekit-plugins-openai" },
]
[[package]]
name = "livekit-plugins-minimal"
source = { editable = "livekit-plugins/livekit-plugins-minimal" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-neuphonic"
source = { editable = "livekit-plugins/livekit-plugins-neuphonic" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-nltk"
source = { editable = "livekit-plugins/livekit-plugins-nltk" }
dependencies = [
{ name = "livekit-agents" },
{ name = "nltk" },
]
[package.metadata]
requires-dist = [
{ name = "livekit-agents", editable = "livekit-agents" },
{ name = "nltk", specifier = ">=3.9.1,<4" },
]
[[package]]
name = "livekit-plugins-openai"
source = { editable = "livekit-plugins/livekit-plugins-openai" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs", "images"] },
{ name = "openai", extra = ["realtime"] },
]
[package.optional-dependencies]
vertex = [
{ name = "google-auth" },
]
[package.metadata]
requires-dist = [
{ name = "google-auth", marker = "extra == 'vertex'", specifier = ">=2.0.0" },
{ name = "livekit-agents", extras = ["codecs", "images"], editable = "livekit-agents" },
{ name = "openai", extras = ["realtime"], specifier = ">=1.68.2" },
]
provides-extras = ["vertex"]
[[package]]
name = "livekit-plugins-playai"
source = { editable = "livekit-plugins/livekit-plugins-playai" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit" },
{ name = "livekit-agents", extra = ["codecs"] },
{ name = "pyht" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp" },
{ name = "livekit" },
{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" },
{ name = "pyht", specifier = ">=0.1.14" },
]
[[package]]
name = "livekit-plugins-resemble"
source = { editable = "livekit-plugins/livekit-plugins-resemble" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-rime"
source = { editable = "livekit-plugins/livekit-plugins-rime" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs"] },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-silero"
source = { editable = "livekit-plugins/livekit-plugins-silero" }
dependencies = [
{ name = "livekit-agents" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "onnxruntime", version = "1.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "onnxruntime", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "livekit-agents", editable = "livekit-agents" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "onnxruntime", specifier = ">=1.18" },
]
[[package]]
name = "livekit-plugins-speechify"
source = { editable = "livekit-plugins/livekit-plugins-speechify" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs"] },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", extras = ["codecs"], editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-speechmatics"
source = { editable = "livekit-plugins/livekit-plugins-speechmatics" }
dependencies = [
{ name = "livekit-agents" },
]
[package.metadata]
requires-dist = [{ name = "livekit-agents", editable = "livekit-agents" }]
[[package]]
name = "livekit-plugins-turn-detector"
source = { editable = "livekit-plugins/livekit-plugins-turn-detector" }
dependencies = [
{ name = "jinja2" },
{ name = "livekit-agents" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "onnxruntime", version = "1.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "onnxruntime", version = "1.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "transformers" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2" },
{ name = "livekit-agents", editable = "livekit-agents" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "onnxruntime", specifier = ">=1.18" },
{ name = "transformers", specifier = ">=4.47.1" },
]
[[package]]
name = "livekit-protocol"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/1e/1ff940a3407dbde172c4c78431247c30a7f932abf2063aab1ef3c9234075/livekit_protocol-1.0.1.tar.gz", hash = "sha256:98619b02a09f2e27600d7639e4b3c4846f09c2b4fac3229dbfc20318d7d92bed", size = 55446 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/d6/362e267e74dd58cd3bb53877661898994bcc9bdb0bdbd0e4792bf814f338/livekit_protocol-1.0.1-py3-none-any.whl", hash = "sha256:0681c9d1be0c444345bbf0cf581c91a691a5c849ec6dbb26a29d9f6d6dd3e6b9", size = 65712 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 },
{ url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 },
{ url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 },
{ url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 },
{ url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 },
{ url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 },
{ url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 },
{ url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 },
{ url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 },
{ url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 },
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 },
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 },
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 },
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 },
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 },
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 },
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 },
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 },
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
{ url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 },
{ url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 },
{ url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 },
{ url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 },
{ url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 },
{ url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 },
{ url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 },
{ url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 },
{ url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 },
{ url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 },
]
[[package]]
name = "multidict"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/68/259dee7fd14cf56a17c554125e534f6274c2860159692a414d0b402b9a6d/multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", size = 48628 },
{ url = "https://files.pythonhosted.org/packages/50/79/53ba256069fe5386a4a9e80d4e12857ced9de295baf3e20c68cdda746e04/multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", size = 29327 },
{ url = "https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", size = 29689 },
{ url = "https://files.pythonhosted.org/packages/71/45/70bac4f87438ded36ad4793793c0095de6572d433d98575a5752629ef549/multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", size = 126639 },
{ url = "https://files.pythonhosted.org/packages/80/cf/17f35b3b9509b4959303c05379c4bfb0d7dd05c3306039fc79cf035bbac0/multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", size = 134315 },
{ url = "https://files.pythonhosted.org/packages/ef/1f/652d70ab5effb33c031510a3503d4d6efc5ec93153562f1ee0acdc895a57/multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", size = 129471 },
{ url = "https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", size = 124585 },
{ url = "https://files.pythonhosted.org/packages/87/56/e6ee5459894c7e554b57ba88f7257dc3c3d2d379cb15baaa1e265b8c6165/multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", size = 116957 },
{ url = "https://files.pythonhosted.org/packages/36/9e/616ce5e8d375c24b84f14fc263c7ef1d8d5e8ef529dbc0f1df8ce71bb5b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db", size = 128609 },
{ url = "https://files.pythonhosted.org/packages/8c/4f/4783e48a38495d000f2124020dc96bacc806a4340345211b1ab6175a6cb4/multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", size = 123016 },
{ url = "https://files.pythonhosted.org/packages/3e/b3/4950551ab8fc39862ba5e9907dc821f896aa829b4524b4deefd3e12945ab/multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", size = 133542 },
{ url = "https://files.pythonhosted.org/packages/96/4d/f0ce6ac9914168a2a71df117935bb1f1781916acdecbb43285e225b484b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", size = 130163 },
{ url = "https://files.pythonhosted.org/packages/be/72/17c9f67e7542a49dd252c5ae50248607dfb780bcc03035907dafefb067e3/multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", size = 126832 },
{ url = "https://files.pythonhosted.org/packages/71/9f/72d719e248cbd755c8736c6d14780533a1606ffb3fbb0fbd77da9f0372da/multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", size = 26402 },
{ url = "https://files.pythonhosted.org/packages/04/5a/d88cd5d00a184e1ddffc82aa2e6e915164a6d2641ed3606e766b5d2f275a/multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", size = 28800 },
{ url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 },
{ url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 },
{ url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 },
{ url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 },
{ url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 },
{ url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 },
{ url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 },
{ url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 },
{ url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 },
{ url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 },
{ url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 },
{ url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 },
{ url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 },
{ url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 },
{ url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 },
{ url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 },
{ url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 },
{ url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 },
{ url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 },
{ url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 },
{ url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 },
{ url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 },
{ url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 },
{ url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 },
{ url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 },
{ url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 },
{ url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 },
{ url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 },
{ url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 },
{ url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 },
{ url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 },
{ url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 },
{ url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 },
{ url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 },
{ url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 },
{ url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 },
{ url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 },
{ url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 },
{ url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 },
{ url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 },
{ url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 },
{ url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 },
{ url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 },
{ url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 },
{ url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 },
{ url = "https://files.pythonhosted.org/packages/e7/c9/9e153a6572b38ac5ff4434113af38acf8d5e9957897cdb1f513b3d6614ed/multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c", size = 48550 },
{ url = "https://files.pythonhosted.org/packages/76/f5/79565ddb629eba6c7f704f09a09df085c8dc04643b12506f10f718cee37a/multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1", size = 29298 },
{ url = "https://files.pythonhosted.org/packages/60/1b/9851878b704bc98e641a3e0bce49382ae9e05743dac6d97748feb5b7baba/multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c", size = 29641 },
{ url = "https://files.pythonhosted.org/packages/89/87/d451d45aab9e422cb0fb2f7720c31a4c1d3012c740483c37f642eba568fb/multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c", size = 126202 },
{ url = "https://files.pythonhosted.org/packages/fa/b4/27cbe9f3e2e469359887653f2e45470272eef7295139916cc21107c6b48c/multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f", size = 133925 },
{ url = "https://files.pythonhosted.org/packages/4d/a3/afc841899face8adfd004235ce759a37619f6ec99eafd959650c5ce4df57/multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875", size = 129039 },
{ url = "https://files.pythonhosted.org/packages/5e/41/0d0fb18c1ad574f807196f5f3d99164edf9de3e169a58c6dc2d6ed5742b9/multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255", size = 124072 },
{ url = "https://files.pythonhosted.org/packages/00/22/defd7a2e71a44e6e5b9a5428f972e5b572e7fe28e404dfa6519bbf057c93/multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30", size = 116532 },
{ url = "https://files.pythonhosted.org/packages/91/25/f7545102def0b1d456ab6449388eed2dfd822debba1d65af60194904a23a/multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057", size = 128173 },
{ url = "https://files.pythonhosted.org/packages/45/79/3dbe8d35fc99f5ea610813a72ab55f426cb9cf482f860fa8496e5409be11/multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657", size = 122654 },
{ url = "https://files.pythonhosted.org/packages/97/cb/209e735eeab96e1b160825b5d0b36c56d3862abff828fc43999bb957dcad/multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28", size = 133197 },
{ url = "https://files.pythonhosted.org/packages/e4/3a/a13808a7ada62808afccea67837a79d00ad6581440015ef00f726d064c2d/multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972", size = 129754 },
{ url = "https://files.pythonhosted.org/packages/77/dd/8540e139eafb240079242da8f8ffdf9d3f4b4ad1aac5a786cd4050923783/multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43", size = 126402 },
{ url = "https://files.pythonhosted.org/packages/86/99/e82e1a275d8b1ea16d3a251474262258dbbe41c05cce0c01bceda1fc8ea5/multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada", size = 26421 },
{ url = "https://files.pythonhosted.org/packages/86/1c/9fa630272355af7e4446a2c7550c259f11ee422ab2d30ff90a0a71cf3d9e/multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a", size = 28791 },
{ url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 },
]
[[package]]
name = "mypy"
version = "1.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433 },
{ url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472 },
{ url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424 },
{ url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450 },
{ url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765 },
{ url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701 },
{ url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 },
{ url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 },
{ url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 },
{ url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 },
{ url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 },
{ url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 },
{ url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 },
{ url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 },
{ url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 },
{ url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 },
{ url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 },
{ url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 },
{ url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 },
{ url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 },
{ url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 },
{ url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 },
{ url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 },
{ url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 },
{ url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129 },
{ url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335 },
{ url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935 },
{ url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827 },
{ url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924 },
{ url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176 },
{ url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 },
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
]
[[package]]
name = "nest-asyncio"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 },
]
[[package]]
name = "nltk"
version = "3.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "joblib" },
{ name = "regex" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/87/db8be88ad32c2d042420b6fd9ffd4a149f9a0d7f0e86b3f543be2eeeedd2/nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868", size = 2904691 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/66/7d9e26593edda06e8cb531874633f7c2372279c3b0f46235539fe546df8b/nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1", size = 1505442 },
]
[[package]]
name = "numpy"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245 },
{ url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540 },
{ url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623 },
{ url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774 },
{ url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081 },
{ url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451 },
{ url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572 },
{ url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722 },
{ url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170 },
{ url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558 },
{ url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137 },
{ url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552 },
{ url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957 },
{ url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573 },
{ url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330 },
{ url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895 },
{ url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253 },
{ url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074 },
{ url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640 },
{ url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230 },
{ url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803 },
{ url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835 },
{ url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499 },
{ url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497 },
{ url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158 },
{ url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173 },
{ url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174 },
{ url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701 },
{ url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313 },
{ url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179 },
{ url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942 },
{ url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512 },
{ url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976 },
{ url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494 },
{ url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596 },
{ url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099 },
{ url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823 },
{ url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424 },
{ url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809 },
{ url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314 },
{ url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288 },
{ url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793 },
{ url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885 },
{ url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784 },
]
[[package]]
name = "numpy"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/90/8956572f5c4ae52201fdec7ba2044b2c882832dcec7d5d0922c9e9acf2de/numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020", size = 20262700 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/e1/1816d5d527fa870b260a1c2c5904d060caad7515637bd54f495a5ce13ccd/numpy-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbc6472e01952d3d1b2772b720428f8b90e2deea8344e854df22b0618e9cce71", size = 21232911 },
{ url = "https://files.pythonhosted.org/packages/29/46/9f25dc19b359f10c0e52b6bac25d3181eb1f4b4d04c9846a32cf5ea52762/numpy-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdfe0c22692a30cd830c0755746473ae66c4a8f2e7bd508b35fb3b6a0813d787", size = 14371955 },
{ url = "https://files.pythonhosted.org/packages/72/d7/de941296e6b09a5c81d3664ad912f1496a0ecdd2f403318e5e35604ff70f/numpy-2.2.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:e37242f5324ffd9f7ba5acf96d774f9276aa62a966c0bad8dae692deebec7716", size = 5410476 },
{ url = "https://files.pythonhosted.org/packages/36/ce/55f685995110f8a268fdca0f198c9a84fa87b39512830965cc1087af6391/numpy-2.2.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:95172a21038c9b423e68be78fd0be6e1b97674cde269b76fe269a5dfa6fadf0b", size = 6945730 },
{ url = "https://files.pythonhosted.org/packages/4f/84/abdb9f6e22576d89c259401c3234d4755b322539491bbcffadc8bcb120d3/numpy-2.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5b47c440210c5d1d67e1cf434124e0b5c395eee1f5806fdd89b553ed1acd0a3", size = 14350752 },
{ url = "https://files.pythonhosted.org/packages/e9/88/3870cfa9bef4dffb3a326507f430e6007eeac258ebeef6b76fc542aef66d/numpy-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0391ea3622f5c51a2e29708877d56e3d276827ac5447d7f45e9bc4ade8923c52", size = 16399386 },
{ url = "https://files.pythonhosted.org/packages/02/10/3f629682dd0b457525c131945329c4e81e2dadeb11256e6ce4c9a1a6fb41/numpy-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f6b3dfc7661f8842babd8ea07e9897fe3d9b69a1d7e5fbb743e4160f9387833b", size = 15561826 },
{ url = "https://files.pythonhosted.org/packages/da/18/fd35673ba9751eba449d4ce5d24d94e3b612cdbfba79348da71488c0b7ac/numpy-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ad78ce7f18ce4e7df1b2ea4019b5817a2f6a8a16e34ff2775f646adce0a5027", size = 18188593 },
{ url = "https://files.pythonhosted.org/packages/ce/4c/c0f897b580ea59484b4cc96a441fea50333b26675a60a1421bc912268b5f/numpy-2.2.3-cp310-cp310-win32.whl", hash = "sha256:5ebeb7ef54a7be11044c33a17b2624abe4307a75893c001a4800857956b41094", size = 6590421 },
{ url = "https://files.pythonhosted.org/packages/e5/5b/aaabbfc7060c5c8f0124c5deb5e114a3b413a548bbc64e372c5b5db36165/numpy-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:596140185c7fa113563c67c2e894eabe0daea18cf8e33851738c19f70ce86aeb", size = 12925667 },
{ url = "https://files.pythonhosted.org/packages/96/86/453aa3949eab6ff54e2405f9cb0c01f756f031c3dc2a6d60a1d40cba5488/numpy-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16372619ee728ed67a2a606a614f56d3eabc5b86f8b615c79d01957062826ca8", size = 21237256 },
{ url = "https://files.pythonhosted.org/packages/20/c3/93ecceadf3e155d6a9e4464dd2392d8d80cf436084c714dc8535121c83e8/numpy-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5521a06a3148686d9269c53b09f7d399a5725c47bbb5b35747e1cb76326b714b", size = 14408049 },
{ url = "https://files.pythonhosted.org/packages/8d/29/076999b69bd9264b8df5e56f2be18da2de6b2a2d0e10737e5307592e01de/numpy-2.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7c8dde0ca2f77828815fd1aedfdf52e59071a5bae30dac3b4da2a335c672149a", size = 5408655 },
{ url = "https://files.pythonhosted.org/packages/e2/a7/b14f0a73eb0fe77cb9bd5b44534c183b23d4229c099e339c522724b02678/numpy-2.2.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:77974aba6c1bc26e3c205c2214f0d5b4305bdc719268b93e768ddb17e3fdd636", size = 6949996 },
{ url = "https://files.pythonhosted.org/packages/72/2f/8063da0616bb0f414b66dccead503bd96e33e43685c820e78a61a214c098/numpy-2.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d42f9c36d06440e34226e8bd65ff065ca0963aeecada587b937011efa02cdc9d", size = 14355789 },
{ url = "https://files.pythonhosted.org/packages/e6/d7/3cd47b00b8ea95ab358c376cf5602ad21871410950bc754cf3284771f8b6/numpy-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2712c5179f40af9ddc8f6727f2bd910ea0eb50206daea75f58ddd9fa3f715bb", size = 16411356 },
{ url = "https://files.pythonhosted.org/packages/27/c0/a2379e202acbb70b85b41483a422c1e697ff7eee74db642ca478de4ba89f/numpy-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c8b0451d2ec95010d1db8ca733afc41f659f425b7f608af569711097fd6014e2", size = 15576770 },
{ url = "https://files.pythonhosted.org/packages/bc/63/a13ee650f27b7999e5b9e1964ae942af50bb25606d088df4229283eda779/numpy-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9b4a8148c57ecac25a16b0e11798cbe88edf5237b0df99973687dd866f05e1b", size = 18200483 },
{ url = "https://files.pythonhosted.org/packages/4c/87/e71f89935e09e8161ac9c590c82f66d2321eb163893a94af749dfa8a3cf8/numpy-2.2.3-cp311-cp311-win32.whl", hash = "sha256:1f45315b2dc58d8a3e7754fe4e38b6fce132dab284a92851e41b2b344f6441c5", size = 6588415 },
{ url = "https://files.pythonhosted.org/packages/b9/c6/cd4298729826af9979c5f9ab02fcaa344b82621e7c49322cd2d210483d3f/numpy-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f48ba6f6c13e5e49f3d3efb1b51c8193215c42ac82610a04624906a9270be6f", size = 12929604 },
{ url = "https://files.pythonhosted.org/packages/43/ec/43628dcf98466e087812142eec6d1c1a6c6bdfdad30a0aa07b872dc01f6f/numpy-2.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12c045f43b1d2915eca6b880a7f4a256f59d62df4f044788c8ba67709412128d", size = 20929458 },
{ url = "https://files.pythonhosted.org/packages/9b/c0/2f4225073e99a5c12350954949ed19b5d4a738f541d33e6f7439e33e98e4/numpy-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87eed225fd415bbae787f93a457af7f5990b92a334e346f72070bf569b9c9c95", size = 14115299 },
{ url = "https://files.pythonhosted.org/packages/ca/fa/d2c5575d9c734a7376cc1592fae50257ec95d061b27ee3dbdb0b3b551eb2/numpy-2.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:712a64103d97c404e87d4d7c47fb0c7ff9acccc625ca2002848e0d53288b90ea", size = 5145723 },
{ url = "https://files.pythonhosted.org/packages/eb/dc/023dad5b268a7895e58e791f28dc1c60eb7b6c06fcbc2af8538ad069d5f3/numpy-2.2.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a5ae282abe60a2db0fd407072aff4599c279bcd6e9a2475500fc35b00a57c532", size = 6678797 },
{ url = "https://files.pythonhosted.org/packages/3f/19/bcd641ccf19ac25abb6fb1dcd7744840c11f9d62519d7057b6ab2096eb60/numpy-2.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5266de33d4c3420973cf9ae3b98b54a2a6d53a559310e3236c4b2b06b9c07d4e", size = 14067362 },
{ url = "https://files.pythonhosted.org/packages/39/04/78d2e7402fb479d893953fb78fa7045f7deb635ec095b6b4f0260223091a/numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe", size = 16116679 },
{ url = "https://files.pythonhosted.org/packages/d0/a1/e90f7aa66512be3150cb9d27f3d9995db330ad1b2046474a13b7040dfd92/numpy-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34c1b7e83f94f3b564b35f480f5652a47007dd91f7c839f404d03279cc8dd021", size = 15264272 },
{ url = "https://files.pythonhosted.org/packages/dc/b6/50bd027cca494de4fa1fc7bf1662983d0ba5f256fa0ece2c376b5eb9b3f0/numpy-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4d8335b5f1b6e2bce120d55fb17064b0262ff29b459e8493d1785c18ae2553b8", size = 17880549 },
{ url = "https://files.pythonhosted.org/packages/96/30/f7bf4acb5f8db10a96f73896bdeed7a63373137b131ca18bd3dab889db3b/numpy-2.2.3-cp312-cp312-win32.whl", hash = "sha256:4d9828d25fb246bedd31e04c9e75714a4087211ac348cb39c8c5f99dbb6683fe", size = 6293394 },
{ url = "https://files.pythonhosted.org/packages/42/6e/55580a538116d16ae7c9aa17d4edd56e83f42126cb1dfe7a684da7925d2c/numpy-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d", size = 12626357 },
{ url = "https://files.pythonhosted.org/packages/0e/8b/88b98ed534d6a03ba8cddb316950fe80842885709b58501233c29dfa24a9/numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba", size = 20916001 },
{ url = "https://files.pythonhosted.org/packages/d9/b4/def6ec32c725cc5fbd8bdf8af80f616acf075fe752d8a23e895da8c67b70/numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50", size = 14130721 },
{ url = "https://files.pythonhosted.org/packages/20/60/70af0acc86495b25b672d403e12cb25448d79a2b9658f4fc45e845c397a8/numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1", size = 5130999 },
{ url = "https://files.pythonhosted.org/packages/2e/69/d96c006fb73c9a47bcb3611417cf178049aae159afae47c48bd66df9c536/numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5", size = 6665299 },
{ url = "https://files.pythonhosted.org/packages/5a/3f/d8a877b6e48103733ac224ffa26b30887dc9944ff95dffdfa6c4ce3d7df3/numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2", size = 14064096 },
{ url = "https://files.pythonhosted.org/packages/e4/43/619c2c7a0665aafc80efca465ddb1f260287266bdbdce517396f2f145d49/numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1", size = 16114758 },
{ url = "https://files.pythonhosted.org/packages/d9/79/ee4fe4f60967ccd3897aa71ae14cdee9e3c097e3256975cc9575d393cb42/numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304", size = 15259880 },
{ url = "https://files.pythonhosted.org/packages/fb/c8/8b55cf05db6d85b7a7d414b3d1bd5a740706df00bfa0824a08bf041e52ee/numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d", size = 17876721 },
{ url = "https://files.pythonhosted.org/packages/21/d6/b4c2f0564b7dcc413117b0ffbb818d837e4b29996b9234e38b2025ed24e7/numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693", size = 6290195 },
{ url = "https://files.pythonhosted.org/packages/97/e7/7d55a86719d0de7a6a597949f3febefb1009435b79ba510ff32f05a8c1d7/numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b", size = 12619013 },
{ url = "https://files.pythonhosted.org/packages/a6/1f/0b863d5528b9048fd486a56e0b97c18bf705e88736c8cea7239012119a54/numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890", size = 20944621 },
{ url = "https://files.pythonhosted.org/packages/aa/99/b478c384f7a0a2e0736177aafc97dc9152fc036a3fdb13f5a3ab225f1494/numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c", size = 14142502 },
{ url = "https://files.pythonhosted.org/packages/fb/61/2d9a694a0f9cd0a839501d362de2a18de75e3004576a3008e56bdd60fcdb/numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94", size = 5176293 },
{ url = "https://files.pythonhosted.org/packages/33/35/51e94011b23e753fa33f891f601e5c1c9a3d515448659b06df9d40c0aa6e/numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0", size = 6691874 },
{ url = "https://files.pythonhosted.org/packages/ff/cf/06e37619aad98a9d03bd8d65b8e3041c3a639be0f5f6b0a0e2da544538d4/numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610", size = 14036826 },
{ url = "https://files.pythonhosted.org/packages/0c/93/5d7d19955abd4d6099ef4a8ee006f9ce258166c38af259f9e5558a172e3e/numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76", size = 16096567 },
{ url = "https://files.pythonhosted.org/packages/af/53/d1c599acf7732d81f46a93621dab6aa8daad914b502a7a115b3f17288ab2/numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a", size = 15242514 },
{ url = "https://files.pythonhosted.org/packages/53/43/c0f5411c7b3ea90adf341d05ace762dad8cb9819ef26093e27b15dd121ac/numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf", size = 17872920 },
{ url = "https://files.pythonhosted.org/packages/5b/57/6dbdd45ab277aff62021cafa1e15f9644a52f5b5fc840bc7591b4079fb58/numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef", size = 6346584 },
{ url = "https://files.pythonhosted.org/packages/97/9b/484f7d04b537d0a1202a5ba81c6f53f1846ae6c63c2127f8df869ed31342/numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082", size = 12706784 },
{ url = "https://files.pythonhosted.org/packages/0a/b5/a7839f5478be8f859cb880f13d90fcfe4b0ec7a9ebaff2bcc30d96760596/numpy-2.2.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c2ec8a0f51d60f1e9c0c5ab116b7fc104b165ada3f6c58abf881cb2eb16044d", size = 21064244 },
{ url = "https://files.pythonhosted.org/packages/29/e8/5da32ffcaa7a72f7ecd82f90c062140a061eb823cb88e90279424e515cf4/numpy-2.2.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:ed2cf9ed4e8ebc3b754d398cba12f24359f018b416c380f577bbae112ca52fc9", size = 6809418 },
{ url = "https://files.pythonhosted.org/packages/a8/a9/68aa7076c7656a7308a0f73d0a2ced8c03f282c9fd98fa7ce21c12634087/numpy-2.2.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39261798d208c3095ae4f7bc8eaeb3481ea8c6e03dc48028057d3cbdbdb8937e", size = 16215461 },
{ url = "https://files.pythonhosted.org/packages/17/7f/d322a4125405920401450118dbdc52e0384026bd669939484670ce8b2ab9/numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4", size = 12839607 },
]
[[package]]
name = "onnxruntime"
version = "1.19.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "coloredlogs", marker = "python_full_version < '3.10'" },
{ name = "flatbuffers", marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "packaging", marker = "python_full_version < '3.10'" },
{ name = "protobuf", marker = "python_full_version < '3.10'" },
{ name = "sympy", marker = "python_full_version < '3.10'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/18/272d3d7406909141d3c9943796e3e97cafa53f4342d9231c0cfd8cb05702/onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e", size = 16776408 },
{ url = "https://files.pythonhosted.org/packages/d8/d3/eb93f4ae511cfc725d0c69e07008800f8ac018de19ea1e497b306f174ccc/onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666", size = 11491779 },
{ url = "https://files.pythonhosted.org/packages/ca/4b/ce5958074abe4b6e8d1da9c10e443e01a681558a9ec17e5cc7619438e094/onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3a4ce906105d99ebbe817f536d50a91ed8a4d1592553f49b3c23c4be2560ae6", size = 13170428 },
{ url = "https://files.pythonhosted.org/packages/ce/0f/6df82dfe02467d12adbaa05c2bd17519c29c7df531ed600231f0c741ad22/onnxruntime-1.19.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3", size = 9591305 },
{ url = "https://files.pythonhosted.org/packages/3c/d8/68b63dc86b502169d017a86fe8bc718f4b0055ef1f6895bfaddd04f2eead/onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5", size = 11084902 },
{ url = "https://files.pythonhosted.org/packages/f0/ff/77bee5df55f034ee81d2e1bc58b2b8511b9c54f06ce6566cb562c5d95aa5/onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd", size = 16779187 },
{ url = "https://files.pythonhosted.org/packages/f3/78/e29f5fb76e0f6524f3520e8e5b9d53282784b45d14068c5112db9f712b0a/onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6", size = 11496005 },
{ url = "https://files.pythonhosted.org/packages/60/ce/be4152da5c1030ab5a159a4a792ed9abad6ba498d79ef0aeba593ff7b5bf/onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36511dc07c5c964b916697e42e366fa43c48cdb3d3503578d78cef30417cb84", size = 13167809 },
{ url = "https://files.pythonhosted.org/packages/e1/00/9740a074eb0e0a21ff13a2c4f32aecc5b21110b2c9b9177d8ac132b66e2d/onnxruntime-1.19.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7", size = 9591445 },
{ url = "https://files.pythonhosted.org/packages/1e/f5/9d995a685f97508b3254f17015b4a78641b0625e79480a7aed7a7a105d7c/onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8", size = 11085695 },
{ url = "https://files.pythonhosted.org/packages/f2/a5/2a02687a88fc8a2507bef65876c90e96b9f8de5ba1f810acbf67c140fc67/onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed", size = 16790434 },
{ url = "https://files.pythonhosted.org/packages/47/64/da42254ec14452cad2cdd4cf407094841c0a378c0d08944e9a36172197e9/onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168", size = 11486028 },
{ url = "https://files.pythonhosted.org/packages/b2/92/3574f6836f33b1b25f272293e72538c38451b12c2d9aa08630bb6bc0f057/onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:477b93df4db467e9cbf34051662a4b27c18e131fa1836e05974eae0d6e4cf29b", size = 13175054 },
{ url = "https://files.pythonhosted.org/packages/ff/c9/8c37e413a830cac7f7dc094fffbd0c998c8bcb66a6f0b0a3201a49bc742b/onnxruntime-1.19.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147", size = 9592681 },
{ url = "https://files.pythonhosted.org/packages/44/c0/59768846533786a82cafb38d8d2f900ad666bc91f0ae634774d286fa3c47/onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857", size = 11086411 },
{ url = "https://files.pythonhosted.org/packages/52/33/52f81d9a10a027e77f139bab93213702002785c41d6ca254b90d83d7c525/onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848", size = 16776457 },
{ url = "https://files.pythonhosted.org/packages/88/e7/8263cff2ca837a8a1cefad1a3bf2e38d1701b4369f750507aa41eca66d2c/onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68", size = 11498178 },
{ url = "https://files.pythonhosted.org/packages/12/f4/39c395c98e9ecccb0751f80897a5d065d5070c69823b0c94e95b371b568c/onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fae4b4de45894b9ce7ae418c5484cbf0341db6813effec01bb2216091c52f7fb", size = 13170017 },
{ url = "https://files.pythonhosted.org/packages/49/bc/f52f14ee62f3a033585308ea99644d65fdce5fb008c73ddf82f0f0a4710d/onnxruntime-1.19.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3", size = 9591669 },
{ url = "https://files.pythonhosted.org/packages/93/b0/d88a23048c7f9033471cb667e9036fa1b0f6451d3a640a46314634a74a06/onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49", size = 11085591 },
]
[[package]]
name = "onnxruntime"
version = "1.21.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version == '3.10.*'",
]
dependencies = [
{ name = "coloredlogs", marker = "python_full_version >= '3.10'" },
{ name = "flatbuffers", marker = "python_full_version >= '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging", marker = "python_full_version >= '3.10'" },
{ name = "protobuf", marker = "python_full_version >= '3.10'" },
{ name = "sympy", marker = "python_full_version >= '3.10'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/b5/433e46baf8f31a84684f9d3446d8683473706e2810b6171e19beed88ecb9/onnxruntime-1.21.0-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:95513c9302bc8dd013d84148dcf3168e782a80cdbf1654eddc948a23147ccd3d", size = 33639595 },
{ url = "https://files.pythonhosted.org/packages/23/78/1ec7358f9c9de82299cb99a1a48bdb871b4180533cfe5900e2ede102668e/onnxruntime-1.21.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:635d4ab13ae0f150dd4c6ff8206fd58f1c6600636ecc796f6f0c42e4c918585b", size = 14159036 },
{ url = "https://files.pythonhosted.org/packages/eb/66/fcd3e1201f546c736b0050cb2e889296596ff7862f36bd17027fbef5f24d/onnxruntime-1.21.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d06bfa0dd5512bd164f25a2bf594b2e7c9eabda6fc064b684924f3e81bdab1b", size = 16000047 },
{ url = "https://files.pythonhosted.org/packages/29/eb/16abd29cdff9cb3237ba13adfafad20048c8f5a4a50b7e4689dd556c58d6/onnxruntime-1.21.0-cp310-cp310-win_amd64.whl", hash = "sha256:b0fc22d219791e0284ee1d9c26724b8ee3fbdea28128ef25d9507ad3b9621f23", size = 11758587 },
{ url = "https://files.pythonhosted.org/packages/df/34/fd780c62b3ec9268224ada4205a5256618553b8cc26d7205d3cf8aafde47/onnxruntime-1.21.0-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:8e16f8a79df03919810852fb46ffcc916dc87a9e9c6540a58f20c914c575678c", size = 33644022 },
{ url = "https://files.pythonhosted.org/packages/7b/df/622594b43d1a8644ac4d947f52e34a0e813b3d76a62af34667e343c34e98/onnxruntime-1.21.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9156cf6f8ee133d07a751e6518cf6f84ed37fbf8243156bd4a2c4ee6e073c8", size = 14159570 },
{ url = "https://files.pythonhosted.org/packages/f9/49/1e916e8d1d957a1432c1662ef2e94f3e4afab31f6f1888fb80d4da374a5d/onnxruntime-1.21.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a5d09815a9e209fa0cb20c2985b34ab4daeba7aea94d0f96b8751eb10403201", size = 16001965 },
{ url = "https://files.pythonhosted.org/packages/09/05/15ec0933f8543f85743571da9b3bf4397f71792c9d375f01f61c6019f130/onnxruntime-1.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:1d970dff1e2fa4d9c53f2787b3b7d0005596866e6a31997b41169017d1362dd0", size = 11759373 },
{ url = "https://files.pythonhosted.org/packages/ff/21/593c9bc56002a6d1ea7c2236f4a648e081ec37c8d51db2383a9e83a63325/onnxruntime-1.21.0-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:893d67c68ca9e7a58202fa8d96061ed86a5815b0925b5a97aef27b8ba246a20b", size = 33658780 },
{ url = "https://files.pythonhosted.org/packages/4a/b4/33ec675a8ac150478091262824413e5d4acc359e029af87f9152e7c1c092/onnxruntime-1.21.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37b7445c920a96271a8dfa16855e258dc5599235b41c7bbde0d262d55bcc105f", size = 14159975 },
{ url = "https://files.pythonhosted.org/packages/8b/08/eead6895ed83b56711ca6c0d31d82f109401b9937558b425509e497d6fb4/onnxruntime-1.21.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a04aafb802c1e5573ba4552f8babcb5021b041eb4cfa802c9b7644ca3510eca", size = 16019285 },
{ url = "https://files.pythonhosted.org/packages/77/39/e83d56e3c215713b5263cb4d4f0c69e3964bba11634233d8ae04fc7e6bf3/onnxruntime-1.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f801318476cd7003d636a5b392f7a37c08b6c8d2f829773f3c3887029e03f32", size = 11760975 },
{ url = "https://files.pythonhosted.org/packages/f2/25/93f65617b06c741a58eeac9e373c99df443b02a774f4cb6511889757c0da/onnxruntime-1.21.0-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:85718cbde1c2912d3a03e3b3dc181b1480258a229c32378408cace7c450f7f23", size = 33659581 },
{ url = "https://files.pythonhosted.org/packages/f9/03/6b6829ee8344490ab5197f39a6824499ed097d1fc8c85b1f91c0e6767819/onnxruntime-1.21.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94dff3a61538f3b7b0ea9a06bc99e1410e90509c76e3a746f039e417802a12ae", size = 14160534 },
{ url = "https://files.pythonhosted.org/packages/a6/81/e280ddf05f83ad5e0d066ef08e31515b17bd50bb52ef2ea713d9e455e67a/onnxruntime-1.21.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1e704b0eda5f2bbbe84182437315eaec89a450b08854b5a7762c85d04a28a0a", size = 16018947 },
{ url = "https://files.pythonhosted.org/packages/d3/ea/011dfc2536e46e2ea984d2c0256dc585ebb1352366dffdd98764f1f44ee4/onnxruntime-1.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:19b630c6a8956ef97fb7c94948b17691167aa1aaf07b5f214fa66c3e4136c108", size = 11760731 },
{ url = "https://files.pythonhosted.org/packages/47/6b/a00f31322e91c610c7825377ef0cad884483c30d1370b896d57e7032e912/onnxruntime-1.21.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3995c4a2d81719623c58697b9510f8de9fa42a1da6b4474052797b0d712324fe", size = 14172215 },
{ url = "https://files.pythonhosted.org/packages/58/4b/98214f13ac1cd675dfc2713ba47b5722f55ce4fba526d2b2826f2682a42e/onnxruntime-1.21.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36b18b8f39c0f84e783902112a0dd3c102466897f96d73bb83f6a6bff283a423", size = 15990612 },
]
[[package]]
name = "openai"
version = "1.68.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/6b/6b002d5d38794645437ae3ddb42083059d556558493408d39a0fcea608bc/openai-1.68.2.tar.gz", hash = "sha256:b720f0a95a1dbe1429c0d9bb62096a0d98057bcda82516f6e8af10284bdd5b19", size = 413429 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/34/cebce15f64eb4a3d609a83ac3568d43005cc9a1cba9d7fde5590fd415423/openai-1.68.2-py3-none-any.whl", hash = "sha256:24484cb5c9a33b58576fdc5acf0e5f92603024a4e39d0b99793dfa1eb14c2b36", size = 606073 },
]
[package.optional-dependencies]
realtime = [
{ name = "websockets" },
]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pillow"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/1c/2dcea34ac3d7bc96a1fd1bd0a6e06a57c67167fec2cff8d95d88229a8817/pillow-11.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8", size = 3229983 },
{ url = "https://files.pythonhosted.org/packages/14/ca/6bec3df25e4c88432681de94a3531cc738bd85dea6c7aa6ab6f81ad8bd11/pillow-11.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192", size = 3101831 },
{ url = "https://files.pythonhosted.org/packages/d4/2c/668e18e5521e46eb9667b09e501d8e07049eb5bfe39d56be0724a43117e6/pillow-11.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07dba04c5e22824816b2615ad7a7484432d7f540e6fa86af60d2de57b0fcee2", size = 4314074 },
{ url = "https://files.pythonhosted.org/packages/02/80/79f99b714f0fc25f6a8499ecfd1f810df12aec170ea1e32a4f75746051ce/pillow-11.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e267b0ed063341f3e60acd25c05200df4193e15a4a5807075cd71225a2386e26", size = 4394933 },
{ url = "https://files.pythonhosted.org/packages/81/aa/8d4ad25dc11fd10a2001d5b8a80fdc0e564ac33b293bdfe04ed387e0fd95/pillow-11.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd165131fd51697e22421d0e467997ad31621b74bfc0b75956608cb2906dda07", size = 4353349 },
{ url = "https://files.pythonhosted.org/packages/84/7a/cd0c3eaf4a28cb2a74bdd19129f7726277a7f30c4f8424cd27a62987d864/pillow-11.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:abc56501c3fd148d60659aae0af6ddc149660469082859fa7b066a298bde9482", size = 4476532 },
{ url = "https://files.pythonhosted.org/packages/8f/8b/a907fdd3ae8f01c7670dfb1499c53c28e217c338b47a813af8d815e7ce97/pillow-11.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:54ce1c9a16a9561b6d6d8cb30089ab1e5eb66918cb47d457bd996ef34182922e", size = 4279789 },
{ url = "https://files.pythonhosted.org/packages/6f/9a/9f139d9e8cccd661c3efbf6898967a9a337eb2e9be2b454ba0a09533100d/pillow-11.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73ddde795ee9b06257dac5ad42fcb07f3b9b813f8c1f7f870f402f4dc54b5269", size = 4413131 },
{ url = "https://files.pythonhosted.org/packages/a8/68/0d8d461f42a3f37432203c8e6df94da10ac8081b6d35af1c203bf3111088/pillow-11.1.0-cp310-cp310-win32.whl", hash = "sha256:3a5fe20a7b66e8135d7fd617b13272626a28278d0e578c98720d9ba4b2439d49", size = 2291213 },
{ url = "https://files.pythonhosted.org/packages/14/81/d0dff759a74ba87715509af9f6cb21fa21d93b02b3316ed43bda83664db9/pillow-11.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6123aa4a59d75f06e9dd3dac5bf8bc9aa383121bb3dd9a7a612e05eabc9961a", size = 2625725 },
{ url = "https://files.pythonhosted.org/packages/ce/1f/8d50c096a1d58ef0584ddc37e6f602828515219e9d2428e14ce50f5ecad1/pillow-11.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:a76da0a31da6fcae4210aa94fd779c65c75786bc9af06289cd1c184451ef7a65", size = 2375213 },
{ url = "https://files.pythonhosted.org/packages/dd/d6/2000bfd8d5414fb70cbbe52c8332f2283ff30ed66a9cde42716c8ecbe22c/pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457", size = 3229968 },
{ url = "https://files.pythonhosted.org/packages/d9/45/3fe487010dd9ce0a06adf9b8ff4f273cc0a44536e234b0fad3532a42c15b/pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35", size = 3101806 },
{ url = "https://files.pythonhosted.org/packages/e3/72/776b3629c47d9d5f1c160113158a7a7ad177688d3a1159cd3b62ded5a33a/pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2", size = 4322283 },
{ url = "https://files.pythonhosted.org/packages/e4/c2/e25199e7e4e71d64eeb869f5b72c7ddec70e0a87926398785ab944d92375/pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070", size = 4402945 },
{ url = "https://files.pythonhosted.org/packages/c1/ed/51d6136c9d5911f78632b1b86c45241c712c5a80ed7fa7f9120a5dff1eba/pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6", size = 4361228 },
{ url = "https://files.pythonhosted.org/packages/48/a4/fbfe9d5581d7b111b28f1d8c2762dee92e9821bb209af9fa83c940e507a0/pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1", size = 4484021 },
{ url = "https://files.pythonhosted.org/packages/39/db/0b3c1a5018117f3c1d4df671fb8e47d08937f27519e8614bbe86153b65a5/pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2", size = 4287449 },
{ url = "https://files.pythonhosted.org/packages/d9/58/bc128da7fea8c89fc85e09f773c4901e95b5936000e6f303222490c052f3/pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96", size = 4419972 },
{ url = "https://files.pythonhosted.org/packages/5f/bb/58f34379bde9fe197f51841c5bbe8830c28bbb6d3801f16a83b8f2ad37df/pillow-11.1.0-cp311-cp311-win32.whl", hash = "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f", size = 2291201 },
{ url = "https://files.pythonhosted.org/packages/3a/c6/fce9255272bcf0c39e15abd2f8fd8429a954cf344469eaceb9d0d1366913/pillow-11.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761", size = 2625686 },
{ url = "https://files.pythonhosted.org/packages/c8/52/8ba066d569d932365509054859f74f2a9abee273edcef5cd75e4bc3e831e/pillow-11.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71", size = 2375194 },
{ url = "https://files.pythonhosted.org/packages/95/20/9ce6ed62c91c073fcaa23d216e68289e19d95fb8188b9fb7a63d36771db8/pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a", size = 3226818 },
{ url = "https://files.pythonhosted.org/packages/b9/d8/f6004d98579a2596c098d1e30d10b248798cceff82d2b77aa914875bfea1/pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b", size = 3101662 },
{ url = "https://files.pythonhosted.org/packages/08/d9/892e705f90051c7a2574d9f24579c9e100c828700d78a63239676f960b74/pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3", size = 4329317 },
{ url = "https://files.pythonhosted.org/packages/8c/aa/7f29711f26680eab0bcd3ecdd6d23ed6bce180d82e3f6380fb7ae35fcf3b/pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a", size = 4412999 },
{ url = "https://files.pythonhosted.org/packages/c8/c4/8f0fe3b9e0f7196f6d0bbb151f9fba323d72a41da068610c4c960b16632a/pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1", size = 4368819 },
{ url = "https://files.pythonhosted.org/packages/38/0d/84200ed6a871ce386ddc82904bfadc0c6b28b0c0ec78176871a4679e40b3/pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f", size = 4496081 },
{ url = "https://files.pythonhosted.org/packages/84/9c/9bcd66f714d7e25b64118e3952d52841a4babc6d97b6d28e2261c52045d4/pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91", size = 4296513 },
{ url = "https://files.pythonhosted.org/packages/db/61/ada2a226e22da011b45f7104c95ebda1b63dcbb0c378ad0f7c2a710f8fd2/pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c", size = 4431298 },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fc6e86750523f367923522014b821c11ebc5ad402e659d8c9d09b3c9d70c/pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6", size = 2291630 },
{ url = "https://files.pythonhosted.org/packages/08/5c/2104299949b9d504baf3f4d35f73dbd14ef31bbd1ddc2c1b66a5b7dfda44/pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf", size = 2626369 },
{ url = "https://files.pythonhosted.org/packages/37/f3/9b18362206b244167c958984b57c7f70a0289bfb59a530dd8af5f699b910/pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5", size = 2375240 },
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640 },
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437 },
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605 },
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173 },
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145 },
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340 },
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906 },
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759 },
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657 },
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304 },
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117 },
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060 },
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192 },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805 },
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623 },
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191 },
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494 },
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595 },
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
{ url = "https://files.pythonhosted.org/packages/9a/1f/9df5ac77491fddd2e36c352d16976dc11fbe6ab842f5df85fd7e31b847b9/pillow-11.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bf902d7413c82a1bfa08b06a070876132a5ae6b2388e2712aab3a7cbc02205c6", size = 3229995 },
{ url = "https://files.pythonhosted.org/packages/a6/62/c7b359e924dca274173b04922ac06aa63614f7e934d132f2fe1d852509aa/pillow-11.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c1eec9d950b6fe688edee07138993e54ee4ae634c51443cfb7c1e7613322718e", size = 3101890 },
{ url = "https://files.pythonhosted.org/packages/7b/63/136f21340a434de895b62bcf2c386005a8aa24066c4facd619f5e0e9f283/pillow-11.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e275ee4cb11c262bd108ab2081f750db2a1c0b8c12c1897f27b160c8bd57bbc", size = 4310366 },
{ url = "https://files.pythonhosted.org/packages/f6/46/0bd0ca03d9d1164a7fa33d285ef6d1c438e963d0c8770e4c5b3737ef5abe/pillow-11.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db853948ce4e718f2fc775b75c37ba2efb6aaea41a1a5fc57f0af59eee774b2", size = 4391582 },
{ url = "https://files.pythonhosted.org/packages/0c/55/f182db572b28bd833b8e806f933f782ceb2df64c40e4d8bd3d4226a46eca/pillow-11.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ab8a209b8485d3db694fa97a896d96dd6533d63c22829043fd9de627060beade", size = 4350278 },
{ url = "https://files.pythonhosted.org/packages/75/fb/e330fdbbcbc4744214b5f53b84d9d8a9f4ffbebc2e9c2ac10475386e3296/pillow-11.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:54251ef02a2309b5eec99d151ebf5c9904b77976c8abdcbce7891ed22df53884", size = 4471768 },
{ url = "https://files.pythonhosted.org/packages/eb/51/20ee6c4da4448d7a67ffb720a5fcdb965115a78e211a1f58f9845ae15f86/pillow-11.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5bb94705aea800051a743aa4874bb1397d4695fb0583ba5e425ee0328757f196", size = 4276549 },
{ url = "https://files.pythonhosted.org/packages/37/f2/a25c0bdaa6d6fd5cc3d4a6f65b5a7ea46e7af58bee00a98efe0a5af79c58/pillow-11.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89dbdb3e6e9594d512780a5a1c42801879628b38e3efc7038094430844e271d8", size = 4409350 },
{ url = "https://files.pythonhosted.org/packages/12/a7/06687947604cd3e47abeea1b78b65d34ffce7feab03cfe0dd985f115dca3/pillow-11.1.0-cp39-cp39-win32.whl", hash = "sha256:e5449ca63da169a2e6068dd0e2fcc8d91f9558aba89ff6d02121ca8ab11e79e5", size = 2291271 },
{ url = "https://files.pythonhosted.org/packages/21/a6/f51d47675940b5c63b08ff0575b3518428b4acb891f88526fa4ee1edab6f/pillow-11.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3362c6ca227e65c54bf71a5f88b3d4565ff1bcbc63ae72c34b07bbb1cc59a43f", size = 2625783 },
{ url = "https://files.pythonhosted.org/packages/95/56/97750bd33e68648fa432dfadcb8ede7624bd905822d42262d34bcebdd9d7/pillow-11.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:b20be51b37a75cc54c2c55def3fa2c65bb94ba859dde241cd0a4fd302de5ae0a", size = 2375193 },
{ url = "https://files.pythonhosted.org/packages/fa/c5/389961578fb677b8b3244fcd934f720ed25a148b9a5cc81c91bdf59d8588/pillow-11.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8c730dc3a83e5ac137fbc92dfcfe1511ce3b2b5d7578315b63dbbb76f7f51d90", size = 3198345 },
{ url = "https://files.pythonhosted.org/packages/c4/fa/803c0e50ffee74d4b965229e816af55276eac1d5806712de86f9371858fd/pillow-11.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d33d2fae0e8b170b6a6c57400e077412240f6f5bb2a342cf1ee512a787942bb", size = 3072938 },
{ url = "https://files.pythonhosted.org/packages/dc/67/2a3a5f8012b5d8c63fe53958ba906c1b1d0482ebed5618057ef4d22f8076/pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8d65b38173085f24bc07f8b6c505cbb7418009fa1a1fcb111b1f4961814a442", size = 3400049 },
{ url = "https://files.pythonhosted.org/packages/e5/a0/514f0d317446c98c478d1872497eb92e7cde67003fed74f696441e647446/pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015c6e863faa4779251436db398ae75051469f7c903b043a48f078e437656f83", size = 3422431 },
{ url = "https://files.pythonhosted.org/packages/cd/00/20f40a935514037b7d3f87adfc87d2c538430ea625b63b3af8c3f5578e72/pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d44ff19eea13ae4acdaaab0179fa68c0c6f2f45d66a4d8ec1eda7d6cecbcc15f", size = 3446208 },
{ url = "https://files.pythonhosted.org/packages/28/3c/7de681727963043e093c72e6c3348411b0185eab3263100d4490234ba2f6/pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d3d8da4a631471dfaf94c10c85f5277b1f8e42ac42bade1ac67da4b4a7359b73", size = 3509746 },
{ url = "https://files.pythonhosted.org/packages/41/67/936f9814bdd74b2dfd4822f1f7725ab5d8ff4103919a1664eb4874c58b2f/pillow-11.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0", size = 2626353 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "propcache"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/76/f941e63d55c0293ff7829dd21e7cf1147e90a526756869a9070f287a68c9/propcache-0.3.0.tar.gz", hash = "sha256:a8fd93de4e1d278046345f49e2238cdb298589325849b2645d4a94c53faeffc5", size = 42722 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/f0/dc9ec44d2e63c13f816a16398c039329736712440ff82b682dd9a78d2258/propcache-0.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d", size = 79574 },
{ url = "https://files.pythonhosted.org/packages/99/3a/33a207dfcb3ee1131ea23a2aeb726c3c4994f89546d7eadf8c50627c8b63/propcache-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c", size = 45898 },
{ url = "https://files.pythonhosted.org/packages/af/68/0bde765c9f5dc02b4466d2838600af38c81b184c26c6d3cd44643ac668e3/propcache-0.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e7420211f5a65a54675fd860ea04173cde60a7cc20ccfbafcccd155225f8bc", size = 45418 },
{ url = "https://files.pythonhosted.org/packages/06/a6/c682669bae41199358e16cc7b1c818f91c5f9e925cc863dabd98ce32716a/propcache-0.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3302c5287e504d23bb0e64d2a921d1eb4a03fb93a0a0aa3b53de059f5a5d737d", size = 205116 },
{ url = "https://files.pythonhosted.org/packages/fb/ae/82cfb50267d9a1baa0340728eb9e32245a68538fef929d7bb786d01c11a8/propcache-0.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2e068a83552ddf7a39a99488bcba05ac13454fb205c847674da0352602082f", size = 219405 },
{ url = "https://files.pythonhosted.org/packages/ab/16/7b6b2bf8c207cfd0e5ca3d41aea397392de9899867ec024f88c94f9ae2ab/propcache-0.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d913d36bdaf368637b4f88d554fb9cb9d53d6920b9c5563846555938d5450bf", size = 217656 },
{ url = "https://files.pythonhosted.org/packages/f4/eb/41447de61eb5454891658d0fb9b1d7d35d49a4a5dd2e0c86f2c332e8b7e1/propcache-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ee1983728964d6070ab443399c476de93d5d741f71e8f6e7880a065f878e0b9", size = 205414 },
{ url = "https://files.pythonhosted.org/packages/03/b6/9719878f8b5b20d37ee663a40f8dcbf888559e4d3be2ba2fe5c790fc28d2/propcache-0.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36ca5e9a21822cc1746023e88f5c0af6fce3af3b85d4520efb1ce4221bed75cc", size = 195746 },
{ url = "https://files.pythonhosted.org/packages/bb/ec/b79c3210ba459800d1a8f1afeb81d7b503893555a7b79c24082ff26d3314/propcache-0.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9ecde3671e62eeb99e977f5221abcf40c208f69b5eb986b061ccec317c82ebd0", size = 198651 },
{ url = "https://files.pythonhosted.org/packages/48/f6/2b0140bc47013e43575973068e72ad51ee9f22f2dad42e6d6e362d715125/propcache-0.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d383bf5e045d7f9d239b38e6acadd7b7fdf6c0087259a84ae3475d18e9a2ae8b", size = 195858 },
{ url = "https://files.pythonhosted.org/packages/97/3d/2fa19303d87aa21f9a42dcd870d6088a2a776ff5518e394d50412c3679a6/propcache-0.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8cb625bcb5add899cb8ba7bf716ec1d3e8f7cdea9b0713fa99eadf73b6d4986f", size = 197181 },
{ url = "https://files.pythonhosted.org/packages/09/f3/a2170ffc9fa774c1dfd52294113c0fa6cdc5b71dbfd7129bb9378fdd8b42/propcache-0.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5fa159dcee5dba00c1def3231c249cf261185189205073bde13797e57dd7540a", size = 207411 },
{ url = "https://files.pythonhosted.org/packages/d6/1e/cb8a6c82178efffa0b00dc463f36cd086f747345585140aeb95d5cb93666/propcache-0.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7080b0159ce05f179cfac592cda1a82898ca9cd097dacf8ea20ae33474fbb25", size = 210724 },
{ url = "https://files.pythonhosted.org/packages/2b/72/6e273543337a3e22cf462eb836f065a9830b4d41baeb1f58db2695c934f3/propcache-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed7161bccab7696a473fe7ddb619c1d75963732b37da4618ba12e60899fefe4f", size = 203511 },
{ url = "https://files.pythonhosted.org/packages/f3/ea/7412c79bcec06597c967d49789f5a1f7fd76a8654908feeaefafb7447c9a/propcache-0.3.0-cp310-cp310-win32.whl", hash = "sha256:bf0d9a171908f32d54f651648c7290397b8792f4303821c42a74e7805bfb813c", size = 40600 },
{ url = "https://files.pythonhosted.org/packages/a3/42/488c90190491f3e61bd2c2fb0b3d91c1c78778270dde2f0b6633fc9ff723/propcache-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:42924dc0c9d73e49908e35bbdec87adedd651ea24c53c29cac103ede0ea1d340", size = 44714 },
{ url = "https://files.pythonhosted.org/packages/45/c9/cf09ff7e6d09f14149094f7cd50d2dec032b24e61af21fc4540da2b17bfb/propcache-0.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9ddd49258610499aab83b4f5b61b32e11fce873586282a0e972e5ab3bcadee51", size = 79568 },
{ url = "https://files.pythonhosted.org/packages/c8/32/2424d89da88cd81b7d148e0d2b3131461b570a02aa9d84a2e567509adb0d/propcache-0.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2578541776769b500bada3f8a4eeaf944530516b6e90c089aa368266ed70c49e", size = 45895 },
{ url = "https://files.pythonhosted.org/packages/f6/91/ee5b6aa7aa31754fefcf0c5180e09223cac380ef195c4ddc8c266eb641ea/propcache-0.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8074c5dd61c8a3e915fa8fc04754fa55cfa5978200d2daa1e2d4294c1f136aa", size = 45427 },
{ url = "https://files.pythonhosted.org/packages/bf/73/38f0128462b8b616181d8c53bd5d04eac41c50c449b07615c65d56ba0a9b/propcache-0.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b58229a844931bca61b3a20efd2be2a2acb4ad1622fc026504309a6883686fbf", size = 232427 },
{ url = "https://files.pythonhosted.org/packages/59/82/f3d4e84f4539dcfc9c3d338282b9e915f5b63c921986ecfdf7af2d12f87c/propcache-0.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45377d5d6fefe1677da2a2c07b024a6dac782088e37c0b1efea4cfe2b1be19b", size = 239985 },
{ url = "https://files.pythonhosted.org/packages/42/e8/029f58cccbae83c9969a7ee7a06558d5b83a93dfc54e0f4f70234bbaea1b/propcache-0.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5060592d83454e8063e487696ac3783cc48c9a329498bafae0d972bc7816c9", size = 238827 },
{ url = "https://files.pythonhosted.org/packages/8b/a2/c373561777c0cb9b9e7b9b9a10b9b3a7b6bde75a2535b962231cecc8fdb8/propcache-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15010f29fbed80e711db272909a074dc79858c6d28e2915704cfc487a8ac89c6", size = 231348 },
{ url = "https://files.pythonhosted.org/packages/d7/d2/4673f715beedf6038b485bcd976813149231d9df5bb6196cb69a09c185c9/propcache-0.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a254537b9b696ede293bfdbc0a65200e8e4507bc9f37831e2a0318a9b333c85c", size = 220426 },
{ url = "https://files.pythonhosted.org/packages/e0/f6/1da65f900927bafd4675a16e890618ec7643f2f922bf0e4d84bb38645618/propcache-0.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2b975528998de037dfbc10144b8aed9b8dd5a99ec547f14d1cb7c5665a43f075", size = 220294 },
{ url = "https://files.pythonhosted.org/packages/ff/86/620451bdc02e91b1712cd71890c17077ee97e2a28493836a87e47b8e70ff/propcache-0.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:19d36bb351ad5554ff20f2ae75f88ce205b0748c38b146c75628577020351e3c", size = 212492 },
{ url = "https://files.pythonhosted.org/packages/6e/1b/e8f86921ed4016da80faf3b8f515f7829decabdbff106736bfff353bceba/propcache-0.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6032231d4a5abd67c7f71168fd64a47b6b451fbcb91c8397c2f7610e67683810", size = 215113 },
{ url = "https://files.pythonhosted.org/packages/1a/95/a61d86cc49aa0945f6c06f3a4614fc543e311a50558c92861f5e9691a37c/propcache-0.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6985a593417cdbc94c7f9c3403747335e450c1599da1647a5af76539672464d3", size = 228330 },
{ url = "https://files.pythonhosted.org/packages/8f/7d/10dbae48ff2bb189e92c2b3487a48f3229146a25941ad0d485934d1104d4/propcache-0.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a1948df1bb1d56b5e7b0553c0fa04fd0e320997ae99689488201f19fa90d2e7", size = 231942 },
{ url = "https://files.pythonhosted.org/packages/39/ce/82d16aec96c5513ae7db13ab901a65a1e54c915292fb5b2390e33275b61d/propcache-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8319293e85feadbbfe2150a5659dbc2ebc4afdeaf7d98936fb9a2f2ba0d4c35c", size = 223077 },
{ url = "https://files.pythonhosted.org/packages/c8/e0/cb077e8e7a583c733df7f53327fcbdb92e42be59b976ce60bf1d904a0efe/propcache-0.3.0-cp311-cp311-win32.whl", hash = "sha256:63f26258a163c34542c24808f03d734b338da66ba91f410a703e505c8485791d", size = 40455 },
{ url = "https://files.pythonhosted.org/packages/d8/35/57abeb6146fe3c19081eeaf3d9d4cfea256f87f1e5101acf80d3332c1820/propcache-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:cacea77ef7a2195f04f9279297684955e3d1ae4241092ff0cfcef532bb7a1c32", size = 44705 },
{ url = "https://files.pythonhosted.org/packages/8d/2c/921f15dc365796ec23975b322b0078eae72995c7b4d49eba554c6a308d70/propcache-0.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e53d19c2bf7d0d1e6998a7e693c7e87300dd971808e6618964621ccd0e01fe4e", size = 79867 },
{ url = "https://files.pythonhosted.org/packages/11/a5/4a6cc1a559d1f2fb57ea22edc4245158cdffae92f7f92afcee2913f84417/propcache-0.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a61a68d630e812b67b5bf097ab84e2cd79b48c792857dc10ba8a223f5b06a2af", size = 46109 },
{ url = "https://files.pythonhosted.org/packages/e1/6d/28bfd3af3a567ad7d667348e7f46a520bda958229c4d545ba138a044232f/propcache-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb91d20fa2d3b13deea98a690534697742029f4fb83673a3501ae6e3746508b5", size = 45635 },
{ url = "https://files.pythonhosted.org/packages/73/20/d75b42eaffe5075eac2f4e168f6393d21c664c91225288811d85451b2578/propcache-0.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67054e47c01b7b349b94ed0840ccae075449503cf1fdd0a1fdd98ab5ddc2667b", size = 242159 },
{ url = "https://files.pythonhosted.org/packages/a5/fb/4b537dd92f9fd4be68042ec51c9d23885ca5fafe51ec24c58d9401034e5f/propcache-0.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997e7b8f173a391987df40f3b52c423e5850be6f6df0dcfb5376365440b56667", size = 248163 },
{ url = "https://files.pythonhosted.org/packages/e7/af/8a9db04ac596d531ca0ef7dde518feaadfcdabef7b17d6a5ec59ee3effc2/propcache-0.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d663fd71491dde7dfdfc899d13a067a94198e90695b4321084c6e450743b8c7", size = 248794 },
{ url = "https://files.pythonhosted.org/packages/9d/c4/ecfc988879c0fd9db03228725b662d76cf484b6b46f7e92fee94e4b52490/propcache-0.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8884ba1a0fe7210b775106b25850f5e5a9dc3c840d1ae9924ee6ea2eb3acbfe7", size = 243912 },
{ url = "https://files.pythonhosted.org/packages/04/a2/298dd27184faa8b7d91cc43488b578db218b3cc85b54d912ed27b8c5597a/propcache-0.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa806bbc13eac1ab6291ed21ecd2dd426063ca5417dd507e6be58de20e58dfcf", size = 229402 },
{ url = "https://files.pythonhosted.org/packages/be/0d/efe7fec316ca92dbf4bc4a9ba49ca889c43ca6d48ab1d6fa99fc94e5bb98/propcache-0.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f4d7a7c0aff92e8354cceca6fe223973ddf08401047920df0fcb24be2bd5138", size = 226896 },
{ url = "https://files.pythonhosted.org/packages/60/63/72404380ae1d9c96d96e165aa02c66c2aae6072d067fc4713da5cde96762/propcache-0.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9be90eebc9842a93ef8335291f57b3b7488ac24f70df96a6034a13cb58e6ff86", size = 221447 },
{ url = "https://files.pythonhosted.org/packages/9d/18/b8392cab6e0964b67a30a8f4dadeaff64dc7022b5a34bb1d004ea99646f4/propcache-0.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bf15fc0b45914d9d1b706f7c9c4f66f2b7b053e9517e40123e137e8ca8958b3d", size = 222440 },
{ url = "https://files.pythonhosted.org/packages/6f/be/105d9ceda0f97eff8c06bac1673448b2db2a497444de3646464d3f5dc881/propcache-0.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a16167118677d94bb48bfcd91e420088854eb0737b76ec374b91498fb77a70e", size = 234104 },
{ url = "https://files.pythonhosted.org/packages/cb/c9/f09a4ec394cfcce4053d8b2a04d622b5f22d21ba9bb70edd0cad061fa77b/propcache-0.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:41de3da5458edd5678b0f6ff66691507f9885f5fe6a0fb99a5d10d10c0fd2d64", size = 239086 },
{ url = "https://files.pythonhosted.org/packages/ea/aa/96f7f9ed6def82db67c972bdb7bd9f28b95d7d98f7e2abaf144c284bf609/propcache-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:728af36011bb5d344c4fe4af79cfe186729efb649d2f8b395d1572fb088a996c", size = 230991 },
{ url = "https://files.pythonhosted.org/packages/5a/11/bee5439de1307d06fad176f7143fec906e499c33d7aff863ea8428b8e98b/propcache-0.3.0-cp312-cp312-win32.whl", hash = "sha256:6b5b7fd6ee7b54e01759f2044f936dcf7dea6e7585f35490f7ca0420fe723c0d", size = 40337 },
{ url = "https://files.pythonhosted.org/packages/e4/17/e5789a54a0455a61cb9efc4ca6071829d992220c2998a27c59aeba749f6f/propcache-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:2d15bc27163cd4df433e75f546b9ac31c1ba7b0b128bfb1b90df19082466ff57", size = 44404 },
{ url = "https://files.pythonhosted.org/packages/3a/0f/a79dd23a0efd6ee01ab0dc9750d8479b343bfd0c73560d59d271eb6a99d4/propcache-0.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a2b9bf8c79b660d0ca1ad95e587818c30ccdb11f787657458d6f26a1ea18c568", size = 77287 },
{ url = "https://files.pythonhosted.org/packages/b8/51/76675703c90de38ac75adb8deceb3f3ad99b67ff02a0fa5d067757971ab8/propcache-0.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0c1a133d42c6fc1f5fbcf5c91331657a1ff822e87989bf4a6e2e39b818d0ee9", size = 44923 },
{ url = "https://files.pythonhosted.org/packages/01/9b/fd5ddbee66cf7686e73c516227c2fd9bf471dbfed0f48329d095ea1228d3/propcache-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb2f144c6d98bb5cbc94adeb0447cfd4c0f991341baa68eee3f3b0c9c0e83767", size = 44325 },
{ url = "https://files.pythonhosted.org/packages/13/1c/6961f11eb215a683b34b903b82bde486c606516c1466bf1fa67f26906d51/propcache-0.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1323cd04d6e92150bcc79d0174ce347ed4b349d748b9358fd2e497b121e03c8", size = 225116 },
{ url = "https://files.pythonhosted.org/packages/ef/ea/f8410c40abcb2e40dffe9adeed017898c930974650a63e5c79b886aa9f73/propcache-0.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b812b3cb6caacd072276ac0492d249f210006c57726b6484a1e1805b3cfeea0", size = 229905 },
{ url = "https://files.pythonhosted.org/packages/ef/5a/a9bf90894001468bf8e6ea293bb00626cc9ef10f8eb7996e9ec29345c7ed/propcache-0.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:742840d1d0438eb7ea4280f3347598f507a199a35a08294afdcc560c3739989d", size = 233221 },
{ url = "https://files.pythonhosted.org/packages/dd/ce/fffdddd9725b690b01d345c1156b4c2cc6dca09ab5c23a6d07b8f37d6e2f/propcache-0.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c6e7e4f9167fddc438cd653d826f2222222564daed4116a02a184b464d3ef05", size = 227627 },
{ url = "https://files.pythonhosted.org/packages/58/ae/45c89a5994a334735a3032b48e8e4a98c05d9536ddee0719913dc27da548/propcache-0.3.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a94ffc66738da99232ddffcf7910e0f69e2bbe3a0802e54426dbf0714e1c2ffe", size = 214217 },
{ url = "https://files.pythonhosted.org/packages/01/84/bc60188c3290ff8f5f4a92b9ca2d93a62e449c8daf6fd11ad517ad136926/propcache-0.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c6ec957025bf32b15cbc6b67afe233c65b30005e4c55fe5768e4bb518d712f1", size = 212921 },
{ url = "https://files.pythonhosted.org/packages/14/b3/39d60224048feef7a96edabb8217dc3f75415457e5ebbef6814f8b2a27b5/propcache-0.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:549722908de62aa0b47a78b90531c022fa6e139f9166be634f667ff45632cc92", size = 208200 },
{ url = "https://files.pythonhosted.org/packages/9d/b3/0a6720b86791251273fff8a01bc8e628bc70903513bd456f86cde1e1ef84/propcache-0.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5d62c4f6706bff5d8a52fd51fec6069bef69e7202ed481486c0bc3874912c787", size = 208400 },
{ url = "https://files.pythonhosted.org/packages/e9/4f/bb470f3e687790547e2e78105fb411f54e0cdde0d74106ccadd2521c6572/propcache-0.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:24c04f8fbf60094c531667b8207acbae54146661657a1b1be6d3ca7773b7a545", size = 218116 },
{ url = "https://files.pythonhosted.org/packages/34/71/277f7f9add469698ac9724c199bfe06f85b199542121a71f65a80423d62a/propcache-0.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7c5f5290799a3f6539cc5e6f474c3e5c5fbeba74a5e1e5be75587746a940d51e", size = 222911 },
{ url = "https://files.pythonhosted.org/packages/92/e3/a7b9782aef5a2fc765b1d97da9ec7aed2f25a4e985703608e73232205e3f/propcache-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0e7c9c3cf7c276d4f6ab9af8adddc127d04e0fcabede315904d2ff76db626", size = 216563 },
{ url = "https://files.pythonhosted.org/packages/ab/76/0583ca2c551aa08ffcff87b2c6849c8f01c1f6fb815a5226f0c5c202173e/propcache-0.3.0-cp313-cp313-win32.whl", hash = "sha256:ee0bd3a7b2e184e88d25c9baa6a9dc609ba25b76daae942edfb14499ac7ec374", size = 39763 },
{ url = "https://files.pythonhosted.org/packages/80/ec/c6a84f9a36f608379b95f0e786c111d5465926f8c62f12be8cdadb02b15c/propcache-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f7d896a16da9455f882870a507567d4f58c53504dc2d4b1e1d386dfe4588a", size = 43650 },
{ url = "https://files.pythonhosted.org/packages/ee/95/7d32e3560f5bf83fc2f2a4c1b0c181d327d53d5f85ebd045ab89d4d97763/propcache-0.3.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e560fd75aaf3e5693b91bcaddd8b314f4d57e99aef8a6c6dc692f935cc1e6bbf", size = 82140 },
{ url = "https://files.pythonhosted.org/packages/86/89/752388f12e6027a5e63f5d075f15291ded48e2d8311314fff039da5a9b11/propcache-0.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65a37714b8ad9aba5780325228598a5b16c47ba0f8aeb3dc0514701e4413d7c0", size = 47296 },
{ url = "https://files.pythonhosted.org/packages/1b/4c/b55c98d586c69180d3048984a57a5ea238bdeeccf82dbfcd598e935e10bb/propcache-0.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:07700939b2cbd67bfb3b76a12e1412405d71019df00ca5697ce75e5ef789d829", size = 46724 },
{ url = "https://files.pythonhosted.org/packages/0f/b6/67451a437aed90c4e951e320b5b3d7eb584ade1d5592f6e5e8f678030989/propcache-0.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c0fdbdf6983526e269e5a8d53b7ae3622dd6998468821d660d0daf72779aefa", size = 291499 },
{ url = "https://files.pythonhosted.org/packages/ee/ff/e4179facd21515b24737e1e26e02615dfb5ed29416eed4cf5bc6ac5ce5fb/propcache-0.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:794c3dd744fad478b6232289c866c25406ecdfc47e294618bdf1697e69bd64a6", size = 293911 },
{ url = "https://files.pythonhosted.org/packages/76/8d/94a8585992a064a23bd54f56c5e58c3b8bf0c0a06ae10e56f2353ae16c3d/propcache-0.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4544699674faf66fb6b4473a1518ae4999c1b614f0b8297b1cef96bac25381db", size = 293301 },
{ url = "https://files.pythonhosted.org/packages/b0/b8/2c860c92b4134f68c7716c6f30a0d723973f881c32a6d7a24c4ddca05fdf/propcache-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddb8870bdb83456a489ab67c6b3040a8d5a55069aa6f72f9d872235fbc52f54", size = 281947 },
{ url = "https://files.pythonhosted.org/packages/cd/72/b564be7411b525d11757b713c757c21cd4dc13b6569c3b2b8f6d3c96fd5e/propcache-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f857034dc68d5ceb30fb60afb6ff2103087aea10a01b613985610e007053a121", size = 268072 },
{ url = "https://files.pythonhosted.org/packages/37/68/d94649e399e8d7fc051e5a4f2334efc567993525af083db145a70690a121/propcache-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02df07041e0820cacc8f739510078f2aadcfd3fc57eaeeb16d5ded85c872c89e", size = 275190 },
{ url = "https://files.pythonhosted.org/packages/d8/3c/446e125f5bbbc1922964dd67cb541c01cdb678d811297b79a4ff6accc843/propcache-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f47d52fd9b2ac418c4890aad2f6d21a6b96183c98021f0a48497a904199f006e", size = 254145 },
{ url = "https://files.pythonhosted.org/packages/f4/80/fd3f741483dc8e59f7ba7e05eaa0f4e11677d7db2077522b92ff80117a2a/propcache-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9ff4e9ecb6e4b363430edf2c6e50173a63e0820e549918adef70515f87ced19a", size = 257163 },
{ url = "https://files.pythonhosted.org/packages/dc/cf/6292b5ce6ed0017e6a89024a827292122cc41b6259b30ada0c6732288513/propcache-0.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ecc2920630283e0783c22e2ac94427f8cca29a04cfdf331467d4f661f4072dac", size = 280249 },
{ url = "https://files.pythonhosted.org/packages/e8/f0/fd9b8247b449fe02a4f96538b979997e229af516d7462b006392badc59a1/propcache-0.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c441c841e82c5ba7a85ad25986014be8d7849c3cfbdb6004541873505929a74e", size = 288741 },
{ url = "https://files.pythonhosted.org/packages/64/71/cf831fdc2617f86cfd7f414cfc487d018e722dac8acc098366ce9bba0941/propcache-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c929916cbdb540d3407c66f19f73387f43e7c12fa318a66f64ac99da601bcdf", size = 277061 },
{ url = "https://files.pythonhosted.org/packages/42/78/9432542a35d944abeca9e02927a0de38cd7a298466d8ffa171536e2381c3/propcache-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:0c3e893c4464ebd751b44ae76c12c5f5c1e4f6cbd6fbf67e3783cd93ad221863", size = 42252 },
{ url = "https://files.pythonhosted.org/packages/6f/45/960365f4f8978f48ebb56b1127adf33a49f2e69ecd46ac1f46d6cf78a79d/propcache-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:75e872573220d1ee2305b35c9813626e620768248425f58798413e9c39741f46", size = 46425 },
{ url = "https://files.pythonhosted.org/packages/6d/05/2695901870f8b8f5d68f7cbb05de92a7f21f032a0edc42a5b527d22eab28/propcache-0.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03c091bb752349402f23ee43bb2bff6bd80ccab7c9df6b88ad4322258d6960fc", size = 80692 },
{ url = "https://files.pythonhosted.org/packages/57/5e/54d314533896ed43f5573ac80366a056f17a397234ada6e4303fa84a232f/propcache-0.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46ed02532cb66612d42ae5c3929b5e98ae330ea0f3900bc66ec5f4862069519b", size = 46434 },
{ url = "https://files.pythonhosted.org/packages/40/61/3624c088406e9e54beb42801e9da53cc8b379f4c1b4ee3911876282d4af6/propcache-0.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11ae6a8a01b8a4dc79093b5d3ca2c8a4436f5ee251a9840d7790dccbd96cb649", size = 45956 },
{ url = "https://files.pythonhosted.org/packages/e6/65/09b1bacf723721e36a84034ff0a4d64d13c7ddb92cfefe9c0b861886f814/propcache-0.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df03cd88f95b1b99052b52b1bb92173229d7a674df0ab06d2b25765ee8404bce", size = 208068 },
{ url = "https://files.pythonhosted.org/packages/57/7b/a6c8de8814f9f07b74c959e6d2ef1137ac2ff622fa1bd4cd00c5a6890525/propcache-0.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03acd9ff19021bd0567582ac88f821b66883e158274183b9e5586f678984f8fe", size = 223581 },
{ url = "https://files.pythonhosted.org/packages/fb/03/8c081bfb32bb0c12118aff9720c498015c332630858c9aaec7930c40911d/propcache-0.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd54895e4ae7d32f1e3dd91261df46ee7483a735017dc6f987904f194aa5fd14", size = 221567 },
{ url = "https://files.pythonhosted.org/packages/70/b8/a6dc434561bac3601644724635328e05ea6b9163e4a628f5f4222a384625/propcache-0.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a67e5c04e3119594d8cfae517f4b9330c395df07ea65eab16f3d559b7068fe", size = 208536 },
{ url = "https://files.pythonhosted.org/packages/1f/96/6f6fdb8bfd749803b160f23c446ef45f7cb51e355a24c5b07d8687ae2ee9/propcache-0.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee25f1ac091def37c4b59d192bbe3a206298feeb89132a470325bf76ad122a1e", size = 198920 },
{ url = "https://files.pythonhosted.org/packages/1b/6e/b407dff7f7dbbd9efd65236a53d4512929ce37026670af5c12f91bb95862/propcache-0.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58e6d2a5a7cb3e5f166fd58e71e9a4ff504be9dc61b88167e75f835da5764d07", size = 203802 },
{ url = "https://files.pythonhosted.org/packages/2f/77/2dc3a33bcbd3652686038267aff2a2ff03e71e9a7f76f444c72cadf1ba21/propcache-0.3.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:be90c94570840939fecedf99fa72839aed70b0ced449b415c85e01ae67422c90", size = 199682 },
{ url = "https://files.pythonhosted.org/packages/5f/49/bb38b9159cfd6c74a6daf368e644eecbbda05a2f4731b6d5b6446a7bcb34/propcache-0.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49ea05212a529c2caffe411e25a59308b07d6e10bf2505d77da72891f9a05641", size = 200815 },
{ url = "https://files.pythonhosted.org/packages/a3/d7/2d3cdf6e4fcc28bb3dd4cf23f6ae34cb24f2db4b7131a421bd7f38d70e56/propcache-0.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:119e244ab40f70a98c91906d4c1f4c5f2e68bd0b14e7ab0a06922038fae8a20f", size = 211553 },
{ url = "https://files.pythonhosted.org/packages/a7/64/efe070403dcb086d200a801dbf6e4d09f7f1278b15fae038038ad573eb22/propcache-0.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:507c5357a8d8b4593b97fb669c50598f4e6cccbbf77e22fa9598aba78292b4d7", size = 214878 },
{ url = "https://files.pythonhosted.org/packages/8f/ec/4ae54f9f8874c58ca1659a9dd260c3b312ca9911d3c74542ef003ca6e9b4/propcache-0.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8526b0941ec5a40220fc4dfde76aed58808e2b309c03e9fa8e2260083ef7157f", size = 207562 },
{ url = "https://files.pythonhosted.org/packages/d7/92/e07bd88ece413fd069d66533d95cbc83649b57b60990f26a35a7f84e25ed/propcache-0.3.0-cp39-cp39-win32.whl", hash = "sha256:7cedd25e5f678f7738da38037435b340694ab34d424938041aa630d8bac42663", size = 41152 },
{ url = "https://files.pythonhosted.org/packages/26/8f/676ea691f5788bd9376ba77475204093a559c883ee1b6def0291e41020dc/propcache-0.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf4298f366ca7e1ad1d21bbb58300a6985015909964077afd37559084590c929", size = 45263 },
{ url = "https://files.pythonhosted.org/packages/b5/35/6c4c6fc8774a9e3629cd750dc24a7a4fb090a25ccd5c3246d127b70f9e22/propcache-0.3.0-py3-none-any.whl", hash = "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043", size = 12101 },
]
[[package]]
name = "proto-plus"
version = "1.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163 },
]
[[package]]
name = "protobuf"
version = "5.29.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708 },
{ url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508 },
{ url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825 },
{ url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573 },
{ url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672 },
{ url = "https://files.pythonhosted.org/packages/85/a6/bf65a38f8be5ab8c3b575822acfd338702fdf7ac9abd8c81630cc7c9f4bd/protobuf-5.29.3-cp39-cp39-win32.whl", hash = "sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7", size = 422676 },
{ url = "https://files.pythonhosted.org/packages/ac/e2/48d46adc86369ff092eaece3e537f76b3baaab45ca3dde257838cde831d2/protobuf-5.29.3-cp39-cp39-win_amd64.whl", hash = "sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da", size = 434593 },
{ url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550 },
]
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 },
]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
]
[[package]]
name = "pydantic"
version = "2.10.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 },
{ url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 },
{ url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 },
{ url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 },
{ url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 },
{ url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 },
{ url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 },
{ url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 },
{ url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 },
{ url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 },
{ url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 },
{ url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 },
{ url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 },
{ url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 },
{ url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 },
{ url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 },
{ url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 },
{ url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 },
{ url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 },
{ url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 },
{ url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 },
{ url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 },
{ url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 },
{ url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 },
{ url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 },
{ url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 },
{ url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 },
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
{ url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 },
{ url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 },
{ url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 },
{ url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 },
{ url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 },
{ url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 },
{ url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 },
{ url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 },
{ url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 },
{ url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 },
{ url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 },
{ url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 },
{ url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 },
{ url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 },
{ url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 },
{ url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 },
{ url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 },
{ url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 },
{ url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 },
{ url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 },
{ url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 },
{ url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 },
{ url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 },
{ url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 },
{ url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 },
{ url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 },
{ url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 },
{ url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 },
{ url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 },
{ url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 },
{ url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 },
]
[[package]]
name = "pydub"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 },
]
[[package]]
name = "pyht"
version = "0.1.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "filelock" },
{ name = "grpcio" },
{ name = "protobuf" },
{ name = "requests" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/52/56509b7f7f47846283b3873025c7be77ba98f4fbac23c000c6ffd0664422/pyht-0.1.14.tar.gz", hash = "sha256:f6d8414712bcfdf0306f790eca1be78b4dddd1c7053aae391ffcc1d1f887e6a1", size = 32610 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/61/372fc080561bd84265c75aaa5b20fa6896cf1714b5ddb51512e67d37de21/pyht-0.1.14-py3-none-any.whl", hash = "sha256:f97034ecad1291a6cf7bad5bf3efeb0eeb499603ecc8e1467f85b4daba1fb799", size = 33036 },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
]
[[package]]
name = "pyreadline3"
version = "3.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
]
[[package]]
name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
]
[[package]]
name = "pytest-asyncio"
version = "0.25.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 },
{ url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 },
{ url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 },
{ url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 },
{ url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 },
{ url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 },
{ url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 },
{ url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 },
{ url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 },
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 },
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 },
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 },
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 },
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 },
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 },
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 },
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 },
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 },
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 },
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 },
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 },
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 },
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 },
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 },
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 },
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 },
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 },
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
{ url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 },
{ url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 },
{ url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 },
{ url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 },
{ url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 },
{ url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 },
{ url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 },
{ url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 },
{ url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 },
]
[[package]]
name = "rapidfuzz"
version = "3.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/be/8dff25a6157dfbde9867720b1282157fe7b809e085130bb89d7655c62186/rapidfuzz-3.12.2.tar.gz", hash = "sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3", size = 57907839 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/47/55413211ec32f76c39a6e4f88d024d2194fd4c23abe8102cdbcf28cf80eb/rapidfuzz-3.12.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b9a75e0385a861178adf59e86d6616cbd0d5adca7228dc9eeabf6f62cf5b0b1", size = 1959750 },
{ url = "https://files.pythonhosted.org/packages/a3/7f/7350c9a68952b52f669b50528b0e53fca2a9d633457fc2a53d8a5e4b1bb2/rapidfuzz-3.12.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6906a7eb458731e3dd2495af1d0410e23a21a2a2b7ced535e6d5cd15cb69afc5", size = 1433727 },
{ url = "https://files.pythonhosted.org/packages/43/b0/148a34adc92f49582add349faaad9d8f4462a76cc30ad2f1d86bdba4fa44/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4b3334a8958b689f292d5ce8a928140ac98919b51e084f04bf0c14276e4c6ba", size = 1423353 },
{ url = "https://files.pythonhosted.org/packages/1e/8f/923ca60dcd814dba1772420c38c8b70e1fe4e6f0b5699bb3afcbe8c4bed1/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85a54ce30345cff2c79cbcffa063f270ad1daedd0d0c3ff6e541d3c3ba4288cf", size = 5641810 },
{ url = "https://files.pythonhosted.org/packages/b8/91/b57ea560a8ff54e0ebb131a62740501ff7f6ffa14dc16e9853a97289614c/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb63c5072c08058f8995404201a52fc4e1ecac105548a4d03c6c6934bda45a3", size = 1683536 },
{ url = "https://files.pythonhosted.org/packages/fd/5b/fba390383a82353b72c32b5d14f0f7669a542e7205c55f6d2ae6112369bf/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5385398d390c6571f0f2a7837e6ddde0c8b912dac096dc8c87208ce9aaaa7570", size = 1685847 },
{ url = "https://files.pythonhosted.org/packages/15/6f/5211f2e80d4e82ff793f214429cbc8a8a69ef7978fd299112ae1c5595ae8/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5032cbffa245b4beba0067f8ed17392ef2501b346ae3c1f1d14b950edf4b6115", size = 3142196 },
{ url = "https://files.pythonhosted.org/packages/92/fc/d2b4efecf81180c49da09ff97657e0517a5ea55a99b16a1adc56d2900c0b/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:195adbb384d89d6c55e2fd71e7fb262010f3196e459aa2f3f45f31dd7185fe72", size = 2521222 },
{ url = "https://files.pythonhosted.org/packages/ef/5f/a27e284d37632c808eb7cd6c49178dd52354bfb290843e253af4bd46fa61/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f43b773a4d4950606fb25568ecde5f25280daf8f97b87eb323e16ecd8177b328", size = 7867428 },
{ url = "https://files.pythonhosted.org/packages/45/68/59168dd67d319a958c525a4eeada5d62a83f83a42b79f9b55917da70f1a7/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:55a43be0e0fa956a919043c19d19bd988991d15c59f179d413fe5145ed9deb43", size = 2904044 },
{ url = "https://files.pythonhosted.org/packages/5e/40/6bbe014b94d3cef718cfe0be41eb0cecf6fda4b1cd31ba1dddf1984afa08/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:71cf1ea16acdebe9e2fb62ee7a77f8f70e877bebcbb33b34e660af2eb6d341d9", size = 3551416 },
{ url = "https://files.pythonhosted.org/packages/e4/6b/2f8e0f7de4a5ac54258be885c2e735a315c71187481a7f3d655d650c5c4c/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a3692d4ab36d44685f61326dca539975a4eda49b2a76f0a3df177d8a2c0de9d2", size = 4589777 },
{ url = "https://files.pythonhosted.org/packages/51/b3/84927233624d5e308e4739c748d2cb4ba46675efb7e021661c68b7a7b941/rapidfuzz-3.12.2-cp310-cp310-win32.whl", hash = "sha256:09227bd402caa4397ba1d6e239deea635703b042dd266a4092548661fb22b9c6", size = 1862195 },
{ url = "https://files.pythonhosted.org/packages/c9/49/e101be3e62b6524ea8b271b2e949878c8b58c31a0dc5d30b90f4f5c980e7/rapidfuzz-3.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:0f05b7b95f9f87254b53fa92048367a8232c26cee7fc8665e4337268c3919def", size = 1625063 },
{ url = "https://files.pythonhosted.org/packages/ed/21/a7cbb1eacad92a840a62a22f49d98b423154da49874b787e24bb630f4689/rapidfuzz-3.12.2-cp310-cp310-win_arm64.whl", hash = "sha256:6938738e00d9eb6e04097b3f565097e20b0c398f9c58959a2bc64f7f6be3d9da", size = 870054 },
{ url = "https://files.pythonhosted.org/packages/8e/41/985b8786f7895f7a7f03f80b547e04a5b9f41187f43de386ad2f32b9f9fc/rapidfuzz-3.12.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9c4d984621ae17404c58f8d06ed8b025e167e52c0e6a511dfec83c37e9220cd", size = 1960568 },
{ url = "https://files.pythonhosted.org/packages/90/9e/9278b4160bf86346fc5f110b5daf07af629343bfcd04a9366d355bc6104e/rapidfuzz-3.12.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f9132c55d330f0a1d34ce6730a76805323a6250d97468a1ca766a883d6a9a25", size = 1434362 },
{ url = "https://files.pythonhosted.org/packages/e7/53/fe3fb50111e203da4e82b8694c29cbf44101cdbf1efd7ef721cdf85e0aca/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b343b6cb4b2c3dbc8d2d4c5ee915b6088e3b144ddf8305a57eaab16cf9fc74", size = 1417839 },
{ url = "https://files.pythonhosted.org/packages/fd/c4/aa11749bc9d9c0539061d32f2c525d99e11588867d3d6e94693ccd4e0dd0/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24081077b571ec4ee6d5d7ea0e49bc6830bf05b50c1005028523b9cd356209f3", size = 5620525 },
{ url = "https://files.pythonhosted.org/packages/5f/62/463c618a5a8a44bf6b087325353e13dbd5bc19c44cc06134d3c9eff0d04a/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c988a4fc91856260355773bf9d32bebab2083d4c6df33fafeddf4330e5ae9139", size = 1671267 },
{ url = "https://files.pythonhosted.org/packages/ca/b6/ec87c56ed0fab59f8220f5b832d5c1dd374667bee73318a01392ccc8c23d/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:780b4469ee21cf62b1b2e8ada042941fd2525e45d5fb6a6901a9798a0e41153c", size = 1683415 },
{ url = "https://files.pythonhosted.org/packages/46/08/862e65a1022cbfa2935e7b3f04cdaa73b0967ebf4762ddf509735da47d73/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edd84b0a323885493c893bad16098c5e3b3005d7caa995ae653da07373665d97", size = 3139234 },
{ url = "https://files.pythonhosted.org/packages/ee/fa/7e8c0d1d26a4b892344c743f17e2c8482f749b616cd651590bd60994b49f/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efa22059c765b3d8778083805b199deaaf643db070f65426f87d274565ddf36a", size = 2523730 },
{ url = "https://files.pythonhosted.org/packages/8a/52/1d5b80e990c2e9998e47be118c2dbabda75daa2a5f5ff978df1ed76d7f81/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:095776b11bb45daf7c2973dd61cc472d7ea7f2eecfa454aef940b4675659b92f", size = 7880525 },
{ url = "https://files.pythonhosted.org/packages/0c/18/9c8cd7378272590a1eb0855b587f3a1fbd3492bd1612825d675320eeeb1b/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7e2574cf4aa86065600b664a1ac7b8b8499107d102ecde836aaaa403fc4f1784", size = 2905180 },
{ url = "https://files.pythonhosted.org/packages/4b/94/992de5d0fc9269a93ce62979aced028e0939d3477ea99d87fd0e22f44e8d/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d5a3425a6c50fd8fbd991d8f085ddb504791dae6ef9cc3ab299fea2cb5374bef", size = 3548613 },
{ url = "https://files.pythonhosted.org/packages/9b/25/ed3a0317f118131ee297de5936e1587e48b059e6438f4bbf92ef3bbc4927/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97fb05e1ddb7b71a054040af588b0634214ee87cea87900d309fafc16fd272a4", size = 4583047 },
{ url = "https://files.pythonhosted.org/packages/4d/27/10585a5a62ff6ebbefa3e836a3fd8c123e2ed0bbde8cfcdd7477032cd458/rapidfuzz-3.12.2-cp311-cp311-win32.whl", hash = "sha256:b4c5a0413589aef936892fbfa94b7ff6f7dd09edf19b5a7b83896cc9d4e8c184", size = 1863208 },
{ url = "https://files.pythonhosted.org/packages/38/4c/faacecf70a4e202a02f029ec6f6e04e910d95c4ef36d7d63b83b160f7f3e/rapidfuzz-3.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:58d9ae5cf9246d102db2a2558b67fe7e73c533e5d769099747921232d88b9be2", size = 1630876 },
{ url = "https://files.pythonhosted.org/packages/a7/4b/4931da26e0677880a9a533ef75ccbe564c091aa4a3579aed0355c7e06900/rapidfuzz-3.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:7635fe34246cd241c8e35eb83084e978b01b83d5ef7e5bf72a704c637f270017", size = 870896 },
{ url = "https://files.pythonhosted.org/packages/a7/d2/e071753227c9e9f7f3550b983f30565f6e994581529815fa5a8879e7cd10/rapidfuzz-3.12.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1d982a651253ffe8434d9934ff0c1089111d60502228464721a2a4587435e159", size = 1944403 },
{ url = "https://files.pythonhosted.org/packages/aa/d1/4a10d21cc97aa36f4019af24382b5b4dc5ea6444499883c1c1286c6089ba/rapidfuzz-3.12.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02e6466caa0222d5233b1f05640873671cd99549a5c5ba4c29151634a1e56080", size = 1430287 },
{ url = "https://files.pythonhosted.org/packages/6a/2d/76d39ab0beeb884d432096fe288c41850e37608e0145264081d0cb809f3c/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e956b3f053e474abae69ac693a52742109d860ac2375fe88e9387d3277f4c96c", size = 1403693 },
{ url = "https://files.pythonhosted.org/packages/85/1a/719b0f6498c003627e4b83b841bdcd48b11de8a9908a9051c4d2a0bc2245/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dee7d740a2d5418d4f964f39ab8d89923e6b945850db833e798a1969b19542a", size = 5555878 },
{ url = "https://files.pythonhosted.org/packages/af/48/14d952a73254b4b0e517141acd27979bd23948adaf197f6ca2dc722fde6a/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a057cdb0401e42c84b6516c9b1635f7aedd5e430c6e388bd5f6bcd1d6a0686bb", size = 1655301 },
{ url = "https://files.pythonhosted.org/packages/db/3f/b093e154e9752325d7459aa6dca43b7acbcaffa05133507e2403676e3e75/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dccf8d4fb5b86d39c581a59463c596b1d09df976da26ff04ae219604223d502f", size = 1678069 },
{ url = "https://files.pythonhosted.org/packages/d6/7e/88853ecae5b5456eb1a1d8a01cbd534e25b671735d5d974609cbae082542/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21d5b3793c6f5aecca595cd24164bf9d3c559e315ec684f912146fc4e769e367", size = 3137119 },
{ url = "https://files.pythonhosted.org/packages/4d/d2/b1f809b815aaf682ddac9c57929149f740b90feeb4f8da2f535c196de821/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:46a616c0e13cff2de1761b011e0b14bb73b110182f009223f1453d505c9a975c", size = 2491639 },
{ url = "https://files.pythonhosted.org/packages/61/e4/a908d7b8db6e52ba2f80f6f0d0709ef9fdedb767db4307084331742b67f0/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:19fa5bc4301a1ee55400d4a38a8ecf9522b0391fc31e6da5f4d68513fe5c0026", size = 7821561 },
{ url = "https://files.pythonhosted.org/packages/f3/83/0250c49deefff15c46f5e590d8ee6abbd0f056e20b85994db55c16ac6ead/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:544a47190a0d25971658a9365dba7095397b4ce3e897f7dd0a77ca2cf6fa984e", size = 2874048 },
{ url = "https://files.pythonhosted.org/packages/6c/3f/8d433d964c6e476476ee53eae5fa77b9f16b38d312eb1571e9099a6a3b12/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f21af27c5e001f0ba1b88c36a0936437dfe034c452548d998891c21125eb640f", size = 3522801 },
{ url = "https://files.pythonhosted.org/packages/82/85/4931bfa41ef837b1544838e46e0556640d18114b3da9cf05e10defff00ae/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b63170d9db00629b5b3f2862114d8d6ee19127eaba0eee43762d62a25817dbe0", size = 4567304 },
{ url = "https://files.pythonhosted.org/packages/b1/fe/fdae322869885115dd19a38c1da71b73a8832aa77757c93f460743d4f54c/rapidfuzz-3.12.2-cp312-cp312-win32.whl", hash = "sha256:6c7152d77b2eb6bfac7baa11f2a9c45fd5a2d848dbb310acd0953b3b789d95c9", size = 1845332 },
{ url = "https://files.pythonhosted.org/packages/ca/a4/2ccebda5fb8a266d163d57a42c2a6ef6f91815df5d89cf38c12e8aa6ed0b/rapidfuzz-3.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:1a314d170ee272ac87579f25a6cf8d16a031e1f7a7b07663434b41a1473bc501", size = 1617926 },
{ url = "https://files.pythonhosted.org/packages/a5/bc/aa8a4dc4ebff966dd039cce017c614cfd202049b4d1a2daafee7d018521b/rapidfuzz-3.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:d41e8231326e94fd07c4d8f424f6bed08fead6f5e6688d1e6e787f1443ae7631", size = 864737 },
{ url = "https://files.pythonhosted.org/packages/96/59/2ea3b5bb82798eae73d6ee892264ebfe42727626c1f0e96c77120f0d5cf6/rapidfuzz-3.12.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260", size = 1936870 },
{ url = "https://files.pythonhosted.org/packages/54/85/4e486bf9ea05e771ad231731305ed701db1339157f630b76b246ce29cf71/rapidfuzz-3.12.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438", size = 1424231 },
{ url = "https://files.pythonhosted.org/packages/dc/60/aeea3eed402c40a8cf055d554678769fbee0dd95c22f04546070a22bb90e/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714", size = 1398055 },
{ url = "https://files.pythonhosted.org/packages/33/6b/757106f4c21fe3f20ce13ba3df560da60e52fe0dc390fd22bf613761669c/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe", size = 5526188 },
{ url = "https://files.pythonhosted.org/packages/1e/a2/7c680cdc5532746dba67ecf302eed975252657094e50ae334fa9268352e8/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac", size = 1648483 },
{ url = "https://files.pythonhosted.org/packages/f6/b0/ce942a1448b1a75d64af230dd746dede502224dd29ca9001665bbfd4bee6/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14", size = 1676076 },
{ url = "https://files.pythonhosted.org/packages/ba/71/81f77b08333200be6984b6cdf2bdfd7cfca4943f16b478a2f7838cba8d66/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5", size = 3114169 },
{ url = "https://files.pythonhosted.org/packages/01/16/f3f34b207fdc8c61a33f9d2d61fc96b62c7dadca88bda1df1be4b94afb0b/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1", size = 2485317 },
{ url = "https://files.pythonhosted.org/packages/b2/a6/b954f0766f644eb8dd8df44703e024ab4f5f15a8f8f5ea969963dd036f50/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705", size = 7844495 },
{ url = "https://files.pythonhosted.org/packages/fb/8f/1dc604d05e07150a02b56a8ffc47df75ce316c65467259622c9edf098451/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208", size = 2873242 },
{ url = "https://files.pythonhosted.org/packages/78/a9/9c649ace4b7f885e0a5fdcd1f33b057ebd83ecc2837693e6659bd944a2bb/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41", size = 3519124 },
{ url = "https://files.pythonhosted.org/packages/f5/81/ce0b774e540a2e22ec802e383131d7ead18347197304d584c4ccf7b8861a/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba", size = 4557831 },
{ url = "https://files.pythonhosted.org/packages/13/28/7bf0ee8d35efa7ab14e83d1795cdfd54833aa0428b6f87e987893136c372/rapidfuzz-3.12.2-cp313-cp313-win32.whl", hash = "sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090", size = 1842802 },
{ url = "https://files.pythonhosted.org/packages/ef/7e/792d609484776c8a40e1695ebd28b62196be9f8347b785b9104604dc7268/rapidfuzz-3.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2", size = 1615808 },
{ url = "https://files.pythonhosted.org/packages/4b/43/ca3d1018b392f49131843648e10b08ace23afe8dad3bee5f136e4346b7cd/rapidfuzz-3.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34", size = 863535 },
{ url = "https://files.pythonhosted.org/packages/15/67/e35d9193badb9e5c2271af2619fcdc5c5bfc3eded2f1290aa623cf12ac64/rapidfuzz-3.12.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c4c852cd8bed1516a64fd6e2d4c6f270d4356196ee03fda2af1e5a9e13c34643", size = 1963182 },
{ url = "https://files.pythonhosted.org/packages/f1/62/ba3fc527043f3aedc9260e249aea7ad284878fa97e57e2fdf3b8c253bed8/rapidfuzz-3.12.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42e7f747b55529a6d0d1588695d71025e884ab48664dca54b840413dea4588d8", size = 1436741 },
{ url = "https://files.pythonhosted.org/packages/f4/ae/2133b1a9a96e23e0d4f8b050681aee12560f7fc37982f815c8b86b2a3978/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a749fd2690f24ef256b264a781487746bbb95344364fe8fe356f0eef7ef206ba", size = 1431434 },
{ url = "https://files.pythonhosted.org/packages/cf/f8/4236af04f4de6609a7b392fbad010caf4dd69694399d7dac4db188408887/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a11e1d036170bbafa43a9e63d8c309273564ec5bdfc5439062f439d1a16965a", size = 5641842 },
{ url = "https://files.pythonhosted.org/packages/97/39/2f5c3973abda8cf80666922204bab408f8b8538a010c2797b38edf12d80c/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfb337f1832c1231e3d5621bd0ebebb854e46036aedae3e6a49c1fc08f16f249", size = 1678859 },
{ url = "https://files.pythonhosted.org/packages/d3/63/2732e64ae6e42c6a72cb66549d968fb85be17456780a0a080328781f86cd/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e88c6e68fca301722fa3ab7fd3ca46998012c14ada577bc1e2c2fc04f2067ca6", size = 1682144 },
{ url = "https://files.pythonhosted.org/packages/7f/0b/22a4299b534a24c660a0bba597834320943b76692d65ec648767833adfdf/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e1a3a8b4b5125cfb63a6990459b25b87ea769bdaf90d05bb143f8febef076a", size = 3147458 },
{ url = "https://files.pythonhosted.org/packages/d4/0c/beb68a732668f29e2d1ac24100c70ab83694b111291a855d7107fdd15d17/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9f8177b24ccc0a843e85932b1088c5e467a7dd7a181c13f84c684b796bea815", size = 2519335 },
{ url = "https://files.pythonhosted.org/packages/37/c3/1a60df1bfe4145552f0afd23aeeedfe060dd1db2fae1106c3fe9966265a0/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6c506bdc2f304051592c0d3b0e82eed309248ec10cdf802f13220251358375ea", size = 7862504 },
{ url = "https://files.pythonhosted.org/packages/c3/70/8faebb311218fb9d4c92549dc0283a2fb9082a585463153310f627c2f727/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:30bf15c1ecec2798b713d551df17f23401a3e3653ad9ed4e83ad1c2b06e86100", size = 2899948 },
{ url = "https://files.pythonhosted.org/packages/9b/80/e512f552ef64dd43f0359633f59293515276ae47d853abc42eb914be1df5/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bd9a67cfc83e8453ef17ddd1c2c4ce4a74d448a197764efb54c29f29fb41f611", size = 3547701 },
{ url = "https://files.pythonhosted.org/packages/2a/0e/8d5eff5de34846da426c93460c130672908cdf5fb1967cd23b3367c03e5d/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6eaec2ef658dd650c6eb9b36dff7a361ebd7d8bea990ce9d639b911673b2cb", size = 4583294 },
{ url = "https://files.pythonhosted.org/packages/02/9f/2be30d436ebf13d89d19abc8c6b1a4cdbef3f343daac10c3b89fd039a6ef/rapidfuzz-3.12.2-cp39-cp39-win32.whl", hash = "sha256:d7701769f110332cde45c41759cb2a497de8d2dca55e4c519a46aed5fbb19d1a", size = 1865017 },
{ url = "https://files.pythonhosted.org/packages/e7/c9/780b83ce66b5e1115b017fed1f4144ada00bf2e2406fa6c8809481ab0c29/rapidfuzz-3.12.2-cp39-cp39-win_amd64.whl", hash = "sha256:296bf0fd4f678488670e262c87a3e4f91900b942d73ae38caa42a417e53643b1", size = 1627824 },
{ url = "https://files.pythonhosted.org/packages/2a/72/94c45478866bced213aa36cf3de08ed061434352c2b92584f4a1ef170697/rapidfuzz-3.12.2-cp39-cp39-win_arm64.whl", hash = "sha256:7957f5d768de14f6b2715303ccdf224b78416738ee95a028a2965c95f73afbfb", size = 871672 },
{ url = "https://files.pythonhosted.org/packages/92/77/a72abb16c5cb093980570871aa152e6d47fc9cf2482daeea9687708be655/rapidfuzz-3.12.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e5fd3ce849b27d063755829cda27a9dab6dbd63be3801f2a40c60ec563a4c90f", size = 1858463 },
{ url = "https://files.pythonhosted.org/packages/8c/93/06a29076722ef6b05a81132eac9847592185ee97a1dadc7ead2f37334ebe/rapidfuzz-3.12.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:54e53662d71ed660c83c5109127c8e30b9e607884b7c45d2aff7929bbbd00589", size = 1368517 },
{ url = "https://files.pythonhosted.org/packages/f9/4f/36e8ae37e82a617b8d8da8162744bf69b15091743c3f70699090cb793dd5/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b9e43cf2213e524f3309d329f1ad8dbf658db004ed44f6ae1cd2919aa997da5", size = 1364411 },
{ url = "https://files.pythonhosted.org/packages/63/f5/ac535622eb163b9a242c40633587916e71f23233bcd6e3d3e70ae2a99a4c/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29ca445e320e5a8df3bd1d75b4fa4ecfa7c681942b9ac65b55168070a1a1960e", size = 5486500 },
{ url = "https://files.pythonhosted.org/packages/6f/de/87fcb20fda640a2cf0cebe4b0dc3ab970b1ef8a9d48d05363e375fc05982/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83eb7ef732c2f8533c6b5fbe69858a722c218acc3e1fc190ab6924a8af7e7e0e", size = 3064900 },
{ url = "https://files.pythonhosted.org/packages/c3/67/c7c4129e8b8b674a7b1d82edc36ed093418fdcf011e3a25150895b24a963/rapidfuzz-3.12.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:648adc2dd2cf873efc23befcc6e75754e204a409dfa77efd0fea30d08f22ef9d", size = 1555181 },
{ url = "https://files.pythonhosted.org/packages/ee/4d/e910b70839d88d1c38ba806b0ddaa94b478cca8a09f4e7155b2b607c34b2/rapidfuzz-3.12.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b1e6f48e1ffa0749261ee23a1c6462bdd0be5eac83093f4711de17a42ae78ad", size = 1860425 },
{ url = "https://files.pythonhosted.org/packages/fd/62/54914f63e185539fbcca65acb1f7c879740a278d240527ed5ddd40bd7690/rapidfuzz-3.12.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ae9ded463f2ca4ba1eb762913c5f14c23d2e120739a62b7f4cc102eab32dc90", size = 1369066 },
{ url = "https://files.pythonhosted.org/packages/56/4a/de2cfab279497d0b2529d3fec398f60cf8e27a51d667b6529081fbdb0af2/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dda45f47b559be72ecbce45c7f71dc7c97b9772630ab0f3286d97d2c3025ab71", size = 1365330 },
{ url = "https://files.pythonhosted.org/packages/dd/48/170c37cfdf04efa34e7cafc688a8517c9098c1d27e1513393ad71bf3165c/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3745c6443890265513a3c8777f2de4cb897aeb906a406f97741019be8ad5bcc", size = 5481251 },
{ url = "https://files.pythonhosted.org/packages/4e/2d/107c489443f6438780d2e40747d5880c8d9374a64e17487eb4085fe7f1f5/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d3ef4f047ed1bc96fa29289f9e67a637ddca5e4f4d3dc7cb7f50eb33ec1664", size = 3060633 },
{ url = "https://files.pythonhosted.org/packages/09/f6/fa777f336629aee8938f3d5c95c09df38459d4eadbdbe34642889857fb6a/rapidfuzz-3.12.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:54bb69ebe5ca0bd7527357e348f16a4c0c52fe0c2fcc8a041010467dcb8385f7", size = 1555000 },
{ url = "https://files.pythonhosted.org/packages/c1/89/43139cfdcd523024fcef1a5a6f2544f25919d80d18fe495be7e7275ed0ec/rapidfuzz-3.12.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3f2ddd5b99b254039a8c82be5749d4d75943f62eb2c2918acf6ffd586852834f", size = 1863971 },
{ url = "https://files.pythonhosted.org/packages/be/c9/b37bc91ec12dedc8d7eff0aeb921909b51e6593f4264c9927a4e04a1f8ea/rapidfuzz-3.12.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8117dab9b26a1aaffab59b4e30f80ac4d55e61ad4139a637c149365960933bee", size = 1373461 },
{ url = "https://files.pythonhosted.org/packages/56/4f/0e4844c0e0848de9993f453337e0e7255f687da37545e539cf000b41a74c/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40c0f16d62d6553527de3dab2fb69709c4383430ea44bce8fb4711ed4cbc6ae3", size = 1372105 },
{ url = "https://files.pythonhosted.org/packages/5c/87/59dc6c5b3601c476ac12d0f978607c618daa1b35e3805a7092a91bf7c2d2/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f177e1eb6e4f5261a89c475e21bce7a99064a8f217d2336fb897408f46f0ceaf", size = 5486722 },
{ url = "https://files.pythonhosted.org/packages/27/87/d041dc29a99e376ebb5a7c35d11e1a52c5a5a962543c4d81bcbea958e56e/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df0cecc2852fcb078ed1b4482fac4fc2c2e7787f3edda8920d9a4c0f51b1c95", size = 3071880 },
{ url = "https://files.pythonhosted.org/packages/a1/51/0d7b1eecd83982fe190baa8ea7060307854436e349bc8ccc4dcea5087ff4/rapidfuzz-3.12.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3b3c4df0321df6f8f0b61afbaa2ced9622750ee1e619128db57a18533d139820", size = 1556257 },
]
[[package]]
name = "regex"
version = "2024.11.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/3c/4651f6b130c6842a8f3df82461a8950f923925db8b6961063e82744bddcc/regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91", size = 482674 },
{ url = "https://files.pythonhosted.org/packages/15/51/9f35d12da8434b489c7b7bffc205c474a0a9432a889457026e9bc06a297a/regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0", size = 287684 },
{ url = "https://files.pythonhosted.org/packages/bd/18/b731f5510d1b8fb63c6b6d3484bfa9a59b84cc578ac8b5172970e05ae07c/regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e", size = 284589 },
{ url = "https://files.pythonhosted.org/packages/78/a2/6dd36e16341ab95e4c6073426561b9bfdeb1a9c9b63ab1b579c2e96cb105/regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde", size = 782511 },
{ url = "https://files.pythonhosted.org/packages/1b/2b/323e72d5d2fd8de0d9baa443e1ed70363ed7e7b2fb526f5950c5cb99c364/regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e", size = 821149 },
{ url = "https://files.pythonhosted.org/packages/90/30/63373b9ea468fbef8a907fd273e5c329b8c9535fee36fc8dba5fecac475d/regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2", size = 809707 },
{ url = "https://files.pythonhosted.org/packages/f2/98/26d3830875b53071f1f0ae6d547f1d98e964dd29ad35cbf94439120bb67a/regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf", size = 781702 },
{ url = "https://files.pythonhosted.org/packages/87/55/eb2a068334274db86208ab9d5599ffa63631b9f0f67ed70ea7c82a69bbc8/regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c", size = 771976 },
{ url = "https://files.pythonhosted.org/packages/74/c0/be707bcfe98254d8f9d2cff55d216e946f4ea48ad2fd8cf1428f8c5332ba/regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86", size = 697397 },
{ url = "https://files.pythonhosted.org/packages/49/dc/bb45572ceb49e0f6509f7596e4ba7031f6819ecb26bc7610979af5a77f45/regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67", size = 768726 },
{ url = "https://files.pythonhosted.org/packages/5a/db/f43fd75dc4c0c2d96d0881967897926942e935d700863666f3c844a72ce6/regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d", size = 775098 },
{ url = "https://files.pythonhosted.org/packages/99/d7/f94154db29ab5a89d69ff893159b19ada89e76b915c1293e98603d39838c/regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2", size = 839325 },
{ url = "https://files.pythonhosted.org/packages/f7/17/3cbfab1f23356fbbf07708220ab438a7efa1e0f34195bf857433f79f1788/regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008", size = 843277 },
{ url = "https://files.pythonhosted.org/packages/7e/f2/48b393b51900456155de3ad001900f94298965e1cad1c772b87f9cfea011/regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62", size = 773197 },
{ url = "https://files.pythonhosted.org/packages/45/3f/ef9589aba93e084cd3f8471fded352826dcae8489b650d0b9b27bc5bba8a/regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e", size = 261714 },
{ url = "https://files.pythonhosted.org/packages/42/7e/5f1b92c8468290c465fd50c5318da64319133231415a8aa6ea5ab995a815/regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519", size = 274042 },
{ url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669 },
{ url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684 },
{ url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589 },
{ url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121 },
{ url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275 },
{ url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257 },
{ url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727 },
{ url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667 },
{ url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963 },
{ url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700 },
{ url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592 },
{ url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929 },
{ url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213 },
{ url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734 },
{ url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052 },
{ url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 },
{ url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 },
{ url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 },
{ url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 },
{ url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 },
{ url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 },
{ url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 },
{ url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 },
{ url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 },
{ url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 },
{ url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 },
{ url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 },
{ url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 },
{ url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 },
{ url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 },
{ url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 },
{ url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 },
{ url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 },
{ url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 },
{ url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 },
{ url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 },
{ url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 },
{ url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 },
{ url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 },
{ url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 },
{ url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 },
{ url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 },
{ url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 },
{ url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 },
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 },
{ url = "https://files.pythonhosted.org/packages/89/23/c4a86df398e57e26f93b13ae63acce58771e04bdde86092502496fa57f9c/regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839", size = 482682 },
{ url = "https://files.pythonhosted.org/packages/3c/8b/45c24ab7a51a1658441b961b86209c43e6bb9d39caf1e63f46ce6ea03bc7/regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e", size = 287679 },
{ url = "https://files.pythonhosted.org/packages/7a/d1/598de10b17fdafc452d11f7dada11c3be4e379a8671393e4e3da3c4070df/regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf", size = 284578 },
{ url = "https://files.pythonhosted.org/packages/49/70/c7eaa219efa67a215846766fde18d92d54cb590b6a04ffe43cef30057622/regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b", size = 782012 },
{ url = "https://files.pythonhosted.org/packages/89/e5/ef52c7eb117dd20ff1697968219971d052138965a4d3d9b95e92e549f505/regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0", size = 820580 },
{ url = "https://files.pythonhosted.org/packages/5f/3f/9f5da81aff1d4167ac52711acf789df13e789fe6ac9545552e49138e3282/regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b", size = 809110 },
{ url = "https://files.pythonhosted.org/packages/86/44/2101cc0890c3621b90365c9ee8d7291a597c0722ad66eccd6ffa7f1bcc09/regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef", size = 780919 },
{ url = "https://files.pythonhosted.org/packages/ce/2e/3e0668d8d1c7c3c0d397bf54d92fc182575b3a26939aed5000d3cc78760f/regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48", size = 771515 },
{ url = "https://files.pythonhosted.org/packages/a6/49/1bc4584254355e3dba930a3a2fd7ad26ccba3ebbab7d9100db0aff2eedb0/regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13", size = 696957 },
{ url = "https://files.pythonhosted.org/packages/c8/dd/42879c1fc8a37a887cd08e358af3d3ba9e23038cd77c7fe044a86d9450ba/regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2", size = 768088 },
{ url = "https://files.pythonhosted.org/packages/89/96/c05a0fe173cd2acd29d5e13c1adad8b706bcaa71b169e1ee57dcf2e74584/regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95", size = 774752 },
{ url = "https://files.pythonhosted.org/packages/b5/f3/a757748066255f97f14506483436c5f6aded7af9e37bca04ec30c90ca683/regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9", size = 838862 },
{ url = "https://files.pythonhosted.org/packages/5c/93/c6d2092fd479dcaeea40fc8fa673822829181ded77d294a7f950f1dda6e2/regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f", size = 842622 },
{ url = "https://files.pythonhosted.org/packages/ff/9c/daa99532c72f25051a90ef90e1413a8d54413a9e64614d9095b0c1c154d0/regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b", size = 772713 },
{ url = "https://files.pythonhosted.org/packages/13/5d/61a533ccb8c231b474ac8e3a7d70155b00dfc61af6cafdccd1947df6d735/regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57", size = 261756 },
{ url = "https://files.pythonhosted.org/packages/dc/7b/e59b7f7c91ae110d154370c24133f947262525b5d6406df65f23422acc17/regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983", size = 274110 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "urllib3", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "rsa"
version = "4.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 },
]
[[package]]
name = "ruff"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158 },
{ url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071 },
{ url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944 },
{ url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725 },
{ url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435 },
{ url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664 },
{ url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856 },
{ url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156 },
{ url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167 },
{ url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311 },
{ url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039 },
{ url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939 },
{ url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259 },
{ url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212 },
{ url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905 },
{ url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730 },
{ url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 },
]
[[package]]
name = "s3transfer"
version = "0.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/24/1390172471d569e281fcfd29b92f2f73774e95972c965d14b6c802ff2352/s3transfer-0.11.3.tar.gz", hash = "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a", size = 148042 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/81/48c41b554a54d75d4407740abb60e3a102ae416284df04d1dbdcbe3dbf24/s3transfer-0.11.3-py3-none-any.whl", hash = "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d", size = 84246 },
]
[[package]]
name = "safetensors"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/7e/2d5d6ee7b40c0682315367ec7475693d110f512922d582fef1bd4a63adc3/safetensors-0.5.3.tar.gz", hash = "sha256:b6b0d6ecacec39a4fdd99cc19f4576f5219ce858e6fd8dbe7609df0b8dc56965", size = 67210 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/ae/88f6c49dbd0cc4da0e08610019a3c78a7d390879a919411a410a1876d03a/safetensors-0.5.3-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd20eb133db8ed15b40110b7c00c6df51655a2998132193de2f75f72d99c7073", size = 436917 },
{ url = "https://files.pythonhosted.org/packages/b8/3b/11f1b4a2f5d2ab7da34ecc062b0bc301f2be024d110a6466726bec8c055c/safetensors-0.5.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:21d01c14ff6c415c485616b8b0bf961c46b3b343ca59110d38d744e577f9cce7", size = 418419 },
{ url = "https://files.pythonhosted.org/packages/5d/9a/add3e6fef267658075c5a41573c26d42d80c935cdc992384dfae435feaef/safetensors-0.5.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11bce6164887cd491ca75c2326a113ba934be596e22b28b1742ce27b1d076467", size = 459493 },
{ url = "https://files.pythonhosted.org/packages/df/5c/bf2cae92222513cc23b3ff85c4a1bb2811a2c3583ac0f8e8d502751de934/safetensors-0.5.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a243be3590bc3301c821da7a18d87224ef35cbd3e5f5727e4e0728b8172411e", size = 472400 },
{ url = "https://files.pythonhosted.org/packages/58/11/7456afb740bd45782d0f4c8e8e1bb9e572f1bf82899fb6ace58af47b4282/safetensors-0.5.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8bd84b12b1670a6f8e50f01e28156422a2bc07fb16fc4e98bded13039d688a0d", size = 522891 },
{ url = "https://files.pythonhosted.org/packages/57/3d/fe73a9d2ace487e7285f6e157afee2383bd1ddb911b7cb44a55cf812eae3/safetensors-0.5.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:391ac8cab7c829452175f871fcaf414aa1e292b5448bd02620f675a7f3e7abb9", size = 537694 },
{ url = "https://files.pythonhosted.org/packages/a6/f8/dae3421624fcc87a89d42e1898a798bc7ff72c61f38973a65d60df8f124c/safetensors-0.5.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cead1fa41fc54b1e61089fa57452e8834f798cb1dc7a09ba3524f1eb08e0317a", size = 471642 },
{ url = "https://files.pythonhosted.org/packages/ce/20/1fbe16f9b815f6c5a672f5b760951e20e17e43f67f231428f871909a37f6/safetensors-0.5.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1077f3e94182d72618357b04b5ced540ceb71c8a813d3319f1aba448e68a770d", size = 502241 },
{ url = "https://files.pythonhosted.org/packages/5f/18/8e108846b506487aa4629fe4116b27db65c3dde922de2c8e0cc1133f3f29/safetensors-0.5.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:799021e78287bac619c7b3f3606730a22da4cda27759ddf55d37c8db7511c74b", size = 638001 },
{ url = "https://files.pythonhosted.org/packages/82/5a/c116111d8291af6c8c8a8b40628fe833b9db97d8141c2a82359d14d9e078/safetensors-0.5.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df26da01aaac504334644e1b7642fa000bfec820e7cef83aeac4e355e03195ff", size = 734013 },
{ url = "https://files.pythonhosted.org/packages/7d/ff/41fcc4d3b7de837963622e8610d998710705bbde9a8a17221d85e5d0baad/safetensors-0.5.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:32c3ef2d7af8b9f52ff685ed0bc43913cdcde135089ae322ee576de93eae5135", size = 670687 },
{ url = "https://files.pythonhosted.org/packages/40/ad/2b113098e69c985a3d8fbda4b902778eae4a35b7d5188859b4a63d30c161/safetensors-0.5.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:37f1521be045e56fc2b54c606d4455573e717b2d887c579ee1dbba5f868ece04", size = 643147 },
{ url = "https://files.pythonhosted.org/packages/0a/0c/95aeb51d4246bd9a3242d3d8349c1112b4ee7611a4b40f0c5c93b05f001d/safetensors-0.5.3-cp38-abi3-win32.whl", hash = "sha256:cfc0ec0846dcf6763b0ed3d1846ff36008c6e7290683b61616c4b040f6a54ace", size = 296677 },
{ url = "https://files.pythonhosted.org/packages/69/e2/b011c38e5394c4c18fb5500778a55ec43ad6106126e74723ffaee246f56e/safetensors-0.5.3-cp38-abi3-win_amd64.whl", hash = "sha256:836cbbc320b47e80acd40e44c8682db0e8ad7123209f69b093def21ec7cafd11", size = 308878 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "sounddevice"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/2d/b04ae180312b81dbb694504bee170eada5372242e186f6298139fd3a0513/sounddevice-0.5.1.tar.gz", hash = "sha256:09ca991daeda8ce4be9ac91e15a9a81c8f81efa6b695a348c9171ea0c16cb041", size = 52896 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/d1/464b5fca3decdd0cfec8c47f7b4161a0b12972453201c1bf03811f367c5e/sounddevice-0.5.1-py3-none-any.whl", hash = "sha256:e2017f182888c3f3c280d9fbac92e5dbddac024a7e3442f6e6116bd79dab8a9c", size = 32276 },
{ url = "https://files.pythonhosted.org/packages/6f/f6/6703fe7cf3d7b7279040c792aeec6334e7305956aba4a80f23e62c8fdc44/sounddevice-0.5.1-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:d16cb23d92322526a86a9490c427bf8d49e273d9ccc0bd096feecd229cde6031", size = 107916 },
{ url = "https://files.pythonhosted.org/packages/57/a5/78a5e71f5ec0faedc54f4053775d61407bfbd7d0c18228c7f3d4252fd276/sounddevice-0.5.1-py3-none-win32.whl", hash = "sha256:d84cc6231526e7a08e89beff229c37f762baefe5e0cc2747cbe8e3a565470055", size = 312494 },
{ url = "https://files.pythonhosted.org/packages/af/9b/15217b04f3b36d30de55fef542389d722de63f1ad81f9c72d8afc98cb6ab/sounddevice-0.5.1-py3-none-win_amd64.whl", hash = "sha256:4313b63f2076552b23ac3e0abd3bcfc0c1c6a696fc356759a13bd113c9df90f1", size = 363634 },
]
[[package]]
name = "sympy"
version = "1.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/8a/5a7fd6284fa8caac23a26c9ddf9c30485a48169344b4bd3b0f02fef1890f/sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9", size = 7533196 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/ff/c87e0622b1dadea79d2fb0b25ade9ed98954c9033722eb707053d310d4f3/sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73", size = 6189483 },
]
[[package]]
name = "tokenizers"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767 },
{ url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555 },
{ url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541 },
{ url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058 },
{ url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278 },
{ url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253 },
{ url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225 },
{ url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874 },
{ url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448 },
{ url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877 },
{ url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645 },
{ url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380 },
{ url = "https://files.pythonhosted.org/packages/e8/5b/a5d98c89f747455e8b7a9504910c865d5e51da55e825a7ae641fb5ff0a58/tokenizers-0.21.1-cp39-abi3-win32.whl", hash = "sha256:1039a3a5734944e09de1d48761ade94e00d0fa760c0e0551151d4dd851ba63e3", size = 2239506 },
{ url = "https://files.pythonhosted.org/packages/e6/b6/072a8e053ae600dcc2ac0da81a23548e3b523301a442a6ca900e92ac35be/tokenizers-0.21.1-cp39-abi3-win_amd64.whl", hash = "sha256:0f0dcbcc9f6e13e675a66d7a5f2f225a736745ce484c1a4e07476a89ccdad382", size = 2435481 },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 },
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 },
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 },
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 },
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 },
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 },
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 },
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 },
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
]
[[package]]
name = "transformers"
version = "4.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "huggingface-hub" },
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "numpy", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "requests" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/50/46573150944f46df8ec968eda854023165a84470b42f69f67c7d475dabc5/transformers-4.49.0.tar.gz", hash = "sha256:7e40e640b5b8dc3f48743f5f5adbdce3660c82baafbd3afdfc04143cdbd2089e", size = 8610952 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/37/1f29af63e9c30156a3ed6ebc2754077016577c094f31de7b2631e5d379eb/transformers-4.49.0-py3-none-any.whl", hash = "sha256:6b4fded1c5fee04d384b1014495b4235a2b53c87503d7d592423c06128cbbe03", size = 9970275 },
]
[[package]]
name = "types-protobuf"
version = "4.25.0.20240417"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/21/757620113af23233496c04b8a66e0201e78695495b1db8e676672608588b/types-protobuf-4.25.0.20240417.tar.gz", hash = "sha256:c34eff17b9b3a0adb6830622f0f302484e4c089f533a46e3f147568313544352", size = 53340 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/78/6f0351f80a682c4005775c7e1fd2b17235b88d87c85ef59214bd1f60ff60/types_protobuf-4.25.0.20240417-py3-none-any.whl", hash = "sha256:e9b613227c2127e3d4881d75d93c93b4d6fd97b5f6a099a0b654a05351c8685d", size = 67896 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "urllib3"
version = "1.26.20"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225 },
]
[[package]]
name = "urllib3"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version >= '3.11' and python_full_version < '3.13'",
"python_full_version == '3.10.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
]
[[package]]
name = "watchfiles"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/26/c705fc77d0a9ecdb9b66f1e2976d95b81df3cae518967431e7dbf9b5e219/watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205", size = 94625 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/02/22fcaed0396730b0d362bc8d1ffb3be2658fd473eecbb2ba84243e157f11/watchfiles-1.0.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ba5bb3073d9db37c64520681dd2650f8bd40902d991e7b4cfaeece3e32561d08", size = 395212 },
{ url = "https://files.pythonhosted.org/packages/e9/3d/ec5a2369a46edf3ebe092c39d9ae48e8cb6dacbde51c4b4f98936c524269/watchfiles-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f25d0ba0fe2b6d2c921cf587b2bf4c451860086534f40c384329fb96e2044d1", size = 384815 },
{ url = "https://files.pythonhosted.org/packages/df/b4/898991cececbe171e67142c31905510203649569d9817848f47c4177ee42/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47eb32ef8c729dbc4f4273baece89398a4d4b5d21a1493efea77a17059f4df8a", size = 450680 },
{ url = "https://files.pythonhosted.org/packages/58/f7/d4aa3000e812cfb5e5c2c6c0a3ec9d0a46a42489a8727edd160631c4e210/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:076f293100db3b0b634514aa0d294b941daa85fc777f9c698adb1009e5aca0b1", size = 455923 },
{ url = "https://files.pythonhosted.org/packages/dd/95/7e2e4c6aba1b02fb5c76d2f6a450b85215921ec5f8f7ad5efd075369563f/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1eacd91daeb5158c598fe22d7ce66d60878b6294a86477a4715154990394c9b3", size = 482339 },
{ url = "https://files.pythonhosted.org/packages/bb/67/4265b0fabcc2ef2c9e3e8802ba7908cf718a357ebfb49c72e53787156a48/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13c2ce7b72026cfbca120d652f02c7750f33b4c9395d79c9790b27f014c8a5a2", size = 519908 },
{ url = "https://files.pythonhosted.org/packages/0d/96/b57802d5f8164bdf070befb4fd3dec4edba5a364ec0670965a97eb8098ce/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90192cdc15ab7254caa7765a98132a5a41471cf739513cc9bcf7d2ffcc0ec7b2", size = 501410 },
{ url = "https://files.pythonhosted.org/packages/8b/18/6db0de4e8911ba14e31853201b40c0fa9fea5ecf3feb86b0ad58f006dfc3/watchfiles-1.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278aaa395f405972e9f523bd786ed59dfb61e4b827856be46a42130605fd0899", size = 452876 },
{ url = "https://files.pythonhosted.org/packages/df/df/092a961815edf723a38ba2638c49491365943919c3526cc9cf82c42786a6/watchfiles-1.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a462490e75e466edbb9fc4cd679b62187153b3ba804868452ef0577ec958f5ff", size = 615353 },
{ url = "https://files.pythonhosted.org/packages/f3/cf/b85fe645de4ff82f3f436c5e9032379fce37c303f6396a18f9726cc34519/watchfiles-1.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8d0d0630930f5cd5af929040e0778cf676a46775753e442a3f60511f2409f48f", size = 613187 },
{ url = "https://files.pythonhosted.org/packages/f6/d4/a9fea27aef4dd69689bc3556718c1157a7accb72aa035ece87c1fa8483b5/watchfiles-1.0.4-cp310-cp310-win32.whl", hash = "sha256:cc27a65069bcabac4552f34fd2dce923ce3fcde0721a16e4fb1b466d63ec831f", size = 270799 },
{ url = "https://files.pythonhosted.org/packages/df/02/dbe9d4439f15dd4ad0720b6e039bde9d66d1f830331f34c18eb70fa6608e/watchfiles-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:8b1f135238e75d075359cf506b27bf3f4ca12029c47d3e769d8593a2024ce161", size = 284145 },
{ url = "https://files.pythonhosted.org/packages/0f/bb/8461adc4b1fed009546fb797fc0d5698dcfe5e289cb37e1b8f16a93cdc30/watchfiles-1.0.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2a9f93f8439639dc244c4d2902abe35b0279102bca7bbcf119af964f51d53c19", size = 394869 },
{ url = "https://files.pythonhosted.org/packages/55/88/9ebf36b3547176d1709c320de78c1fa3263a46be31b5b1267571d9102686/watchfiles-1.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eea33ad8c418847dd296e61eb683cae1c63329b6d854aefcd412e12d94ee235", size = 384905 },
{ url = "https://files.pythonhosted.org/packages/03/8a/04335ce23ef78d8c69f0913e8b20cf7d9233e3986543aeef95ef2d6e43d2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31f1a379c9dcbb3f09cf6be1b7e83b67c0e9faabed0471556d9438a4a4e14202", size = 449944 },
{ url = "https://files.pythonhosted.org/packages/17/4e/c8d5dcd14fe637f4633616dabea8a4af0a10142dccf3b43e0f081ba81ab4/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab594e75644421ae0a2484554832ca5895f8cab5ab62de30a1a57db460ce06c6", size = 456020 },
{ url = "https://files.pythonhosted.org/packages/5e/74/3e91e09e1861dd7fbb1190ce7bd786700dc0fbc2ccd33bb9fff5de039229/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc2eb5d14a8e0d5df7b36288979176fbb39672d45184fc4b1c004d7c3ce29317", size = 482983 },
{ url = "https://files.pythonhosted.org/packages/a1/3d/e64de2d1ce4eb6a574fd78ce3a28c279da263be9ef3cfcab6f708df192f2/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f68d8e9d5a321163ddacebe97091000955a1b74cd43724e346056030b0bacee", size = 520320 },
{ url = "https://files.pythonhosted.org/packages/2c/bd/52235f7063b57240c66a991696ed27e2a18bd6fcec8a1ea5a040b70d0611/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9ce064e81fe79faa925ff03b9f4c1a98b0bbb4a1b8c1b015afa93030cb21a49", size = 500988 },
{ url = "https://files.pythonhosted.org/packages/3a/b0/ff04194141a5fe650c150400dd9e42667916bc0f52426e2e174d779b8a74/watchfiles-1.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b77d5622ac5cc91d21ae9c2b284b5d5c51085a0bdb7b518dba263d0af006132c", size = 452573 },
{ url = "https://files.pythonhosted.org/packages/3d/9d/966164332c5a178444ae6d165082d4f351bd56afd9c3ec828eecbf190e6a/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1941b4e39de9b38b868a69b911df5e89dc43767feeda667b40ae032522b9b5f1", size = 615114 },
{ url = "https://files.pythonhosted.org/packages/94/df/f569ae4c1877f96ad4086c153a8eee5a19a3b519487bf5c9454a3438c341/watchfiles-1.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f8c4998506241dedf59613082d1c18b836e26ef2a4caecad0ec41e2a15e4226", size = 613076 },
{ url = "https://files.pythonhosted.org/packages/15/ae/8ce5f29e65d5fa5790e3c80c289819c55e12be2e1b9f5b6a0e55e169b97d/watchfiles-1.0.4-cp311-cp311-win32.whl", hash = "sha256:4ebbeca9360c830766b9f0df3640b791be569d988f4be6c06d6fae41f187f105", size = 271013 },
{ url = "https://files.pythonhosted.org/packages/a4/c6/79dc4a7c598a978e5fafa135090aaf7bbb03b8dec7bada437dfbe578e7ed/watchfiles-1.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:05d341c71f3d7098920f8551d4df47f7b57ac5b8dad56558064c3431bdfc0b74", size = 284229 },
{ url = "https://files.pythonhosted.org/packages/37/3d/928633723211753f3500bfb138434f080363b87a1b08ca188b1ce54d1e05/watchfiles-1.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:32b026a6ab64245b584acf4931fe21842374da82372d5c039cba6bf99ef722f3", size = 276824 },
{ url = "https://files.pythonhosted.org/packages/5b/1a/8f4d9a1461709756ace48c98f07772bc6d4519b1e48b5fa24a4061216256/watchfiles-1.0.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:229e6ec880eca20e0ba2f7e2249c85bae1999d330161f45c78d160832e026ee2", size = 391345 },
{ url = "https://files.pythonhosted.org/packages/bc/d2/6750b7b3527b1cdaa33731438432e7238a6c6c40a9924049e4cebfa40805/watchfiles-1.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5717021b199e8353782dce03bd8a8f64438832b84e2885c4a645f9723bf656d9", size = 381515 },
{ url = "https://files.pythonhosted.org/packages/4e/17/80500e42363deef1e4b4818729ed939aaddc56f82f4e72b2508729dd3c6b/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0799ae68dfa95136dde7c472525700bd48777875a4abb2ee454e3ab18e9fc712", size = 449767 },
{ url = "https://files.pythonhosted.org/packages/10/37/1427fa4cfa09adbe04b1e97bced19a29a3462cc64c78630787b613a23f18/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43b168bba889886b62edb0397cab5b6490ffb656ee2fcb22dec8bfeb371a9e12", size = 455677 },
{ url = "https://files.pythonhosted.org/packages/c5/7a/39e9397f3a19cb549a7d380412fd9e507d4854eddc0700bfad10ef6d4dba/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb2c46e275fbb9f0c92e7654b231543c7bbfa1df07cdc4b99fa73bedfde5c844", size = 482219 },
{ url = "https://files.pythonhosted.org/packages/45/2d/7113931a77e2ea4436cad0c1690c09a40a7f31d366f79c6f0a5bc7a4f6d5/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:857f5fc3aa027ff5e57047da93f96e908a35fe602d24f5e5d8ce64bf1f2fc733", size = 518830 },
{ url = "https://files.pythonhosted.org/packages/f9/1b/50733b1980fa81ef3c70388a546481ae5fa4c2080040100cd7bf3bf7b321/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55ccfd27c497b228581e2838d4386301227fc0cb47f5a12923ec2fe4f97b95af", size = 497997 },
{ url = "https://files.pythonhosted.org/packages/2b/b4/9396cc61b948ef18943e7c85ecfa64cf940c88977d882da57147f62b34b1/watchfiles-1.0.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c11ea22304d17d4385067588123658e9f23159225a27b983f343fcffc3e796a", size = 452249 },
{ url = "https://files.pythonhosted.org/packages/fb/69/0c65a5a29e057ad0dc691c2fa6c23b2983c7dabaa190ba553b29ac84c3cc/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:74cb3ca19a740be4caa18f238298b9d472c850f7b2ed89f396c00a4c97e2d9ff", size = 614412 },
{ url = "https://files.pythonhosted.org/packages/7f/b9/319fcba6eba5fad34327d7ce16a6b163b39741016b1996f4a3c96b8dd0e1/watchfiles-1.0.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7cce76c138a91e720d1df54014a047e680b652336e1b73b8e3ff3158e05061e", size = 611982 },
{ url = "https://files.pythonhosted.org/packages/f1/47/143c92418e30cb9348a4387bfa149c8e0e404a7c5b0585d46d2f7031b4b9/watchfiles-1.0.4-cp312-cp312-win32.whl", hash = "sha256:b045c800d55bc7e2cadd47f45a97c7b29f70f08a7c2fa13241905010a5493f94", size = 271822 },
{ url = "https://files.pythonhosted.org/packages/ea/94/b0165481bff99a64b29e46e07ac2e0df9f7a957ef13bec4ceab8515f44e3/watchfiles-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:c2acfa49dd0ad0bf2a9c0bb9a985af02e89345a7189be1efc6baa085e0f72d7c", size = 285441 },
{ url = "https://files.pythonhosted.org/packages/11/de/09fe56317d582742d7ca8c2ca7b52a85927ebb50678d9b0fa8194658f536/watchfiles-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:22bb55a7c9e564e763ea06c7acea24fc5d2ee5dfc5dafc5cfbedfe58505e9f90", size = 277141 },
{ url = "https://files.pythonhosted.org/packages/08/98/f03efabec64b5b1fa58c0daab25c68ef815b0f320e54adcacd0d6847c339/watchfiles-1.0.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8012bd820c380c3d3db8435e8cf7592260257b378b649154a7948a663b5f84e9", size = 390954 },
{ url = "https://files.pythonhosted.org/packages/16/09/4dd49ba0a32a45813debe5fb3897955541351ee8142f586303b271a02b40/watchfiles-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa216f87594f951c17511efe5912808dfcc4befa464ab17c98d387830ce07b60", size = 381133 },
{ url = "https://files.pythonhosted.org/packages/76/59/5aa6fc93553cd8d8ee75c6247763d77c02631aed21551a97d94998bf1dae/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c9953cf85529c05b24705639ffa390f78c26449e15ec34d5339e8108c7c407", size = 449516 },
{ url = "https://files.pythonhosted.org/packages/4c/aa/df4b6fe14b6317290b91335b23c96b488d365d65549587434817e06895ea/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cf684aa9bba4cd95ecb62c822a56de54e3ae0598c1a7f2065d51e24637a3c5d", size = 454820 },
{ url = "https://files.pythonhosted.org/packages/5e/71/185f8672f1094ce48af33252c73e39b48be93b761273872d9312087245f6/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44a39aee3cbb9b825285ff979ab887a25c5d336e5ec3574f1506a4671556a8d", size = 481550 },
{ url = "https://files.pythonhosted.org/packages/85/d7/50ebba2c426ef1a5cb17f02158222911a2e005d401caf5d911bfca58f4c4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38320582736922be8c865d46520c043bff350956dfc9fbaee3b2df4e1740a4b", size = 518647 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/4c009342e393c545d68987e8010b937f72f47937731225b2b29b7231428f/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39f4914548b818540ef21fd22447a63e7be6e24b43a70f7642d21f1e73371590", size = 497547 },
{ url = "https://files.pythonhosted.org/packages/0f/7c/1cf50b35412d5c72d63b2bf9a4fffee2e1549a245924960dd087eb6a6de4/watchfiles-1.0.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f12969a3765909cf5dc1e50b2436eb2c0e676a3c75773ab8cc3aa6175c16e902", size = 452179 },
{ url = "https://files.pythonhosted.org/packages/d6/a9/3db1410e1c1413735a9a472380e4f431ad9a9e81711cda2aaf02b7f62693/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0986902677a1a5e6212d0c49b319aad9cc48da4bd967f86a11bde96ad9676ca1", size = 614125 },
{ url = "https://files.pythonhosted.org/packages/f2/e1/0025d365cf6248c4d1ee4c3d2e3d373bdd3f6aff78ba4298f97b4fad2740/watchfiles-1.0.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:308ac265c56f936636e3b0e3f59e059a40003c655228c131e1ad439957592303", size = 611911 },
{ url = "https://files.pythonhosted.org/packages/55/55/035838277d8c98fc8c917ac9beeb0cd6c59d675dc2421df5f9fcf44a0070/watchfiles-1.0.4-cp313-cp313-win32.whl", hash = "sha256:aee397456a29b492c20fda2d8961e1ffb266223625346ace14e4b6d861ba9c80", size = 271152 },
{ url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216 },
{ url = "https://files.pythonhosted.org/packages/15/81/54484fc2fa715abe79694b975692af963f0878fb9d72b8251aa542bf3f10/watchfiles-1.0.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:d3452c1ec703aa1c61e15dfe9d482543e4145e7c45a6b8566978fbb044265a21", size = 394967 },
{ url = "https://files.pythonhosted.org/packages/14/b3/557f0cd90add86586fe3deeebd11e8299db6bc3452b44a534f844c6ab831/watchfiles-1.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7b75fee5a16826cf5c46fe1c63116e4a156924d668c38b013e6276f2582230f0", size = 384707 },
{ url = "https://files.pythonhosted.org/packages/03/a3/34638e1bffcb85a405e7b005e30bb211fd9be2ab2cb1847f2ceb81bef27b/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e997802d78cdb02623b5941830ab06f8860038faf344f0d288d325cc9c5d2ff", size = 450442 },
{ url = "https://files.pythonhosted.org/packages/8f/9f/6a97460dd11a606003d634c7158d9fea8517e98daffc6f56d0f5fde2e86a/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0611d244ce94d83f5b9aff441ad196c6e21b55f77f3c47608dcf651efe54c4a", size = 455959 },
{ url = "https://files.pythonhosted.org/packages/9d/bb/e0648c6364e4d37ec692bc3f0c77507d17d8bb8f75689148819142010bbf/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9745a4210b59e218ce64c91deb599ae8775c8a9da4e95fb2ee6fe745fc87d01a", size = 483187 },
{ url = "https://files.pythonhosted.org/packages/dd/ad/d9290586a25288a81dfa8ad6329cf1de32aa1a9798ace45259eb95dcfb37/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4810ea2ae622add560f4aa50c92fef975e475f7ac4900ce5ff5547b2434642d8", size = 519733 },
{ url = "https://files.pythonhosted.org/packages/4e/a9/150c1666825cc9637093f8cae7fc6f53b3296311ab8bd65f1389acb717cb/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d103cd01458f22462dedeb5a3382b7f2c57d07ff033fbc9465919e5e1d0f3", size = 502275 },
{ url = "https://files.pythonhosted.org/packages/44/dc/5bfd21e20a330aca1706ac44713bc322838061938edf4b53130f97a7b211/watchfiles-1.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbd912a61543a36aef85e34f212e5d2486e7c53ebfdb70d1e0b060cc50dd0bf", size = 452907 },
{ url = "https://files.pythonhosted.org/packages/50/fe/8f4fc488f1699f564687b697456eb5c0cb8e2b0b8538150511c234c62094/watchfiles-1.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0bc80d91ddaf95f70258cf78c471246846c1986bcc5fd33ccc4a1a67fcb40f9a", size = 615927 },
{ url = "https://files.pythonhosted.org/packages/ad/19/2e45f6f6eec89dd97a4d281635e3d73c17e5f692e7432063bdfdf9562c89/watchfiles-1.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab0311bb2ffcd9f74b6c9de2dda1612c13c84b996d032cd74799adb656af4e8b", size = 613435 },
{ url = "https://files.pythonhosted.org/packages/91/17/dc5ac62ca377827c24321d68050efc2eaee2ebaf3f21d055bbce2206d309/watchfiles-1.0.4-cp39-cp39-win32.whl", hash = "sha256:02a526ee5b5a09e8168314c905fc545c9bc46509896ed282aeb5a8ba9bd6ca27", size = 270810 },
{ url = "https://files.pythonhosted.org/packages/82/2b/dad851342492d538e7ffe72a8c756f747dd147988abb039ac9d6577d2235/watchfiles-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:a5ae5706058b27c74bac987d615105da17724172d5aaacc6c362a40599b6de43", size = 284866 },
{ url = "https://files.pythonhosted.org/packages/6f/06/175d5ac6b838fb319008c0cd981d7bf289317c510154d411d3584ca2b67b/watchfiles-1.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdcc92daeae268de1acf5b7befcd6cfffd9a047098199056c72e4623f531de18", size = 396269 },
{ url = "https://files.pythonhosted.org/packages/86/ee/5db93b0b57dc0587abdbac4149296ee73275f615d790a82cb5598af0557f/watchfiles-1.0.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8d3d9203705b5797f0af7e7e5baa17c8588030aaadb7f6a86107b7247303817", size = 386010 },
{ url = "https://files.pythonhosted.org/packages/75/61/fe0dc5fedf152bfc085a53711f740701f6bdb8ab6b5c950402b681d4858b/watchfiles-1.0.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdef5a1be32d0b07dcea3318a0be95d42c98ece24177820226b56276e06b63b0", size = 450913 },
{ url = "https://files.pythonhosted.org/packages/9f/dd/3c7731af3baf1a9957afc643d176f94480921a690ec3237c9f9d11301c08/watchfiles-1.0.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:342622287b5604ddf0ed2d085f3a589099c9ae8b7331df3ae9845571586c4f3d", size = 453474 },
{ url = "https://files.pythonhosted.org/packages/6b/b4/c3998f54c91a35cee60ee6d3a855a069c5dff2bae6865147a46e9090dccd/watchfiles-1.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9fe37a2de80aa785d340f2980276b17ef697ab8db6019b07ee4fd28a8359d2f3", size = 395565 },
{ url = "https://files.pythonhosted.org/packages/3f/05/ac1a4d235beb9ddfb8ac26ce93a00ba6bd1b1b43051ef12d7da957b4a9d1/watchfiles-1.0.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:9d1ef56b56ed7e8f312c934436dea93bfa3e7368adfcf3df4c0da6d4de959a1e", size = 385406 },
{ url = "https://files.pythonhosted.org/packages/4c/ea/36532e7d86525f4e52a10efed182abf33efb106a93d49f5fbc994b256bcd/watchfiles-1.0.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b42cac65beae3a362629950c444077d1b44f1790ea2772beaea95451c086bb", size = 450424 },
{ url = "https://files.pythonhosted.org/packages/7a/e9/3cbcf4d70cd0b6d3f30631deae1bf37cc0be39887ca327a44462fe546bf5/watchfiles-1.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e0227b8ed9074c6172cf55d85b5670199c99ab11fd27d2c473aa30aec67ee42", size = 452488 },
]
[[package]]
name = "websockets"
version = "13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/94/d15dbfc6a5eb636dbc754303fba18208f2e88cf97e733e1d64fb9cb5c89e/websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", size = 157815 },
{ url = "https://files.pythonhosted.org/packages/30/02/c04af33f4663945a26f5e8cf561eb140c35452b50af47a83c3fbcfe62ae1/websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", size = 155466 },
{ url = "https://files.pythonhosted.org/packages/35/e8/719f08d12303ea643655e52d9e9851b2dadbb1991d4926d9ce8862efa2f5/websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6", size = 155716 },
{ url = "https://files.pythonhosted.org/packages/91/e1/14963ae0252a8925f7434065d25dcd4701d5e281a0b4b460a3b5963d2594/websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", size = 164806 },
{ url = "https://files.pythonhosted.org/packages/ec/fa/ab28441bae5e682a0f7ddf3d03440c0c352f930da419301f4a717f675ef3/websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", size = 163810 },
{ url = "https://files.pythonhosted.org/packages/44/77/dea187bd9d16d4b91566a2832be31f99a40d0f5bfa55eeb638eb2c3bc33d/websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", size = 164125 },
{ url = "https://files.pythonhosted.org/packages/cf/d9/3af14544e83f1437eb684b399e6ba0fa769438e869bf5d83d74bc197fae8/websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", size = 164532 },
{ url = "https://files.pythonhosted.org/packages/1c/8a/6d332eabe7d59dfefe4b8ba6f46c8c5fabb15b71c8a8bc3d2b65de19a7b6/websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", size = 163948 },
{ url = "https://files.pythonhosted.org/packages/1a/91/a0aeadbaf3017467a1ee03f8fb67accdae233fe2d5ad4b038c0a84e357b0/websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", size = 163898 },
{ url = "https://files.pythonhosted.org/packages/71/31/a90fb47c63e0ae605be914b0b969d7c6e6ffe2038cd744798e4b3fbce53b/websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", size = 158706 },
{ url = "https://files.pythonhosted.org/packages/93/ca/9540a9ba80da04dc7f36d790c30cae4252589dbd52ccdc92e75b0be22437/websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", size = 159141 },
{ url = "https://files.pythonhosted.org/packages/b2/f0/cf0b8a30d86b49e267ac84addbebbc7a48a6e7bb7c19db80f62411452311/websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", size = 157813 },
{ url = "https://files.pythonhosted.org/packages/bf/e7/22285852502e33071a8cf0ac814f8988480ec6db4754e067b8b9d0e92498/websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", size = 155469 },
{ url = "https://files.pythonhosted.org/packages/68/d4/c8c7c1e5b40ee03c5cc235955b0fb1ec90e7e37685a5f69229ad4708dcde/websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", size = 155717 },
{ url = "https://files.pythonhosted.org/packages/c9/e4/c50999b9b848b1332b07c7fd8886179ac395cb766fda62725d1539e7bc6c/websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", size = 165379 },
{ url = "https://files.pythonhosted.org/packages/bc/49/4a4ad8c072f18fd79ab127650e47b160571aacfc30b110ee305ba25fffc9/websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", size = 164376 },
{ url = "https://files.pythonhosted.org/packages/af/9b/8c06d425a1d5a74fd764dd793edd02be18cf6fc3b1ccd1f29244ba132dc0/websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", size = 164753 },
{ url = "https://files.pythonhosted.org/packages/d5/5b/0acb5815095ff800b579ffc38b13ab1b915b317915023748812d24e0c1ac/websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", size = 165051 },
{ url = "https://files.pythonhosted.org/packages/30/93/c3891c20114eacb1af09dedfcc620c65c397f4fd80a7009cd12d9457f7f5/websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", size = 164489 },
{ url = "https://files.pythonhosted.org/packages/28/09/af9e19885539759efa2e2cd29b8b3f9eecef7ecefea40d46612f12138b36/websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", size = 164438 },
{ url = "https://files.pythonhosted.org/packages/b6/08/6f38b8e625b3d93de731f1d248cc1493327f16cb45b9645b3e791782cff0/websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", size = 158710 },
{ url = "https://files.pythonhosted.org/packages/fb/39/ec8832ecb9bb04a8d318149005ed8cee0ba4e0205835da99e0aa497a091f/websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", size = 159137 },
{ url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821 },
{ url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480 },
{ url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715 },
{ url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647 },
{ url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592 },
{ url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012 },
{ url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311 },
{ url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692 },
{ url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686 },
{ url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712 },
{ url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145 },
{ url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828 },
{ url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487 },
{ url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721 },
{ url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609 },
{ url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556 },
{ url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993 },
{ url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360 },
{ url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745 },
{ url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732 },
{ url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709 },
{ url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144 },
{ url = "https://files.pythonhosted.org/packages/61/26/5f7a7fb03efedb4f90ed61968338bfe7c389863b0ceda239b94ae61c5ae4/websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d", size = 157810 },
{ url = "https://files.pythonhosted.org/packages/0e/d4/9b4814a07dffaa7a79d71b4944d10836f9adbd527a113f6675734ef3abed/websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7", size = 155467 },
{ url = "https://files.pythonhosted.org/packages/1a/1a/2abdc7ce3b56429ae39d6bfb48d8c791f5a26bbcb6f44aabcf71ffc3fda2/websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a", size = 155714 },
{ url = "https://files.pythonhosted.org/packages/2a/98/189d7cf232753a719b2726ec55e7922522632248d5d830adf078e3f612be/websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa", size = 164587 },
{ url = "https://files.pythonhosted.org/packages/a5/2b/fb77cedf3f9f55ef8605238c801eef6b9a5269b01a396875a86896aea3a6/websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa", size = 163588 },
{ url = "https://files.pythonhosted.org/packages/a3/b7/070481b83d2d5ac0f19233d9f364294e224e6478b0762f07fa7f060e0619/websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79", size = 163894 },
{ url = "https://files.pythonhosted.org/packages/eb/be/d6e1cff7d441cfe5eafaacc5935463e5f14c8b1c0d39cb8afde82709b55a/websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17", size = 164315 },
{ url = "https://files.pythonhosted.org/packages/8b/5e/ffa234473e46ab2d3f9fd9858163d5db3ecea1439e4cb52966d78906424b/websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6", size = 163714 },
{ url = "https://files.pythonhosted.org/packages/cc/92/cea9eb9d381ca57065a5eb4ec2ce7a291bd96c85ce742915c3c9ffc1069f/websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5", size = 163673 },
{ url = "https://files.pythonhosted.org/packages/a4/f1/279104fff239bfd04c12b1e58afea227d72fd1acf431e3eed3f6ac2c96d2/websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c", size = 158702 },
{ url = "https://files.pythonhosted.org/packages/25/0b/b87370ff141375c41f7dd67941728e4b3682ebb45882591516c792a2ebee/websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d", size = 159146 },
{ url = "https://files.pythonhosted.org/packages/2d/75/6da22cb3ad5b8c606963f9a5f9f88656256fecc29d420b4b2bf9e0c7d56f/websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", size = 155499 },
{ url = "https://files.pythonhosted.org/packages/c0/ba/22833d58629088fcb2ccccedfae725ac0bbcd713319629e97125b52ac681/websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", size = 155737 },
{ url = "https://files.pythonhosted.org/packages/95/54/61684fe22bdb831e9e1843d972adadf359cf04ab8613285282baea6a24bb/websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", size = 157095 },
{ url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701 },
{ url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654 },
{ url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192 },
{ url = "https://files.pythonhosted.org/packages/59/fd/e4bf9a7159dba6a16c59ae9e670e3e8ad9dcb6791bc0599eb86de32d50a9/websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e", size = 155499 },
{ url = "https://files.pythonhosted.org/packages/74/42/d48ede93cfe0c343f3b552af08efc60778d234989227b16882eed1b8b189/websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09", size = 155731 },
{ url = "https://files.pythonhosted.org/packages/f6/f2/2ef6bff1c90a43b80622a17c0852b48c09d3954ab169266ad7b15e17cdcb/websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842", size = 157093 },
{ url = "https://files.pythonhosted.org/packages/d1/14/6f20bbaeeb350f155edf599aad949c554216f90e5d4ae7373d1f2e5931fb/websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb", size = 156701 },
{ url = "https://files.pythonhosted.org/packages/c7/86/38279dfefecd035e22b79c38722d4f87c4b6196f1556b7a631d0a3095ca7/websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20", size = 156649 },
{ url = "https://files.pythonhosted.org/packages/f6/c5/12c6859a2eaa8c53f59a647617a27f1835a226cd7106c601067c53251d98/websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678", size = 159187 },
{ url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134 },
]
[[package]]
name = "wrapt"
version = "1.17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307 },
{ url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486 },
{ url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777 },
{ url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314 },
{ url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947 },
{ url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778 },
{ url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716 },
{ url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548 },
{ url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334 },
{ url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427 },
{ url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774 },
{ url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 },
{ url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 },
{ url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 },
{ url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 },
{ url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 },
{ url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 },
{ url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 },
{ url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 },
{ url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 },
{ url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 },
{ url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 },
{ url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 },
{ url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 },
{ url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 },
{ url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 },
{ url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 },
{ url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 },
{ url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 },
{ url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 },
{ url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 },
{ url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 },
{ url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 },
{ url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 },
{ url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 },
{ url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 },
{ url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 },
{ url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 },
{ url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 },
{ url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 },
{ url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 },
{ url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 },
{ url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 },
{ url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 },
{ url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 },
{ url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 },
{ url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 },
{ url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 },
{ url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 },
{ url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 },
{ url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 },
{ url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 },
{ url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 },
{ url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 },
{ url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 },
{ url = "https://files.pythonhosted.org/packages/8a/f4/6ed2b8f6f1c832933283974839b88ec7c983fd12905e01e97889dadf7559/wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a", size = 53308 },
{ url = "https://files.pythonhosted.org/packages/a2/a9/712a53f8f4f4545768ac532619f6e56d5d0364a87b2212531685e89aeef8/wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061", size = 38489 },
{ url = "https://files.pythonhosted.org/packages/fa/9b/e172c8f28a489a2888df18f953e2f6cb8d33b1a2e78c9dfc52d8bf6a5ead/wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82", size = 38776 },
{ url = "https://files.pythonhosted.org/packages/cf/cb/7a07b51762dcd59bdbe07aa97f87b3169766cadf240f48d1cbe70a1be9db/wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9", size = 83050 },
{ url = "https://files.pythonhosted.org/packages/a5/51/a42757dd41032afd6d8037617aa3bc6803ba971850733b24dfb7d5c627c4/wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f", size = 74718 },
{ url = "https://files.pythonhosted.org/packages/bf/bb/d552bfe47db02fcfc950fc563073a33500f8108efa5f7b41db2f83a59028/wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b", size = 82590 },
{ url = "https://files.pythonhosted.org/packages/77/99/77b06b3c3c410dbae411105bf22496facf03a5496bfaca8fbcf9da381889/wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f", size = 81462 },
{ url = "https://files.pythonhosted.org/packages/2d/21/cf0bd85ae66f92600829ea1de8e1da778e5e9f6e574ccbe74b66db0d95db/wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8", size = 74309 },
{ url = "https://files.pythonhosted.org/packages/6d/16/112d25e9092398a0dd6fec50ab7ac1b775a0c19b428f049785096067ada9/wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9", size = 81081 },
{ url = "https://files.pythonhosted.org/packages/2b/49/364a615a0cc0872685646c495c7172e4fc7bf1959e3b12a1807a03014e05/wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb", size = 36423 },
{ url = "https://files.pythonhosted.org/packages/00/ad/5d2c1b34ba3202cd833d9221833e74d6500ce66730974993a8dc9a94fb8c/wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb", size = 38772 },
{ url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 },
]
[[package]]
name = "yarl"
version = "1.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/98/e005bc608765a8a5569f58e650961314873c8469c333616eb40bff19ae97/yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34", size = 141458 },
{ url = "https://files.pythonhosted.org/packages/df/5d/f8106b263b8ae8a866b46d9be869ac01f9b3fb7f2325f3ecb3df8003f796/yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7", size = 94365 },
{ url = "https://files.pythonhosted.org/packages/56/3e/d8637ddb9ba69bf851f765a3ee288676f7cf64fb3be13760c18cbc9d10bd/yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed", size = 92181 },
{ url = "https://files.pythonhosted.org/packages/76/f9/d616a5c2daae281171de10fba41e1c0e2d8207166fc3547252f7d469b4e1/yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde", size = 315349 },
{ url = "https://files.pythonhosted.org/packages/bb/b4/3ea5e7b6f08f698b3769a06054783e434f6d59857181b5c4e145de83f59b/yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b", size = 330494 },
{ url = "https://files.pythonhosted.org/packages/55/f1/e0fc810554877b1b67420568afff51b967baed5b53bcc983ab164eebf9c9/yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5", size = 326927 },
{ url = "https://files.pythonhosted.org/packages/a9/42/b1753949b327b36f210899f2dd0a0947c0c74e42a32de3f8eb5c7d93edca/yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc", size = 319703 },
{ url = "https://files.pythonhosted.org/packages/f0/6d/e87c62dc9635daefb064b56f5c97df55a2e9cc947a2b3afd4fd2f3b841c7/yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd", size = 310246 },
{ url = "https://files.pythonhosted.org/packages/e3/ef/e2e8d1785cdcbd986f7622d7f0098205f3644546da7919c24b95790ec65a/yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990", size = 319730 },
{ url = "https://files.pythonhosted.org/packages/fc/15/8723e22345bc160dfde68c4b3ae8b236e868f9963c74015f1bc8a614101c/yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db", size = 321681 },
{ url = "https://files.pythonhosted.org/packages/86/09/bf764e974f1516efa0ae2801494a5951e959f1610dd41edbfc07e5e0f978/yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62", size = 324812 },
{ url = "https://files.pythonhosted.org/packages/f6/4c/20a0187e3b903c97d857cf0272d687c1b08b03438968ae8ffc50fe78b0d6/yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760", size = 337011 },
{ url = "https://files.pythonhosted.org/packages/c9/71/6244599a6e1cc4c9f73254a627234e0dad3883ece40cc33dce6265977461/yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b", size = 338132 },
{ url = "https://files.pythonhosted.org/packages/af/f5/e0c3efaf74566c4b4a41cb76d27097df424052a064216beccae8d303c90f/yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690", size = 331849 },
{ url = "https://files.pythonhosted.org/packages/8a/b8/3d16209c2014c2f98a8f658850a57b716efb97930aebf1ca0d9325933731/yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6", size = 84309 },
{ url = "https://files.pythonhosted.org/packages/fd/b7/2e9a5b18eb0fe24c3a0e8bae994e812ed9852ab4fd067c0107fadde0d5f0/yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8", size = 90484 },
{ url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555 },
{ url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351 },
{ url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286 },
{ url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649 },
{ url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623 },
{ url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007 },
{ url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145 },
{ url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133 },
{ url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967 },
{ url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397 },
{ url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206 },
{ url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089 },
{ url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267 },
{ url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141 },
{ url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402 },
{ url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030 },
{ url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644 },
{ url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962 },
{ url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795 },
{ url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368 },
{ url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314 },
{ url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987 },
{ url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914 },
{ url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765 },
{ url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444 },
{ url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760 },
{ url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484 },
{ url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864 },
{ url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537 },
{ url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861 },
{ url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097 },
{ url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399 },
{ url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789 },
{ url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144 },
{ url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974 },
{ url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587 },
{ url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386 },
{ url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421 },
{ url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384 },
{ url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689 },
{ url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453 },
{ url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872 },
{ url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497 },
{ url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981 },
{ url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229 },
{ url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383 },
{ url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152 },
{ url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 },
{ url = "https://files.pythonhosted.org/packages/6a/3b/fec4b08f5e88f68e56ee698a59284a73704df2e0e0b5bdf6536c86e76c76/yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04", size = 142780 },
{ url = "https://files.pythonhosted.org/packages/ed/85/796b0d6a22d536ec8e14bdbb86519250bad980cec450b6e299b1c2a9079e/yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719", size = 94981 },
{ url = "https://files.pythonhosted.org/packages/ee/0e/a830fd2238f7a29050f6dd0de748b3d6f33a7dbb67dbbc081a970b2bbbeb/yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e", size = 92789 },
{ url = "https://files.pythonhosted.org/packages/0f/4f/438c9fd668954779e48f08c0688ee25e0673380a21bb1e8ccc56de5b55d7/yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee", size = 317327 },
{ url = "https://files.pythonhosted.org/packages/bd/79/a78066f06179b4ed4581186c136c12fcfb928c475cbeb23743e71a991935/yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789", size = 336999 },
{ url = "https://files.pythonhosted.org/packages/55/02/527963cf65f34a06aed1e766ff9a3b3e7d0eaa1c90736b2948a62e528e1d/yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8", size = 331693 },
{ url = "https://files.pythonhosted.org/packages/a2/2a/167447ae39252ba624b98b8c13c0ba35994d40d9110e8a724c83dbbb5822/yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c", size = 321473 },
{ url = "https://files.pythonhosted.org/packages/55/03/07955fabb20082373be311c91fd78abe458bc7ff9069d34385e8bddad20e/yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5", size = 313571 },
{ url = "https://files.pythonhosted.org/packages/95/e2/67c8d3ec58a8cd8ddb1d63bd06eb7e7b91c9f148707a3eeb5a7ed87df0ef/yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1", size = 325004 },
{ url = "https://files.pythonhosted.org/packages/06/43/51ceb3e427368fe6ccd9eccd162be227fd082523e02bad1fd3063daf68da/yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24", size = 322677 },
{ url = "https://files.pythonhosted.org/packages/e4/0e/7ef286bfb23267739a703f7b967a858e2128c10bea898de8fa027e962521/yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318", size = 332806 },
{ url = "https://files.pythonhosted.org/packages/c8/94/2d1f060f4bfa47c8bd0bcb652bfe71fba881564bcac06ebb6d8ced9ac3bc/yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985", size = 339919 },
{ url = "https://files.pythonhosted.org/packages/8e/8d/73b5f9a6ab69acddf1ca1d5e7bc92f50b69124512e6c26b36844531d7f23/yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910", size = 340960 },
{ url = "https://files.pythonhosted.org/packages/41/13/ce6bc32be4476b60f4f8694831f49590884b2c975afcffc8d533bf2be7ec/yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1", size = 336592 },
{ url = "https://files.pythonhosted.org/packages/81/d5/6e0460292d6299ac3919945f912b16b104f4e81ab20bf53e0872a1296daf/yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5", size = 84833 },
{ url = "https://files.pythonhosted.org/packages/b2/fc/a8aef69156ad5508165d8ae956736d55c3a68890610834bd985540966008/yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9", size = 90968 },
{ url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 },
]