-
Notifications
You must be signed in to change notification settings - Fork 680
Feat/lemonslice plugin stream #628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7b6edf6
3d13915
687fea9
a5384a2
0d5a0de
b6c7c72
db38985
2d209da
b5b5fed
b531d99
ff1e90a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,57 @@ | ||
| import asyncio | ||
| import json | ||
|
|
||
| import httpx | ||
| import numpy as np | ||
| import pytest | ||
| from vision_agents.core.agents.inference import AudioOutputStream | ||
| from getstream import AsyncStream | ||
| from getstream.video.rtc.track_util import AudioFormat, PcmData | ||
| from vision_agents.core.agents.inference import AudioOutputFlush, AudioOutputStream | ||
| from vision_agents.core.utils.utils import cancel_and_wait | ||
| from vision_agents.core.utils.video_track import QueuedVideoTrack | ||
| from vision_agents.plugins.lemonslice.lemonslice_avatar import LemonSliceAvatar | ||
| from vision_agents.plugins.lemonslice.track import AvatarInputTrack | ||
|
|
||
|
|
||
| def _make_avatar(**overrides) -> LemonSliceAvatar: | ||
| default_kwargs = { | ||
| "agent_id": "test-agent", | ||
| "api_key": "ls-test-key", | ||
| "livekit_url": "wss://test.livekit.cloud", | ||
| "livekit_api_key": "devkey", | ||
| "livekit_api_secret": "devsecret", | ||
| "api_key": "lemonslice-key", | ||
| "stream_api_key": "key", | ||
| "stream_api_secret": "secret", | ||
| } | ||
| return LemonSliceAvatar(**{**default_kwargs, **overrides}) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def session_requests() -> list[httpx.Request]: | ||
| return [] | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def session_transport(session_requests: list[httpx.Request]) -> httpx.MockTransport: | ||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| session_requests.append(request) | ||
| return httpx.Response(200, json={"session_id": "session-1"}) | ||
|
|
||
| return httpx.MockTransport(handler) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def call_events() -> list[dict]: | ||
| return [] | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def call_event_transport(call_events: list[dict]) -> httpx.MockTransport: | ||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| if request.url.path.endswith("/event"): | ||
| call_events.append(json.loads(request.content)["custom"]) | ||
| return httpx.Response(200, json={"duration": "0ms"}) | ||
|
|
||
| return httpx.MockTransport(handler) | ||
|
|
||
|
|
||
| class TestLemonSliceAvatar: | ||
| async def test_init_with_agent_image_url_instead_of_id(self): | ||
| avatar = _make_avatar( | ||
|
|
@@ -32,20 +69,13 @@ async def test_init_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch | |
| with pytest.raises(ValueError, match="API key required"): | ||
| _make_avatar(api_key=None) | ||
|
|
||
| async def test_init_missing_livekit_url_raises( | ||
| async def test_init_missing_stream_secret_raises( | ||
| self, monkeypatch: pytest.MonkeyPatch | ||
| ): | ||
| monkeypatch.delenv("LIVEKIT_URL", raising=False) | ||
| with pytest.raises(ValueError, match="LiveKit URL required"): | ||
| _make_avatar(livekit_url=None) | ||
|
|
||
| async def test_init_missing_livekit_secret_raises( | ||
| self, monkeypatch: pytest.MonkeyPatch | ||
| ): | ||
| monkeypatch.delenv("LIVEKIT_API_KEY", raising=False) | ||
| monkeypatch.delenv("LIVEKIT_API_SECRET", raising=False) | ||
| with pytest.raises(ValueError, match="LiveKit API key and secret required"): | ||
| _make_avatar(livekit_api_key=None, livekit_api_secret=None) | ||
| monkeypatch.delenv("STREAM_API_KEY", raising=False) | ||
| monkeypatch.delenv("STREAM_API_SECRET", raising=False) | ||
| with pytest.raises(ValueError, match="Stream API key and secret required"): | ||
| _make_avatar(stream_api_key=None, stream_api_secret=None) | ||
|
|
||
| async def test_video_output(self): | ||
| avatar = _make_avatar(width=640, height=480) | ||
|
|
@@ -65,3 +95,102 @@ async def test_init_odd_height_raises(self): | |
| async def test_audio_output(self): | ||
| avatar = _make_avatar() | ||
| assert isinstance(avatar.audio_output(), AudioOutputStream) | ||
|
|
||
| async def test_extra_params_are_sent_in_the_session_request( | ||
| self, | ||
| session_transport: httpx.MockTransport, | ||
| session_requests: list[httpx.Request], | ||
| ): | ||
| avatar = _make_avatar( | ||
| lemonslice_properties={"voice_id": "nova", "metadata": {"tier": "pro"}} | ||
| ) | ||
| avatar._client._http_client = httpx.AsyncClient(transport=session_transport) | ||
|
|
||
| await avatar._client.create_session( | ||
| call_id="call-1", call_type="default", token="token", api_key="stream-key" | ||
| ) | ||
|
|
||
| payload = json.loads(session_requests[0].content) | ||
| assert payload["voice_id"] == "nova" | ||
| assert payload["metadata"] == {"tier": "pro"} | ||
|
|
||
| async def test_extra_params_do_not_override_transport_fields( | ||
| self, | ||
| session_transport: httpx.MockTransport, | ||
| session_requests: list[httpx.Request], | ||
| ): | ||
| avatar = _make_avatar( | ||
| lemonslice_properties={"transport_type": "websocket", "properties": {}} | ||
| ) | ||
| avatar._client._http_client = httpx.AsyncClient(transport=session_transport) | ||
|
|
||
| await avatar._client.create_session( | ||
| call_id="call-1", call_type="default", token="token", api_key="stream-key" | ||
| ) | ||
|
|
||
| payload = json.loads(session_requests[0].content) | ||
| assert payload["transport_type"] == "stream" | ||
| assert payload["properties"]["call_id"] == "call-1" | ||
|
|
||
| async def test_end_utterance_and_interrupt_events_respect_audio_boundaries( | ||
| self, call_events: list[dict], call_event_transport: httpx.MockTransport | ||
|
Comment on lines
+135
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Complete the test signature annotations. Add As per coding guidelines, “Use type annotations everywhere” and use Source: Coding guidelines |
||
| ): | ||
| avatar = _make_avatar() | ||
| manager = avatar._rtc_manager | ||
| manager._client = AsyncStream( | ||
| api_key="key", api_secret="secret", transport=call_event_transport | ||
| ) | ||
| manager._call = manager._client.video.call("default", "call-1") | ||
| manager._input_track = AvatarInputTrack(sample_rate=16000, channels=1) | ||
| manager._connected = True | ||
|
|
||
| for _ in range(3): | ||
| await manager.send_audio( | ||
| PcmData( | ||
| samples=np.zeros(480, dtype=np.int16), | ||
| sample_rate=24000, | ||
| format=AudioFormat.S16, | ||
| channels=1, | ||
| ) | ||
| ) | ||
|
|
||
| await manager.flush() | ||
|
|
||
| emitted_samples = 0 | ||
| while True: | ||
| try: | ||
| emitted_samples += ( | ||
| await asyncio.wait_for(manager._input_track.recv(), timeout=0.05) | ||
| ).samples | ||
| except TimeoutError: | ||
| break | ||
|
|
||
| assert call_events[0] == { | ||
| "type": "lemonslice.end_utterance", | ||
| "pts": emitted_samples, | ||
| "event_id": 1, | ||
| } | ||
|
|
||
| # An interruption discards the buffered audio, so announcing an | ||
| # end-of-utterance PTS that covers it points the avatar at audio that | ||
| # never arrives. | ||
| await manager._input_track.write( | ||
| PcmData( | ||
| samples=np.zeros(16000, dtype=np.int16), | ||
| sample_rate=16000, | ||
| format=AudioFormat.S16, | ||
| channels=1, | ||
| ) | ||
| ) | ||
|
|
||
| stream = AudioOutputStream() | ||
| avatar.attach_audio_input(stream) | ||
| task = asyncio.create_task(avatar._process_audio_input()) | ||
| stream.send_nowait(AudioOutputFlush()) | ||
| await asyncio.sleep(0.05) | ||
| await cancel_and_wait(task) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| assert [event["type"] for event in call_events] == [ | ||
| "lemonslice.end_utterance", | ||
| "lemonslice.interrupt", | ||
| ] | ||
Uh oh!
There was an error while loading. Please reload this page.