vox-rtc-server 0.1.1__py3-none-any.whl

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.
@@ -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,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,8 @@
1
+ vox_rtc_server/__init__.py,sha256=bUpCrq4dkT_V9gkkFPgsujgvTzUny9pjTxrrVkxw3ig,585
2
+ vox_rtc_server/client.py,sha256=O3tw5ixLge0ru8ajpEkKJ4k9EHyMvPLNhyNcZ7DwQ7I,6219
3
+ vox_rtc_server/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ vox_rtc_server/session.py,sha256=fQasNrcuLzpizdVJTwiigou9D4LSr6tx9KE7mq8n_fA,5087
5
+ vox_rtc_server/types.py,sha256=36J5U8NMFSMjWjR4K6gbOC2fz4bKe-CE3ES18oJACxI,2675
6
+ vox_rtc_server-0.1.1.dist-info/METADATA,sha256=It5BLjSMa1GUqjDWexaYP1CzJLUScSU05SggLZ1ZG-0,1953
7
+ vox_rtc_server-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ vox_rtc_server-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any