feat(eot): add audio models AGT-2520 (#4722)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Brian Yin <brian.yin@livekit.io>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Long Chen <longch1024@gmail.com>
Co-authored-by: Aditya <147301021+adityajha2005@users.noreply.github.com>
Co-authored-by: Théo Monnom <theo.8bits@gmail.com>
This commit is contained in:
Chenghao Mou
2026-06-16 19:28:59 +01:00
committed by GitHub
parent 887ced38b1
commit 61b14fdcd9
112 changed files with 5231 additions and 699 deletions
+1
View File
@@ -35,6 +35,7 @@ it.
| Marker | Flag | Meaning |
|--------|------|---------|
| `pytest.mark.unit` | `--unit` | fast, hermetic, no external providers/credentials/network |
| `pytest.mark.audio_eot` | `--audio_eot` | hermetic audio end-of-turn / turn-detection suite |
| `pytest.mark.plugin("name")` | `--plugin [name]` | provider integration test (needs that provider's deps/keys) |
| `pytest.mark.stt` | `--stt` | cross-provider speech-to-text suite (`tests/test_stt.py`) |
| `pytest.mark.tts` | `--tts` | cross-provider text-to-speech suite (`tests/test_tts.py`) |
+113
View File
@@ -0,0 +1,113 @@
LIVEKIT MODEL LICENSE AGREEMENT
1. Introduction
LiveKit Incorporated ("LiveKit") is making available its proprietary models for
use pursuant to the terms and conditions of this Agreement. As further
described below, you may use these LiveKit models freely but can only use them
together with the LiveKit Agents framework. You cannot use the LiveKit models
on a standalone basis or with any other frameworks.
BY CLICKING "I ACCEPT," OR BY DOWNLOADING, INSTALLING, OR OTHERWISE ACCESSING
OR USING THE LIVEKIT MATERIALS, YOU AGREE THAT YOU HAVE READ AND UNDERSTOOD,
AND, AS A CONDITION TO YOUR USE OF THE LIVEKIT MATERIALS, YOU AGREE TO BE
BOUND BY, THE FOLLOWING TERMS AND CONDITIONS.
2. Definitions
"Agreement" means this LiveKit Model License Agreement.
"Documentation" means the specifications, manuals, and documentation
accompanying any LiveKit Model and distributed by LiveKit.
"Licensee" or "you" means the individual or entity agreeing to be bound by
this Agreement.
"LiveKit Agents" means the proprietary LiveKit software framework for building
real-time multimodal AI applications with programmable backend participants.
"LiveKit Materials" means, collectively, the LiveKit Models and Documentation.
"LiveKit Model" means any of LiveKit's proprietary software models or
algorithms, including machine-learning software code, model weights,
inference-enabling software code, training-enabling software code, and
fine-tuning enabling software code. Any derivative works of a LiveKit Model,
whether developed by LiveKit, you, or any third party, will be deemed the
"LiveKit Model" for the purposes of this Agreement.
3. License Rights
Right to Use LiveKit Materials. Subject to the terms and conditions of this
Agreement, including the requirements of Section 3.b, LiveKit grants you a
nonexclusive, nontransferable, worldwide, royalty-free license under LiveKit's
intellectual property rights to use, reproduce, distribute, copy, and create
derivative works of the LiveKit Materials.
Limitation on Use. As a condition to your use of the LiveKit Materials, you
agree: (i) not to use any LiveKit Models on a standalone basis or with any
frameworks other than LiveKit Agents; (ii) not to use any LiveKit Materials or
any output from, or results of using, LiveKit Models (including any derivative
works thereof) to improve or otherwise develop any other models that are not
LiveKit Models; or (iii) distribute or otherwise make available the LiveKit
Materials (including any derivative works thereof) except (x) pursuant to the
terms of this Agreement, and (y) you reproduce the above copyright notice.
4. Intellectual Property
The LiveKit Materials are owned by LiveKit and its licensors. Except for the
rights granted to you under this Agreement, all rights are reserved and no
other express or implied rights are granted.
You will own any derivative works that you created from the LiveKit Materials,
subject to the terms of this Agreement.
5. Disclaimer
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, LIVEKIT PROVIDES
THE LIVEKIT MATERIALS, AND ANY OUTPUT OR RESULTS THEREFROM, 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 LIVEKIT MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR
USE OF THE LIVEKIT MATERIALS AND ANY OUTPUT AND RESULTS.
6. 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, WILL LIVEKIT BE LIABLE TO
YOU FOR INDIRECT DAMAGES, INCLUDING ANY SPECIAL, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES OF ANY CHARACTER ARISING AS A RESULT OF THIS AGREEMENT OR OUT OF THE
USE OR INABILITY TO USE THE LIVEKIT MATERIALS OR ANY OUTPUT OR RESULTS
THEREFROM (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 LIVEKIT HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
7. Trademarks
This Agreement does not grant permission to use the trade names, trademarks,
service marks, or product names of LiveKit, except as required for reasonable
and customary use in describing the origin of the LiveKit Materials.
8. Term and Termination
The term of this Agreement commences upon your acceptance of this Agreement
and continues in effect until you cease using the LiveKit Materials or it is
terminated by either party (on immediate written notice to the other party).
This Agreement will automatically terminate if you breach any of its terms.
Upon termination, you must immediately cease all use of the LiveKit Materials.
Sections 4, 5, 6, and 9 will survive termination.
9. Governing Law and Venue
This Agreement is subject to the laws of the State of California, without
regard to its conflict of laws principles. The UN Convention on Contracts for
the International Sale of Goods does not apply to this Agreement. The courts
located in San Francisco, California, have exclusive jurisdiction for any
dispute arising out of this Agreement.
+ + + +
Last Updated: November 25, 2024
+7 -4
View File
@@ -47,7 +47,7 @@ agents that can see, hear, and understand.
To install the core Agents library, along with plugins for popular model providers:
```bash
pip install "livekit-agents[openai,silero,deepgram,cartesia,turn-detector]"
pip install "livekit-agents[openai,deepgram,cartesia]"
```
## Docs and guides
@@ -92,7 +92,6 @@ from livekit.agents import (
function_tool,
inference,
)
from livekit.plugins import silero
@function_tool
@@ -111,7 +110,7 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
vad=inference.VAD(),
# any combination of STT, LLM, TTS, or realtime API can be used
# this example shows LiveKit Inference, a unified API to access different models via LiveKit Cloud
# to use model provider keys directly, replace with the following:
@@ -200,7 +199,7 @@ class StoryAgent(Agent):
async def entrypoint(ctx: JobContext):
userdata = StoryData()
session = AgentSession[StoryData](
vad=silero.VAD.load(),
vad=inference.VAD(),
stt="deepgram/nova-3",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
@@ -382,6 +381,10 @@ python myagent.py start
Runs the agent with production-ready optimizations.
## License
The Agents framework is licensed under [Apache-2.0](LICENSE). The LiveKit turn detection models are licensed under the [LiveKit Model License](MODEL_LICENSE).
## Contributing
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 the [LiveKit community](https://docs.livekit.io/intro/community/).
+1 -1
View File
@@ -54,7 +54,7 @@ To run the examples, you'll need:
- A [LiveKit Cloud](https://cloud.livekit.io) account or a local [LiveKit server](https://github.com/livekit/livekit)
- API keys for the model providers you want to use in a `.env` file
- Python 3.9 or higher
- Python 3.10 or higher
- [uv](https://docs.astral.sh/uv/)
### Environment file
@@ -20,7 +20,6 @@ from livekit.agents import (
from livekit.agents.voice.avatar import AvatarSession, DataStreamAudioOutput
from livekit.agents.voice.io import PlaybackFinishedEvent, PlaybackStartedEvent
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
from livekit.plugins import silero
load_dotenv()
@@ -114,7 +113,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("google/gemini-2.5-flash"),
tts=inference.TTS("cartesia/sonic-3"),
vad=silero.VAD.load(),
resume_false_interruption=False,
)
@@ -14,9 +14,8 @@ from livekit.agents import (
function_tool,
inference,
)
from livekit.plugins import keyframe, silero
from livekit.plugins import keyframe
from livekit.plugins.keyframe import Emotion
from livekit.plugins.turn_detector.multilingual import MultilingualModel
load_dotenv()
@@ -51,8 +50,6 @@ async def entrypoint(ctx: JobContext):
llm=inference.LLM("google/gemini-2.5-flash"),
tts=inference.TTS("cartesia/sonic-3"),
resume_false_interruption=False,
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
avatar = keyframe.AvatarSession(persona_slug="public:cosmo_persona-1.5-live")
-4
View File
@@ -34,8 +34,6 @@ from livekit.agents import (
function_tool,
inference,
)
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
load_dotenv()
@@ -487,8 +485,6 @@ async def drive_thru_agent(ctx: JobContext) -> None:
),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3", voice="f786b574-daa5-4673-aa0c-cbe3e8534c02"),
turn_detection=MultilingualModel(),
vad=silero.VAD.load(),
max_tool_steps=10,
)
-4
View File
@@ -38,8 +38,6 @@ from livekit.agents.evals import (
task_completion_judge,
tool_use_judge,
)
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
load_dotenv()
@@ -266,8 +264,6 @@ async def frontdesk_agent(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("google/gemini-2.5-flash"),
tts=inference.TTS("cartesia/sonic-3", voice="39b376fc-488e-4d0c-8b37-e00b72059fdd"),
turn_detection=MultilingualModel(),
vad=silero.VAD.load(),
max_tool_steps=1,
)
-2
View File
@@ -32,7 +32,6 @@ from livekit.agents.beta.workflows import (
WarmTransferTask,
)
from livekit.agents.llm import ToolError, function_tool
from livekit.plugins import silero
logger = logging.getLogger("HealthcareAgent")
@@ -754,7 +753,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3", language="multi"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("inworld/inworld-tts-1"),
vad=silero.VAD.load(),
preemptive_generation=True,
)
-2
View File
@@ -12,7 +12,6 @@ from livekit.agents import (
cli,
inference,
)
from livekit.plugins import silero
from livekit.rtc import RpcInvocationData
logger = logging.getLogger("inference")
@@ -59,7 +58,6 @@ async def entrypoint(ctx: JobContext) -> None:
stt=inference.STT(model=DEFAULT_STT),
llm=inference.LLM(model=DEFAULT_LLM),
tts=inference.TTS(model=DEFAULT_TTS),
vad=silero.VAD.load(),
)
def parse_value(payload: str, fallback: str) -> str:
+2 -10
View File
@@ -2,8 +2,8 @@ import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, JobProcess, cli, inference
from livekit.plugins import elevenlabs, silero
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference
from livekit.plugins import elevenlabs
logger = logging.getLogger("realtime-scribe-v2")
logger.setLevel(logging.INFO)
@@ -14,13 +14,6 @@ load_dotenv()
server = AgentServer()
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
# Using ElevenLabs STT plugin directly for realtime mode support
@@ -37,7 +30,6 @@ async def entrypoint(ctx: JobContext) -> None:
session: AgentSession = AgentSession(
allow_interruptions=True,
vad=ctx.proc.userdata["vad"],
stt=stt,
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
+9 -10
View File
@@ -2,9 +2,16 @@ import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, JobProcess, cli, metrics
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
JobContext,
cli,
metrics,
)
from livekit.agents.voice import MetricsCollectedEvent
from livekit.plugins import deepgram, openai, silero
from livekit.plugins import deepgram, openai
logger = logging.getLogger("kokoro-tts-agent")
@@ -27,13 +34,6 @@ class MyAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext):
# each log entry will include these fields
@@ -42,7 +42,6 @@ async def entrypoint(ctx: JobContext):
"user_id": "your user_id",
}
session = AgentSession(
vad=ctx.proc.userdata["vad"],
# any combination of STT, LLM, TTS, or realtime API can be used
llm=openai.LLM(model="gpt-4.1-mini"),
stt=deepgram.STT(model="nova-3", language="multi"),
@@ -10,7 +10,6 @@ from livekit.agents import (
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
StopResponse,
cli,
inference,
@@ -18,7 +17,6 @@ from livekit.agents import (
room_io,
utils,
)
from livekit.plugins import silero
load_dotenv()
@@ -91,9 +89,7 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
session = AgentSession()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
@@ -136,11 +132,5 @@ async def entrypoint(ctx: JobContext):
ctx.add_shutdown_callback(cleanup)
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
if __name__ == "__main__":
cli.run_app(server)
+3 -2
View File
@@ -11,11 +11,12 @@ from livekit.agents import (
MetricsCollectedEvent,
StopResponse,
cli,
inference,
llm,
metrics,
room_io,
)
from livekit.plugins import openai, silero
from livekit.plugins import openai
load_dotenv()
@@ -50,7 +51,7 @@ async def entrypoint(ctx: JobContext):
session = AgentSession(
# vad is needed for non-streaming STT implementations
vad=silero.VAD.load(min_silence_duration=0.3),
vad=inference.VAD(model="silero", min_silence_duration=0.3),
)
@session.on("metrics_collected")
+1 -2
View File
@@ -17,7 +17,7 @@ from livekit.agents import (
room_io,
utils,
)
from livekit.plugins import openai, silero
from livekit.plugins import openai
load_dotenv()
@@ -76,7 +76,6 @@ async def entrypoint(ctx: JobContext):
session = AgentSession(
# vad is only needed for non-streaming STT implementations
vad=silero.VAD.load(),
)
@session.on("metrics_collected")
+3 -2
View File
@@ -9,9 +9,9 @@ from livekit.agents import (
AutoSubscribe,
JobContext,
cli,
inference,
)
from livekit.agents.vad import VADEventType
from livekit.plugins import silero
load_dotenv()
logger = logging.getLogger("echo-agent")
@@ -35,7 +35,8 @@ async def entrypoint(ctx: JobContext):
participant=participant,
track_source=rtc.TrackSource.SOURCE_MICROPHONE,
)
vad = silero.VAD.load(
vad = inference.VAD(
model="silero",
min_speech_duration=0.2,
min_silence_duration=0.6,
)
-4
View File
@@ -21,8 +21,6 @@ from livekit.agents import (
)
from livekit.agents.beta.workflows import GetEmailTask, TaskGroup
from livekit.agents.llm import function_tool
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
logger = logging.getLogger("SurveyAgent")
@@ -354,8 +352,6 @@ async def entrypoint(ctx: JobContext):
llm=inference.LLM("google/gemini-2.5-flash"),
stt=inference.STT("deepgram/nova-3", language="multi"),
tts=inference.TTS("inworld/inworld-tts-1"),
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
preemptive_generation=True,
)
-12
View File
@@ -11,12 +11,9 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
cli,
inference,
)
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
logger = logging.getLogger("basic-agent")
@@ -35,13 +32,6 @@ class MyAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext):
ctx.log_context_fields = {
@@ -51,8 +41,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3", language="multi"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3", voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc"),
turn_detection=MultilingualModel(),
vad=ctx.proc.userdata["vad"],
preemptive_generation=True,
)
@@ -9,7 +9,6 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
MetricsCollectedEvent,
RunContext,
cli,
@@ -17,7 +16,6 @@ from livekit.agents import (
metrics,
)
from livekit.agents.llm.tool_context import function_tool
from livekit.plugins import silero
logger = logging.getLogger("phone-tree-agent")
@@ -76,13 +74,6 @@ class DtmfAgent(Agent):
context.session.shutdown(drain=True)
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session(agent_name=PHONE_TREE_AGENT_DISPATCH_NAME)
async def dtmf_session(ctx: JobContext) -> None:
await ctx.connect()
@@ -91,7 +82,6 @@ async def dtmf_session(ctx: JobContext) -> None:
}
session: AgentSession = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=inference.LLM("openai/gpt-4.1"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("rime/arcana"),
@@ -22,7 +22,6 @@ from livekit.agents import (
AgentSession,
AgentTask,
JobContext,
JobProcess,
MetricsCollectedEvent,
cli,
inference,
@@ -30,8 +29,6 @@ from livekit.agents import (
)
from livekit.agents.beta.workflows.dtmf_inputs import GetDtmfTask
from livekit.agents.llm.tool_context import ToolError
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
load_dotenv()
@@ -624,13 +621,6 @@ class RewardsTask(BaseBankTask):
SubmenuTaskType = DepositAccountsTask | CreditCardsTask | LoansTask | RewardsTask
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session(agent_name=BANK_IVR_DISPATCH_NAME)
async def bank_ivr_session(ctx: JobContext) -> None:
ctx.log_context_fields = {"room": ctx.room.name}
@@ -639,11 +629,9 @@ async def bank_ivr_session(ctx: JobContext) -> None:
state = SessionState()
session: AgentSession[SessionState] = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=inference.LLM("openai/gpt-4.1"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("cartesia/sonic-3"),
turn_detection=MultilingualModel(),
userdata=state,
)
-4
View File
@@ -18,8 +18,6 @@ from livekit.agents.beta.workflows.dtmf_inputs import (
from livekit.agents.llm.tool_context import ToolError, function_tool
from livekit.agents.voice.events import RunContext
from livekit.agents.worker import AgentServer
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
logger = logging.getLogger("dtmf-agent")
@@ -136,11 +134,9 @@ async def entrypoint(ctx: JobContext) -> None:
}
session: AgentSession = AgentSession(
vad=silero.VAD.load(),
llm=inference.LLM("openai/gpt-4.1-mini"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("inworld/inworld-tts-1"),
turn_detection=MultilingualModel(),
)
@session.on("metrics_collected")
@@ -8,7 +8,6 @@ from pydantic import Field # noqa: F401
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference
from livekit.agents.llm import function_tool
from livekit.plugins import silero
logger = logging.getLogger("annotated-tool-args")
logger.setLevel(logging.INFO)
@@ -92,7 +91,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
agent = AgentSession(
vad=silero.VAD.load(),
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("google/gemini-2.5-flash"),
tts=inference.TTS("rime/arcana"),
@@ -24,7 +24,6 @@ from livekit.agents import (
inference,
llm,
)
from livekit.plugins import silero
logger = logging.getLogger("async-travel-helper")
@@ -219,7 +218,6 @@ async def entrypoint(ctx: JobContext):
llm=inference.LLM("google/gemini-3.1-flash-lite"),
tts=inference.TTS("cartesia/sonic-3", voice="e07c00bc-4134-4eae-9ea4-1a55fb45746b"),
# llm=google.realtime.RealtimeModel(),
vad=silero.VAD.load(),
turn_handling={"interruption": {"mode": "vad"}},
)
+1 -15
View File
@@ -7,7 +7,6 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
MetricsCollectedEvent,
RunContext,
TurnHandlingOptions,
@@ -19,8 +18,6 @@ from livekit.agents import (
)
from livekit.agents.beta import EndCallTool
from livekit.agents.llm import function_tool
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
# uncomment to enable Krisp background voice/noise cancellation
# from livekit.plugins import noise_cancellation
@@ -71,13 +68,6 @@ class MyAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
# each log entry will include these fields
@@ -94,11 +84,7 @@ async def entrypoint(ctx: JobContext) -> None:
# Text-to-speech (TTS) is your agent's voice, turning the LLM's text into speech that the user can hear
# See all available models as well as voice selections at https://docs.livekit.io/agents/models/tts/
tts=inference.TTS("cartesia/sonic-3", voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc"),
vad=ctx.proc.userdata["vad"],
turn_handling=TurnHandlingOptions(
# VAD and turn detection are used to determine when the user is speaking and when the agent should respond
# See more at https://docs.livekit.io/agents/build/turns
turn_detection=MultilingualModel(),
interruption={
# sometimes background noise could interrupt the agent session, these are considered false positive interruptions
# when it's detected, you may resume the agent's speech
@@ -114,7 +100,7 @@ async def entrypoint(ctx: JobContext) -> None:
tts_text_transforms=[
"filter_emoji",
"filter_markdown",
text_transforms.replace({"LiveKit": "<<ˈ|l|aɪ|v>> <<ˈ|k|ɪ|t>>"}),
text_transforms.replace({"LiveKit": "<<ˈ|l|aɪ|v|k|ɪ|t>>"}),
],
)
@@ -18,7 +18,6 @@ from livekit.agents import (
function_tool,
inference,
)
from livekit.plugins import silero
logger = logging.getLogger("grok-agent")
logger.setLevel(logging.INFO)
@@ -115,7 +114,6 @@ async def entrypoint(ctx: JobContext):
)
session = AgentSession(
vad=silero.VAD.load(),
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
-2
View File
@@ -13,7 +13,6 @@ from livekit.agents import (
inference,
)
from livekit.agents.llm import function_tool
from livekit.plugins import silero
logger = logging.getLogger("get-email-agent")
@@ -61,7 +60,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
llm=inference.LLM("openai/gpt-4.1-mini"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("cartesia/sonic-3"),
-2
View File
@@ -9,7 +9,6 @@ from livekit.agents import AgentServer, JobContext, cli, inference
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 silero
from livekit.rtc import ParticipantKind
logger = logging.getLogger("my-worker")
@@ -30,7 +29,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
vad=silero.VAD.load(),
)
custom_error_audio = os.path.join(pathlib.Path(__file__).parent.absolute(), "error_message.ogg")
+1 -2
View File
@@ -13,7 +13,7 @@ from livekit.agents import (
llm,
)
from livekit.agents.llm.chat_context import ChatContext, ChatMessage
from livekit.plugins import deepgram, groq, openai, silero
from livekit.plugins import deepgram, groq, openai
logger = logging.getLogger("pre-reseponse-agent")
@@ -82,7 +82,6 @@ async def entrypoint(ctx: JobContext):
session = AgentSession(
stt=deepgram.STT(),
tts=openai.TTS(),
vad=silero.VAD.load(),
)
await session.start(PreResponseAgent(), room=ctx.room)
-2
View File
@@ -18,7 +18,6 @@ from livekit.agents import (
llm,
metrics,
)
from livekit.plugins import silero
logger = logging.getLogger("flush-llm-node")
@@ -111,7 +110,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
llm="openai/gpt-4.1-mini",
stt="deepgram/nova-3:en",
tts="cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
+3 -10
View File
@@ -15,16 +15,16 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
MetricsCollectedEvent,
RunContext,
TurnHandlingOptions,
cli,
inference,
metrics,
room_io,
)
from livekit.agents.llm import function_tool
from livekit.plugins import gnani, groq, silero
from livekit.plugins import gnani, groq
logger = logging.getLogger("gnani-agent")
@@ -68,13 +68,6 @@ class GnaniTestAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
ctx.log_context_fields = {
@@ -85,7 +78,7 @@ async def entrypoint(ctx: JobContext) -> None:
stt=gnani.STT(language="en-IN"),
llm=groq.LLM(model="llama-3.1-8b-instant"),
tts=gnani.TTS(voice="Karan"),
vad=ctx.proc.userdata["vad"],
vad=inference.VAD(),
turn_handling=TurnHandlingOptions(
interruption={
"resume_false_interruption": True,
+1 -1
View File
@@ -21,7 +21,7 @@ export XAI_API_KEY=<your-xai-api-key>
### 2. Install dependencies
```bash
uv add "livekit-agents[xai,silero,turn-detector]" livekit-plugins-noise-cancellation
uv add "livekit-agents[xai,silero]" livekit-plugins-noise-cancellation
```
### 3. Run the agent
@@ -7,12 +7,10 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
cli,
room_io,
)
from livekit.plugins import silero, xai
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import xai
# uncomment lines 18 and 66-68 to enable Krisp background voice/noise cancellation
# from livekit.plugins import noise_cancellation
@@ -35,13 +33,6 @@ class Assistant(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def my_agent(ctx: JobContext):
ctx.log_context_fields = {
@@ -50,9 +41,7 @@ async def my_agent(ctx: JobContext):
session = AgentSession(
llm=xai.realtime.RealtimeModel(voice="ara"),
turn_detection=MultilingualModel(),
tools=[xai.realtime.XSearch(), xai.realtime.WebSearch()],
vad=ctx.proc.userdata["vad"],
preemptive_generation=True,
)
-2
View File
@@ -12,7 +12,6 @@ from livekit.agents import (
cli,
inference,
)
from livekit.plugins import silero
logger = logging.getLogger("get-email-agent")
@@ -24,7 +23,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
llm=inference.LLM("openai/gpt-4.1-mini"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("cartesia/sonic-3"),
@@ -8,13 +8,11 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
cli,
function_tool,
inference,
)
from livekit.agents.beta import Instructions
from livekit.plugins import silero
logger = logging.getLogger("instructions-per-modality")
@@ -82,20 +80,12 @@ class SchedulingAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
session = AgentSession(
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
vad=ctx.proc.userdata["vad"],
)
await session.start(agent=SchedulingAgent(), room=ctx.room)
+2 -4
View File
@@ -21,8 +21,7 @@ from livekit.agents.stt import FallbackAdapter as FallbackSTTAdapter
from livekit.agents.telemetry import set_tracer_provider
from livekit.agents.tts import FallbackAdapter as FallbackTTSAdapter
from livekit.agents.voice import MetricsCollectedEvent
from livekit.plugins import openai, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import openai
logger = logging.getLogger("langfuse-trace-example")
@@ -95,7 +94,6 @@ class Kelly(Agent):
inference.TTS("rime/arcana"),
]
),
turn_detection=MultilingualModel(),
tools=[lookup_weather],
)
@@ -149,7 +147,7 @@ async def entrypoint(ctx: JobContext):
ctx.add_shutdown_callback(flush_trace)
session = AgentSession(vad=silero.VAD.load())
session = AgentSession()
@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent):
+2 -14
View File
@@ -12,12 +12,10 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
cli,
inference,
)
from livekit.plugins import langchain, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import langchain
logger = logging.getLogger("basic-agent")
@@ -30,18 +28,11 @@ load_dotenv()
# In order to run this example, you need the following dependencies
# - langchain[openai]
# - langgraph
# - livekit-agents[openai,silero,langchain,deepgram,turn_detector]
# - livekit-agents[openai,silero,langchain,deepgram]
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
class State(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
@@ -71,12 +62,9 @@ async def entrypoint(ctx: JobContext):
)
session = AgentSession(
vad=ctx.proc.userdata["vad"],
# any combination of STT, LLM, TTS, or realtime API can be used
stt=inference.STT("deepgram/nova-3", language="multi"),
tts=inference.TTS("cartesia/sonic-3"),
# use LiveKit's turn detection model
turn_detection=MultilingualModel(),
)
await session.start(
@@ -22,7 +22,6 @@ from livekit.agents import (
llm,
)
from livekit.agents.voice.agent import ModelSettings
from livekit.plugins import silero
load_dotenv()
@@ -54,7 +53,6 @@ class ChatEngineAgent(Agent):
"with users will be voice. You should use short and concise "
"responses, and avoiding usage of unpronouncable punctuation."
),
vad=silero.VAD.load(),
stt=inference.STT("deepgram/nova-3"),
llm=DummyLLM(), # use a dummy LLM to enable the pipeline reply
tts=inference.TTS("cartesia/sonic-3"),
@@ -18,7 +18,6 @@ from livekit.agents import (
inference,
llm,
)
from livekit.plugins import silero
load_dotenv()
@@ -59,7 +58,6 @@ async def entrypoint(ctx: JobContext):
"with users will be voice. You should use short and concise "
"responses, and avoiding usage of unpronouncable punctuation."
),
vad=silero.VAD.load(),
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
@@ -19,7 +19,7 @@ from livekit.agents import (
llm,
)
from livekit.agents.voice.agent import ModelSettings
from livekit.plugins import deepgram, openai, silero
from livekit.plugins import deepgram, openai
load_dotenv()
@@ -46,7 +46,6 @@ class RetrievalAgent(Agent):
"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(),
@@ -13,7 +13,6 @@ from livekit.agents import (
inference,
)
from livekit.agents.llm import function_tool
from livekit.plugins import silero
logger = logging.getLogger("long-running-function")
logger.setLevel(logging.INFO)
@@ -69,7 +68,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
vad=silero.VAD.load(),
)
await session.start(agent=MyAgent(), room=ctx.room)
-4
View File
@@ -3,8 +3,6 @@ import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference, mcp
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
logger = logging.getLogger("mcp-agent")
@@ -32,11 +30,9 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
stt=inference.STT("deepgram/nova-3", language="multi"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
turn_detection=MultilingualModel(),
tools=[
mcp.MCPToolset(
id="mcp_toolset_1", mcp_server=mcp.MCPServerHTTP(url="http://localhost:8000/sse")
+1 -10
View File
@@ -10,7 +10,6 @@ from livekit.agents import (
AgentSession,
ChatContext,
JobContext,
JobProcess,
RunContext,
cli,
metrics,
@@ -18,7 +17,7 @@ from livekit.agents import (
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
from livekit.plugins import deepgram, openai
# uncomment to enable Krisp BVC noise cancellation, currently supported on Linux and MacOS
# from livekit.plugins import noise_cancellation
@@ -132,17 +131,9 @@ class StoryAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext):
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-4.1-mini"),
stt=deepgram.STT(model="nova-3"),
+2 -10
View File
@@ -6,25 +6,18 @@ from livekit.agents import (
Agent,
AgentSession,
JobContext,
JobProcess,
WorkerOptions,
cli,
)
from livekit.plugins import nvidia, openai, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import nvidia, openai
logger = logging.getLogger("basic-agent")
load_dotenv()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=openai.LLM(model="gpt-4.1-mini"),
stt=nvidia.STT(),
tts=nvidia.TTS(),
@@ -32,7 +25,6 @@ async def entrypoint(ctx: JobContext):
resume_false_interruption=True,
false_interruption_timeout=1.0,
min_interruption_duration=0.2,
turn_detection=MultilingualModel(),
)
await session.start(
@@ -42,4 +34,4 @@ async def entrypoint(ctx: JobContext):
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
@@ -11,7 +11,7 @@ from livekit.agents import (
cli,
function_tool,
)
from livekit.plugins import openai, silero # noqa: F401
from livekit.plugins import openai # 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,
@@ -72,7 +72,7 @@ async def entrypoint(ctx: JobContext):
# stt=openai.STT(),
# llm=openai.LLM(),
# tts=openai.TTS(),
# vad=silero.VAD.load(),
# vad=inference.VAD(model="silero"),
llm=openai.realtime.RealtimeModel()
)
await session.start(RawFunctionAgent(), room=ctx.room)
@@ -56,7 +56,7 @@ from livekit.agents import (
room_io,
)
from livekit.agents.llm import function_tool
from livekit.plugins import aws, silero
from livekit.plugins import aws
load_dotenv()
@@ -221,7 +221,6 @@ async def entrypoint(ctx: agents.JobContext):
stt=aws.STT(),
llm=aws.LLM(),
tts=aws.TTS(),
vad=silero.VAD.load(),
)
else:
print("⚡ Using REALTIME mode: Nova Sonic 2.0")
@@ -3,9 +3,9 @@ import logging
from dotenv import load_dotenv
from google.genai import types # noqa: F401
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, JobProcess, cli
from livekit.plugins import deepgram, google, openai, silero # noqa: F401
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
from livekit.agents.inference import TurnDetector
from livekit.plugins import deepgram, google, openai # noqa: F401
logger = logging.getLogger("realtime-turn-detector")
logger.setLevel(logging.INFO)
@@ -24,8 +24,7 @@ server = AgentServer()
async def entrypoint(ctx: JobContext):
session = AgentSession(
allow_interruptions=True,
turn_detection=MultilingualModel(),
vad=ctx.proc.userdata["vad"],
turn_detection=TurnDetector(),
stt=deepgram.STT(),
# To use OpenAI Realtime API
llm=openai.realtime.RealtimeModel(
@@ -48,11 +47,5 @@ async def entrypoint(ctx: JobContext):
await session.start(agent=Agent(instructions="You are a helpful assistant."), room=ctx.room)
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
if __name__ == "__main__":
cli.run_app(server)
@@ -11,7 +11,7 @@ from livekit.agents import (
room_io,
voice, # noqa: F401
)
from livekit.plugins import google, silero
from livekit.plugins import google
logger = logging.getLogger("realtime-video-agent")
@@ -23,7 +23,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
# both Gemini and OpenAI Realtime API support streaming video input
llm=google.realtime.RealtimeModel(),
# customize how video frames are sampled
@@ -8,9 +8,6 @@ from pydantic import Field
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, RunContext, cli, inference
from livekit.agents.llm import function_tool
from livekit.plugins import silero
# from livekit.plugins import noise_cancellation
# This example demonstrates a multi-agent system where tasks are delegated to sub-agents
# based on the user's request.
@@ -330,7 +327,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT(model="deepgram/nova-3"),
llm=inference.LLM(model="openai/gpt-4.1-mini"),
tts=inference.TTS(model="cartesia/sonic-3"),
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"),
@@ -3,7 +3,6 @@ import logging
from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference
from livekit.plugins import silero
logger = logging.getLogger("resume-agent")
@@ -21,7 +20,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
llm=inference.LLM("openai/gpt-4.1-mini"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("cartesia/sonic-3"),
@@ -4,7 +4,7 @@ from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, CloseEvent, JobContext, cli
from livekit.agents.beta.tools import EndCallTool
from livekit.plugins import google, silero # noqa: F401
from livekit.plugins import google # noqa: F401
logger = logging.getLogger("my-worker")
logger.setLevel(logging.INFO)
@@ -42,9 +42,7 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
)
session = AgentSession()
await session.start(agent=MyAgent(), room=ctx.room)
@@ -12,7 +12,6 @@ from livekit.agents import (
inference,
)
from livekit.agents.llm import function_tool
from livekit.plugins import silero
logger = logging.getLogger("silent-function-call")
logger.setLevel(logging.INFO)
@@ -60,7 +59,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
vad=silero.VAD.load(),
)
@session.on("function_tools_executed")
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
from livekit.agents.stt import MultiSpeakerAdapter
from livekit.plugins import deepgram, openai, silero, speechmatics # noqa: F401
from livekit.plugins import deepgram, openai, speechmatics # noqa: F401
# Load environment variables from .env file
# Required: SPEECHMATICS_API_KEY, OPENAI_API_KEY
@@ -54,7 +54,6 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
session = AgentSession(
vad=silero.VAD.load(),
llm=openai.LLM(),
tts=openai.TTS(),
stt=MultiSpeakerAdapter(
@@ -16,7 +16,6 @@ from livekit.agents import (
inference,
utils,
)
from livekit.plugins import silero
try:
import librosa
@@ -92,8 +91,6 @@ server = AgentServer()
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)
@@ -109,7 +106,6 @@ async def entrypoint(ctx: JobContext):
"user_id": "your user_id",
}
session = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=inference.LLM("openai/gpt-4.1-mini"),
stt=inference.STT("deepgram/nova-3"),
tts=inference.TTS("cartesia/sonic-3"),
+2 -6
View File
@@ -18,8 +18,7 @@ from livekit.agents import (
ModelSettings,
cli,
)
from livekit.plugins import openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
from livekit.plugins import openai
logger = logging.getLogger("structured-output")
load_dotenv()
@@ -128,10 +127,7 @@ server = AgentServer()
@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
turn_detection=EnglishModel(),
)
session = AgentSession()
await session.start(agent=MyAgent(), room=ctx.room)
@@ -7,7 +7,7 @@ from dotenv import load_dotenv
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference, room_io
from livekit.agents.types import TimedString
from livekit.agents.voice.agent import ModelSettings
from livekit.plugins import cartesia, silero
from livekit.plugins import cartesia
logger = logging.getLogger("my-worker")
logger.setLevel(logging.INFO)
@@ -44,7 +44,6 @@ async def entrypoint(ctx: JobContext):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("google/gemini-2.5-flash"),
tts=cartesia.TTS(),
vad=silero.VAD.load(),
# enable TTS-aligned transcript, can be configured at the Agent level as well
use_tts_aligned_transcript=True,
)
@@ -31,7 +31,6 @@ from livekit.agents import (
)
from livekit.agents.beta.toolsets import ToolProxyToolset, ToolSearchToolset
from livekit.agents.metrics.base import LLMMetrics
from livekit.plugins import silero
logger = logging.getLogger("tool-search-example")
logger.setLevel(logging.INFO)
@@ -172,7 +171,6 @@ async def entrypoint(ctx: JobContext):
llm=inference.LLM("openai/gpt-4.1-mini"),
tts=inference.TTS("cartesia/sonic-3"),
stt=inference.STT("deepgram/nova-3"),
vad=silero.VAD.load(),
)
# Track token usage to observe prompt caching behavior.
@@ -11,7 +11,6 @@ from livekit.agents import (
function_tool,
room_io,
)
from livekit.plugins import silero
from livekit.plugins.ultravox.realtime import RealtimeModel
logger = logging.getLogger("ultravox-agent")
@@ -28,7 +27,6 @@ class MyAgent(Agent):
voice="Jessica",
language_hint="en",
),
vad=silero.VAD.load(),
)
async def on_enter(self):
@@ -9,14 +9,11 @@ from livekit.agents import (
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
cli,
inference,
mcp,
metrics,
)
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
load_dotenv(dotenv_path=".env.local")
logger = logging.getLogger("voice-agent")
@@ -32,8 +29,6 @@ class Assistant(Agent):
stt=inference.STT("deepgram/nova-3"),
llm=inference.LLM("google/gemini-2.5-flash"),
tts=inference.TTS("rime/arcana"),
# use LiveKit's transformer-based turn detector
turn_detection=MultilingualModel(),
)
async def on_enter(self):
@@ -45,13 +40,6 @@ class Assistant(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext):
logger.info(f"connecting to room {ctx.room.name}")
@@ -75,7 +63,6 @@ async def entrypoint(ctx: JobContext):
logger.warning("ZAPIER_MCP_SERVER environment variable not set. MCP integration disabled.")
session = AgentSession(
vad=ctx.proc.userdata["vad"],
# minimum delay for endpointing, used when turn detector believes the user is done with their turn # noqa: E501
min_endpointing_delay=0.5,
# maximum delay for endpointing, used when turn detector does not believe the user is done with their turn # noqa: E501
+1 -4
View File
@@ -13,8 +13,7 @@ from livekit.agents import (
)
from livekit.agents.beta.workflows import WarmTransferTask
from livekit.agents.llm import ToolError, function_tool
from livekit.plugins import noise_cancellation, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import noise_cancellation
logger = logging.getLogger("warm-transfer")
@@ -96,11 +95,9 @@ server = AgentServer()
@server.rtc_session(agent_name="sip-inbound")
async def entrypoint(ctx: JobContext):
session = AgentSession(
vad=silero.VAD.load(),
llm="openai/gpt-4.1-mini",
stt="deepgram/nova-3:en",
tts="cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
turn_detection=MultilingualModel(),
)
support_agent = SupportAgent()
@@ -1,3 +1,4 @@
from .eot import TurnDetector, TurnDetectorModels, TurnDetectorVersions
from .interruption import (
AdaptiveInterruptionDetector,
InterruptionDataFrameType,
@@ -7,17 +8,23 @@ from .interruption import (
from .llm import LLM, LLMModels, LLMStream
from .stt import STT, STTModels
from .tts import TTS, TTSModels
from .vad import VAD, VADModels
__all__ = [
"STT",
"TTS",
"LLM",
"VAD",
"LLMStream",
"STTModels",
"TTSModels",
"LLMModels",
"VADModels",
"AdaptiveInterruptionDetector",
"InterruptionDetectionError",
"OverlappingSpeechEvent",
"InterruptionDataFrameType",
"TurnDetector",
"TurnDetectorModels",
"TurnDetectorVersions",
]
@@ -0,0 +1,10 @@
"""Side-effect module: imported in the forkserver preload list so the
native ``livekit-local-inference`` model singletons are loaded inside the
forkserver process. Child job processes then inherit the resident weight
pages via COW.
"""
import livekit.local_inference as _li
_li.init_vad()
_li.init_eot()
@@ -0,0 +1,4 @@
from .detector import TurnDetector
from .languages import TurnDetectorModels, TurnDetectorVersions
__all__ = ["TurnDetector", "TurnDetectorModels", "TurnDetectorVersions"]
@@ -0,0 +1,389 @@
"""Audio EOT detector base, the per-window inference stream (with built-in
cloud→local fallback), and the transport Protocol concrete backends satisfy.
Lives next to its transports rather than in ``voice/turn`` so the
fallback logic is a peer of the transports it switches between rather than a
template-method-across-packages.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
from livekit import rtc
from ... import utils
from ..._exceptions import APITimeoutError
from ...language import LanguageCode
from ...log import logger
from ...types import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectOptions,
)
from ...utils import aio
from ...voice.turn import TurnDetectionEvent
from .languages import ThresholdOptions, TurnDetectorModels
DEFAULT_SAMPLE_RATE: int = 16000
MIN_SILENCE_DURATION_MS = 200
"""Minimum VAD silence the audio EOT detector needs before it sends
an inference request. Enforced against the caller-supplied VAD's
``min_silence_duration`` in ``AudioRecognition``."""
@dataclass
class TurnDetectorOptions:
sample_rate: int
thresholds: ThresholdOptions
@runtime_checkable
class _StreamingTurnDetectionTransport(Protocol):
async def run(self) -> None: ...
def run_inference(self, request_id: str) -> None: ...
def push_frame(self, frame: rtc.AudioFrame) -> None: ...
def flush(self) -> None: ...
def attach(self, stream: _BaseStreamingTurnDetectorStream) -> None: ...
def detach(self) -> None: ...
class _BaseStreamingTurnDetector(rtc.EventEmitter[Literal["metrics_collected"]]):
def __init__(self, *, opts: TurnDetectorOptions) -> None:
super().__init__()
self._opts = opts
@property
def model(self) -> TurnDetectorModels:
raise NotImplementedError
@property
def provider(self) -> str:
return "livekit"
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> _BaseStreamingTurnDetectorStream:
raise NotImplementedError
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None:
return self._opts.thresholds.lookup(language)
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None:
return self._opts.thresholds.lookup_backchannel(language)
async def supports_language(self, language: LanguageCode | None) -> bool:
return self._opts.thresholds.supports(language)
class _BaseStreamingTurnDetectorStream:
@dataclass
class _FlushSentinel:
reason: str | None = None
def __init__(
self,
*,
detector: _BaseStreamingTurnDetector,
opts: TurnDetectorOptions,
transport: _StreamingTurnDetectionTransport,
model: TurnDetectorModels = "turn-detector-v1-mini",
) -> None:
self._detector = detector
self._opts = opts
self._transport = transport
self._transport.attach(self)
self._model: TurnDetectorModels = model
self._is_fallback = False
self._warned_cloud_failure = False
self._warned_local_failure = False
self._transport_task: asyncio.Task[None] | None = None
self._fallback_requested = False
self._audio_input_sample_rate: int | None = None
self._audio_input_num_channels: int | None = None
self._audio_resampler: rtc.AudioResampler | None = None
self._audio_ch = aio.Chan[
rtc.AudioFrame | _BaseStreamingTurnDetectorStream._FlushSentinel
]()
self._request_id: str | None = None
self._request_fut: asyncio.Future[TurnDetectionEvent] | None = None
self._tasks: set[asyncio.Task[None]] = set()
self._task = asyncio.create_task(self._main_task())
# region: detector proxies
@property
def model(self) -> TurnDetectorModels:
# The stream owns its active model, so after a fallback this reports
# "turn-detector-v1-mini". The detector and stream share one mutable
# ``ThresholdOptions``, and the cloud→local fallback it performs is
# one-way and sticky: once degraded it never returns to cloud, so the
# detector view stays consistent for the rest of its lifetime.
return self._model
@property
def provider(self) -> str:
return self._detector.provider
@property
def is_fallback(self) -> bool:
return self._is_fallback
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None:
return self._opts.thresholds.lookup(language)
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None:
return self._opts.thresholds.lookup_backchannel(language)
async def supports_language(self, language: LanguageCode | None) -> bool:
return self._opts.thresholds.supports(language)
# endregion
# region: inference requests
def predict(self) -> asyncio.Future[TurnDetectionEvent]:
"""Start a new inference request and return its future."""
if self._audio_ch.closed:
fut: asyncio.Future[TurnDetectionEvent] = asyncio.get_running_loop().create_future()
fut.set_result(self._default_event(1.0))
return fut
self.cancel_inference() # supersede any previous request
self._request_id = utils.shortuuid("turn_request_")
self._request_fut = asyncio.get_running_loop().create_future()
self._transport.run_inference(self._request_id)
return self._request_fut
def cancel_inference(self, *, timed_out: bool = False) -> None:
"""Close the current inference request (new speech, turn boundary,
prediction timeout, mode change) and fall back if needed.
"""
if self._request_id is not None:
fut = self._request_fut
self._request_id = None
self._request_fut = None
if fut is not None and not fut.done():
fut.set_result(self._default_event(0.0))
# trigger fallback immediately
if timed_out and self._model == "turn-detector-v1":
self._fall_back_to_local(reason=APITimeoutError("eot prediction timed out"))
def flush(self, reason: str | None = None) -> None:
# Idempotent: a second call sends another sentinel that transports
# treat as a no-op (cloud: redundant session_flush; local: empty trim).
if self._audio_ch.closed:
return
for resampled_frame in self._flush_audio_resampler():
self._audio_ch.send_nowait(resampled_frame)
self._audio_ch.send_nowait(_BaseStreamingTurnDetectorStream._FlushSentinel(reason=reason))
self.cancel_inference()
@staticmethod
def _default_event(probability: float) -> TurnDetectionEvent:
return TurnDetectionEvent(
type="eot_prediction",
last_speaking_time=time.time(),
end_of_turn_probability=probability,
)
# endregion
# region: audio ingress
def push_audio(self, frame: rtc.AudioFrame) -> None:
if self._audio_ch.closed:
return
for resampled_frame in self._resample_audio_frame(frame):
self._audio_ch.send_nowait(resampled_frame)
def end_input(self) -> None:
self.flush()
self._audio_ch.close()
def _resample_audio_frame(self, frame: rtc.AudioFrame) -> list[rtc.AudioFrame]:
if self._audio_input_sample_rate is None or self._audio_input_num_channels is None:
self._audio_input_sample_rate = frame.sample_rate
self._audio_input_num_channels = frame.num_channels
if self._audio_input_sample_rate != self._opts.sample_rate:
self._audio_resampler = rtc.AudioResampler(
input_rate=self._audio_input_sample_rate,
output_rate=self._opts.sample_rate,
num_channels=self._audio_input_num_channels,
quality=rtc.AudioResamplerQuality.QUICK,
)
elif (
frame.sample_rate != self._audio_input_sample_rate
or frame.num_channels != self._audio_input_num_channels
):
logger.error(
"a frame with different audio format was already pushed",
extra={
"sample_rate": frame.sample_rate,
"expected_sample_rate": self._audio_input_sample_rate,
"num_channels": frame.num_channels,
"expected_num_channels": self._audio_input_num_channels,
},
)
return []
if self._audio_resampler is None:
return [frame]
return self._audio_resampler.push(frame)
def _flush_audio_resampler(self) -> list[rtc.AudioFrame]:
frames = self._audio_resampler.flush() if self._audio_resampler is not None else []
self._reset_audio_resampler()
return frames
def _reset_audio_resampler(self) -> None:
self._audio_resampler = None
self._audio_input_sample_rate = None
self._audio_input_num_channels = None
# endregion
# region: results
def _resolve_prediction(
self,
request_id: str,
probability: float,
*,
inference_duration: float | None = None,
detection_delay: float | None = None,
backchannel_probability: float | None = None,
) -> None:
"""Accept a prediction from a transport. Stale response is ignored."""
if request_id != self._request_id:
return
fut = self._request_fut
self._request_id = None
self._request_fut = None
if fut is not None and not fut.done():
fut.set_result(
TurnDetectionEvent(
type="eot_prediction",
last_speaking_time=time.time(),
end_of_turn_probability=probability,
detection_delay=detection_delay,
inference_duration=inference_duration,
backchannel_probability=backchannel_probability,
)
)
# endregion
# region: teardown
async def aclose(self) -> None:
self._transport.detach()
self.end_input() # the flush inside closes the in-flight request
await aio.cancel_and_wait(self._task)
await aio.cancel_and_wait(*self._tasks)
self.cancel_inference() # defensive, normally a no-op
# endregion
# region: main task + fallback
async def _main_task(self) -> None:
await self._run()
async def _drain_audio_channel(self) -> None:
async for item in self._audio_ch:
if isinstance(item, _BaseStreamingTurnDetectorStream._FlushSentinel):
self._transport.flush()
else:
self._transport.push_frame(item)
async def _run(self) -> None:
"""Run the active transport, retrying on cloud failure by swapping in
a local transport in-place. ``turn-detector-v1-mini`` just runs the
transport once and surfaces failures to the caller via
``_resolve_prediction`` (default 1.0)."""
while True:
task = asyncio.create_task(self._transport.run())
self._transport_task = task
try:
await task
return
except asyncio.CancelledError:
# _fall_back_to_local sets _fallback_requested before cancelling
# this child task; any other cancellation (e.g. aclose cancelling
# the parent) leaves the flag unset and propagates.
if self._fallback_requested:
self._fallback_requested = False
continue
if not task.done():
await aio.cancel_and_wait(task)
raise
except Exception as e: # noqa: BLE001 — any cloud error degrades to local
if self._model == "turn-detector-v1":
self._fall_back_to_local(reason=e)
continue
self._on_local_failure(reason=e)
return
def _fall_back_to_local(self, *, reason: BaseException) -> None:
# Lazy import: transports.py imports this module for the Protocol and
# constants, so importing it at module load would cycle.
from .transports import _LocalTransport
if not self._warned_cloud_failure:
logger.warning(
"cloud turn detector failed (%s); falling back to local mini model",
reason,
)
self._warned_cloud_failure = True
self._emit_default_for_inflight()
self._transport.detach()
self._opts.thresholds._to_local_fallback()
from .detector import TurnDetector
if isinstance(self._detector, TurnDetector):
self._detector._model = "turn-detector-v1-mini"
self._transport = _LocalTransport(opts=self._opts)
self._transport.attach(self)
self._model = "turn-detector-v1-mini"
self._is_fallback = True
# If transport.run() is still in flight (e.g. predict timeout while
# the cloud session was otherwise idle), signal+cancel so _run loops
# onto the local transport. Without this the orphaned WS lingers until
# the gateway closes it for inactivity and the ensuing error surfaces
# as a misleading log.
task = self._transport_task
if task is not None and not task.done():
self._fallback_requested = True
task.cancel()
def _on_local_failure(self, *, reason: BaseException) -> None:
if not self._warned_local_failure:
logger.warning(
"local audio turn detector failed (%s); defaulting to 1.0 and retrying on next turn",
reason,
)
self._warned_local_failure = True
self._emit_default_for_inflight()
def _emit_default_for_inflight(self) -> None:
# Positive default so any waiter commits after min_endpointing_delay.
request_id = self._request_id
if request_id is not None:
self._resolve_prediction(request_id, 1.0)
# endregion
@@ -0,0 +1,175 @@
"""Audio end-of-turn detector with cloud → local fallback."""
from __future__ import annotations
from dataclasses import replace
import aiohttp
from ... import utils
from ...language import LanguageCode
from ...log import logger
from ...types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ...utils import is_given
from .._utils import get_default_inference_url
from .base import (
DEFAULT_SAMPLE_RATE,
TurnDetectorOptions,
_BaseStreamingTurnDetector,
_BaseStreamingTurnDetectorStream,
_StreamingTurnDetectionTransport,
)
from .languages import ThresholdOptions, TurnDetectorModels, TurnDetectorVersions
from .transports import _CloudTransport, _CloudTransportOptions, _LocalTransport
__all__ = ["TurnDetector"]
class TurnDetector(_BaseStreamingTurnDetector):
def __init__(
self,
*,
version: NotGivenOr[TurnDetectorVersions] = NOT_GIVEN,
unlikely_threshold: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
backchannel_threshold: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
api_secret: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = DEFAULT_SAMPLE_RATE,
http_session: aiohttp.ClientSession | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
auto = not is_given(version)
resolved_version: TurnDetectorVersions = (
version
if is_given(version)
else ("v1" if (utils.is_hosted() or utils.is_dev_mode()) else "v1-mini")
)
resolved_model: TurnDetectorModels = (
"turn-detector-v1" if resolved_version == "v1" else "turn-detector-v1-mini"
)
cloud_opts: _CloudTransportOptions | None = None
if resolved_version == "v1":
lk_base_url = utils.resolve_env_var(
base_url,
"LIVEKIT_INFERENCE_URL",
default=get_default_inference_url(),
)
lk_api_key = utils.resolve_env_var(
api_key, "LIVEKIT_INFERENCE_API_KEY", "LIVEKIT_API_KEY", default=""
)
lk_api_secret = utils.resolve_env_var(
api_secret,
"LIVEKIT_INFERENCE_API_SECRET",
"LIVEKIT_API_SECRET",
default="",
)
missing: list[str] = []
if not lk_base_url:
missing.append("LIVEKIT_INFERENCE_URL")
if not lk_api_key:
missing.append("LIVEKIT_API_KEY")
if not lk_api_secret:
missing.append("LIVEKIT_API_SECRET")
if missing:
if auto:
logger.warning(
"LIVEKIT_INFERENCE_URL is set but %s missing; "
"falling back to the turn-detector-v1-mini model",
", ".join(missing),
)
resolved_model = "turn-detector-v1-mini"
else:
raise ValueError(
f"TurnDetector(version='v1') requires "
f"{', '.join(missing)} (env or constructor argument)."
)
else:
cloud_opts = _CloudTransportOptions(
base_url=lk_base_url,
api_key=lk_api_key,
api_secret=lk_api_secret,
conn_options=conn_options,
)
opts = TurnDetectorOptions(
sample_rate=sample_rate,
thresholds=ThresholdOptions(resolved_model, unlikely_threshold, backchannel_threshold),
)
super().__init__(opts=opts)
self._model: TurnDetectorModels = resolved_model
self._cloud_opts = cloud_opts
self._http_session = http_session
self._warn_threshold_override()
@property
def model(self) -> TurnDetectorModels:
return self._model
def _warn_threshold_override(self) -> None:
thresholds = self._opts.thresholds
if is_given(overrides := thresholds.overrides):
logger.warning(
"a non-default turn detection threshold was provided "
"(unlikely_threshold=%s); the server provides calibrated defaults and "
"overriding them may be suboptimal",
overrides,
)
if is_given(bc_overrides := thresholds.backchannel_overrides):
logger.warning(
"a non-default backchannel threshold was provided "
"(backchannel_threshold=%s); the server provides calibrated defaults and "
"overriding them may be suboptimal",
bc_overrides,
)
def update_options(
self,
*,
unlikely_threshold: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
backchannel_threshold: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
) -> None:
if is_given(unlikely_threshold):
self._opts.thresholds.update_overrides(unlikely_threshold)
if is_given(backchannel_threshold):
self._opts.thresholds.update_backchannel_overrides(backchannel_threshold)
self._warn_threshold_override()
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> _BaseStreamingTurnDetectorStream:
cloud_opts = (
replace(self._cloud_opts, conn_options=conn_options)
if self._cloud_opts is not None
else None
)
transport: _StreamingTurnDetectionTransport
if self._model == "turn-detector-v1":
assert cloud_opts is not None, "turn-detector-v1 requires cloud_opts"
transport = _CloudTransport(
detector=self,
opts=self._opts,
cloud_opts=cloud_opts,
http_session=self._http_session,
)
else:
transport = _LocalTransport(opts=self._opts)
return _BaseStreamingTurnDetectorStream(
detector=self,
opts=self._opts,
transport=transport,
model=self._model,
)
@@ -0,0 +1,207 @@
"""Per-language ``unlikely`` thresholds for the mini detector."""
from __future__ import annotations
from typing import Literal, cast
from ..._exceptions import APIError
from ...language import LanguageCode
from ...types import NOT_GIVEN, NotGivenOr
from ...utils.misc import is_given
LOCAL_LANGUAGES: dict[str, float] = {
"ar": 0.3500,
"de": 0.2450,
"en": 0.3600,
"es": 0.3500,
"fr": 0.2850,
"hi": 0.3050,
"id": 0.3450,
"it": 0.2300,
"ja": 0.2950,
"ko": 0.4000,
"nl": 0.2000,
"pt": 0.3200,
"tr": 0.2550,
"zh": 0.3550,
}
TurnDetectorModels = Literal["turn-detector-v1", "turn-detector-v1-mini"]
TurnDetectorVersions = Literal["v1", "v1-mini"]
def _normalize_overrides(
overrides: NotGivenOr[float | dict[LanguageCode | str, float]],
) -> NotGivenOr[float | dict[str, float]]:
if not is_given(overrides) or not isinstance(overrides, dict):
return overrides
return {LanguageCode(k).language: float(v) for k, v in overrides.items()}
class ThresholdOptions:
def __init__(
self,
model: TurnDetectorModels,
overrides: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
backchannel_overrides: NotGivenOr[float | dict[LanguageCode | str, float]] = NOT_GIVEN,
) -> None:
self._model = model
self._overrides = _normalize_overrides(overrides)
self._bc_overrides = _normalize_overrides(backchannel_overrides)
# server/shipped defaults
self._server_thresholds: dict[str, float] | None = None
self._server_default: float | None = None
if model == "turn-detector-v1-mini":
self._server_thresholds = dict(LOCAL_LANGUAGES)
self._server_default = LOCAL_LANGUAGES["en"]
# backchannel server defaults: cloud-only (the local mini model produces no
# backchannel probability), arrive via ``SessionCreated``.
self._server_bc_thresholds: dict[str, float] | None = None
self._server_bc_default: float | None = None
# materialized values (server defaults layered with user overrides)
self._thresholds: dict[str, float] = {}
self._default: float | None = None
self._bc_thresholds: dict[str, float] = {}
self._bc_default: float | None = None
self._resolve()
@property
def model(self) -> TurnDetectorModels:
return self._model
@property
def overrides(self) -> NotGivenOr[float | dict[str, float]]:
return self._overrides
@property
def backchannel_overrides(self) -> NotGivenOr[float | dict[str, float]]:
return self._bc_overrides
@property
def thresholds(self) -> dict[str, float]:
return self._thresholds
@property
def default_threshold(self) -> float | None:
return self._default
def lookup(self, language: LanguageCode | None) -> float | None:
lang_key = language.language if language else "en"
return self._thresholds.get(lang_key, self.default_threshold)
def lookup_backchannel(self, language: LanguageCode | None) -> float | None:
if not self._bc_thresholds and not self._bc_default:
return None
lang_key = language.language if language else "en"
threshold = self._bc_thresholds.get(lang_key, self._bc_default)
return threshold if threshold and threshold > 0 else None
def supports(self, language: LanguageCode | None) -> bool:
pending = self._model == "turn-detector-v1" and self._server_thresholds is None
return pending or self.lookup(language) is not None
def update_overrides(
self, overrides: NotGivenOr[float | dict[LanguageCode | str, float]]
) -> None:
self._overrides = _normalize_overrides(overrides)
self._resolve()
def update_backchannel_overrides(
self, overrides: NotGivenOr[float | dict[LanguageCode | str, float]]
) -> None:
self._bc_overrides = _normalize_overrides(overrides)
self._resolve()
def _update_defaults(
self,
server_thresholds: dict[str, float],
server_default: float,
backchannel_thresholds: dict[str, float] | None = None,
backchannel_default: float = 0.0,
) -> None:
if not server_thresholds or server_default <= 0:
raise APIError(
"turn detector session created without usable default thresholds",
retryable=False,
)
self._server_thresholds = {
LanguageCode(lang).language: round(value, 4)
for lang, value in server_thresholds.items()
}
self._server_default = round(server_default, 4)
# backchannel defaults are optional; an absent/empty map keeps backchannel disabled
self._server_bc_thresholds = (
{
LanguageCode(lang).language: round(value, 4)
for lang, value in backchannel_thresholds.items()
}
if backchannel_thresholds
else None
)
self._server_bc_default = round(backchannel_default, 4) if backchannel_default > 0 else None
self._resolve()
def _to_local_fallback(self) -> None:
if self._model == "turn-detector-v1-mini":
return
rescaled: dict[str, float] | None = None
if server := self._server_thresholds:
effective = {lang: self.lookup(LanguageCode(lang)) for lang in server}
rescaled = {
lang: LOCAL_LANGUAGES[lang] * (active_t / server[lang])
for lang, active_t in effective.items()
if active_t is not None and lang in LOCAL_LANGUAGES and server[lang] != 0
}
self._model = "turn-detector-v1-mini"
self._server_thresholds = dict(LOCAL_LANGUAGES)
self._server_default = LOCAL_LANGUAGES["en"]
# the mini model produces no backchannel probability
self._server_bc_thresholds = None
self._server_bc_default = None
self._resolve()
if rescaled is not None:
self._thresholds = rescaled
self._default = self.lookup(LanguageCode("en"))
def _resolve(self) -> None:
self._thresholds, self._default = self._resolve_layer(
self._server_thresholds, self._server_default, self._overrides
)
self._bc_thresholds, self._bc_default = self._resolve_layer(
self._server_bc_thresholds, self._server_bc_default, self._bc_overrides
)
@staticmethod
def _resolve_layer(
server_thresholds: dict[str, float] | None,
server_default: float | None,
overrides: NotGivenOr[float | dict[str, float]],
) -> tuple[dict[str, float], float | None]:
"""Layer a user override onto the server defaults.
A scalar override replaces the whole map (every language resolves through
it); a dict override is merged over the server map. Before server defaults
arrive, only a scalar override resolves up front.
"""
scalar_override = is_given(overrides) and not isinstance(overrides, dict)
if server_thresholds is None or server_default is None:
return {}, (float(cast(float, overrides)) if scalar_override else None)
if not is_given(overrides):
return dict(server_thresholds), server_default
if scalar_override:
return {}, float(cast(float, overrides))
override = cast("dict[str, float]", overrides)
return {**server_thresholds, **override}, server_default
@@ -0,0 +1,424 @@
"""Audio EOT transports: cloud (WebSocket) + local (livekit-local-inference)."""
from __future__ import annotations
import asyncio
import time
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import aiohttp
import numpy as np
from google.protobuf.timestamp_pb2 import Timestamp
from livekit import rtc
from livekit.local_inference import EOT as _EOT
from livekit.protocol.agent_pb.agent_inference import (
AUDIO_ENCODING_PCM_S16LE,
ClientMessage,
EotPrediction,
InferenceStart,
InputAudio,
ServerMessage,
SessionClose,
SessionCreate,
SessionFlush,
SessionSettings,
)
from ... import utils
from ..._exceptions import (
APIConnectionError,
APIError,
APIStatusError,
APITimeoutError,
create_api_error_from_http,
)
from ...log import logger
from ...metrics import EOTInferenceMetrics
from ...metrics.base import Metadata
from ...types import APIConnectOptions
from ...utils import aio, is_given
from .._utils import create_access_token, get_inference_headers
from .base import (
DEFAULT_SAMPLE_RATE,
TurnDetectorOptions,
_BaseStreamingTurnDetectorStream,
_StreamingTurnDetectionTransport,
)
if TYPE_CHECKING:
from .detector import TurnDetector
__all__ = [
"_CloudTransport",
"_CloudTransportOptions",
"_LocalTransport",
"_StreamingTurnDetectionTransport",
]
@dataclass
class _CloudTransportOptions:
"""Cloud-WebSocket-specific options. Held separately from
``TurnDetectorOptions`` so the local transport doesn't see fields that
don't apply to it."""
base_url: str
api_key: str
api_secret: str
conn_options: APIConnectOptions
_CLIENT_BUFFER_SECONDS = 1.2
_CLIENT_BUFFER_SAMPLES = int(_CLIENT_BUFFER_SECONDS * DEFAULT_SAMPLE_RATE)
class _CloudTransport:
"""WebSocket transport for `turn-detector-v1`."""
def __init__(
self,
*,
detector: TurnDetector,
opts: TurnDetectorOptions,
cloud_opts: _CloudTransportOptions,
http_session: aiohttp.ClientSession | None,
) -> None:
self._detector_ref: weakref.ref[TurnDetector] = weakref.ref(detector)
self._opts = opts
self._cloud_opts = cloud_opts
self._conn_options = cloud_opts.conn_options
self._session_holder = http_session
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._num_retries = 0
self._send_ch: aio.Chan[ClientMessage] | None = None
self._stream_ref: weakref.ref[_BaseStreamingTurnDetectorStream] | None = None
def attach(self, stream: _BaseStreamingTurnDetectorStream) -> None:
self._stream_ref = weakref.ref(stream)
def run_inference(self, request_id: str) -> None:
self._send_message(ClientMessage(inference_start=InferenceStart(request_id=request_id)))
def push_frame(self, frame: rtc.AudioFrame) -> None:
pcm_bytes = bytes(frame.data)
if not pcm_bytes:
return
audio_created_at = Timestamp()
audio_created_at.GetCurrentTime()
self._send_message(
ClientMessage(
input_audio=InputAudio(
audio=pcm_bytes,
num_samples=frame.samples_per_channel,
created_at=audio_created_at,
)
)
)
def flush(self) -> None:
self._send_message(ClientMessage(session_flush=SessionFlush()))
def detach(self) -> None:
if self._send_ch is not None:
self._send_ch.close()
self._ws = None
def _ensure_session(self) -> aiohttp.ClientSession:
if self._session_holder is None:
self._session_holder = utils.http_context.http_session()
return self._session_holder
def _build_auth_headers(self) -> dict[str, str]:
return {
**get_inference_headers(),
"Authorization": f"Bearer {create_access_token(self._cloud_opts.api_key, self._cloud_opts.api_secret)}",
}
def _send_message(self, msg: ClientMessage) -> None:
ch = self._send_ch
if ch is None or ch.closed or self._ws is None or self._ws.closed:
return
try:
ch.send_nowait(msg)
except aio.ChanClosed:
pass
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
base_url = self._cloud_opts.base_url
if base_url.startswith(("http://", "https://")):
base_url = base_url.replace("http", "ws", 1)
try:
ws = await asyncio.wait_for(
self._ensure_session().ws_connect(
f"{base_url}/eot",
headers=self._build_auth_headers(),
),
self._conn_options.timeout,
)
session_create_msg = ClientMessage(
session_create=SessionCreate(
settings=SessionSettings(
sample_rate=self._opts.sample_rate,
encoding=AUDIO_ENCODING_PCM_S16LE,
),
)
)
created_at = Timestamp()
created_at.GetCurrentTime()
session_create_msg.created_at.CopyFrom(created_at)
await ws.send_bytes(session_create_msg.SerializeToString())
except aiohttp.ClientResponseError as e:
raise create_api_error_from_http(e.message, status=e.status) from e
except asyncio.TimeoutError as e:
raise APITimeoutError("turn detector connection timed out") from e
except aiohttp.ClientConnectorError as e:
raise APIConnectionError("failed to connect to turn detector") from e
except Exception as e:
raise APIConnectionError("failed to connect to turn detector") from e
return ws
def _warn_transport_latency(self, msg: ServerMessage) -> None:
current_time = Timestamp()
current_time.GetCurrentTime()
if (
transport_latency := current_time.ToMilliseconds()
- msg.client_created_at.ToMilliseconds()
) > 500 and msg.client_created_at.ToMilliseconds() > 0:
logger.warning(
"turn detection transport latency is too high: %sms",
transport_latency,
)
def _process_message(self, msg: ServerMessage) -> None:
stream = self._stream_ref() if self._stream_ref is not None else None
if stream is None:
return
match msg.WhichOneof("message"):
case "eot_prediction":
prediction: EotPrediction = msg.eot_prediction
inference_stats = prediction.inference_stats
request_sent_at_ms = inference_stats.latest_client_created_at.ToMilliseconds()
current_time = Timestamp()
current_time.GetCurrentTime()
detection_delay_ms = current_time.ToMilliseconds() - request_sent_at_ms
inference_duration_ms = inference_stats.server_e2e_latency.ToMilliseconds()
stream._resolve_prediction(
msg.request_id,
prediction.probability,
detection_delay=detection_delay_ms / 1000.0,
inference_duration=inference_duration_ms / 1000.0,
backchannel_probability=prediction.backchannel_probability,
)
client_e2e_ms = inference_stats.client_e2e_latency.ToMilliseconds()
detector = self._detector_ref()
if detector is not None:
detector.emit(
"metrics_collected",
EOTInferenceMetrics(
timestamp=time.time(),
total_duration=client_e2e_ms / 1000.0,
prediction_duration=inference_duration_ms / 1000.0,
detection_delay=detection_delay_ms / 1000.0,
num_requests=1,
metadata=Metadata(
model_name=detector.model,
model_provider=detector.provider,
),
),
)
case "session_created":
self._warn_transport_latency(msg)
created = msg.session_created
thresholds = stream._opts.thresholds
thresholds._update_defaults(
dict(created.default_thresholds),
created.default_threshold,
dict(created.default_backchannel_thresholds),
created.default_backchannel_threshold,
)
logger.debug(
"audio turn detector initialized",
extra={
"model": thresholds.model,
"thresholds": thresholds.thresholds,
"default_threshold": thresholds.default_threshold,
"overrides": thresholds.overrides
if is_given(thresholds.overrides)
else None,
},
)
case "session_closed" | "inference_started" | "inference_stopped":
self._warn_transport_latency(msg)
case "error":
raise APIStatusError(
f"{msg.error.message}",
status_code=msg.error.code,
request_id=msg.request_id,
)
case _:
logger.warning("unexpected turn detector message: %s", msg.WhichOneof("message"))
async def run(self) -> None:
max_retries = self._conn_options.max_retry
while self._num_retries <= max_retries:
try:
return await self._run_once()
except APIError as e:
if max_retries == 0 or not e.retryable:
raise
if self._num_retries == max_retries:
raise APIConnectionError(
f"failed to connect livekit turn detector after {self._num_retries} attempts",
) from e
retry_interval = self._conn_options._interval_for_retry(self._num_retries)
logger.warning(
"livekit turn detector connection failed: %s, retrying in %ss",
e,
retry_interval,
extra={"attempt": self._num_retries},
)
await asyncio.sleep(retry_interval)
self._num_retries += 1
async def _run_once(self) -> None:
stream = self._stream_ref() if self._stream_ref is not None else None
if stream is None:
return
closing_ws = False
send_ch: aio.Chan[ClientMessage] = aio.Chan()
self._send_ch = send_ch
async def drain_audio_task() -> None:
nonlocal closing_ws
await stream._drain_audio_channel()
closing_ws = True
self._send_message(ClientMessage(session_close=SessionClose()))
send_ch.close()
async def sender_task(ws: aiohttp.ClientWebSocketResponse) -> None:
async for msg in send_ch:
if ws.closed:
return
if not msg.HasField("created_at"):
created_at = Timestamp()
created_at.GetCurrentTime()
msg.created_at.CopyFrom(created_at)
try:
await ws.send_bytes(msg.SerializeToString())
except (ConnectionResetError, aiohttp.ClientConnectionError):
return
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
while True:
ws_msg = await ws.receive()
if ws_msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws or self._ensure_session().closed:
return
raise APIStatusError(
message="turn detector connection closed unexpectedly",
status_code=ws.close_code or -1,
body=f"{ws_msg.data=} {ws_msg.extra=}",
retryable=False,
)
if ws_msg.type != aiohttp.WSMsgType.BINARY:
logger.warning("unexpected turn detector message type %s", ws_msg.type)
continue
server_msg = ServerMessage()
server_msg.ParseFromString(ws_msg.data)
self._process_message(server_msg)
ws: aiohttp.ClientWebSocketResponse | None = None
try:
ws = await self._connect_ws()
self._ws = ws
self._num_retries = 0
tasks = [
asyncio.create_task(drain_audio_task()),
asyncio.create_task(sender_task(ws)),
asyncio.create_task(recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
await aio.gracefully_cancel(*tasks)
finally:
send_ch.close()
if self._send_ch is send_ch:
self._send_ch = None
self._ws = None
if ws is not None:
await ws.close()
class _LocalTransport:
"""In-process ctypes transport for `turn-detector-v1-mini`."""
def __init__(self, *, opts: TurnDetectorOptions) -> None:
self._opts = opts
self._buf = utils.AudioArrayBuffer(
buffer_size=_CLIENT_BUFFER_SAMPLES, sample_rate=DEFAULT_SAMPLE_RATE
)
self._eot = _EOT()
self._stream_ref: weakref.ref[_BaseStreamingTurnDetectorStream] | None = None
self._tasks: set[asyncio.Task[Any]] = set()
def attach(self, stream: _BaseStreamingTurnDetectorStream) -> None:
self._stream_ref = weakref.ref(stream)
def run_inference(self, request_id: str) -> None:
task = asyncio.create_task(self._predict(request_id, self._buf.read()))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _predict(self, request_id: str, pcm_snapshot: np.ndarray) -> None:
prob = 0.0
t0 = time.monotonic()
try:
prob = float(await asyncio.to_thread(self._eot.predict, pcm_snapshot))
except Exception:
logger.exception("local audio EOT prediction failed")
inference_duration = time.monotonic() - t0
stream = self._stream_ref() if self._stream_ref is not None else None
if stream is None:
return
stream._resolve_prediction(request_id, prob, inference_duration=inference_duration)
def push_frame(self, frame: rtc.AudioFrame) -> None:
self._buf.push_frame(frame)
def flush(self) -> None:
if len(self._buf) > 0:
self._buf.shift(len(self._buf))
def detach(self) -> None:
for task in list(self._tasks):
task.cancel()
self._tasks.clear()
async def run(self) -> None:
stream = self._stream_ref() if self._stream_ref is not None else None
if stream is None:
return
await stream._drain_audio_channel()
@@ -235,14 +235,9 @@ def _resolve_vad_for_model(
)
return None
if is_speechmatics and vad_instance is None:
try:
from livekit.plugins.silero import VAD as SileroVAD
except ImportError as e:
raise ImportError(
"livekit-plugins-silero is required: model "
f"{model!r} does not handle endpointing server-side."
) from e
vad_instance = SileroVAD.load()
from .vad import VAD
vad_instance = VAD()
return vad_instance
@@ -0,0 +1,490 @@
# Copyright 2026 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 dataclasses import dataclass, replace
from typing import Literal
import numpy as np
from livekit import rtc
from livekit.local_inference import VAD as _NativeVAD, VAD_WINDOW_SAMPLES
from .. import utils, vad
from ..log import logger
from ..types import NOT_GIVEN, NotGivenOr
from ..utils import is_given
SLOW_INFERENCE_THRESHOLD = 0.2 # late by 200ms
_MODEL_SAMPLE_RATE = 16000
VADModels = Literal["silero"]
@dataclass
class _VADOptions:
min_speech_duration: float
min_silence_duration: float
prefix_padding_duration: float
max_buffered_speech: float
activation_threshold: float
deactivation_threshold: float
class VAD(vad.VAD):
"""Voice Activity Detection backed by ``livekit-local-inference``.
The native model singleton is loaded once at module import (via the
pybind11 ``.so`` constructor); each stream allocates its own per-instance
LSTM/context state.
"""
def __init__(
self,
*,
model: VADModels = "silero",
min_speech_duration: float = 0.05,
min_silence_duration: float = 0.25,
prefix_padding_duration: float = 0.5,
max_buffered_speech: float = 60.0,
activation_threshold: float = 0.5,
deactivation_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
super().__init__(capabilities=vad.VADCapabilities(update_interval=0.032))
if model != "silero":
raise ValueError(f"Unknown VAD model: {model!r}. Supported: 'silero'.")
if is_given(deactivation_threshold) and deactivation_threshold <= 0:
raise ValueError("deactivation_threshold must be greater than 0")
self._model = model
self._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,
deactivation_threshold=deactivation_threshold
if is_given(deactivation_threshold)
else max(activation_threshold - 0.15, 0.01),
)
self._streams: weakref.WeakSet[_VADStream] = weakref.WeakSet()
@property
def model(self) -> str:
return self._model
@property
def provider(self) -> str:
return "livekit-local-inference"
def stream(self) -> vad.VADStream:
# Each stream owns its own _VADOptions snapshot so that
# _VADStream.update_options() can read the prior value of
# max_buffered_speech before mutating it. Sharing the dataclass would
# let VAD.update_options() mutate the stream's view first, and the
# stream would never observe an increase.
stream = _VADStream(self, replace(self._opts))
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,
deactivation_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
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 is_given(deactivation_threshold):
self._opts.deactivation_threshold = deactivation_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,
deactivation_threshold=deactivation_threshold,
)
@property
def min_silence_duration(self) -> float | None:
return self._opts.min_silence_duration
class _VADStream(vad.VADStream):
def __init__(self, parent: VAD, opts: _VADOptions) -> None:
super().__init__(parent)
self._opts = opts
self._native_vad = _NativeVAD()
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,
deactivation_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
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 is_given(deactivation_threshold):
self._opts.deactivation_threshold = deactivation_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
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
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: list[rtc.AudioFrame] = []
inference_frames: list[rtc.AudioFrame] = []
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
def _reset_state() -> None:
nonlocal speech_buffer_index
nonlocal pub_speaking, pub_speech_duration, pub_silence_duration
nonlocal pub_current_sample, pub_timestamp
nonlocal speech_threshold_duration, silence_threshold_duration
nonlocal input_frames, inference_frames, resampler
nonlocal input_copy_remaining_fract, extra_inference_time
self._native_vad.reset()
speech_buffer_index = 0
self._speech_buffer_max_reached = False
if self._speech_buffer is not None:
self._speech_buffer.fill(0)
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 = []
input_copy_remaining_fract = 0.0
extra_inference_time = 0.0
if self._input_sample_rate and self._input_sample_rate != _MODEL_SAMPLE_RATE:
resampler = rtc.AudioResampler(
input_rate=self._input_sample_rate,
output_rate=_MODEL_SAMPLE_RATE,
quality=rtc.AudioResamplerQuality.QUICK,
)
else:
resampler = None
async for input_frame in self._input_ch:
if isinstance(input_frame, self._FlushSentinel):
_reset_state()
continue
if not isinstance(input_frame, rtc.AudioFrame):
continue
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 != _MODEL_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=_MODEL_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 < VAD_WINDOW_SAMPLES:
break # not enough samples to run inference
input_frame = utils.combine_frames(input_frames)
inference_frame = utils.combine_frames(inference_frames)
# native lib takes int16 directly — no float32 conversion
inference_window = np.asarray(
inference_frame.data[:VAD_WINDOW_SAMPLES], dtype=np.int16
)
# run the inference
p = await asyncio.to_thread(self._native_vad.predict, inference_window)
window_duration = VAD_WINDOW_SAMPLES / _MODEL_SAMPLE_RATE
pub_current_sample += VAD_WINDOW_SAMPLES
pub_timestamp += window_duration
resampling_ratio = self._input_sample_rate / _MODEL_SAMPLE_RATE
to_copy = VAD_WINDOW_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)
self._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() -> None:
nonlocal speech_buffer_index
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(
vad.VADEvent(
type=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 or (
pub_speaking and p > self._opts.deactivation_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(
vad.VADEvent(
type=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_silence_duration = silence_threshold_duration
self._event_ch.send_nowait(
vad.VADEvent(
type=vad.VADEventType.END_OF_SPEECH,
samples_index=pub_current_sample,
timestamp=pub_timestamp,
silence_duration=pub_silence_duration,
speech_duration=max(
0.0, pub_speech_duration - silence_threshold_duration
),
frames=[_copy_speech_buffer()],
speaking=False,
)
)
pub_speech_duration = 0.0
_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) - VAD_WINDOW_SAMPLES > 0:
data = inference_frame.data[VAD_WINDOW_SAMPLES:].tobytes()
inference_frames.append(
rtc.AudioFrame(
data=data,
sample_rate=_MODEL_SAMPLE_RATE,
num_channels=1,
samples_per_channel=len(data) // 2,
)
)
@@ -1,5 +1,6 @@
from .base import (
AgentMetrics,
EOTInferenceMetrics,
EOUMetrics,
InterruptionMetrics,
LLMMetrics,
@@ -10,6 +11,7 @@ from .base import (
)
from .usage import (
AgentSessionUsage,
EOTModelUsage,
InterruptionModelUsage,
LLMModelUsage,
ModelUsage,
@@ -25,6 +27,7 @@ __all__ = [
"AgentMetrics",
"VADMetrics",
"EOUMetrics",
"EOTInferenceMetrics",
"STTMetrics",
"TTSMetrics",
"RealtimeModelMetrics",
@@ -34,6 +37,7 @@ __all__ = [
"TTSModelUsage",
"STTModelUsage",
"InterruptionModelUsage",
"EOTModelUsage",
"ModelUsage",
"AgentSessionUsage",
"ModelUsageCollector",
@@ -112,6 +112,22 @@ class EOUMetrics(_BaseMetrics):
metadata: Metadata | None = None
class EOTInferenceMetrics(_BaseMetrics):
"""Per-inference metrics emitted by the EOT model on each prediction."""
type: Literal["eot_inference_metrics"] = "eot_inference_metrics"
timestamp: float
total_duration: float
"""Earliest audio creation time in an inference to response receive time."""
detection_delay: float
"""Latest audio creation time in an inference to response receive time."""
prediction_duration: float
"""Server side model inference time."""
num_requests: int = 1
"""Number of inference requests made during one inference."""
metadata: Metadata | None = None
class RealtimeModelMetrics(_BaseMetrics):
class CachedTokenDetails(BaseModel):
audio_tokens: int = 0
@@ -199,6 +215,7 @@ AgentMetrics = (
| TTSMetrics
| VADMetrics
| EOUMetrics
| EOTInferenceMetrics
| RealtimeModelMetrics
| InterruptionMetrics
| AvatarMetrics
+33 -2
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel
from .base import (
AgentMetrics,
EOTInferenceMetrics,
InterruptionMetrics,
LLMMetrics,
RealtimeModelMetrics,
@@ -108,7 +109,19 @@ class InterruptionModelUsage(_BaseModelUsage):
"""Total number of requests sent to the interruption detection model."""
ModelUsage = LLMModelUsage | TTSModelUsage | STTModelUsage | InterruptionModelUsage
class EOTModelUsage(_BaseModelUsage):
"""Usage summary for end-of-turn detection models."""
type: Literal["eot_usage"] = "eot_usage"
provider: str
"""The provider name (e.g., 'livekit')."""
model: str
"""The model name (e.g., 'turn-detector-v1')."""
total_requests: int = 0
"""Total number of inference requests sent to the EOT model."""
ModelUsage = LLMModelUsage | TTSModelUsage | STTModelUsage | InterruptionModelUsage | EOTModelUsage
"""Union type for all model usage types."""
@@ -125,13 +138,19 @@ class ModelUsageCollector:
self._tts_usage: dict[tuple[str, str], TTSModelUsage] = {}
self._stt_usage: dict[tuple[str, str], STTModelUsage] = {}
self._interruption_usage: dict[tuple[str, str], InterruptionModelUsage] = {}
self._eot_usage: dict[tuple[str, str], EOTModelUsage] = {}
def __call__(self, metrics: AgentMetrics) -> None:
self.collect(metrics)
def _extract_provider_model(
self,
metrics: LLMMetrics | STTMetrics | TTSMetrics | RealtimeModelMetrics | InterruptionMetrics,
metrics: LLMMetrics
| STTMetrics
| TTSMetrics
| RealtimeModelMetrics
| InterruptionMetrics
| EOTInferenceMetrics,
) -> tuple[str, str]:
"""Extract provider and model from metrics metadata."""
provider = ""
@@ -169,6 +188,13 @@ class ModelUsageCollector:
self._interruption_usage[key] = InterruptionModelUsage(provider=provider, model=model)
return self._interruption_usage[key]
def _get_eot_usage(self, provider: str, model: str) -> EOTModelUsage:
"""Get or create an EOTModelUsage for the given provider/model combination."""
key = (provider, model)
if key not in self._eot_usage:
self._eot_usage[key] = EOTModelUsage(provider=provider, model=model)
return self._eot_usage[key]
def collect(self, metrics: AgentMetrics) -> None:
if isinstance(metrics, LLMMetrics):
provider, model = self._extract_provider_model(metrics)
@@ -225,6 +251,10 @@ class ModelUsageCollector:
provider, model = self._extract_provider_model(metrics)
interruption_usage = self._get_interruption_usage(provider, model)
interruption_usage.total_requests += metrics.num_requests
elif isinstance(metrics, EOTInferenceMetrics):
provider, model = self._extract_provider_model(metrics)
eot_usage = self._get_eot_usage(provider, model)
eot_usage.total_requests += metrics.num_requests
def flatten(self) -> list[ModelUsage]:
"""Returns a list of usage summaries, one per model/provider combination."""
@@ -233,4 +263,5 @@ class ModelUsageCollector:
result.extend(u.model_copy(deep=True) for u in self._tts_usage.values())
result.extend(u.model_copy(deep=True) for u in self._stt_usage.values())
result.extend(u.model_copy(deep=True) for u in self._interruption_usage.values())
result.extend(u.model_copy(deep=True) for u in self._eot_usage.values())
return result
@@ -59,6 +59,9 @@ ATTR_USER_TRANSCRIPT = "lk.user_transcript"
ATTR_TRANSCRIPT_CONFIDENCE = "lk.transcript_confidence"
ATTR_TRANSCRIPTION_DELAY = "lk.transcription_delay"
ATTR_END_OF_TURN_DELAY = "lk.end_of_turn_delay"
ATTR_EOU_SOURCE = "lk.eou.source"
ATTR_EOU_DETECTION_DELAY = "lk.eou.detection_delay"
ATTR_EOU_FROM_CACHE = "lk.eou.from_cache"
# metrics
ATTR_LLM_METRICS = "lk.llm_metrics"
@@ -4,6 +4,7 @@ from . import aio, audio, codecs, http_context, http_server, hw, images
from .audio import AudioArrayBuffer, AudioBuffer, combine_frames, merge_frames
from .bounded_dict import BoundedDict
from .connection_pool import ConnectionPool
from .env import resolve_env_var
from .exp_filter import ExpFilter
from .log import log_exceptions
from .misc import is_dev_mode, is_given, is_hosted, nodename, shortuuid, time_ms
@@ -39,6 +40,7 @@ __all__ = [
"wait_for_agent",
"wait_for_participant",
"wait_for_track_publication",
"resolve_env_var",
]
# Cleanup docs of unexported modules
@@ -0,0 +1,33 @@
import os
from ..types import NotGivenOr
from .misc import is_given
def resolve_env_var(val: NotGivenOr[str], *env_vars: str, default: str = "") -> str:
"""
Resolve an environment variable from a list of potential sources.
Args:
val: The value to resolve.
*env_vars: The environment variables to check. Order matters, the first non-None value will be returned.
default: The default value to return if no environment variables are set.
Returns:
The resolved environment variable.
Examples:
>>> resolve_env_var(
... NOT_GIVEN,
... "ABC_URL",
... default="https://agent-gateway.livekit.cloud/v1",
... )
"https://agent-gateway.livekit.cloud/v1"
"""
if is_given(val):
return val
for env_var in env_vars:
curr_val = os.getenv(env_var, None)
if curr_val is not None and curr_val != "":
return curr_val
return default
@@ -54,6 +54,7 @@ from .events import (
AgentFalseInterruptionEvent,
AgentState,
AgentStateChangedEvent,
EotPredictionEvent,
ErrorEvent,
FunctionToolsExecutedEvent,
MetricsCollectedEvent,
@@ -61,6 +62,7 @@ from .events import (
SpeechCreatedEvent,
UserInputTranscribedEvent,
UserTurnExceededEvent,
_AgentBackchannelOpportunityEvent,
)
from .generation import (
ToolExecutionOutput,
@@ -80,7 +82,14 @@ from .generation import (
)
from .speech_handle import DEFAULT_INPUT_DETAILS, InputDetails, SpeechHandle
from .tool_executor import _resolve_async_tool_options, _ToolExecutor
from .turn import EndpointingOptions, PreemptiveGenerationOptions, TurnDetectionMode
from .turn import (
EndpointingOptions,
PreemptiveGenerationOptions,
TurnDetectionMode,
_resolve_endpointing,
_StreamingTurnDetector,
_StreamingTurnDetectorStream,
)
if TYPE_CHECKING:
from ..llm import mcp
@@ -109,6 +118,7 @@ _OnEnterContextVar = contextvars.ContextVar["_OnEnterData"]("agents_activity_on_
class _ReusableResources:
stt_pipeline: _STTPipeline | None = None
rt_session: llm.RealtimeSession | None = None
turn_detector_stream: _StreamingTurnDetectorStream | None = None
async def cleanup(self) -> None:
tasks = []
@@ -118,6 +128,9 @@ class _ReusableResources:
if self.rt_session is not None:
tasks.append(self.rt_session.aclose())
self.rt_session = None
if self.turn_detector_stream is not None:
tasks.append(self.turn_detector_stream.aclose())
self.turn_detector_stream = None
if tasks:
outputs = await asyncio.gather(*tasks, return_exceptions=True)
@@ -249,7 +262,21 @@ class AgentActivity(RecognitionHooks):
self, turn_detection: TurnDetectionMode | None
) -> TurnDetectionMode | None:
if turn_detection is not None and not isinstance(turn_detection, str):
# return directly if turn_detection is _TurnDetector
if isinstance(turn_detection, _StreamingTurnDetector):
if self.vad is None:
logger.warning(
"TurnDetector requires a VAD model. Pass vad=inference.VAD() to AgentSession/Agent"
" or turn_detection=None to disable the default TurnDetector"
)
return None
if isinstance(self.llm, llm.RealtimeModel) and self.llm.capabilities.turn_detection:
logger.warning(
"turn_detection is a TurnDetector, but the LLM is a RealtimeModel "
"with server-side turn detection enabled, ignoring the turn_detection setting"
)
return None
return turn_detection
mode = turn_detection if isinstance(turn_detection, str) else None
@@ -295,8 +322,13 @@ class AgentActivity(RecognitionHooks):
)
mode = None
# fallback to VAD if server side turn detection is disabled and VAD is available
if not llm_model.capabilities.turn_detection and vad_model and mode is None:
# fallback to VAD if server side turn detection is disabled and user supplied VAD is available
if (
not llm_model.capabilities.turn_detection
and vad_model is not None
and not self.using_default_vad
and mode is None
):
mode = "vad"
elif mode == "realtime_llm":
@@ -354,15 +386,11 @@ class AgentActivity(RecognitionHooks):
@property
def endpointing_opts(self) -> EndpointingOptions:
# session should always have a valid endpointing val based on either defaults or overrides
agent_endpointing = self._agent._turn_handling.get("endpointing", {})
session_endpointing = self.session._opts.turn_handling["endpointing"]
return EndpointingOptions(
mode=agent_endpointing.get("mode", session_endpointing["mode"]),
min_delay=agent_endpointing.get("min_delay", session_endpointing["min_delay"]),
max_delay=agent_endpointing.get("max_delay", session_endpointing["max_delay"]),
alpha=agent_endpointing.get("alpha", session_endpointing["alpha"]),
)
overrides: EndpointingOptions = {
**self.session._opts.endpointing_overrides,
**(self._agent._turn_handling.get("endpointing") or EndpointingOptions()), # type: ignore[typeddict-item]
}
return _resolve_endpointing(overrides, turn_detection=self._turn_detection)
@property
def preemptive_generation_opts(self) -> PreemptiveGenerationOptions:
@@ -655,6 +683,14 @@ class AgentActivity(RecognitionHooks):
):
resources.stt_pipeline = await self._audio_recognition.detach_stt()
# reuse the stream during a handoff whenever we can
if (
self._audio_recognition
and isinstance(self._turn_detection, _StreamingTurnDetector)
and self._turn_detection is new_activity._turn_detection
):
resources.turn_detector_stream = self._audio_recognition.detach_turn_detector()
# rt session
if (
self._rt_session is not None
@@ -768,6 +804,9 @@ class AgentActivity(RecognitionHooks):
self._interruption_detector.on("error", self._on_error)
self._interruption_detector.on("overlapping_speech", self._on_overlap_speech_ended)
if isinstance(self._turn_detection, inference.TurnDetector):
self._turn_detection.on("metrics_collected", self._on_metrics_collected)
if isinstance(self.llm, llm.RealtimeModel):
rt_reused = reuse_resources is not None and reuse_resources.rt_session is not None
if rt_reused:
@@ -840,23 +879,41 @@ class AgentActivity(RecognitionHooks):
self._session._chat_ctx.insert(initial_config)
await self._resume_scheduling_task()
# skip default vad when llm does not need it
wired_vad = self.vad
if (
wired_vad is not None
and self.using_default_vad
and isinstance(self.llm, llm.RealtimeModel)
and self.llm.capabilities.turn_detection
):
wired_vad = None
self._audio_recognition = AudioRecognition(
self._session,
hooks=self,
stt=self._agent.stt_node if self.stt else None,
vad=self.vad,
vad=wired_vad,
using_default_vad=self.using_default_vad,
interruption_detection=self._interruption_detector,
endpointing=create_endpointing(self.endpointing_opts),
turn_detection=self._turn_detection,
stt_model=self.stt.model if self.stt else None,
stt_provider=self.stt.provider if self.stt else None,
)
if reuse_resources and reuse_resources.stt_pipeline is not None:
stt_pipeline = reuse_resources.stt_pipeline if reuse_resources else None
turn_detector_stream = reuse_resources.turn_detector_stream if reuse_resources else None
if stt_pipeline is not None:
logger.debug("reusing STT pipeline from previous activity")
self._audio_recognition.start(stt_pipeline=reuse_resources.stt_pipeline)
reuse_resources.stt_pipeline = None # ownership transferred
else:
self._audio_recognition.start()
if turn_detector_stream is not None:
logger.debug("reusing turn detector stream from previous activity")
self._audio_recognition.start(
stt_pipeline=stt_pipeline,
turn_detector_stream=turn_detector_stream,
)
if reuse_resources:
# ownership transferred to the new AudioRecognition
reuse_resources.stt_pipeline = None
reuse_resources.turn_detector_stream = None
@tracer.start_as_current_span("drain_agent_activity")
async def drain(
@@ -1029,6 +1086,9 @@ class AgentActivity(RecognitionHooks):
self._interruption_detector.off("error", self._on_error)
self._interruption_detector.off("overlapping_speech", self._on_overlap_speech_ended)
if isinstance(self._turn_detection, inference.TurnDetector):
self._turn_detection.off("metrics_collected", self._on_metrics_collected)
if self._rt_session is not None:
await self._rt_session.aclose()
@@ -1634,7 +1694,7 @@ class AgentActivity(RecognitionHooks):
self._session.emit("overlapping_speech", ev)
def _on_input_speech_started(self, _: llm.InputSpeechStartedEvent) -> None:
if self.vad is None:
if self.vad is None or self.using_default_vad:
self._session._update_user_state("speaking")
if self._audio_recognition:
self._audio_recognition.on_start_of_speech(
@@ -1652,7 +1712,7 @@ class AgentActivity(RecognitionHooks):
)
def _on_input_speech_stopped(self, ev: llm.InputSpeechStoppedEvent) -> None:
if self.vad is None:
if self.vad is None or self.using_default_vad:
if self._audio_recognition:
self._audio_recognition.on_end_of_speech(
ended_at=time.time(),
@@ -2008,6 +2068,15 @@ class AgentActivity(RecognitionHooks):
created_at=time.time(),
)
def on_eot_prediction(self, ev: EotPredictionEvent) -> None:
if (host := self._session._session_host) is not None:
host._on_eot_prediction(ev)
def on_agent_backchannel_opportunity(self, ev: _AgentBackchannelOpportunityEvent) -> None:
# TODO: consume the backchannel opportunity internally (e.g. trigger a
# backchannel phrase). Kept internal for now — not surfaced as a public event.
pass
def on_end_of_turn(self, info: _EndOfTurnInfo) -> bool:
# IMPORTANT: This method is sync to avoid it being cancelled by the AudioRecognition
# We explicitly create a new task here
@@ -3928,6 +3997,12 @@ class AgentActivity(RecognitionHooks):
return None
return self._agent.vad if is_given(self._agent.vad) else self._session.vad
@property
def using_default_vad(self) -> bool:
if is_given(self._agent.vad):
return False
return self._session._using_default_vad
def _resolve_interruption_detection(self) -> inference.AdaptiveInterruptionDetector | None:
if not (
self.stt is not None
@@ -142,6 +142,8 @@ class SessionConnectOptions:
@dataclass
class AgentSessionOptions:
turn_handling: TurnHandlingOptions
endpointing_overrides: EndpointingOptions
"""sparse endpointing keys the user provided explicitly"""
max_tool_steps: int
user_away_timeout: float | None
min_consecutive_speech_delay: float
@@ -225,7 +227,7 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
self,
*,
stt: NotGivenOr[stt.STT | STTModels | str] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | LLMModels | str] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS | TTSModels | str] = NOT_GIVEN,
turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN,
@@ -273,7 +275,10 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
Args:
stt (stt.STT | str, optional): Speech-to-text backend.
vad (vad.VAD, optional): Voice-activity detector
vad (vad.VAD, optional): Voice-activity detector. Defaults to the
bundled silero VAD (``inference.VAD(model="silero")``) when
omitted. Pass ``vad=None`` to opt out, or pass an explicit
instance to customise options.
llm (llm.LLM | llm.RealtimeModel | str, optional): LLM or RealtimeModel
tts (tts.TTS | str, optional): Text-to-speech engine.
tools (list[llm.FunctionTool | llm.RawFunctionTool], optional): List of
@@ -341,13 +346,8 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
turn_handling = (
_migrate_turn_handling(
# backward compatibility for deprecated parameters that had default values
min_endpointing_delay=(
min_endpointing_delay if is_given(min_endpointing_delay) else 0.5
),
max_endpointing_delay=(
max_endpointing_delay if is_given(max_endpointing_delay) else 3.0
),
min_endpointing_delay=min_endpointing_delay,
max_endpointing_delay=max_endpointing_delay,
false_interruption_timeout=false_interruption_timeout,
turn_detection=turn_detection,
discard_audio_if_uninterruptible=discard_audio_if_uninterruptible,
@@ -362,11 +362,14 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
else turn_handling
)
endpointing = _resolve_endpointing(turn_handling.get("endpointing"))
raw_turn_detection: TurnDetectionMode | None = turn_handling.get(
"turn_detection", inference.TurnDetector()
)
endpointing_overrides = turn_handling.get("endpointing") or EndpointingOptions()
endpointing = _resolve_endpointing(endpointing_overrides, turn_detection=raw_turn_detection)
interruption = _resolve_interruption(turn_handling.get("interruption"))
preemptive_gen = _resolve_preemptive_generation(turn_handling.get("preemptive_generation"))
user_turn_limit = _resolve_user_turn_limit(turn_handling.get("user_turn_limit"))
raw_turn_detection = turn_handling.get("turn_detection", None)
# This is the "global" chat_context, it holds the entire conversation history
self._chat_ctx = ChatContext.empty()
@@ -378,6 +381,7 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
preemptive_generation=preemptive_gen,
user_turn_limit=user_turn_limit,
),
endpointing_overrides=endpointing_overrides,
max_tool_steps=max_tool_steps,
user_away_timeout=user_away_timeout,
min_consecutive_speech_delay=min_consecutive_speech_delay,
@@ -406,6 +410,9 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
tts = inference.TTS.from_model_string(tts)
self._stt = stt or None
self._using_default_vad = not is_given(vad)
if not is_given(vad):
vad = inference.VAD(model="silero")
self._vad = vad or None
self._llm = llm or None
self._tts = tts or None
@@ -1100,29 +1107,25 @@ class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
"min_endpointing_delay and max_endpointing_delay are deprecated, "
"use endpointing_opts instead"
)
endpointing_opts = EndpointingOptions(
mode=self._opts.endpointing["mode"],
min_delay=(
min_endpointing_delay
if is_given(min_endpointing_delay)
else self._opts.endpointing["min_delay"]
),
max_delay=(
max_endpointing_delay
if is_given(max_endpointing_delay)
else self._opts.endpointing["max_delay"]
),
)
endpointing_opts = EndpointingOptions()
if is_given(min_endpointing_delay):
endpointing_opts["min_delay"] = min_endpointing_delay
if is_given(max_endpointing_delay):
endpointing_opts["max_delay"] = max_endpointing_delay
if is_given(endpointing_opts):
if (mode := endpointing_opts.get("mode")) is not None:
self._opts.endpointing["mode"] = mode
self._opts.endpointing_overrides["mode"] = mode
if (min_delay := endpointing_opts.get("min_delay")) is not None:
self._opts.endpointing["min_delay"] = min_delay
self._opts.endpointing_overrides["min_delay"] = min_delay
if (max_delay := endpointing_opts.get("max_delay")) is not None:
self._opts.endpointing["max_delay"] = max_delay
self._opts.endpointing_overrides["max_delay"] = max_delay
if (alpha := endpointing_opts.get("alpha")) is not None:
self._opts.endpointing["alpha"] = alpha
self._opts.endpointing_overrides["alpha"] = alpha
if is_given(turn_detection):
self._turn_detection = turn_detection
@@ -7,7 +7,7 @@ import time
from collections import deque
from collections.abc import AsyncIterable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, Literal, Protocol
from opentelemetry import trace
from opentelemetry.sdk.trace import ReadableSpan
@@ -16,6 +16,7 @@ from livekit import rtc
from .. import inference, llm, stt, tokenize, utils, vad
from .._exceptions import APIError
from ..inference.eot.base import MIN_SILENCE_DURATION_MS
from ..inference.interruption import (
_AgentSpeechEndedSentinel,
_AgentSpeechStartedSentinel,
@@ -32,8 +33,18 @@ from ..vad import VADStream
from . import io
from ._utils import _set_participant_attributes
from .endpointing import BaseEndpointing
from .events import UserTurnExceededEvent
from .turn import TurnDetectionMode as TurnDetectionMode
from .events import (
EotPredictionEvent,
UserTurnExceededEvent,
_AgentBackchannelOpportunityEvent,
)
from .turn import (
TurnDetectionEvent,
TurnDetectionMode as TurnDetectionMode,
_StreamingTurnDetector,
_StreamingTurnDetectorStream,
_TurnDetector,
)
if TYPE_CHECKING:
from .agent_session import AgentSession
@@ -123,6 +134,8 @@ class RecognitionHooks(Protocol):
def on_interim_transcript(self, ev: stt.SpeechEvent, *, speaking: bool | None) -> None: ...
def on_final_transcript(self, ev: stt.SpeechEvent, *, speaking: bool | None = None) -> None: ...
def on_end_of_turn(self, info: _EndOfTurnInfo) -> bool: ...
def on_eot_prediction(self, ev: EotPredictionEvent) -> None: ...
def on_agent_backchannel_opportunity(self, ev: _AgentBackchannelOpportunityEvent) -> None: ...
def on_preemptive_generation(self, info: _PreemptiveGenerationInfo) -> None: ...
def on_user_turn_exceeded(self, ev: UserTurnExceededEvent) -> None: ...
def retrieve_chat_ctx(self) -> llm.ChatContext: ...
@@ -186,6 +199,7 @@ class AudioRecognition:
endpointing: BaseEndpointing,
stt: io.STTNode | None,
vad: vad.VAD | None,
using_default_vad: bool,
interruption_detection: inference.AdaptiveInterruptionDetector | None,
turn_detection: TurnDetectionMode | None,
stt_model: str | None = None,
@@ -202,6 +216,7 @@ class AudioRecognition:
self._turn_detector = turn_detection if not isinstance(turn_detection, str) else None
self._stt = stt
self._vad = vad
self._using_default_vad = using_default_vad
self._stt_model = stt_model
self._stt_provider = stt_provider
self._turn_detection_mode = turn_detection if isinstance(turn_detection, str) else None
@@ -271,6 +286,14 @@ class AudioRecognition:
self._turn_tracker = _UserTurnTracker()
self._word_tokenizer = tokenize.basic.WordTokenizer()
# streaming audio turn detection
self._turn_detector_stream: _StreamingTurnDetectorStream | None = None
self._turn_detector_prediction_fut: asyncio.Future[TurnDetectionEvent] | None = None
self._turn_detector_flushed: bool = False
self._turn_detector_late_prediction_warned: bool = False
self._last_emitted_prediction: TurnDetectionEvent | None = None
self._user_speaking_event = asyncio.Event()
def update_options(
self,
*,
@@ -284,7 +307,9 @@ class AudioRecognition:
self._endpointing = endpointing
if is_given(turn_detection):
self._turn_detector = turn_detection if not isinstance(turn_detection, str) else None
self.update_turn_detector(
turn_detection if not isinstance(turn_detection, str) else None
)
mode = turn_detection if isinstance(turn_detection, str) else None
if self._turn_detection_mode != mode:
@@ -298,15 +323,26 @@ class AudioRecognition:
self._end_of_turn_task.cancel()
self._end_of_turn_task = None
self._user_turn_committed = False
if self._turn_detector_stream is not None:
self._turn_detector_stream.cancel_inference()
self._turn_detector_prediction_fut = None
def start(self, *, stt_pipeline: _STTPipeline | None = None) -> None:
def start(
self,
*,
stt_pipeline: _STTPipeline | None = None,
turn_detector_stream: _StreamingTurnDetectorStream | None = None,
) -> None:
self.update_stt(self._stt, pipeline=stt_pipeline)
self.update_vad(self._vad)
self.update_interruption_detection(self._interruption_detection)
if isinstance(self._turn_detector, _StreamingTurnDetector) or self._turn_detector is None:
self.update_turn_detector(self._turn_detector, stream=turn_detector_stream)
def stop(self) -> None:
self.update_stt(None)
self.update_vad(None)
self.update_turn_detector(None)
self.update_interruption_detection(None)
@property
@@ -423,12 +459,11 @@ class AudioRecognition:
user_speaking_span: trace.Span | None = None,
interruption: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
should_ignore = is_given(interruption) and not interruption and self._agent_speaking
if self._speaking:
self._endpointing.on_end_of_speech(
ended_at=ended_at,
should_ignore=(
is_given(interruption) and not interruption and self._agent_speaking
),
should_ignore=should_ignore,
)
self.on_end_of_overlap_speech(ended_at=ended_at, user_speaking_span=user_speaking_span)
@@ -628,13 +663,13 @@ class AudioRecognition:
if self._interruption_ch is not None:
self._interruption_ch.send_nowait(frame)
if self._turn_detector_stream is not None:
self._turn_detector_stream.push_audio(frame)
async def aclose(self) -> None:
self._closing.set()
if self._commit_user_turn_atask is not None:
try:
await self._commit_user_turn_atask
except asyncio.CancelledError:
pass
await aio.cancel_and_wait(self._commit_user_turn_atask)
if self._stt_pipeline is not None:
await self._stt_pipeline.aclose()
@@ -652,10 +687,12 @@ class AudioRecognition:
await aio.cancel_and_wait(self._interruption_atask)
if self._end_of_turn_task is not None:
try:
await self._end_of_turn_task
except asyncio.CancelledError:
pass
await aio.cancel_and_wait(self._end_of_turn_task)
if self._turn_detector_stream is not None:
await self._turn_detector_stream.aclose()
self._turn_detector_stream = None
self._turn_detector_prediction_fut = None
if self._backchannel_boundary_timer is not None:
self._backchannel_boundary_timer.cancel()
@@ -693,8 +730,26 @@ class AudioRecognition:
self._tasks.add(task)
self._stt_pipeline = None
def _check_vad_silence_requirement(
self,
detector: NotGivenOr[_TurnDetector | _StreamingTurnDetector | None] = NOT_GIVEN,
) -> None:
if not is_given(detector):
detector = self._turn_detector
if not isinstance(detector, _StreamingTurnDetector) or self._vad is None:
return
if (current := getattr(self._vad, "min_silence_duration", None)) is None:
return
required = (MIN_SILENCE_DURATION_MS + 50) / 1000
if current < required:
raise ValueError(
f"vad min_silence_duration={current}s is too low for the TurnDetector. "
f"Raise the VAD's min_silence_duration to at least {required}s."
)
def update_vad(self, vad: vad.VAD | None) -> None:
self._vad = vad
self._check_vad_silence_requirement()
if vad:
self._vad_stream = None
self._vad_ch = aio.Chan[rtc.AudioFrame]()
@@ -759,6 +814,45 @@ class AudioRecognition:
self._interruption_detection is not None and self._vad is not None
)
def update_turn_detector(
self,
detector: _TurnDetector | _StreamingTurnDetector | None,
*,
stream: _StreamingTurnDetectorStream | None = None,
) -> None:
"""Update the turn detector and turn detector stream if possible.
When *stream* is provided it is adopted as-is (handoff reuse) instead of
opening a fresh stream on *detector*; the live transport stream — and its
per-session cloud->local fallback state — survives the handoff.
"""
self._check_vad_silence_requirement(detector)
self._turn_detector = detector
if (old_stream := self._turn_detector_stream) is not None and old_stream is not stream:
task = asyncio.create_task(old_stream.aclose())
task.add_done_callback(lambda _: self._tasks.discard(task))
self._tasks.add(task)
if stream is None:
stream = detector.stream() if isinstance(detector, _StreamingTurnDetector) else None
if self._turn_detector_stream is not stream:
self._turn_detector_prediction_fut = None
self._turn_detector_flushed = False
self._turn_detector_stream = stream
def detach_turn_detector(self) -> _StreamingTurnDetectorStream | None:
"""Detach the turn detector stream for handoff to another AudioRecognition.
Returns the live stream (transport run loop intact) without closing it.
The caller passes it to the new AudioRecognition via
``start(..., turn_detector_stream=stream)``. The adopting recognition
starts a fresh inference request on its next VAD event, superseding
any request that survived the handoff.
"""
stream, self._turn_detector_stream = self._turn_detector_stream, None
self._turn_detector_prediction_fut = None
return stream
def clear_user_turn(self) -> None:
self._audio_transcript = ""
self._audio_interim_transcript = ""
@@ -769,6 +863,12 @@ class AudioRecognition:
self._last_speaking_time = None
self._vad_speech_started = False
self._user_turn_committed = False
self._last_emitted_prediction = None
if self._turn_detector_stream is not None:
self._turn_detector_stream.flush(reason="clear_user_turn")
self._turn_detector_prediction_fut = None
self._turn_detector_flushed = True
self._turn_tracker = _UserTurnTracker()
# end any in-progress user_turn span so the next speech starts a fresh one
@@ -850,7 +950,11 @@ class AudioRecognition:
transcript = self._audio_transcript
self._audio_interim_transcript = ""
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx, skip_reply=skip_reply)
self._run_eou_detection(
chat_ctx,
skip_reply=skip_reply,
trigger="manual",
)
self._user_turn_committed = True
if not fut.done():
fut.set_result(transcript)
@@ -951,7 +1055,8 @@ class AudioRecognition:
self._hooks.on_final_transcript(
ev,
speaking=self._speaking
if self._vad or self._turn_detection_mode == "stt"
if (self._vad is not None and not self._using_default_vad)
or self._turn_detection_mode == "stt"
else None,
)
if self._session.amd is not None:
@@ -970,7 +1075,7 @@ class AudioRecognition:
self._audio_interim_transcript = ""
self._audio_preflight_transcript = ""
if not self._vad or self._last_speaking_time is None:
if self._vad is None or self._using_default_vad or self._last_speaking_time is None:
# vad disabled or missed a speech, use stt timestamp
self._last_speaking_time = stt_last_speaking_time
@@ -994,13 +1099,17 @@ class AudioRecognition:
if not self._speaking:
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
self._run_eou_detection(
chat_ctx,
trigger="stt",
)
elif ev.type == stt.SpeechEventType.PREFLIGHT_TRANSCRIPT:
self._hooks.on_interim_transcript(
ev,
speaking=self._speaking
if self._vad or self._turn_detection_mode == "stt"
if (self._vad is not None and not self._using_default_vad)
or self._turn_detection_mode == "stt"
else None,
)
transcript = ev.alternatives[0].text
@@ -1026,7 +1135,7 @@ class AudioRecognition:
self._audio_preflight_transcript = (self._audio_transcript + " " + transcript).lstrip()
self._audio_interim_transcript = transcript
if not self._vad or self._last_speaking_time is None:
if self._vad is None or self._using_default_vad or self._last_speaking_time is None:
# vad disabled or missed a speech, use stt timestamp
self._last_speaking_time = stt_last_speaking_time
@@ -1044,7 +1153,8 @@ class AudioRecognition:
self._hooks.on_interim_transcript(
ev,
speaking=self._speaking
if self._vad or self._turn_detection_mode == "stt"
if (self._vad is not None and not self._using_default_vad)
or self._turn_detection_mode == "stt"
else None,
)
self._audio_interim_transcript = ev.alternatives[0].text
@@ -1076,12 +1186,15 @@ class AudioRecognition:
self._speaking = False
self._user_turn_committed = True
if not self._vad or self._last_speaking_time is None:
if self._vad is None or self._using_default_vad or self._last_speaking_time is None:
# vad disabled or missed a speech, use stt timestamp
self._last_speaking_time = stt_last_speaking_time
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
self._run_eou_detection(
chat_ctx,
trigger="stt",
)
elif ev.type == stt.SpeechEventType.START_OF_SPEECH and self._turn_detection_mode == "stt":
# If the plugin provided a server onset timestamp, use it;
@@ -1110,6 +1223,12 @@ class AudioRecognition:
self._hooks.on_start_of_speech(ev, speech_start_time=speech_start_time)
self._speaking = True
self._user_speaking_event.set()
if self._turn_detector_stream is not None:
self._turn_detector_stream.cancel_inference()
self._turn_detector_prediction_fut = None
self._turn_detector_flushed = False
if self._end_of_turn_task is not None:
self._end_of_turn_task.cancel()
@@ -1126,6 +1245,20 @@ class AudioRecognition:
if self._speech_start_time is None:
self._speech_start_time = time.time() - ev.raw_accumulated_speech
self._user_speaking_event.set()
if self._speaking and self._turn_detector_prediction_fut is not None:
if self._turn_detector_stream is not None:
self._turn_detector_stream.cancel_inference()
self._turn_detector_prediction_fut = None
elif not self._speaking:
self._user_speaking_event.clear()
if ev.raw_accumulated_silence >= MIN_SILENCE_DURATION_MS / 1000 and self._speaking:
if (
self._turn_detector_stream is not None
and self._turn_detector_prediction_fut is None
):
self._turn_detector_prediction_fut = self._turn_detector_stream.predict()
elif ev.type == vad.VADEventType.END_OF_SPEECH:
with trace.use_span(self._ensure_user_turn_span()):
@@ -1133,12 +1266,14 @@ class AudioRecognition:
self._vad_speech_started = False
self._speaking = False
self._user_speaking_event.clear()
self._last_speaking_time = time.time() - ev.silence_duration - ev.inference_duration
if self._vad_base_turn_detection or (
self._turn_detection_mode == "stt" and self._user_turn_committed
):
chat_ctx = self._hooks.retrieve_chat_ctx().copy()
self._run_eou_detection(chat_ctx)
self._run_eou_detection(chat_ctx, trigger="vad")
if self._session.amd is not None:
self._session.amd._on_user_speech_ended(ev.silence_duration)
@@ -1153,16 +1288,44 @@ class AudioRecognition:
if ev.is_interruption:
self._hooks.on_interruption(ev)
def _run_eou_detection(self, chat_ctx: llm.ChatContext, skip_reply: bool = False) -> None:
def _on_missing_eot_prediction(self) -> None:
if self._turn_detector_flushed:
if not self._turn_detector_late_prediction_warned:
self._turn_detector_late_prediction_warned = True
logger.warning(
"eou detection ran after the audio eot turn was already flushed "
"(likely a late stt final). consider raising `min_delay` in the "
"endpointing options to accommodate slow stt. subsequent "
"occurrences will log at debug level.",
)
else:
logger.debug("stt transcript arrived after a turn flush, skipping eot prediction")
else:
logger.debug("no eot inference request in flight, skipping eot prediction")
def _run_eou_detection(
self,
chat_ctx: llm.ChatContext,
*,
trigger: Literal["vad", "stt", "manual"],
skip_reply: bool = False,
) -> None:
if self._stt and not self._audio_transcript and self._turn_detection_mode != "manual":
# stt enabled but no transcript yet
return
chat_ctx = chat_ctx.copy()
chat_ctx.add_message(role="user", content=self._audio_transcript)
if self._audio_transcript:
chat_ctx.add_message(role="user", content=self._audio_transcript)
turn_detector = (
self._turn_detector
if self._audio_transcript and self._turn_detection_mode != "manual"
(
self._turn_detector_stream
if isinstance(self._turn_detector, _StreamingTurnDetector)
else self._turn_detector
)
if self._turn_detection_mode != "manual"
and (self._audio_transcript or isinstance(self._turn_detector, _StreamingTurnDetector))
else None # disable EOU model if manual turn detection enabled
)
@@ -1174,6 +1337,11 @@ class AudioRecognition:
) -> None:
endpointing_delay = self._endpointing.min_delay
user_turn_span = self._ensure_user_turn_span()
end_of_turn_probability: float | None = None
unlikely_threshold: float | None = None
backchannel_threshold: float | None = None
if turn_detector is not None:
if not await turn_detector.supports_language(self._last_language):
logger.info("Turn detector does not support language %s", self._last_language)
@@ -1182,57 +1350,166 @@ class AudioRecognition:
trace.use_span(user_turn_span),
tracer.start_as_current_span("eou_detection") as eou_detection_span,
):
# if there are failures, we should not hold the pipeline up
end_of_turn_probability = 0.0
unlikely_threshold: float | None = None
try:
end_of_turn_probability = await turn_detector.predict_end_of_turn(
chat_ctx
from_cache = False
prediction_event: TurnDetectionEvent | None = None
if isinstance(turn_detector, _StreamingTurnDetectorStream):
fut = self._turn_detector_prediction_fut
if fut is None:
self._on_missing_eot_prediction()
else:
from_cache = fut.done()
done, _ = await asyncio.wait([fut], timeout=endpointing_delay)
if fut in done and not fut.cancelled():
prediction_event = fut.result()
end_of_turn_probability = (
prediction_event.end_of_turn_probability
)
unlikely_threshold = await turn_detector.unlikely_threshold(
self._last_language
)
backchannel_threshold = (
await turn_detector.backchannel_threshold(
self._last_language
)
)
else:
logger.warning(
"eot prediction timed out, committing without a prediction",
extra={"timeout": endpointing_delay},
)
turn_detector.cancel_inference(timed_out=True)
self._turn_detector_prediction_fut = None
else:
try:
end_of_turn_probability = await turn_detector.predict_end_of_turn(
chat_ctx,
timeout=endpointing_delay,
)
unlikely_threshold = await turn_detector.unlikely_threshold(
self._last_language
)
except Exception:
logger.exception("Error predicting end of turn")
if (
end_of_turn_probability is not None
and unlikely_threshold is not None
and end_of_turn_probability < unlikely_threshold
):
endpointing_delay = self._endpointing.max_delay
eou_span_attributes: dict[str, Any] = {
trace_types.ATTR_CHAT_CTX: json.dumps(
llm.ChatContext(chat_ctx.items[-_EOU_MAX_HISTORY_TURNS:])
.copy(
exclude_function_call=True,
exclude_instructions=True,
exclude_empty_message=True,
exclude_handoff=True,
exclude_config_update=True,
)
.to_dict(
exclude_audio=True,
exclude_image=True,
exclude_timestamp=True,
exclude_metrics=True,
)
),
trace_types.ATTR_EOU_DELAY: endpointing_delay,
trace_types.ATTR_EOU_LANGUAGE: self._last_language or "",
trace_types.ATTR_EOU_SOURCE: trigger,
trace_types.ATTR_EOU_FROM_CACHE: from_cache,
}
if end_of_turn_probability is not None:
eou_span_attributes[trace_types.ATTR_EOU_PROBABILITY] = (
end_of_turn_probability
)
unlikely_threshold = await turn_detector.unlikely_threshold(
self._last_language
if unlikely_threshold is not None:
eou_span_attributes[trace_types.ATTR_EOU_UNLIKELY_THRESHOLD] = (
unlikely_threshold
)
eou_detection_span.set_attributes(eou_span_attributes)
logger.debug(
"eot prediction",
extra={
"probability": end_of_turn_probability,
"unlikely_threshold": unlikely_threshold,
"endpointing_delay": endpointing_delay,
"language": self._last_language or "",
"trigger": trigger,
"from_cache": from_cache,
},
)
if (
end_of_turn_probability is not None
and unlikely_threshold is not None
and (
prediction_event is None
or prediction_event is not self._last_emitted_prediction
)
):
self._last_emitted_prediction = prediction_event
inference_duration = (
prediction_event.inference_duration
if prediction_event is not None
and prediction_event.inference_duration is not None
else 0.0
)
# end of speech -> prediction receive time
delay = (
time.time() - last_speaking_time
if last_speaking_time is not None
else 0.0
)
self._hooks.on_eot_prediction(
EotPredictionEvent(
probability=end_of_turn_probability,
threshold=unlikely_threshold,
inference_duration=inference_duration,
delay=delay,
)
)
# surface the backchannel opportunity whenever it clears its
# threshold, regardless of end-of-turn; AgentActivity decides
# whether to acknowledge mid-turn or let it lead the reply
backchannel_probability = (
prediction_event.backchannel_probability
if prediction_event is not None
else None
)
if (
unlikely_threshold is not None
and end_of_turn_probability < unlikely_threshold
backchannel_probability is not None
and backchannel_threshold is not None
and backchannel_probability >= backchannel_threshold
):
endpointing_delay = self._endpointing.max_delay
except Exception:
logger.exception("Error predicting end of turn")
eou_detection_span.set_attributes(
{
trace_types.ATTR_CHAT_CTX: json.dumps(
llm.ChatContext(chat_ctx.items[-_EOU_MAX_HISTORY_TURNS:])
.copy(
exclude_function_call=True,
exclude_instructions=True,
exclude_empty_message=True,
exclude_handoff=True,
exclude_config_update=True,
self._hooks.on_agent_backchannel_opportunity(
_AgentBackchannelOpportunityEvent(
probability=backchannel_probability,
threshold=backchannel_threshold,
end_of_turn_probability=end_of_turn_probability,
end_of_turn_threshold=unlikely_threshold,
language=self._last_language,
)
.to_dict(
exclude_audio=True,
exclude_image=True,
exclude_timestamp=True,
exclude_metrics=True,
)
),
trace_types.ATTR_EOU_PROBABILITY: end_of_turn_probability,
trace_types.ATTR_EOU_UNLIKELY_THRESHOLD: unlikely_threshold or 0,
trace_types.ATTR_EOU_DELAY: endpointing_delay,
trace_types.ATTR_EOU_LANGUAGE: self._last_language or "",
}
)
)
if (
prediction_event is not None
and prediction_event.detection_delay is not None
):
eou_detection_span.set_attribute(
trace_types.ATTR_EOU_DETECTION_DELAY,
prediction_event.detection_delay,
)
extra_sleep = endpointing_delay
if last_speaking_time:
extra_sleep += last_speaking_time - time.time()
delay_completed = False
if extra_sleep > 0:
try:
await asyncio.wait_for(self._closing.wait(), timeout=extra_sleep)
except asyncio.TimeoutError:
delay_completed = True
pass
confidence_avg = (
@@ -1259,6 +1536,18 @@ class AudioRecognition:
)
)
if committed:
logger.debug(
"user turn committed",
extra={
"last_speaking_time": last_speaking_time,
"last_final_transcript_time": last_final_transcript_time,
"speech_start_time": speech_start_time,
"delay_completed": delay_completed,
"source": trigger,
"end_of_turn_probability": end_of_turn_probability,
"unlikely_threshold": unlikely_threshold,
},
)
user_turn_span.set_attributes(
{
trace_types.ATTR_USER_TRANSCRIPT: self._audio_transcript,
@@ -1287,15 +1576,64 @@ class AudioRecognition:
self._vad_speech_started = False
self._last_speaking_time = None
if self._turn_detector_stream is not None:
self._turn_detector_stream.flush(reason="turn committed")
self._turn_detector_prediction_fut = None
self._turn_detector_flushed = True
self._user_turn_committed = False
async def _bounce_eou_task_with_speaking_guard(
last_speaking_time: float | None = None,
last_final_transcript_time: float | None = None,
speech_start_time: float | None = None,
) -> None:
if self._speaking:
logger.debug(
"user is still speaking, skipping end of turn task",
extra={
"last_speaking_time": last_speaking_time,
"last_final_transcript_time": last_final_transcript_time,
"speech_start_time": speech_start_time,
},
)
return
tasks = [
(speaking_task := asyncio.create_task(self._user_speaking_event.wait())),
asyncio.create_task(
_bounce_eou_task(
last_speaking_time, last_final_transcript_time, speech_start_time
)
),
]
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
if speaking_task in done:
logger.debug(
"user spoke during endpointing, cancelling end of turn task",
extra={
"last_speaking_time": last_speaking_time,
"last_final_transcript_time": last_final_transcript_time,
"speech_start_time": speech_start_time,
},
)
return
finally:
await aio.cancel_and_wait(*tasks)
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()
task_func = (
_bounce_eou_task_with_speaking_guard
if isinstance(self._turn_detector, _StreamingTurnDetector)
else _bounce_eou_task
)
# copy the last_speaking_time before awaiting (the value can change)
self._end_of_turn_task = asyncio.create_task(
_bounce_eou_task(
task_func(
self._last_speaking_time,
self._last_final_transcript_time,
self._user_turn_start,
+34 -1
View File
@@ -320,6 +320,39 @@ class UserInputTranscribedEvent(BaseModel):
created_at: float = Field(default_factory=time.time)
class EotPredictionEvent(BaseModel):
type: Literal["eot_prediction"] = "eot_prediction"
probability: float
threshold: float
inference_duration: float
"""Server-side model inference time."""
delay: float
"""End of user speech → prediction received latency (s), anchored on the
VAD-backdated last_speaking_time."""
created_at: float = Field(default_factory=time.time)
class _AgentBackchannelOpportunityEvent(BaseModel):
"""Internal: a window in which the agent could backchannel (a short
acknowledgment such as "mm-hmm"), as predicted by the turn detector. Passed to
``AgentActivity`` only — not surfaced as a public ``AgentSession`` event yet.
``AgentActivity`` owns the decision of what to do with it. The end-of-turn margin
(``end_of_turn_threshold - end_of_turn_probability``) gives a progressive risk axis:
a large positive margin means the user is clearly still going, so riskier
backchannels (yeah/okay/right) are safe; a small margin (or a negative one, where
``end_of_turn_probability >= end_of_turn_threshold`` and a reply is imminent) calls
for safe, less ambiguous ones (hmm/uh-huh) that won't collide with the reply."""
type: Literal["agent_backchannel_opportunity"] = "agent_backchannel_opportunity"
probability: float
threshold: float
end_of_turn_probability: float
end_of_turn_threshold: float
language: str | None = None
created_at: float = Field(default_factory=time.time)
class AgentFalseInterruptionEvent(BaseModel):
type: Literal["agent_false_interruption"] = "agent_false_interruption"
resumed: bool
@@ -443,7 +476,7 @@ class ErrorEvent(BaseModel):
@field_serializer("source")
def _serialize_source(self, source: Any) -> Any:
if isinstance(source, (LLM, STT, TTS, RealtimeModel, AdaptiveInterruptionDetector)):
if isinstance(source, LLM | STT | TTS | RealtimeModel | AdaptiveInterruptionDetector):
return {"model": source.model, "provider": source.provider}
if isinstance(source, BaseModel):
return source.model_dump()
@@ -27,6 +27,7 @@ from ..llm import (
from ..log import logger
from ..metrics import (
AgentSessionUsage,
EOTModelUsage,
InterruptionModelUsage,
LLMModelUsage,
STTModelUsage,
@@ -38,6 +39,7 @@ from .events import (
AgentState,
AgentStateChangedEvent,
ConversationItemAddedEvent,
EotPredictionEvent,
ErrorEvent,
FunctionToolsExecutedEvent,
SessionUsageUpdatedEvent,
@@ -551,7 +553,23 @@ class SessionHost:
)
)
# TODO: @chenghao-mou add EOT prediction event
def _on_eot_prediction(self, event: EotPredictionEvent) -> None:
inference_duration = Duration()
inference_duration.FromNanoseconds(int(event.inference_duration * 1e9))
delay = Duration()
delay.FromNanoseconds(int(event.delay * 1e9))
self._send_event(
agent_pb.AgentSessionEvent(
eot_prediction=agent_pb.AgentSessionEvent.EotPrediction(
probability=event.probability,
threshold=event.threshold,
inference_duration=inference_duration,
delay=delay,
)
)
)
def _on_session_usage_updated(self, event: SessionUsageUpdatedEvent) -> None:
self._send_event(
@@ -894,6 +912,16 @@ def _session_usage_to_proto(usage: AgentSessionUsage) -> agent_pb.AgentSessionUs
)
)
)
elif isinstance(mu, EOTModelUsage):
model_usages.append(
agent_pb.ModelUsage(
eot=agent_pb.EotModelUsage(
provider=mu.provider,
model=mu.model,
total_requests=mu.total_requests,
)
)
)
return agent_pb.AgentSessionUsage(model_usage=model_usages)
+91 -7
View File
@@ -1,15 +1,38 @@
from __future__ import annotations
from typing import Literal, Protocol
import asyncio
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
from typing_extensions import TypedDict
from livekit import rtc
from ..language import LanguageCode
from ..llm import ChatContext
from ..types import NOT_GIVEN, NotGivenOr
from ..types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ..utils import is_given
@dataclass
class TurnDetectionEvent:
type: Literal["eot_prediction"]
end_of_turn_probability: float
last_speaking_time: float
detection_delay: float | None = None
"""Latest input audio creation time -> prediction receive time."""
inference_duration: float | None = None
"""Server-side model inference time."""
backchannel_probability: float | None = None
"""How appropriate it is for the agent to backchannel at this pause.
``None`` when the detector does not produce one (e.g. the local mini model)."""
class _TurnDetector(Protocol):
@property
def model(self) -> str:
@@ -28,7 +51,48 @@ class _TurnDetector(Protocol):
) -> float: ...
TurnDetectionMode = Literal["stt", "vad", "realtime_llm", "manual"] | _TurnDetector
@runtime_checkable
class _StreamingTurnDetectorStream(Protocol):
"""I/O stream for the streaming turn detector."""
@property
def model(self) -> str: ...
@property
def provider(self) -> str: ...
@property
def is_fallback(self) -> bool: ...
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None: ...
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None: ...
async def supports_language(self, language: LanguageCode | None) -> bool: ...
def predict(self) -> asyncio.Future[TurnDetectionEvent]: ...
def cancel_inference(self, *, timed_out: bool = False) -> None: ...
def flush(self, reason: str | None = None) -> None: ...
def push_audio(self, frame: rtc.AudioFrame) -> None: ...
def end_input(self) -> None: ...
async def aclose(self) -> None: ...
@runtime_checkable
class _StreamingTurnDetector(Protocol):
"""Turn detector that processes streaming data."""
@property
def model(self) -> str: ...
@property
def provider(self) -> str: ...
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> _StreamingTurnDetectorStream: ...
TurnDetectionMode = (
Literal["stt", "vad", "realtime_llm", "manual"] | _TurnDetector | _StreamingTurnDetector
)
"""
The mode of turn detection to use.
@@ -73,6 +137,13 @@ _ENDPOINTING_DEFAULTS: EndpointingOptions = {
"alpha": 0.9,
}
_STREAMING_ENDPOINTING_DEFAULTS: EndpointingOptions = {
"mode": "fixed",
"min_delay": 0.3,
"max_delay": 2.5,
"alpha": 0.9,
}
class InterruptionOptions(TypedDict, total=False):
"""Configuration for interruption handling.
@@ -222,11 +293,24 @@ def _resolve_preemptive_generation(
return PreemptiveGenerationOptions(**{**_PREEMPTIVE_GENERATION_DEFAULTS, **config})
def _resolve_endpointing(config: EndpointingOptions | None = None) -> EndpointingOptions:
"""Fill in defaults for missing keys."""
def _resolve_endpointing(
config: EndpointingOptions | None = None,
*,
turn_detection: TurnDetectionMode | None = None,
) -> EndpointingOptions:
"""Fill in defaults for missing keys.
When ``turn_detection`` is a streaming turn detector, keys the caller did
not provide fall back to the tighter streaming defaults instead of the
legacy ones."""
base = (
_STREAMING_ENDPOINTING_DEFAULTS
if isinstance(turn_detection, _StreamingTurnDetector)
else _ENDPOINTING_DEFAULTS
)
if config is None:
return EndpointingOptions(**_ENDPOINTING_DEFAULTS)
return EndpointingOptions(**{**_ENDPOINTING_DEFAULTS, **config})
return EndpointingOptions(**base)
return EndpointingOptions(**{**base, **config})
def _resolve_interruption(
+8 -1
View File
@@ -741,7 +741,14 @@ class AgentServer(utils.EventEmitter[EventTypes]):
)
if self._mp_ctx_str == "forkserver":
plugin_packages = [p.package for p in Plugin.registered_plugins] + ["av"]
# `livekit.agents.inference._warmup` is a side-effect module:
# importing it from the forkserver process calls `init_vad()` and
# `init_eot()`, paging the native model weights into the
# forkserver. Forked job processes inherit those pages via COW.
plugin_packages = [p.package for p in Plugin.registered_plugins] + [
"av",
"livekit.agents.inference._warmup",
]
logger.info("preloading plugins", extra={"packages": plugin_packages})
self._mp_ctx.set_forkserver_preload(plugin_packages)
+3 -2
View File
@@ -29,7 +29,8 @@ dependencies = [
"certifi>=2025.6.15",
"livekit==1.1.9",
"livekit-api>=1.0.7,<2",
"livekit-protocol>=1.1.15,<2",
"livekit-local-inference>=0.2.5",
"livekit-protocol>=1.1.17,<2",
"livekit-blingfire~=1.1,<2",
"protobuf>=3",
"pyjwt>=2.0",
@@ -56,7 +57,7 @@ dependencies = [
"typer>=0.15.1",
"click~=8.1",
"sounddevice>=0.5",
"watchfiles>=1.0"
"watchfiles>=1.0",
]
[project.optional-dependencies]
@@ -90,7 +90,6 @@ The `realtime_joke_teller.py` example demonstrates both realtime and pipeline mo
1. **Install dependencies:**
```bash
pip install livekit-plugins-aws[realtime] \
livekit-plugins-silero \
jokeapi \
duckduckgo-search \
python-weather \
@@ -331,13 +330,14 @@ if __name__ == "__main__":
For more control over individual components, use pipeline mode:
```python
from livekit.plugins import aws, silero
from livekit.agents import inference
from livekit.plugins import aws
session = AgentSession(
stt=aws.STT(), # Amazon Transcribe
llm=aws.LLM(), # Nova 2 Lite (default)
tts=aws.TTS(), # Amazon Polly
vad=silero.VAD.load(),
vad=inference.VAD(),
)
```
@@ -82,9 +82,9 @@ stt = baseten.STT(
```python
import os
from livekit import agents
from livekit.agents import AgentSession, Agent, RoomInputOptions
from livekit.plugins import baseten, openai, noise_cancellation, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.agents import AgentSession, Agent, RoomInputOptions, inference
from livekit.plugins import baseten, openai, noise_cancellation
from livekit.agents.inference import TurnDetector
BASETEN_API_KEY = os.getenv("BASETEN_API_KEY")
whisper_model_id = "your-whisper-model-id" # or use chain_id for chain deployments
@@ -116,8 +116,8 @@ async def entrypoint(ctx: agents.JobContext):
".api.baseten.co/environments/production/predict"
),
),
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
vad=inference.VAD(),
turn_detection=TurnDetector(),
)
await session.start(
@@ -127,13 +127,13 @@ from livekit.agents import (
AgentServer,
AgentSession,
JobContext,
JobProcess,
cli,
inference,
metrics,
room_io,
)
from livekit.plugins import inworld, silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.agents.inference import TurnDetector
from livekit.plugins import inworld
logger = logging.getLogger("inworld-agent")
@@ -158,13 +158,6 @@ class InworldAgent(Agent):
server = AgentServer()
def prewarm(proc: JobProcess):
proc.userdata["vad"] = silero.VAD.load()
server.setup_fnc = prewarm
@server.rtc_session()
async def entrypoint(ctx: JobContext):
ctx.log_context_fields = {"room": ctx.room.name}
@@ -173,8 +166,8 @@ async def entrypoint(ctx: JobContext):
stt=inworld.STT(model="inworld/inworld-stt-1"),
llm="openai/gpt-4.1-mini",
tts=inworld.TTS(voice="Clive"),
turn_detection=MultilingualModel(),
vad=ctx.proc.userdata["vad"],
turn_detection=TurnDetector(),
vad=inference.VAD(),
)
usage_collector = metrics.UsageCollector()
@@ -43,8 +43,8 @@ It must be obtained and installed separately from Krisp (https://krisp.ai/develo
For cleaning up user audio before STT/VAD processing using the FrameProcessor approach:
```python
from livekit.agents import AgentSession, Agent, JobContext, room_io
from livekit.plugins import krisp, silero, openai
from livekit.agents import AgentSession, Agent, JobContext, inference, room_io
from livekit.plugins import krisp, openai
@server.rtc_session()
async def entrypoint(ctx: JobContext):
@@ -56,7 +56,7 @@ async def entrypoint(ctx: JobContext):
)
session = AgentSession(
vad=silero.VAD.load(),
vad=inference.VAD(),
stt=openai.STT(),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(),
@@ -13,7 +13,6 @@ Prerequisites:
2. Install required packages:
- livekit-agents (with PR #4145 support for FrameProcessor)
- livekit-plugins-krisp
- livekit-plugins-silero (for VAD)
- livekit-plugins-openai (or your preferred STT/LLM/TTS)
Usage:
@@ -30,9 +29,10 @@ from livekit.agents import (
AgentSession,
JobContext,
cli,
inference,
room_io,
)
from livekit.plugins import krisp, openai, silero
from livekit.plugins import krisp, openai
logger = logging.getLogger("krisp-agent-example")
load_dotenv()
@@ -66,7 +66,7 @@ async def entrypoint(ctx: JobContext):
# Configure the agent session
session = AgentSession(
vad=silero.VAD.load(),
vad=inference.VAD(),
stt=openai.STT(model="whisper-1"),
llm=openai.LLM(model="gpt-4o-mini"),
tts=openai.TTS(voice="alloy"),
@@ -17,6 +17,8 @@
See https://docs.livekit.io/agents/build/turns/vad/ for more information.
"""
import warnings
from .vad import VAD, VADStream
from .version import __version__
@@ -26,6 +28,16 @@ from livekit.agents import Plugin
from .log import logger
warnings.warn(
"livekit-plugins-silero is deprecated and will be removed in v2.0. "
"AgentSession now defaults to the bundled silero VAD, so you can drop the "
"explicit `vad=` argument entirely; pass `vad=None` to opt out, or use "
'`from livekit.agents import inference; inference.VAD(model="silero", ...)`'
" to customise options.",
DeprecationWarning,
stacklevel=2,
)
class SileroPlugin(Plugin):
def __init__(self) -> None:
@@ -71,7 +71,7 @@ class VAD(agents.vad.VAD):
deactivation_threshold: NotGivenOr[float] = NOT_GIVEN,
# deprecated
padding_duration: NotGivenOr[float] = NOT_GIVEN,
) -> VAD:
) -> agents.vad.VAD:
"""
Load and initialize the Silero VAD model.
@@ -128,6 +128,32 @@ class VAD(agents.vad.VAD):
if is_given(deactivation_threshold) and deactivation_threshold <= 0:
raise ValueError("deactivation_threshold must be greater than 0")
# When the requested settings are compatible with the native
# implementation in `livekit-local-inference`, delegate to the new
# `livekit.agents.inference.VAD(model="silero")` so users get the
# COW-shared / faster path without changing their call sites. The
# native lib only supports 16 kHz with the bundled model file, so
# custom sample rate or `onnx_file_path` falls back to the legacy
# onnxruntime path.
if sample_rate == 16000 and not is_given(onnx_file_path):
if not force_cpu:
logger.warning(
"force_cpu=False is ignored when using the bundled native "
"VAD; the model runs CPU-only. Pass `onnx_file_path=...` "
"to keep the legacy onnxruntime path that honors force_cpu."
)
from livekit.agents import inference
return inference.VAD(
model="silero",
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,
deactivation_threshold=deactivation_threshold,
)
session = onnx_model.new_inference_session(force_cpu, onnx_file_path=onnx_file_path or None)
opts = _VADOptions(
min_speech_duration=min_speech_duration,
@@ -221,6 +247,10 @@ class VAD(agents.vad.VAD):
deactivation_threshold=deactivation_threshold,
)
@property
def min_silence_duration(self) -> float | None:
return self._opts.min_silence_duration
class VADStream(agents.vad.VADStream):
def __init__(self, vad: VAD, opts: _VADOptions, model: onnx_model.OnnxModel) -> None:
@@ -37,9 +37,9 @@ The `end_of_utterance_silence_trigger` parameter controls the amount of silence
Usage:
```python
from livekit.agents import AgentSession
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.plugins import speechmatics, silero
from livekit.agents import AgentSession, inference
from livekit.agents.inference import TurnDetector
from livekit.plugins import speechmatics
agent = AgentSession(
stt=speechmatics.STT(
@@ -47,8 +47,8 @@ agent = AgentSession(
speaker_active_format="[Speaker {speaker_id}] {text}",
speaker_passive_format="[Speaker {speaker_id} *PASSIVE*] {text}",
),
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
vad=inference.VAD(),
turn_detection=TurnDetector(),
min_endpointing_delay=0.3,
max_endpointing_delay=5.0,
...
@@ -0,0 +1,5 @@
# Setuptools build outputs
/build/
*.egg-info/
*.egg
dist/
@@ -1,5 +1,7 @@
# Turn detector plugin for LiveKit Agents
> ⚠️ **Deprecated.** This plugin is deprecated and will be removed in a future release. Use [`livekit.agents.inference.TurnDetector`](https://docs.livekit.io/agents/build/turns/turn-detector/) instead — it ships with `livekit-agents`, requires no additional install, and replaces both the English and Multilingual text-based models with a unified audio end-of-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.
@@ -8,39 +10,28 @@ By leveraging a language model specifically trained for this task, this plugin o
See [https://docs.livekit.io/agents/build/turns/turn-detector/](https://docs.livekit.io/agents/build/turns/turn-detector/) for more information.
## Installation
```bash
pip install livekit-plugins-turn-detector
```
## Usage
### Multilingual model
We've trained a multilingual model that supports the following languages: `English, French, Spanish, German, Italian, Portuguese, Dutch, Chinese, Japanese, Korean, Indonesian, Russian, Turkish, Hindi`
The multilingual model requires ~400MB of RAM and completes inferences in ~25ms.
The recommended replacement is `TurnDetector`, available from `livekit-agents` directly:
```python
from livekit.plugins.turn_detector.multilingual import MultilingualModel
from livekit.agents.inference import TurnDetector
session = AgentSession(
...
turn_detection=MultilingualModel(),
turn_detection=TurnDetector(),
)
```
### Usage with RealtimeModel
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.
`TurnDetector` works with speech-to-speech models such as OpenAI's Realtime API:
```python
session = AgentSession(
...
stt=deepgram.STT(model="nova-3", language="multi"),
llm=openai.realtime.RealtimeModel(),
turn_detection=MultilingualModel(),
turn_detection=TurnDetector(),
)
```
@@ -15,8 +15,14 @@
"""Contextually-aware turn detection for LiveKit Agents
See https://docs.livekit.io/agents/build/turns/turn-detector/ for more information.
.. deprecated::
This plugin is deprecated and will be removed in a future release. Use
``livekit.agents.inference.TurnDetector`` instead.
"""
import warnings
from livekit.agents import Plugin
from .base import EOUPlugin
@@ -26,6 +32,13 @@ from .version import __version__
__all__ = ["english", "multilingual", "__version__"]
warnings.warn(
"`livekit.plugins.turn_detector` is deprecated and will be removed in a "
"future release. Use `livekit.agents.inference.TurnDetector` instead.",
DeprecationWarning,
stacklevel=2,
)
Plugin.register_plugin(EOUPlugin(_EUORunnerEn))
Plugin.register_plugin(EOUPlugin(_EUORunnerMultilingual))
@@ -13,7 +13,6 @@ authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
@@ -25,7 +24,8 @@ dependencies = [
"livekit-agents>=1.6.0",
"transformers>=4.47.1,!=4.57.2,!=4.57.3", # 4.57.2-4.57.3 have a bug with local_files_only=True (huggingface/transformers#42369)
"numpy>=1.26",
"onnxruntime>=1.18",
"onnxruntime>=1.18; python_version >= '3.11'",
"onnxruntime>=1.18,<1.24; python_version < '3.11'",
"jinja2",
]
@@ -30,8 +30,7 @@ export LK_ULTRAVOX_DEBUG=true
```python
import asyncio
from livekit.agents import Agent, AgentSession, JobContext, JobProcess, WorkerOptions, cli
from livekit.plugins import silero
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli, inference
from livekit.plugins.ultravox.realtime import RealtimeModel
async def entrypoint(ctx: JobContext):
@@ -39,7 +38,7 @@ async def entrypoint(ctx: JobContext):
session: AgentSession[None] = AgentSession(
allow_interruptions=True,
vad=ctx.proc.userdata["vad"],
vad=inference.VAD(),
llm=RealtimeModel(
model_id="fixie-ai/ultravox",
voice="Mark",
@@ -53,18 +52,14 @@ async def entrypoint(ctx: JobContext):
room=ctx.room,
)
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
```
### Voice Assistant with Tools
```python
from livekit.agents import function_tool, Agent, AgentSession, JobContext, JobProcess, WorkerOptions, cli
from livekit.plugins import silero
from livekit.agents import function_tool, Agent, AgentSession, JobContext, WorkerOptions, cli, inference
from livekit.plugins.ultravox.realtime import RealtimeModel
@function_tool
@@ -82,7 +77,7 @@ async def entrypoint(ctx: JobContext):
session: AgentSession[None] = AgentSession(
allow_interruptions=True,
vad=ctx.proc.userdata["vad"],
vad=inference.VAD(),
llm=RealtimeModel(model_id="fixie-ai/ultravox"),
)
@@ -94,11 +89,8 @@ async def entrypoint(ctx: JobContext):
room=ctx.room,
)
def prewarm(proc: JobProcess) -> None:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
```
+1 -1
View File
@@ -324,4 +324,4 @@ doctor: ## Check development environment health
else \
echo "$(BOLD)$(RED)⚠️ Found $$ISSUES issue(s). Please fix the errors above.$(RESET)"; \
exit 1; \
fi
fi
+6
View File
@@ -163,6 +163,7 @@ pythonpath = ["."]
testpaths = ["tests"]
markers = [
"unit: fast tests with no external providers (select with --unit)",
"audio_eot: hermetic audio end-of-turn / turn-detection tests (select with --audio_eot)",
"plugin(name): provider integration test for `name` (select with --plugin [name])",
"realtime(name): realtime-model test, optionally for provider `name` (--realtime [name])",
"stt(name): speech-to-text test, optionally for provider `name` (--stt [name])",
@@ -223,6 +224,11 @@ ignore_missing_imports = true
module = "speechmatics.*"
follow_untyped_imports = true
[[tool.mypy.overrides]]
module = "bithuman.*"
follow_untyped_imports = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "livekit.plugins"
follow_untyped_imports = true
+5
View File
@@ -51,6 +51,11 @@ def create_session(
)
# allowing overriding default endpointing and interruption options
turn_handling = turn_handling or {}
# Use VAD-based endpointing by default. The AgentSession default is the
# turn-detector-v1-mini model; it runs locally but predicts end-of-turn from
# acoustic features, so it can't fire deterministically on synthetic test
# audio. Model accuracy is covered by the audio_eot suite instead.
turn_handling.setdefault("turn_detection", None)
turn_handling["endpointing"] = EndpointingOptions(
**{**default_endpointing, **turn_handling.get("endpointing", {})}
)
+161
View File
@@ -0,0 +1,161 @@
"""In-process fakes for testing the cloud transport without aiohttp.
Used by cloud-transport tests to drive ``_CloudTransport.run`` deterministically:
- ``FakeTurnDetectorWS`` impersonates an ``aiohttp.ClientWebSocketResponse``.
Captures outbound ``send_bytes`` payloads as parsed ``ClientMessage``
protobufs, and yields scripted server frames from ``receive()``.
- ``ControlledCloudTransport`` overrides ``_connect_ws`` so each connect
attempt is scripted: an exception is raised, ``None`` returns the fake ws.
- ``make_stream(...)`` constructs an ``_BaseStreamingTurnDetectorStream`` in cloud
mode bound to a controlled transport.
- ``wait_until_connected(...)`` blocks until the transport's ``_ws`` is set,
so tests can assert against post-connect state without arbitrary sleeps.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
import aiohttp
from livekit.agents.inference.eot.base import (
TurnDetectorOptions,
_BaseStreamingTurnDetectorStream,
)
from livekit.agents.inference.eot.languages import ThresholdOptions
from livekit.agents.inference.eot.transports import _CloudTransport, _CloudTransportOptions
from livekit.agents.types import APIConnectOptions
from livekit.protocol.agent_pb.agent_inference import ClientMessage
class FakeTurnDetectorWS:
"""Fake ``aiohttp.ClientWebSocketResponse`` for cloud-transport tests."""
def __init__(self) -> None:
self.sent: list[ClientMessage] = []
self._incoming: asyncio.Queue[aiohttp.WSMessage] = asyncio.Queue()
self.closed = False
self.close_code: int | None = None
async def send_bytes(self, data: bytes) -> None:
if self.closed:
raise ConnectionResetError("ws closed")
msg = ClientMessage()
msg.ParseFromString(data)
self.sent.append(msg)
async def receive(self) -> aiohttp.WSMessage:
return await self._incoming.get()
async def close(self) -> None:
self.closed = True
def feed_unexpected_close(self, code: int = 1006) -> None:
"""Simulate a server-initiated drop. ``recv_task`` will raise."""
self.close_code = code
self._incoming.put_nowait(
aiohttp.WSMessage(type=aiohttp.WSMsgType.CLOSE, data=code, extra=None)
)
self.closed = True
class ControlledCloudTransport(_CloudTransport):
"""``_CloudTransport`` subclass that scripts the ``_connect_ws`` outcome.
``connect_script`` is consumed left-to-right. An exception is raised; a
``None`` returns the fake ws. Once exhausted, subsequent calls return
the fake ws.
"""
def __init__(
self,
*,
fake_ws: FakeTurnDetectorWS,
connect_script: list[BaseException | None] | None = None,
**kwargs: Any,
) -> None:
self._fake_ws = fake_ws
self._connect_script: list[BaseException | None] = list(connect_script or [])
self._connect_calls = 0
super().__init__(**kwargs)
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
self._connect_calls += 1
if self._connect_script:
r = self._connect_script.pop(0)
if isinstance(r, BaseException):
raise r
return self._fake_ws # type: ignore[return-value]
def make_stream(
*,
fake_ws: FakeTurnDetectorWS | None = None,
connect_script: list[BaseException | None] | None = None,
max_retry: int = 3,
retry_interval: float = 0.0,
) -> tuple[_BaseStreamingTurnDetectorStream, FakeTurnDetectorWS, ControlledCloudTransport]:
"""Construct a cloud-mode stream with a controlled transport.
Returns the stream, the fake ws, and the transport so callers can read
post-connect state from either side.
"""
fake_ws = fake_ws or FakeTurnDetectorWS()
detector = MagicMock()
detector.model = "turn-detector-v1"
detector.provider = "livekit"
session_mock = MagicMock()
session_mock.closed = False
opts = TurnDetectorOptions(sample_rate=16000, thresholds=ThresholdOptions("turn-detector-v1"))
conn_options = APIConnectOptions(max_retry=max_retry, retry_interval=retry_interval)
cloud_opts = _CloudTransportOptions(
base_url="",
api_key="x",
api_secret="x",
conn_options=conn_options,
)
transport = ControlledCloudTransport(
fake_ws=fake_ws,
connect_script=connect_script,
detector=detector,
opts=opts,
cloud_opts=cloud_opts,
http_session=session_mock,
)
stream = _BaseStreamingTurnDetectorStream(
detector=detector,
opts=opts,
transport=transport,
model="turn-detector-v1",
)
return stream, fake_ws, transport
async def wait_until_connected(
transport: ControlledCloudTransport, *, timeout: float = 1.0
) -> None:
"""Block until ``transport._ws`` is set."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while transport._ws is None:
if loop.time() > deadline:
raise TimeoutError("transport did not connect within timeout")
await asyncio.sleep(0)
async def drain_send_queue(transport: ControlledCloudTransport, *, timeout: float = 1.0) -> None:
"""Yield until the outbound channel is empty (sender task has drained it)."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while True:
ch = transport._send_ch
if ch is None or ch.qsize() == 0:
# Give the sender task one more tick to flush whatever it was awaiting.
await asyncio.sleep(0)
return
if loop.time() > deadline:
raise TimeoutError("send queue did not drain within timeout")
await asyncio.sleep(0)
+183
View File
@@ -737,6 +737,7 @@ async def test_backchannel_boundary_suppresses_start_boundary_backchannel() -> N
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="vad",
)
@@ -768,6 +769,7 @@ async def _make_stt_eos_recognition() -> AudioRecognition:
endpointing=BaseEndpointing(min_delay=0.0, max_delay=0.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="stt",
)
@@ -824,6 +826,7 @@ async def test_backchannel_boundary_releases_end_boundary_transcript() -> None:
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="vad",
)
@@ -962,6 +965,7 @@ async def test_force_flush_held_transcripts_emits_buffered_events() -> None:
endpointing=BaseEndpointing(min_delay=0.1, max_delay=1.0),
stt=None,
vad=None,
using_default_vad=False,
interruption_detection=None,
turn_detection="manual",
)
@@ -1447,6 +1451,185 @@ async def test_silent_tool_call_pause_state_does_not_leak_into_tool_reply() -> N
assert false_interruption_events[-1].resumed is True
async def test_default_vad_is_auto_provisioned() -> None:
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession()
try:
assert session.vad is not None
assert session._using_default_vad is True
finally:
await session.aclose()
async def test_explicit_vad_none_opts_out() -> None:
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(vad=None)
try:
assert session.vad is None
assert session._using_default_vad is False
finally:
await session.aclose()
async def test_user_supplied_vad_clears_default_flag() -> None:
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
user_vad = FakeVAD(fake_user_speeches=[])
session = AgentSession(vad=user_vad)
try:
assert session.vad is user_vad
assert session._using_default_vad is False
finally:
await session.aclose()
async def test_default_turn_detection_builds_default_eot() -> None:
"""No turn_detection given → session auto-provisions a default TurnDetector."""
from livekit.agents.voice.agent_session import AgentSession
from livekit.agents.voice.turn import _StreamingTurnDetector
session = AgentSession()
try:
assert isinstance(session.turn_detection, _StreamingTurnDetector)
finally:
await session.aclose()
async def test_turn_detection_none_opts_out() -> None:
"""Explicit None opts out of turn detection (no default detector built)."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"turn_detection": None})
try:
assert session.turn_detection is None
finally:
await session.aclose()
async def test_user_supplied_turn_detector_passes_through() -> None:
from livekit.agents import inference
from livekit.agents.voice.agent_session import AgentSession
user_detector = inference.TurnDetector(version="v1-mini")
session = AgentSession(turn_handling={"turn_detection": user_detector})
try:
assert session.turn_detection is user_detector
finally:
await session.aclose()
async def test_streaming_detector_uses_streaming_endpointing_defaults() -> None:
"""Default session → streaming detector → tighter 0.3/2.5 endpointing defaults."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession()
try:
assert session._opts.endpointing["min_delay"] == 0.3
assert session._opts.endpointing["max_delay"] == 2.5
assert session._opts.endpointing_overrides == {}
finally:
await session.aclose()
async def test_non_streaming_detector_uses_legacy_endpointing_defaults() -> None:
"""A non-streaming mode keeps the legacy 0.5/3.0 defaults."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"turn_detection": "vad"})
try:
assert session._opts.endpointing["min_delay"] == 0.5
assert session._opts.endpointing["max_delay"] == 3.0
finally:
await session.aclose()
async def test_explicit_endpointing_overrides_streaming_default_per_key() -> None:
"""An explicit delay is honored; the unset one still gets the streaming default."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_handling={"endpointing": {"min_delay": 0.4}})
try:
assert session._opts.endpointing["min_delay"] == 0.4
assert session._opts.endpointing["max_delay"] == 2.5
assert session._opts.endpointing_overrides == {"min_delay": 0.4}
finally:
await session.aclose()
async def test_user_streaming_detector_uses_streaming_defaults() -> None:
"""A user-constructed streaming detector also triggers the streaming defaults."""
from livekit.agents import inference
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(
turn_handling={"turn_detection": inference.TurnDetector(version="v1-mini")}
)
try:
assert session._opts.endpointing["min_delay"] == 0.3
assert session._opts.endpointing["max_delay"] == 2.5
finally:
await session.aclose()
async def test_deprecated_turn_detection_vad_uses_legacy_defaults() -> None:
"""Deprecated turn_detection arg + no delays → legacy defaults (non-streaming)."""
from livekit.agents.voice.agent_session import AgentSession
session = AgentSession(turn_detection="vad")
try:
assert session._opts.endpointing["min_delay"] == 0.5
assert session._opts.endpointing["max_delay"] == 3.0
finally:
await session.aclose()
async def test_agent_turn_detection_override_resolves_endpointing_per_activity() -> None:
"""endpointing_opts uses the activity's resolved detector, not just the session's."""
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
# session default → streaming detector; provide VAD so it validates
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
try:
streaming_activity = AgentActivity(Agent(instructions="test"), session)
assert streaming_activity.endpointing_opts["min_delay"] == 0.3
assert streaming_activity.endpointing_opts["max_delay"] == 2.5
# an agent overriding to VAD falls back to legacy defaults for this activity
vad_activity = AgentActivity(Agent(instructions="test", turn_detection="vad"), session)
assert vad_activity.endpointing_opts["min_delay"] == 0.5
assert vad_activity.endpointing_opts["max_delay"] == 3.0
finally:
await session.aclose()
async def test_runtime_endpointing_opts_survive_handoff() -> None:
"""update_options changes are recorded as overrides, so a new activity keeps them."""
from livekit.agents.voice.agent_session import AgentSession
from .fake_vad import FakeVAD
session = AgentSession(vad=FakeVAD(fake_user_speeches=[]))
try:
session.update_options(endpointing_opts={"mode": "dynamic", "alpha": 0.5, "min_delay": 0.4})
# a fresh activity (as built on agent handoff) re-resolves from overrides
activity = AgentActivity(Agent(instructions="test"), session)
assert activity.endpointing_opts["mode"] == "dynamic"
assert activity.endpointing_opts["alpha"] == 0.5
assert activity.endpointing_opts["min_delay"] == 0.4
# untouched key still gets the streaming default
assert activity.endpointing_opts["max_delay"] == 2.5
finally:
await session.aclose()
class FlushMultiSegmentAgent(Agent):
"""Agent whose llm_node flushes the reply into two segments via FlushSentinel."""

Some files were not shown because too many files have changed in this diff Show More