vox-rtc-server 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vox_rtc_server-0.1.1/.gitignore +12 -0
- vox_rtc_server-0.1.1/PKG-INFO +79 -0
- vox_rtc_server-0.1.1/README.md +70 -0
- vox_rtc_server-0.1.1/pyproject.toml +16 -0
- vox_rtc_server-0.1.1/src/vox_rtc_server/__init__.py +30 -0
- vox_rtc_server-0.1.1/src/vox_rtc_server/client.py +192 -0
- vox_rtc_server-0.1.1/src/vox_rtc_server/py.typed +1 -0
- vox_rtc_server-0.1.1/src/vox_rtc_server/session.py +156 -0
- vox_rtc_server-0.1.1/src/vox_rtc_server/types.py +116 -0
- vox_rtc_server-0.1.1/tests/test_client.py +215 -0
- vox_rtc_server-0.1.1/uv.lock +8 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vox-rtc-server
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Server-side Python SDK for Vox-hosted WebRTC sessions.
|
|
5
|
+
Author: eleven-am
|
|
6
|
+
License: GPL-3.0-or-later
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# `vox-rtc-server`
|
|
11
|
+
|
|
12
|
+
Server-side Python SDK for Vox-hosted WebRTC sessions.
|
|
13
|
+
|
|
14
|
+
This package is for backend applications that need to:
|
|
15
|
+
|
|
16
|
+
- create RTC sessions over HTTP
|
|
17
|
+
- attach to `/v1/socket`
|
|
18
|
+
- join `/rtc/{session_id}`
|
|
19
|
+
- send `session.update`, `response.*`, and `client.event`
|
|
20
|
+
- observe RTC control events
|
|
21
|
+
|
|
22
|
+
It is intentionally narrow. It is not the general STT/TTS/text SDK.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install vox-rtc-server pondsocket-client
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The SDK uses the PondSocket Python client for the control-plane socket.
|
|
31
|
+
Until `pondsocket-client` is published, install it from your local checkout or git ref.
|
|
32
|
+
Authentication can be passed explicitly with `api_key=...` or through `VOX_API_KEY`.
|
|
33
|
+
|
|
34
|
+
## Example
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from vox_rtc_server import (
|
|
41
|
+
ClientEventEnvelope,
|
|
42
|
+
SessionConfig,
|
|
43
|
+
VoxRtcServerClient,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def main() -> None:
|
|
48
|
+
client = VoxRtcServerClient(
|
|
49
|
+
http_base="https://vox.example.com",
|
|
50
|
+
api_key=os.environ.get("VOX_API_KEY"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
bootstrap, session = await client.create_controlled_session()
|
|
54
|
+
print("session:", bootstrap.session_id)
|
|
55
|
+
|
|
56
|
+
session.on_event(lambda event: print(event.type, event.data))
|
|
57
|
+
|
|
58
|
+
session.configure(
|
|
59
|
+
SessionConfig(
|
|
60
|
+
stt_model="parakeet-stt-onnx:tdt-0.6b-v3",
|
|
61
|
+
tts_model="kokoro-tts-onnx:v1.0",
|
|
62
|
+
voice="af_heart",
|
|
63
|
+
turn_profile="browser_default",
|
|
64
|
+
vad_backend="silero",
|
|
65
|
+
turn_detector="livekit",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
session.send_text_response("Hello from Python.")
|
|
70
|
+
session.send_client_event(
|
|
71
|
+
ClientEventEnvelope(
|
|
72
|
+
event="render.url",
|
|
73
|
+
payload={"url": "https://example.com"},
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# `vox-rtc-server`
|
|
2
|
+
|
|
3
|
+
Server-side Python SDK for Vox-hosted WebRTC sessions.
|
|
4
|
+
|
|
5
|
+
This package is for backend applications that need to:
|
|
6
|
+
|
|
7
|
+
- create RTC sessions over HTTP
|
|
8
|
+
- attach to `/v1/socket`
|
|
9
|
+
- join `/rtc/{session_id}`
|
|
10
|
+
- send `session.update`, `response.*`, and `client.event`
|
|
11
|
+
- observe RTC control events
|
|
12
|
+
|
|
13
|
+
It is intentionally narrow. It is not the general STT/TTS/text SDK.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install vox-rtc-server pondsocket-client
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The SDK uses the PondSocket Python client for the control-plane socket.
|
|
22
|
+
Until `pondsocket-client` is published, install it from your local checkout or git ref.
|
|
23
|
+
Authentication can be passed explicitly with `api_key=...` or through `VOX_API_KEY`.
|
|
24
|
+
|
|
25
|
+
## Example
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
from vox_rtc_server import (
|
|
32
|
+
ClientEventEnvelope,
|
|
33
|
+
SessionConfig,
|
|
34
|
+
VoxRtcServerClient,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def main() -> None:
|
|
39
|
+
client = VoxRtcServerClient(
|
|
40
|
+
http_base="https://vox.example.com",
|
|
41
|
+
api_key=os.environ.get("VOX_API_KEY"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
bootstrap, session = await client.create_controlled_session()
|
|
45
|
+
print("session:", bootstrap.session_id)
|
|
46
|
+
|
|
47
|
+
session.on_event(lambda event: print(event.type, event.data))
|
|
48
|
+
|
|
49
|
+
session.configure(
|
|
50
|
+
SessionConfig(
|
|
51
|
+
stt_model="parakeet-stt-onnx:tdt-0.6b-v3",
|
|
52
|
+
tts_model="kokoro-tts-onnx:v1.0",
|
|
53
|
+
voice="af_heart",
|
|
54
|
+
turn_profile="browser_default",
|
|
55
|
+
vad_backend="silero",
|
|
56
|
+
turn_detector="livekit",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
session.send_text_response("Hello from Python.")
|
|
61
|
+
session.send_client_event(
|
|
62
|
+
ClientEventEnvelope(
|
|
63
|
+
event="render.url",
|
|
64
|
+
payload={"url": "https://example.com"},
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vox-rtc-server"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Server-side Python SDK for Vox-hosted WebRTC sessions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "GPL-3.0-or-later" }
|
|
8
|
+
authors = [{ name = "eleven-am" }]
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/vox_rtc_server"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .client import VoxRtcServerClient
|
|
4
|
+
from .session import VoxRtcControlSession
|
|
5
|
+
from .types import (
|
|
6
|
+
ChannelState,
|
|
7
|
+
ClientEventEnvelope,
|
|
8
|
+
ConnectionState,
|
|
9
|
+
RTCIceServer,
|
|
10
|
+
ResponseOptions,
|
|
11
|
+
SessionBootstrap,
|
|
12
|
+
SessionConfig,
|
|
13
|
+
WireEvent,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.1"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ChannelState",
|
|
20
|
+
"ClientEventEnvelope",
|
|
21
|
+
"ConnectionState",
|
|
22
|
+
"RTCIceServer",
|
|
23
|
+
"ResponseOptions",
|
|
24
|
+
"SessionBootstrap",
|
|
25
|
+
"SessionConfig",
|
|
26
|
+
"VoxRtcControlSession",
|
|
27
|
+
"VoxRtcServerClient",
|
|
28
|
+
"WireEvent",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from .session import VoxRtcControlSession
|
|
11
|
+
from .types import (
|
|
12
|
+
ConnectionState,
|
|
13
|
+
RTCIceServer,
|
|
14
|
+
SessionBootstrap,
|
|
15
|
+
SocketClientFactory,
|
|
16
|
+
SocketClientLike,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_base(base: str) -> str:
|
|
21
|
+
return base.rstrip("/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_socket_base(http_base: str) -> str:
|
|
25
|
+
return f"{_normalize_base(http_base)}/v1/socket"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_bootstrap(data: Mapping[str, Any]) -> SessionBootstrap:
|
|
29
|
+
ice_servers = [
|
|
30
|
+
RTCIceServer(
|
|
31
|
+
urls=entry.get("urls", []),
|
|
32
|
+
username=entry.get("username"),
|
|
33
|
+
credential=entry.get("credential"),
|
|
34
|
+
)
|
|
35
|
+
for entry in data.get("ice_servers", [])
|
|
36
|
+
if isinstance(entry, Mapping)
|
|
37
|
+
]
|
|
38
|
+
return SessionBootstrap(
|
|
39
|
+
session_id=str(data["session_id"]),
|
|
40
|
+
client_token=str(data["client_token"]),
|
|
41
|
+
expires_at=str(data["expires_at"]),
|
|
42
|
+
join_token_ttl_seconds=int(data.get("join_token_ttl_seconds", 0)),
|
|
43
|
+
ice_servers=ice_servers,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _state_value(state: Any) -> str:
|
|
48
|
+
return str(getattr(state, "value", state))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_socket_factory(
|
|
52
|
+
endpoint: str,
|
|
53
|
+
params: Mapping[str, Any],
|
|
54
|
+
connection_timeout: float,
|
|
55
|
+
max_reconnect_delay: float,
|
|
56
|
+
) -> SocketClientLike:
|
|
57
|
+
try:
|
|
58
|
+
from pondsocket_client import ClientOptions, PondClient
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"pondsocket-client is required for the default Vox RTC socket transport"
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
options = ClientOptions(
|
|
65
|
+
connection_timeout=connection_timeout,
|
|
66
|
+
max_reconnect_delay=max_reconnect_delay,
|
|
67
|
+
)
|
|
68
|
+
return PondClient(endpoint, dict(params), options)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class VoxRtcServerClient:
|
|
72
|
+
__slots__ = (
|
|
73
|
+
"_connection_timeout",
|
|
74
|
+
"_http_base",
|
|
75
|
+
"_api_key",
|
|
76
|
+
"_join_timeout",
|
|
77
|
+
"_max_reconnect_delay",
|
|
78
|
+
"_request_timeout",
|
|
79
|
+
"_socket",
|
|
80
|
+
"_socket_base",
|
|
81
|
+
"_socket_factory",
|
|
82
|
+
"_socket_params",
|
|
83
|
+
"_urlopen",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
http_base: str,
|
|
90
|
+
api_key: str | None = None,
|
|
91
|
+
socket_base: str | None = None,
|
|
92
|
+
socket_params: Mapping[str, Any] | None = None,
|
|
93
|
+
connection_timeout: float = 10.0,
|
|
94
|
+
max_reconnect_delay: float = 30.0,
|
|
95
|
+
request_timeout: float = 15.0,
|
|
96
|
+
join_timeout: float = 10.0,
|
|
97
|
+
socket_factory: SocketClientFactory | None = None,
|
|
98
|
+
urlopen_impl: Any = urlopen,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._http_base = _normalize_base(http_base)
|
|
101
|
+
resolved_api_key = (api_key if api_key is not None else os.environ.get("VOX_API_KEY", "")).strip()
|
|
102
|
+
self._api_key = resolved_api_key or None
|
|
103
|
+
self._socket_base = (
|
|
104
|
+
_normalize_base(socket_base) if socket_base else _default_socket_base(http_base)
|
|
105
|
+
)
|
|
106
|
+
merged_socket_params = dict(socket_params or {})
|
|
107
|
+
if self._api_key:
|
|
108
|
+
merged_socket_params["api_key"] = self._api_key
|
|
109
|
+
self._socket_params = merged_socket_params
|
|
110
|
+
self._connection_timeout = connection_timeout
|
|
111
|
+
self._max_reconnect_delay = max_reconnect_delay
|
|
112
|
+
self._request_timeout = request_timeout
|
|
113
|
+
self._join_timeout = join_timeout
|
|
114
|
+
self._socket_factory = socket_factory or _default_socket_factory
|
|
115
|
+
self._socket: SocketClientLike | None = None
|
|
116
|
+
self._urlopen = urlopen_impl
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def http_base(self) -> str:
|
|
120
|
+
return self._http_base
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def socket_base(self) -> str:
|
|
124
|
+
return self._socket_base
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def connection_state(self) -> ConnectionState:
|
|
128
|
+
if self._socket is None:
|
|
129
|
+
return ConnectionState.DISCONNECTED
|
|
130
|
+
raw = _state_value(self._socket.get_state())
|
|
131
|
+
return ConnectionState(raw)
|
|
132
|
+
|
|
133
|
+
async def connect(self) -> None:
|
|
134
|
+
socket = self._ensure_socket()
|
|
135
|
+
if _state_value(socket.get_state()) == ConnectionState.CONNECTED.value:
|
|
136
|
+
return
|
|
137
|
+
await socket.connect()
|
|
138
|
+
|
|
139
|
+
async def disconnect(self) -> None:
|
|
140
|
+
socket = self._socket
|
|
141
|
+
if socket is None:
|
|
142
|
+
return
|
|
143
|
+
await socket.disconnect()
|
|
144
|
+
self._socket = None
|
|
145
|
+
|
|
146
|
+
async def create_session(self) -> SessionBootstrap:
|
|
147
|
+
return await asyncio.to_thread(self._create_session_blocking)
|
|
148
|
+
|
|
149
|
+
async def attach_session(self, session_id: str) -> VoxRtcControlSession:
|
|
150
|
+
await self.connect()
|
|
151
|
+
socket = self._ensure_socket()
|
|
152
|
+
channel = socket.create_channel(f"/rtc/{session_id}", {})
|
|
153
|
+
session = VoxRtcControlSession(
|
|
154
|
+
channel,
|
|
155
|
+
session_id,
|
|
156
|
+
join_timeout=self._join_timeout,
|
|
157
|
+
)
|
|
158
|
+
await session.join()
|
|
159
|
+
return session
|
|
160
|
+
|
|
161
|
+
async def create_controlled_session(
|
|
162
|
+
self,
|
|
163
|
+
) -> tuple[SessionBootstrap, VoxRtcControlSession]:
|
|
164
|
+
bootstrap = await self.create_session()
|
|
165
|
+
session = await self.attach_session(bootstrap.session_id)
|
|
166
|
+
return bootstrap, session
|
|
167
|
+
|
|
168
|
+
def _ensure_socket(self) -> SocketClientLike:
|
|
169
|
+
if self._socket is None:
|
|
170
|
+
self._socket = self._socket_factory(
|
|
171
|
+
self._socket_base,
|
|
172
|
+
self._socket_params,
|
|
173
|
+
self._connection_timeout,
|
|
174
|
+
self._max_reconnect_delay,
|
|
175
|
+
)
|
|
176
|
+
return self._socket
|
|
177
|
+
|
|
178
|
+
def _create_session_blocking(self) -> SessionBootstrap:
|
|
179
|
+
request = Request(
|
|
180
|
+
f"{self._http_base}/v1/rtc/sessions",
|
|
181
|
+
data=json.dumps({}).encode("utf-8"),
|
|
182
|
+
headers={"content-type": "application/json"},
|
|
183
|
+
method="POST",
|
|
184
|
+
)
|
|
185
|
+
if self._api_key:
|
|
186
|
+
request.add_header("authorization", f"Bearer {self._api_key}")
|
|
187
|
+
with self._urlopen(request, timeout=self._request_timeout) as response:
|
|
188
|
+
status = int(response.status) if hasattr(response, "status") else int(response.getcode())
|
|
189
|
+
body = response.read().decode("utf-8")
|
|
190
|
+
if status < 200 or status >= 300:
|
|
191
|
+
raise RuntimeError(f"Failed to create Vox RTC session: {status} {body.strip()}")
|
|
192
|
+
return _to_bootstrap(json.loads(body))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
ChannelState,
|
|
9
|
+
ClientEventEnvelope,
|
|
10
|
+
ResponseOptions,
|
|
11
|
+
SessionConfig,
|
|
12
|
+
SocketChannelLike,
|
|
13
|
+
Unsubscribe,
|
|
14
|
+
WireEvent,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _state_value(state: Any) -> str:
|
|
19
|
+
return str(getattr(state, "value", state))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _payload_dict(payload: Any) -> dict[str, Any]:
|
|
23
|
+
if payload is None:
|
|
24
|
+
return {}
|
|
25
|
+
if isinstance(payload, dict):
|
|
26
|
+
return dict(payload)
|
|
27
|
+
if isinstance(payload, Mapping):
|
|
28
|
+
return dict(payload)
|
|
29
|
+
return {"payload": payload}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _response_options_payload(options: ResponseOptions | None) -> dict[str, Any]:
|
|
33
|
+
payload: dict[str, Any] = {}
|
|
34
|
+
if options is not None and options.allow_interruptions is not None:
|
|
35
|
+
payload["allow_interruptions"] = options.allow_interruptions
|
|
36
|
+
return payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VoxRtcControlSession:
|
|
40
|
+
__slots__ = ("_channel", "_channel_name", "_join_timeout", "_session_id")
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
channel: SocketChannelLike,
|
|
45
|
+
session_id: str,
|
|
46
|
+
*,
|
|
47
|
+
join_timeout: float = 10.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._channel = channel
|
|
50
|
+
self._session_id = session_id
|
|
51
|
+
self._channel_name = f"/rtc/{session_id}"
|
|
52
|
+
self._join_timeout = join_timeout
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def session_id(self) -> str:
|
|
56
|
+
return self._session_id
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def channel_name(self) -> str:
|
|
60
|
+
return self._channel_name
|
|
61
|
+
|
|
62
|
+
async def join(self) -> None:
|
|
63
|
+
loop = asyncio.get_running_loop()
|
|
64
|
+
done: asyncio.Future[None] = loop.create_future()
|
|
65
|
+
|
|
66
|
+
def handle_state(state: Any) -> None:
|
|
67
|
+
value = _state_value(state)
|
|
68
|
+
if value == ChannelState.JOINED.value and not done.done():
|
|
69
|
+
done.set_result(None)
|
|
70
|
+
elif value in (ChannelState.DECLINED.value, ChannelState.CLOSED.value) and not done.done():
|
|
71
|
+
done.set_exception(
|
|
72
|
+
RuntimeError(f"RTC channel join failed for {self._channel_name}: {value}")
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
unsubscribe = self._channel.on_channel_state_change(handle_state)
|
|
76
|
+
try:
|
|
77
|
+
self._channel.join()
|
|
78
|
+
await asyncio.wait_for(done, timeout=self._join_timeout)
|
|
79
|
+
finally:
|
|
80
|
+
unsubscribe()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self._channel.leave()
|
|
84
|
+
|
|
85
|
+
def on_event(self, handler: Callable[[WireEvent], None]) -> Unsubscribe:
|
|
86
|
+
def callback(message: Any) -> None:
|
|
87
|
+
handler(
|
|
88
|
+
WireEvent(
|
|
89
|
+
type=str(getattr(message, "event", "")),
|
|
90
|
+
data=_payload_dict(getattr(message, "payload", None)),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return self._channel.on_message(callback)
|
|
95
|
+
|
|
96
|
+
def on(self, event_name: str, handler: Callable[[dict[str, Any]], None]) -> Unsubscribe:
|
|
97
|
+
def callback(message: Any) -> None:
|
|
98
|
+
handler(_payload_dict(getattr(message, "payload", None)))
|
|
99
|
+
|
|
100
|
+
return self._channel.on_message_event(event_name, callback)
|
|
101
|
+
|
|
102
|
+
def send_control(self, event: str, payload: Mapping[str, Any] | None = None) -> None:
|
|
103
|
+
self._channel.send_message(event, dict(payload or {}))
|
|
104
|
+
|
|
105
|
+
def configure(self, config: SessionConfig) -> None:
|
|
106
|
+
session: dict[str, Any] = dict(config.extra)
|
|
107
|
+
if config.stt_model is not None:
|
|
108
|
+
session["stt_model"] = config.stt_model
|
|
109
|
+
if config.tts_model is not None:
|
|
110
|
+
session["tts_model"] = config.tts_model
|
|
111
|
+
if config.voice is not None:
|
|
112
|
+
session["voice"] = config.voice
|
|
113
|
+
if config.turn_profile is not None:
|
|
114
|
+
session["turn_profile"] = config.turn_profile
|
|
115
|
+
if config.vad_backend is not None:
|
|
116
|
+
session["vad_backend"] = config.vad_backend
|
|
117
|
+
if config.turn_detector is not None:
|
|
118
|
+
session["turn_detector"] = config.turn_detector
|
|
119
|
+
self.send_control("session.update", {"session": session})
|
|
120
|
+
|
|
121
|
+
def start_response(self, options: ResponseOptions | None = None) -> None:
|
|
122
|
+
self.send_control("response.start", _response_options_payload(options))
|
|
123
|
+
|
|
124
|
+
def append_response_text(
|
|
125
|
+
self,
|
|
126
|
+
delta: str,
|
|
127
|
+
options: ResponseOptions | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
payload = _response_options_payload(options)
|
|
130
|
+
payload["delta"] = delta
|
|
131
|
+
self.send_control("response.delta", payload)
|
|
132
|
+
|
|
133
|
+
def commit_response(self) -> None:
|
|
134
|
+
self.send_control("response.commit")
|
|
135
|
+
|
|
136
|
+
def cancel_response(self) -> None:
|
|
137
|
+
self.send_control("response.cancel")
|
|
138
|
+
|
|
139
|
+
def send_text_response(
|
|
140
|
+
self,
|
|
141
|
+
text: str,
|
|
142
|
+
options: ResponseOptions | None = None,
|
|
143
|
+
*,
|
|
144
|
+
cancel_first: bool = True,
|
|
145
|
+
) -> None:
|
|
146
|
+
if cancel_first:
|
|
147
|
+
self.cancel_response()
|
|
148
|
+
self.start_response(options)
|
|
149
|
+
self.append_response_text(text, options)
|
|
150
|
+
self.commit_response()
|
|
151
|
+
|
|
152
|
+
def send_client_event(self, envelope: ClientEventEnvelope) -> None:
|
|
153
|
+
self.send_control(
|
|
154
|
+
"client.event",
|
|
155
|
+
{"event": envelope.event, "payload": envelope.payload},
|
|
156
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any, Protocol, TypeAlias
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConnectionState(StrEnum):
|
|
10
|
+
DISCONNECTED = "disconnected"
|
|
11
|
+
CONNECTING = "connecting"
|
|
12
|
+
CONNECTED = "connected"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChannelState(StrEnum):
|
|
16
|
+
IDLE = "IDLE"
|
|
17
|
+
JOINING = "JOINING"
|
|
18
|
+
JOINED = "JOINED"
|
|
19
|
+
STALLED = "STALLED"
|
|
20
|
+
CLOSED = "CLOSED"
|
|
21
|
+
DECLINED = "DECLINED"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class RTCIceServer:
|
|
26
|
+
urls: str | list[str]
|
|
27
|
+
username: str | None = None
|
|
28
|
+
credential: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class SessionBootstrap:
|
|
33
|
+
session_id: str
|
|
34
|
+
client_token: str
|
|
35
|
+
expires_at: str
|
|
36
|
+
join_token_ttl_seconds: int
|
|
37
|
+
ice_servers: list[RTCIceServer] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class SessionConfig:
|
|
42
|
+
stt_model: str | None = None
|
|
43
|
+
tts_model: str | None = None
|
|
44
|
+
voice: str | None = None
|
|
45
|
+
turn_profile: str | None = None
|
|
46
|
+
vad_backend: str | None = None
|
|
47
|
+
turn_detector: str | None = None
|
|
48
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class ResponseOptions:
|
|
53
|
+
allow_interruptions: bool | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class ClientEventEnvelope:
|
|
58
|
+
event: str
|
|
59
|
+
payload: Any = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class WireEvent:
|
|
64
|
+
type: str
|
|
65
|
+
data: dict[str, Any]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
Unsubscribe: TypeAlias = Callable[[], None]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServerMessageLike(Protocol):
|
|
72
|
+
@property
|
|
73
|
+
def event(self) -> str: ...
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def payload(self) -> Any: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SocketChannelLike(Protocol):
|
|
80
|
+
def join(self) -> None: ...
|
|
81
|
+
|
|
82
|
+
def leave(self) -> None: ...
|
|
83
|
+
|
|
84
|
+
def send_message(self, event: str, payload: Mapping[str, Any] | None = None) -> None: ...
|
|
85
|
+
|
|
86
|
+
def on_message(self, callback: Callable[[ServerMessageLike], None]) -> Unsubscribe: ...
|
|
87
|
+
|
|
88
|
+
def on_message_event(
|
|
89
|
+
self, event_name: str, callback: Callable[[ServerMessageLike], None]
|
|
90
|
+
) -> Unsubscribe: ...
|
|
91
|
+
|
|
92
|
+
def on_channel_state_change(
|
|
93
|
+
self, callback: Callable[[Any], None]
|
|
94
|
+
) -> Unsubscribe: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SocketClientLike(Protocol):
|
|
98
|
+
async def connect(self) -> None: ...
|
|
99
|
+
|
|
100
|
+
async def disconnect(self) -> None: ...
|
|
101
|
+
|
|
102
|
+
def get_state(self) -> Any: ...
|
|
103
|
+
|
|
104
|
+
def create_channel(
|
|
105
|
+
self, name: str, params: Mapping[str, Any] | None = None
|
|
106
|
+
) -> SocketChannelLike: ...
|
|
107
|
+
|
|
108
|
+
def on_connection_change(self, callback: Callable[[Any], None]) -> Unsubscribe: ...
|
|
109
|
+
|
|
110
|
+
def on_error(self, callback: Callable[[BaseException], None]) -> Unsubscribe: ...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
SocketClientFactory: TypeAlias = Callable[
|
|
114
|
+
[str, Mapping[str, Any], float, float],
|
|
115
|
+
SocketClientLike,
|
|
116
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from vox_rtc_server import (
|
|
10
|
+
ChannelState,
|
|
11
|
+
ClientEventEnvelope,
|
|
12
|
+
ConnectionState,
|
|
13
|
+
SessionConfig,
|
|
14
|
+
VoxRtcServerClient,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FakeServerMessage:
|
|
20
|
+
event: str
|
|
21
|
+
payload: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeChannel:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.sent: list[tuple[str, dict[str, Any]]] = []
|
|
27
|
+
self.state_handlers: list[Callable[[Any], None]] = []
|
|
28
|
+
self.message_handlers: list[Callable[[FakeServerMessage], None]] = []
|
|
29
|
+
self.message_event_handlers: dict[str, list[Callable[[FakeServerMessage], None]]] = {}
|
|
30
|
+
|
|
31
|
+
def join(self) -> None:
|
|
32
|
+
for handler in list(self.state_handlers):
|
|
33
|
+
handler(ChannelState.JOINED)
|
|
34
|
+
|
|
35
|
+
def leave(self) -> None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def send_message(self, event: str, payload: dict[str, Any] | None = None) -> None:
|
|
39
|
+
self.sent.append((event, dict(payload or {})))
|
|
40
|
+
|
|
41
|
+
def on_message(self, callback: Callable[[FakeServerMessage], None]) -> Callable[[], None]:
|
|
42
|
+
self.message_handlers.append(callback)
|
|
43
|
+
return lambda: self.message_handlers.remove(callback)
|
|
44
|
+
|
|
45
|
+
def on_message_event(
|
|
46
|
+
self, event_name: str, callback: Callable[[FakeServerMessage], None]
|
|
47
|
+
) -> Callable[[], None]:
|
|
48
|
+
handlers = self.message_event_handlers.setdefault(event_name, [])
|
|
49
|
+
handlers.append(callback)
|
|
50
|
+
return lambda: handlers.remove(callback)
|
|
51
|
+
|
|
52
|
+
def on_channel_state_change(self, callback: Callable[[Any], None]) -> Callable[[], None]:
|
|
53
|
+
self.state_handlers.append(callback)
|
|
54
|
+
return lambda: self.state_handlers.remove(callback)
|
|
55
|
+
|
|
56
|
+
def emit(self, event: str, payload: dict[str, Any]) -> None:
|
|
57
|
+
message = FakeServerMessage(event=event, payload=payload)
|
|
58
|
+
for handler in list(self.message_handlers):
|
|
59
|
+
handler(message)
|
|
60
|
+
for handler in list(self.message_event_handlers.get(event, [])):
|
|
61
|
+
handler(message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FakeSocket:
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self.state = ConnectionState.DISCONNECTED
|
|
67
|
+
self.channel = FakeChannel()
|
|
68
|
+
self.connection_handlers: list[Callable[[Any], None]] = []
|
|
69
|
+
self.error_handlers: list[Callable[[BaseException], None]] = []
|
|
70
|
+
|
|
71
|
+
async def connect(self) -> None:
|
|
72
|
+
self.state = ConnectionState.CONNECTED
|
|
73
|
+
for handler in list(self.connection_handlers):
|
|
74
|
+
handler(self.state)
|
|
75
|
+
|
|
76
|
+
async def disconnect(self) -> None:
|
|
77
|
+
self.state = ConnectionState.DISCONNECTED
|
|
78
|
+
for handler in list(self.connection_handlers):
|
|
79
|
+
handler(self.state)
|
|
80
|
+
|
|
81
|
+
def get_state(self) -> ConnectionState:
|
|
82
|
+
return self.state
|
|
83
|
+
|
|
84
|
+
def create_channel(self, name: str, params: dict[str, Any] | None = None) -> FakeChannel:
|
|
85
|
+
assert name == "/rtc/rtc_123"
|
|
86
|
+
return self.channel
|
|
87
|
+
|
|
88
|
+
def on_connection_change(self, callback: Callable[[Any], None]) -> Callable[[], None]:
|
|
89
|
+
self.connection_handlers.append(callback)
|
|
90
|
+
return lambda: self.connection_handlers.remove(callback)
|
|
91
|
+
|
|
92
|
+
def on_error(self, callback: Callable[[BaseException], None]) -> Callable[[], None]:
|
|
93
|
+
self.error_handlers.append(callback)
|
|
94
|
+
return lambda: self.error_handlers.remove(callback)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FakeHTTPResponse:
|
|
98
|
+
def __init__(self, status: int, payload: dict[str, Any]) -> None:
|
|
99
|
+
self.status = status
|
|
100
|
+
self._body = json.dumps(payload).encode("utf-8")
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> FakeHTTPResponse:
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def read(self) -> bytes:
|
|
109
|
+
return self._body
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_create_session_parses_the_rtc_bootstrap_response(monkeypatch: Any) -> None:
|
|
113
|
+
monkeypatch.setenv("VOX_API_KEY", "secret")
|
|
114
|
+
|
|
115
|
+
def fake_urlopen(request: Any, timeout: float | None = None) -> FakeHTTPResponse:
|
|
116
|
+
assert request.full_url == "https://vox.example.com/v1/rtc/sessions"
|
|
117
|
+
assert request.method == "POST"
|
|
118
|
+
assert timeout == 15.0
|
|
119
|
+
assert json.loads(request.data.decode("utf-8")) == {}
|
|
120
|
+
assert request.get_header("Authorization") == "Bearer secret"
|
|
121
|
+
return FakeHTTPResponse(
|
|
122
|
+
200,
|
|
123
|
+
{
|
|
124
|
+
"session_id": "rtc_123",
|
|
125
|
+
"client_token": "tok_123",
|
|
126
|
+
"expires_at": "2026-01-01T00:00:00Z",
|
|
127
|
+
"join_token_ttl_seconds": 120,
|
|
128
|
+
"ice_servers": [{"urls": ["stun:turn.example.com:3478"]}],
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
client = VoxRtcServerClient(
|
|
133
|
+
http_base="https://vox.example.com/",
|
|
134
|
+
urlopen_impl=fake_urlopen,
|
|
135
|
+
socket_factory=lambda *args: FakeSocket(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
bootstrap = asyncio.run(client.create_session())
|
|
139
|
+
|
|
140
|
+
assert bootstrap.session_id == "rtc_123"
|
|
141
|
+
assert bootstrap.client_token == "tok_123"
|
|
142
|
+
assert bootstrap.join_token_ttl_seconds == 120
|
|
143
|
+
assert client.http_base == "https://vox.example.com"
|
|
144
|
+
assert client.socket_base == "https://vox.example.com/v1/socket"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_attach_session_joins_and_sends_expected_control_messages() -> None:
|
|
148
|
+
fake_socket = FakeSocket()
|
|
149
|
+
captured_params: dict[str, Any] = {}
|
|
150
|
+
client = VoxRtcServerClient(
|
|
151
|
+
http_base="https://vox.example.com",
|
|
152
|
+
api_key="secret",
|
|
153
|
+
socket_factory=lambda _endpoint, params, *_args: captured_params.update(params) or fake_socket,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
session = asyncio.run(client.attach_session("rtc_123"))
|
|
157
|
+
session.configure(
|
|
158
|
+
SessionConfig(
|
|
159
|
+
stt_model="stt",
|
|
160
|
+
tts_model="tts",
|
|
161
|
+
voice="voice",
|
|
162
|
+
turn_profile="browser_default",
|
|
163
|
+
vad_backend="silero",
|
|
164
|
+
turn_detector="livekit",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
session.send_text_response("Hello")
|
|
168
|
+
session.send_client_event(
|
|
169
|
+
ClientEventEnvelope(
|
|
170
|
+
event="render.url",
|
|
171
|
+
payload={"url": "https://example.com"},
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
assert [event for event, _payload in fake_socket.channel.sent] == [
|
|
176
|
+
"session.update",
|
|
177
|
+
"response.cancel",
|
|
178
|
+
"response.start",
|
|
179
|
+
"response.delta",
|
|
180
|
+
"response.commit",
|
|
181
|
+
"client.event",
|
|
182
|
+
]
|
|
183
|
+
assert fake_socket.channel.sent[0][1] == {
|
|
184
|
+
"session": {
|
|
185
|
+
"stt_model": "stt",
|
|
186
|
+
"tts_model": "tts",
|
|
187
|
+
"voice": "voice",
|
|
188
|
+
"turn_profile": "browser_default",
|
|
189
|
+
"vad_backend": "silero",
|
|
190
|
+
"turn_detector": "livekit",
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
assert captured_params == {"api_key": "secret"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_on_event_maps_socket_messages_into_wire_events() -> None:
|
|
197
|
+
fake_socket = FakeSocket()
|
|
198
|
+
client = VoxRtcServerClient(
|
|
199
|
+
http_base="https://vox.example.com",
|
|
200
|
+
socket_factory=lambda *args: fake_socket,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
session = asyncio.run(client.attach_session("rtc_123"))
|
|
204
|
+
received: list[tuple[str, dict[str, Any]]] = []
|
|
205
|
+
unsubscribe = session.on_event(lambda event: received.append((event.type, event.data)))
|
|
206
|
+
|
|
207
|
+
fake_socket.channel.emit(
|
|
208
|
+
"turn.state_changed",
|
|
209
|
+
{"state": "speaking", "session_id": "rtc_123"},
|
|
210
|
+
)
|
|
211
|
+
unsubscribe()
|
|
212
|
+
|
|
213
|
+
assert received == [
|
|
214
|
+
("turn.state_changed", {"state": "speaking", "session_id": "rtc_123"})
|
|
215
|
+
]
|