Files
livekit--agents/tests/test_audio_sink_proxy.py
2026-07-13 13:39:38 +08:00

351 lines
9.8 KiB
Python

from __future__ import annotations
import asyncio
import pytest
from livekit import rtc
from livekit.agents.voice.io import (
AgentOutput,
AudioOutput,
AudioOutputCapabilities,
PlaybackFinishedEvent,
_AudioSinkProxy,
)
from .fake_io import FakeAudioOutput
pytestmark = pytest.mark.unit
def _make_agent_output() -> AgentOutput:
return AgentOutput(lambda: None, lambda: None, lambda: None)
def _silence(duration_s: float = 0.01, sample_rate: int = 16000) -> rtc.AudioFrame:
n = int(sample_rate * duration_s)
return rtc.AudioFrame(
data=b"\x00\x00" * n,
sample_rate=sample_rate,
num_channels=1,
samples_per_channel=n,
)
class _PassthroughWrapper(AudioOutput):
"""Minimal wrapper that forwards capture_frame/flush/clear_buffer through
next_in_chain. Used to exercise the auto-wrap and swap mechanics without
pulling in TranscriptSynchronizer or RecorderAudioOutput.
"""
def __init__(self, *, next_in_chain: AudioOutput) -> None:
super().__init__(
label="Passthrough",
capabilities=AudioOutputCapabilities(pause=True),
next_in_chain=next_in_chain,
)
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
await super().capture_frame(frame)
assert self.next_in_chain is not None
await self.next_in_chain.capture_frame(frame)
def flush(self) -> None:
super().flush()
assert self.next_in_chain is not None
self.next_in_chain.flush()
def clear_buffer(self) -> None:
assert self.next_in_chain is not None
self.next_in_chain.clear_buffer()
class _TrackingSink(AudioOutput):
"""Leaf sink that records attach/detach calls for assertion."""
def __init__(self) -> None:
super().__init__(label="TrackingSink", capabilities=AudioOutputCapabilities(pause=True))
self.attached_calls = 0
self.detached_calls = 0
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
await super().capture_frame(frame)
def flush(self) -> None:
super().flush()
def clear_buffer(self) -> None:
pass
def on_attached(self) -> None:
self.attached_calls += 1
super().on_attached()
def on_detached(self) -> None:
self.detached_calls += 1
super().on_detached()
# ---------- auto-wrap ----------
def test_auto_wrap_inserts_proxy_above_bare_leaf() -> None:
leaf = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf)
assert isinstance(wrapper.next_in_chain, _AudioSinkProxy)
assert wrapper.next_in_chain.next_in_chain is leaf
def test_auto_wrap_skipped_when_passed_an_existing_proxy() -> None:
leaf = FakeAudioOutput()
proxy = _AudioSinkProxy(leaf)
wrapper = _PassthroughWrapper(next_in_chain=proxy)
assert wrapper.next_in_chain is proxy
def test_auto_wrap_skipped_when_wrapping_a_non_leaf() -> None:
leaf = FakeAudioOutput()
inner = _PassthroughWrapper(next_in_chain=leaf)
outer = _PassthroughWrapper(next_in_chain=inner)
# outer should reference inner directly — no proxy interposed between them
assert outer.next_in_chain is inner
# ---------- replace_audio_tail ----------
def test_replace_audio_tail_swaps_proxy_inner() -> None:
leaf_a = FakeAudioOutput()
leaf_b = FakeAudioOutput()
output = _make_agent_output()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
output.audio = wrapper
output.replace_audio_tail(leaf_b)
# wrapper chain intact; only the leaf swapped
assert output.audio is wrapper
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
assert proxy.next_in_chain is leaf_b
def test_replace_audio_tail_falls_back_when_no_proxy() -> None:
leaf = FakeAudioOutput()
output = _make_agent_output()
# no wrapper chain yet
output.replace_audio_tail(leaf)
assert output.audio is leaf
# ---------- proxy invariants ----------
@pytest.mark.asyncio
async def test_proxy_accepts_wrapper_chain_as_inner() -> None:
leaf = FakeAudioOutput()
wrapped_sink = _PassthroughWrapper(next_in_chain=leaf)
proxy = _AudioSinkProxy(wrapped_sink)
assert proxy.next_in_chain is wrapped_sink
# events from the inner leaf still bubble up through proxy
received: list[PlaybackFinishedEvent] = []
proxy.on("playback_finished", received.append)
await proxy.capture_frame(_silence())
leaf.on_playback_finished(playback_position=1.0, interrupted=False)
assert len(received) == 1
assert received[0].playback_position == 1.0
# ---------- swap routing ----------
@pytest.mark.asyncio
async def test_swap_routes_playback_events_from_new_leaf() -> None:
leaf_a = FakeAudioOutput()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
received: list[PlaybackFinishedEvent] = []
wrapper.on("playback_finished", received.append)
proxy.set_next_in_chain(leaf_b)
# capture after the swap so leaf_b has a segment to mark as finished
await wrapper.capture_frame(_silence())
leaf_b.on_playback_finished(playback_position=1.0, interrupted=False)
assert len(received) == 1
assert received[0].playback_position == 1.0
@pytest.mark.asyncio
async def test_swap_disconnects_old_leaf() -> None:
leaf_a = FakeAudioOutput()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
# give leaf_a a segment so its on_playback_finished would normally emit
await wrapper.capture_frame(_silence())
received: list[PlaybackFinishedEvent] = []
wrapper.on("playback_finished", received.append)
proxy.set_next_in_chain(leaf_b)
# leaf_a is detached: any event it fires must not reach the wrapper
leaf_a.on_playback_finished(playback_position=0.5, interrupted=False)
assert received == []
# ---------- swap with in-flight playback ----------
class _ClearCountingSink(FakeAudioOutput):
def __init__(self) -> None:
super().__init__()
self.clear_calls = 0
def clear_buffer(self) -> None:
self.clear_calls += 1
super().clear_buffer()
@pytest.mark.asyncio
async def test_swap_finishes_pending_segment_as_interrupted() -> None:
leaf_a = FakeAudioOutput()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
# a flushed segment still playing out on leaf_a (frames are pushed faster than realtime)
await wrapper.capture_frame(_silence(duration_s=1.0))
wrapper.flush()
received: list[PlaybackFinishedEvent] = []
wrapper.on("playback_finished", received.append)
proxy.set_next_in_chain(leaf_b)
# the pending segment must be finished as interrupted so wait_for_playout() doesn't hang
ev = await asyncio.wait_for(wrapper.wait_for_playout(), timeout=0.5)
assert ev.interrupted is True
assert ev.playback_position == pytest.approx(1.0)
assert len(received) == 1
@pytest.mark.asyncio
async def test_swap_clears_old_sink_with_inflight_audio() -> None:
leaf_a = _ClearCountingSink()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
await wrapper.capture_frame(_silence(duration_s=1.0))
wrapper.flush()
proxy.set_next_in_chain(leaf_b)
assert leaf_a.clear_calls == 1
def test_idle_swap_does_not_clear_old_sink() -> None:
leaf_a = _ClearCountingSink()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
proxy.set_next_in_chain(leaf_b)
assert leaf_a.clear_calls == 0
@pytest.mark.asyncio
async def test_swap_mid_capture_segment_finishes_on_new_leaf() -> None:
leaf_a = FakeAudioOutput()
leaf_b = FakeAudioOutput()
wrapper = _PassthroughWrapper(next_in_chain=leaf_a)
proxy = wrapper.next_in_chain
assert isinstance(proxy, _AudioSinkProxy)
received: list[PlaybackFinishedEvent] = []
wrapper.on("playback_finished", received.append)
# swap in the middle of a segment, before flush
await wrapper.capture_frame(_silence(duration_s=0.05))
proxy.set_next_in_chain(leaf_b)
# no synthesized event: the segment continues on leaf_b, which reports it
assert received == []
await wrapper.capture_frame(_silence(duration_s=0.05))
wrapper.flush()
ev = await asyncio.wait_for(wrapper.wait_for_playout(), timeout=1.0)
assert ev.interrupted is False
assert len(received) == 1
# ---------- attached state ----------
def test_proxy_propagates_on_attached_to_current_inner() -> None:
sink = _TrackingSink()
proxy = _AudioSinkProxy(sink)
proxy.on_attached()
assert sink.attached_calls == 1
assert sink.detached_calls == 0
def test_proxy_propagates_on_detached_to_current_inner() -> None:
sink = _TrackingSink()
proxy = _AudioSinkProxy(sink)
proxy.on_attached()
proxy.on_detached()
assert sink.attached_calls == 1
assert sink.detached_calls == 1
def test_swap_while_attached_attaches_new_and_detaches_old() -> None:
sink_a = _TrackingSink()
sink_b = _TrackingSink()
proxy = _AudioSinkProxy(sink_a)
proxy.on_attached()
assert sink_a.attached_calls == 1
proxy.set_next_in_chain(sink_b)
assert sink_a.detached_calls == 1
assert sink_b.attached_calls == 1
def test_swap_while_detached_does_not_fire_lifecycle_hooks() -> None:
sink_a = _TrackingSink()
sink_b = _TrackingSink()
proxy = _AudioSinkProxy(sink_a)
# never attached
proxy.set_next_in_chain(sink_b)
assert sink_a.detached_calls == 0
assert sink_b.attached_calls == 0