Files
2026-07-13 13:39:38 +08:00

341 lines
11 KiB
Python

from __future__ import annotations
import asyncio
from collections import defaultdict
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from livekit import rtc
from livekit.agents.voice.room_io._input import (
_ParticipantAudioInputStream,
_ParticipantInputStream,
)
from livekit.agents.voice.room_io._output import _ParticipantTranscriptionOutput
from livekit.agents.voice.room_io.room_io import RoomIO
from livekit.agents.voice.room_io.types import NoiseCancellationParams
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
# -- helpers ------------------------------------------------------------------
class _FakeRoom:
def __init__(self) -> None:
self._events: dict[str, list[object]] = defaultdict(list)
self.remote_participants: dict[str, object] = {}
self.local_participant = SimpleNamespace(identity="local")
self.name = "test-room"
self._token = "test-token"
self._server_url = "wss://test.livekit.cloud"
def on(self, event: str, callback: object) -> None:
self._events[event].append(callback)
def off(self, event: str, callback: object) -> None:
callbacks = self._events[event]
callbacks.remove(callback)
if not callbacks:
self._events.pop(event, None)
def listener_count(self, event: str) -> int:
return len(self._events.get(event, []))
def isconnected(self) -> bool:
return True
def register_text_stream_handler(self, topic: str, callback: object) -> None:
self.on(f"text:{topic}", callback)
def unregister_text_stream_handler(self, topic: str) -> None:
self._events.pop(f"text:{topic}", None)
class _MockAudioStream:
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
async def aclose(self) -> None:
pass
class _MockFrameProcessor(rtc.FrameProcessor[rtc.AudioFrame]):
def __init__(self) -> None:
self._enabled = True
self.stream_info_calls: list[dict[str, str]] = []
self.credentials_calls: list[dict[str, str]] = []
self.close_calls: int = 0
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def _on_stream_info_updated(
self, *, room_name: str, participant_identity: str, publication_sid: str
) -> None:
self.stream_info_calls.append(
{
"room_name": room_name,
"participant_identity": participant_identity,
"publication_sid": publication_sid,
}
)
def _on_credentials_updated(self, *, token: str, url: str) -> None:
self.credentials_calls.append({"token": token, "url": url})
def _process(self, frame: rtc.AudioFrame) -> rtc.AudioFrame:
return frame
def _close(self) -> None:
self.close_calls += 1
class _NoopAudioInputStream(_ParticipantInputStream[rtc.AudioFrame]):
def __init__(self, room: _FakeRoom) -> None:
super().__init__(room, track_source=rtc.TrackSource.SOURCE_MICROPHONE)
def _create_stream(
self, track: rtc.RemoteTrack, participant: rtc.Participant
) -> rtc.AudioStream:
raise AssertionError("_create_stream should not be called in teardown tests")
class _FakeWriter:
def __init__(self) -> None:
self.close_calls = 0
async def aclose(self, attributes: dict[str, str] | None = None) -> None:
self.close_calls += 1
def _make_track_available_args(
identity: str = "test-user", sid: str = "TR_123"
) -> tuple[MagicMock, MagicMock, MagicMock]:
track = MagicMock()
publication = MagicMock()
publication.source = rtc.TrackSource.SOURCE_MICROPHONE
publication.sid = sid
participant = MagicMock()
participant.identity = identity
return track, publication, participant
def _make_audio_input_stream(
room: _FakeRoom,
noise_cancellation,
) -> _ParticipantAudioInputStream:
return _ParticipantAudioInputStream(
room,
sample_rate=24000,
num_channels=1,
noise_cancellation=noise_cancellation,
auto_gain_control=False,
pre_connect_audio_handler=None,
)
# -- teardown tests -----------------------------------------------------------
@pytest.mark.asyncio
async def test_participant_input_stream_aclose_unregisters_track_unpublished() -> None:
room = _FakeRoom()
stream = _NoopAudioInputStream(room)
assert room.listener_count("track_subscribed") == 1
assert room.listener_count("track_unpublished") == 1
await stream.aclose()
assert room.listener_count("track_subscribed") == 0
assert room.listener_count("track_unpublished") == 0
@pytest.mark.asyncio
async def test_transcription_output_aclose_unregisters_and_closes_resources() -> None:
room = _FakeRoom()
output = _ParticipantTranscriptionOutput(room=room, participant=None)
legacy_output, stream_output = output._ParticipantTranscriptionOutput__outputs
legacy_output._flush_task = asyncio.create_task(asyncio.sleep(0))
writer = _FakeWriter()
stream_output._writer = writer
assert room.listener_count("track_published") == 2
assert room.listener_count("local_track_published") == 2
await output.aclose()
await output.aclose()
assert room.listener_count("track_published") == 0
assert room.listener_count("local_track_published") == 0
assert legacy_output._flush_task is not None and legacy_output._flush_task.done()
assert writer.close_calls == 1
@pytest.mark.asyncio
async def test_roomio_aclose_unregisters_disconnect_and_closes_transcription_outputs() -> None:
room = _FakeRoom()
agent_session = SimpleNamespace(
off=MagicMock(),
input=SimpleNamespace(audio=None, video=None),
output=SimpleNamespace(audio=None, transcription=None),
)
room_io = RoomIO(agent_session, room)
room.on("participant_connected", room_io._on_participant_connected)
room.on("connection_state_changed", room_io._on_connection_state_changed)
room.on("participant_disconnected", room_io._on_participant_disconnected)
order: list[str] = []
async def _mark(name: str) -> None:
order.append(name)
async def _close_sync() -> None:
await _mark("sync")
async def _close_user() -> None:
await _mark("user")
async def _close_agent() -> None:
await _mark("agent")
room_io._tr_synchronizer = SimpleNamespace(aclose=AsyncMock(side_effect=_close_sync))
room_io._user_tr_output = SimpleNamespace(aclose=AsyncMock(side_effect=_close_user))
room_io._agent_tr_output = SimpleNamespace(aclose=AsyncMock(side_effect=_close_agent))
assert room.listener_count("participant_disconnected") == 1
await room_io.aclose()
assert room.listener_count("participant_connected") == 0
assert room.listener_count("connection_state_changed") == 0
assert room.listener_count("participant_disconnected") == 0
assert order == ["sync", "user", "agent"]
room_io._tr_synchronizer.aclose.assert_awaited_once()
room_io._user_tr_output.aclose.assert_awaited_once()
room_io._agent_tr_output.aclose.assert_awaited_once()
# -- frame processor lifecycle tests ------------------------------------------
@pytest.mark.asyncio
async def test_direct_processor_lifecycle() -> None:
"""Direct FrameProcessor survives track transitions and is only closed on aclose()."""
room = _FakeRoom()
processor = _MockFrameProcessor()
stream = _make_audio_input_stream(room, noise_cancellation=processor)
stream.set_participant("test-user")
track1, pub1, participant = _make_track_available_args(sid="TR_001")
track2, pub2, _ = _make_track_available_args(sid="TR_002")
with patch("livekit.rtc.AudioStream.from_track", side_effect=lambda **kw: _MockAudioStream()):
# first track subscription
stream._on_track_available(track1, pub1, participant)
assert stream._processor is processor
assert processor.close_calls == 0
# track switch — processor must survive
stream._on_track_available(track2, pub2, participant)
assert stream._processor is processor
assert processor.close_calls == 0
# final teardown closes the processor exactly once
await stream.aclose()
assert processor.close_calls == 1
assert stream._processor is None
@pytest.mark.asyncio
async def test_selector_processor_lifecycle() -> None:
"""Selector-created processors are closed on track switch; the replacement
receives lifecycle calls and is closed on aclose()."""
room = _FakeRoom()
processors: list[_MockFrameProcessor] = []
def selector(_params: NoiseCancellationParams) -> _MockFrameProcessor:
p = _MockFrameProcessor()
processors.append(p)
return p
stream = _make_audio_input_stream(room, noise_cancellation=selector)
stream.set_participant("test-user")
track1, pub1, participant = _make_track_available_args(sid="TR_001")
track2, pub2, _ = _make_track_available_args(sid="TR_002")
with patch("livekit.rtc.AudioStream.from_track", side_effect=lambda **kw: _MockAudioStream()):
# first track
stream._on_track_available(track1, pub1, participant)
assert len(processors) == 1
assert stream._processor is processors[0]
# track switch — old processor closed, new one receives lifecycle calls
stream._on_track_available(track2, pub2, participant)
assert len(processors) == 2
assert processors[0].close_calls == 1
assert stream._processor is processors[1]
# final teardown closes the active processor
await stream.aclose()
assert processors[1].close_calls == 1
@pytest.mark.asyncio
async def test_selector_processor_track_disappears() -> None:
"""When a track vanishes with no replacement, the selector-created processor is closed."""
room = _FakeRoom()
processor = _MockFrameProcessor()
stream = _make_audio_input_stream(room, noise_cancellation=lambda _params: processor)
stream.set_participant("test-user")
track, publication, participant = _make_track_available_args()
with patch("livekit.rtc.AudioStream.from_track", side_effect=lambda **kw: _MockAudioStream()):
stream._on_track_available(track, publication, participant)
assert stream._processor is processor
# track unpublished with no replacement
stream._on_track_unavailable(publication, participant)
assert processor.close_calls == 1
assert stream._processor is None
await stream.aclose()
@pytest.mark.asyncio
async def test_selector_returns_noise_cancellation_options() -> None:
"""When a selector returns NoiseCancellationOptions instead of a FrameProcessor,
no processor is tracked."""
room = _FakeRoom()
nc_options = rtc.NoiseCancellationOptions(module_id="bvc", options={})
stream = _make_audio_input_stream(room, noise_cancellation=lambda _params: nc_options)
stream.set_participant("test-user")
track, publication, participant = _make_track_available_args()
with patch("livekit.rtc.AudioStream.from_track", side_effect=lambda **kw: _MockAudioStream()):
stream._on_track_available(track, publication, participant)
assert stream._processor is None
await stream.aclose()