Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
from .transcription.text_transforms import TextTransforms


_SIP_RULE_ID_ATTR = "sip.ruleID"
_DEFAULT_AEC_WARMUP_DURATION = 3.0


class RecordingOptions(TypedDict, total=False):
"""Granular control over which recording features are active.

Expand Down Expand Up @@ -290,7 +294,7 @@ def __init__(
# Misc settings
userdata: NotGivenOr[Userdata_T] = NOT_GIVEN,
video_sampler: NotGivenOr[_VideoSampler | None] = NOT_GIVEN,
aec_warmup_duration: float | None = 3.0,
aec_warmup_duration: NotGivenOr[float | None] = NOT_GIVEN,
ivr_detection: bool = False,
user_away_timeout: float | None = 15.0,
session_close_transcript_timeout: float = 2.0,
Expand Down Expand Up @@ -376,7 +380,7 @@ def __init__(
aec_warmup_duration (float, optional): The duration in seconds that the agent
will ignore user's audio interruptions after the agent starts speaking.
This is useful to prevent the agent from being interrupted by echo before AEC is ready.
Set to ``None`` to disable. Default ``3.0`` s.
Defaults to ``3.0``, or ``None`` for outbound SIP calls.
session_close_transcript_timeout (float, optional): Seconds to wait for the
final STT transcript when closing the session (after audio is detached).
Default ``2.0`` s (independent of ``commit_user_turn``'s ``transcript_timeout``).
Expand Down Expand Up @@ -434,6 +438,10 @@ def __init__(
else:
stt_context = None
user_turn_limit = _resolve_user_turn_limit(turn_handling.get("user_turn_limit"))
self._aec_warmup_duration_explicit = is_given(aec_warmup_duration)
resolved_aec_warmup_duration = (
aec_warmup_duration if is_given(aec_warmup_duration) else _DEFAULT_AEC_WARMUP_DURATION
)

# This is the "global" chat_context, it holds the entire conversation history
self._chat_ctx = ChatContext.empty()
Expand All @@ -459,7 +467,7 @@ def __init__(
use_tts_aligned_transcript=(
use_tts_aligned_transcript if is_given(use_tts_aligned_transcript) else None
),
aec_warmup_duration=aec_warmup_duration,
aec_warmup_duration=resolved_aec_warmup_duration,
session_close_transcript_timeout=session_close_transcript_timeout,
)
# expressive mode is not publicly exposed; the pipeline stays disabled
Expand Down Expand Up @@ -515,7 +523,7 @@ def __init__(
self._tts_error_counts = 0

# aec warmup: disable interruptions while AEC warms up
self._aec_warmup_remaining = aec_warmup_duration or 0.0
self._aec_warmup_remaining = resolved_aec_warmup_duration or 0.0
self._aec_warmup_timer: asyncio.TimerHandle | None = None

# configurable IO
Expand Down Expand Up @@ -1720,6 +1728,21 @@ def _on_aec_warmup_expired(self) -> None:
self._aec_warmup_timer.cancel()
self._aec_warmup_timer = None

def _on_room_io_participant_linked(self, participant: rtc.RemoteParticipant) -> None:
if self._aec_warmup_duration_explicit:
return

is_outbound_sip = (
participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
and not participant.attributes.get(_SIP_RULE_ID_ATTR)
)
self._opts.aec_warmup_duration = None if is_outbound_sip else _DEFAULT_AEC_WARMUP_DURATION
self._aec_warmup_remaining = self._opts.aec_warmup_duration or 0.0

if is_outbound_sip and self._aec_warmup_timer is not None:
self._aec_warmup_timer.cancel()
self._aec_warmup_timer = None

def _update_agent_state(
self,
state: AgentState,
Expand Down
1 change: 1 addition & 0 deletions livekit-agents/livekit/agents/voice/room_io/room_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ def _on_participant_connected(self, participant: rtc.RemoteParticipant) -> None:
return

self._participant_available_fut.set_result(participant)
self._agent_session._on_room_io_participant_linked(participant)

def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
if not (linked := self.linked_participant) or participant.identity != linked.identity:
Expand Down
59 changes: 59 additions & 0 deletions tests/test_agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@

import pytest

from livekit import rtc
from livekit.agents import (
NOT_GIVEN,
Agent,
AgentFalseInterruptionEvent,
AgentSession,
AgentStateChangedEvent,
APIConnectionError,
ConversationItemAddedEvent,
Expand Down Expand Up @@ -915,6 +917,63 @@ async def bare_keep() -> None:
_OnEnterContextVar.reset(tk)


@pytest.mark.parametrize(
("kind", "attributes", "expected_duration"),
[
(rtc.ParticipantKind.PARTICIPANT_KIND_SIP, {}, None),
(
rtc.ParticipantKind.PARTICIPANT_KIND_SIP,
{"sip.ruleID": "SDR_inbound"},
3.0,
),
(rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD, {}, 3.0),
],
)
def test_aec_warmup_default_depends_on_call_type(
kind: rtc.ParticipantKind.ValueType,
attributes: dict[str, str],
expected_duration: float | None,
) -> None:
session = AgentSession(vad=None)
participant = MagicMock(spec=rtc.RemoteParticipant)
participant.kind = kind
participant.attributes = attributes

session._on_room_io_participant_linked(participant)

assert session.options.aec_warmup_duration == expected_duration
assert session._aec_warmup_remaining == (expected_duration or 0.0)


@pytest.mark.parametrize("duration", [None, 0.0, 1.5])
def test_explicit_aec_warmup_duration_overrides_outbound_sip_default(
duration: float | None,
) -> None:
session = AgentSession(vad=None, aec_warmup_duration=duration)
participant = MagicMock(spec=rtc.RemoteParticipant)
participant.kind = rtc.ParticipantKind.PARTICIPANT_KIND_SIP
participant.attributes = {}

session._on_room_io_participant_linked(participant)

assert session.options.aec_warmup_duration == duration
assert session._aec_warmup_remaining == (duration or 0.0)


def test_outbound_sip_cancels_aec_warmup_that_already_started() -> None:
session = AgentSession(vad=None)
timer = MagicMock(spec=asyncio.TimerHandle)
session._aec_warmup_timer = timer
participant = MagicMock(spec=rtc.RemoteParticipant)
participant.kind = rtc.ParticipantKind.PARTICIPANT_KIND_SIP
participant.attributes = {}

session._on_room_io_participant_linked(participant)

timer.cancel.assert_called_once()
assert session._aec_warmup_timer is None


async def test_aec_warmup() -> None:
"""AEC warmup should block audio-activity-based interruptions during the warmup window.

Expand Down