spatialreal 0.1.0__tar.gz → 0.2.0__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.
- {spatialreal-0.1.0 → spatialreal-0.2.0}/PKG-INFO +16 -3
- {spatialreal-0.1.0 → spatialreal-0.2.0}/README.md +15 -2
- spatialreal-0.2.0/proto/SHARED_PROTO_COMMIT +1 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/config.py +5 -0
- spatialreal-0.2.0/spatialreal/environments.py +95 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/session.py +27 -8
- spatialreal-0.2.0/spatialreal/version.py +1 -0
- spatialreal-0.1.0/proto/SHARED_PROTO_COMMIT +0 -1
- spatialreal-0.1.0/spatialreal/version.py +0 -1
- {spatialreal-0.1.0 → spatialreal-0.2.0}/.gitignore +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/LICENSE +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/proto/driveningress/v2/driveningress.proto +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/pyproject.toml +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/__init__.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/errors.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/events.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/logid.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/proto/__init__.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/proto/generated/__init__.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/proto/generated/message_pb2.py +0 -0
- {spatialreal-0.1.0 → spatialreal-0.2.0}/spatialreal/proto/generated/message_pb2.pyi +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: spatialreal
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: SpatialReal Python SDK — realtime avatar sessions (audio in, animation/egress out)
|
|
5
5
|
Project-URL: Documentation, https://docs.spatialreal.com
|
|
6
6
|
Project-URL: Website, https://www.spatialreal.ai/
|
|
@@ -45,8 +45,6 @@ session = new_avatar_session(
|
|
|
45
45
|
api_key="...",
|
|
46
46
|
app_id="...",
|
|
47
47
|
avatar_id="...",
|
|
48
|
-
console_endpoint_url="https://api.spatialreal.cloud", # OpenAPI root: session tokens
|
|
49
|
-
ingress_endpoint_url="wss://driven.us-west.spatialreal.cloud/v2/driveningress",
|
|
50
48
|
expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
51
49
|
sample_rate=16000,
|
|
52
50
|
livekit_egress=LiveKitEgressConfig(url=..., api_token=..., room_name=..., publisher_id=...),
|
|
@@ -82,6 +80,19 @@ as `pause_timeout`). Older servers never send it and ignore pause/resume.
|
|
|
82
80
|
|
|
83
81
|
Session objects are single-use: on a dropped connection, create a fresh one
|
|
84
82
|
(request state is deliberately not reusable across connections).
|
|
83
|
+
## Endpoints
|
|
84
|
+
|
|
85
|
+
Endpoints come from the environment you name, so callers write no URLs:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
session = new_avatar_session(..., environment="us-west") # the default
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
They are resolved from SpatialReal's config service (built-in presets cover it being
|
|
92
|
+
unreachable), which is what lets a deployment move, or a region be added, without an
|
|
93
|
+
SDK release. Pass `console_endpoint_url` / `ingress_endpoint_url` to override either one
|
|
94
|
+
— useful against a local or private deployment.
|
|
95
|
+
|
|
85
96
|
`AvatarSDKError.retryable` tells you whether reconnecting can succeed — it
|
|
86
97
|
implements the server's WebSocket close-code contract (40xx: don't retry,
|
|
87
98
|
45xx: retry with backoff).
|
|
@@ -91,6 +102,8 @@ implements the server's WebSocket close-code contract (40xx: don't retry,
|
|
|
91
102
|
- `on_playback(PlaybackSignal)` — structured playback lifecycle; no protobuf
|
|
92
103
|
parsing in caller code (`transport_frames(raw, is_last)` still exists).
|
|
93
104
|
- `CloseCode` + `AvatarSDKError.retryable` — the reconnect contract as API.
|
|
105
|
+
- `environment="us-west"` — endpoints resolved from the config service instead of
|
|
106
|
+
hard-coded URLs; new regions need no SDK release.
|
|
94
107
|
- `session.capabilities` — server-declared capabilities from the handshake
|
|
95
108
|
(feature-detect, don't version-detect).
|
|
96
109
|
- `pause()` / `resume()` + `on_playback_state(PlaybackStateEvent)` — egress-mode
|
|
@@ -15,8 +15,6 @@ session = new_avatar_session(
|
|
|
15
15
|
api_key="...",
|
|
16
16
|
app_id="...",
|
|
17
17
|
avatar_id="...",
|
|
18
|
-
console_endpoint_url="https://api.spatialreal.cloud", # OpenAPI root: session tokens
|
|
19
|
-
ingress_endpoint_url="wss://driven.us-west.spatialreal.cloud/v2/driveningress",
|
|
20
18
|
expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
21
19
|
sample_rate=16000,
|
|
22
20
|
livekit_egress=LiveKitEgressConfig(url=..., api_token=..., room_name=..., publisher_id=...),
|
|
@@ -52,6 +50,19 @@ as `pause_timeout`). Older servers never send it and ignore pause/resume.
|
|
|
52
50
|
|
|
53
51
|
Session objects are single-use: on a dropped connection, create a fresh one
|
|
54
52
|
(request state is deliberately not reusable across connections).
|
|
53
|
+
## Endpoints
|
|
54
|
+
|
|
55
|
+
Endpoints come from the environment you name, so callers write no URLs:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
session = new_avatar_session(..., environment="us-west") # the default
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
They are resolved from SpatialReal's config service (built-in presets cover it being
|
|
62
|
+
unreachable), which is what lets a deployment move, or a region be added, without an
|
|
63
|
+
SDK release. Pass `console_endpoint_url` / `ingress_endpoint_url` to override either one
|
|
64
|
+
— useful against a local or private deployment.
|
|
65
|
+
|
|
55
66
|
`AvatarSDKError.retryable` tells you whether reconnecting can succeed — it
|
|
56
67
|
implements the server's WebSocket close-code contract (40xx: don't retry,
|
|
57
68
|
45xx: retry with backoff).
|
|
@@ -61,6 +72,8 @@ implements the server's WebSocket close-code contract (40xx: don't retry,
|
|
|
61
72
|
- `on_playback(PlaybackSignal)` — structured playback lifecycle; no protobuf
|
|
62
73
|
parsing in caller code (`transport_frames(raw, is_last)` still exists).
|
|
63
74
|
- `CloseCode` + `AvatarSDKError.retryable` — the reconnect contract as API.
|
|
75
|
+
- `environment="us-west"` — endpoints resolved from the config service instead of
|
|
76
|
+
hard-coded URLs; new regions need no SDK release.
|
|
64
77
|
- `session.capabilities` — server-declared capabilities from the handshake
|
|
65
78
|
(feature-detect, don't version-detect).
|
|
66
79
|
- `pause()` / `resume()` + `on_playback_state(PlaybackStateEvent)` — egress-mode
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
845c7f2b2b5c39994bffea3e589bbde51d80f379
|
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from enum import Enum
|
|
9
9
|
|
|
10
|
+
from .environments import DEFAULT_ENVIRONMENT
|
|
10
11
|
from .events import PlaybackSignal, PlaybackStateEvent
|
|
11
12
|
|
|
12
13
|
|
|
@@ -83,6 +84,10 @@ class SessionConfig:
|
|
|
83
84
|
sample_rate: int = 16000
|
|
84
85
|
bitrate: int = 0
|
|
85
86
|
audio_format: AudioFormat = AudioFormat.PCM_S16LE
|
|
87
|
+
# Which deployment to talk to. Endpoints are resolved from it (config service,
|
|
88
|
+
# then built-in presets), so callers normally set neither URL below.
|
|
89
|
+
environment: str = DEFAULT_ENVIRONMENT
|
|
90
|
+
# Explicit overrides; each one given wins over the environment's.
|
|
86
91
|
console_endpoint_url: str = ""
|
|
87
92
|
ingress_endpoint_url: str = ""
|
|
88
93
|
livekit_egress: LiveKitEgressConfig | None = None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Where a deployment lives, and how the SDK finds it.
|
|
2
|
+
|
|
3
|
+
Callers name an environment (``us-west``) instead of writing URLs, so adding a
|
|
4
|
+
region or moving a deployment is a config-service change rather than an SDK
|
|
5
|
+
release. The built-in presets below are only a fallback for when that service
|
|
6
|
+
cannot be reached — they are a copy, not the source of truth.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
CONFIG_SERVICE_URL = "https://config.spatialreal.cloud/sdk"
|
|
18
|
+
# The session must not hang on a config lookup; the presets cover the timeout.
|
|
19
|
+
CONFIG_SERVICE_TIMEOUT = 3.0
|
|
20
|
+
DEFAULT_ENVIRONMENT = "us-west"
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EnvironmentEndpoints:
|
|
27
|
+
"""Origins for one environment; the SDK appends the routes itself."""
|
|
28
|
+
|
|
29
|
+
# OpenAPI root — where session tokens come from.
|
|
30
|
+
api: str
|
|
31
|
+
# driven-ingress origin, without the /v2/driveningress path.
|
|
32
|
+
driveningress: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Public deployments only. Internal ones are reached by passing console_endpoint_url /
|
|
36
|
+
# ingress_endpoint_url explicitly, so naming them here cannot expose them to callers.
|
|
37
|
+
BUILTIN_ENVIRONMENTS: dict[str, EnvironmentEndpoints] = {
|
|
38
|
+
"us-west": EnvironmentEndpoints(
|
|
39
|
+
api="https://api.spatialreal.cloud",
|
|
40
|
+
driveningress="https://driven.us-west.spatialreal.cloud",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Fetched once per process: every session in it resolves against the same answer.
|
|
45
|
+
_cache: dict[str, dict[str, str]] | None = None
|
|
46
|
+
_lock = asyncio.Lock()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reset_cache() -> None:
|
|
50
|
+
"""Forget the fetched config (tests, or a long-lived process that must re-read)."""
|
|
51
|
+
global _cache
|
|
52
|
+
_cache = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _fetch() -> dict[str, dict[str, str]]:
|
|
56
|
+
"""The config service's environments, or {} if it cannot be read."""
|
|
57
|
+
timeout = aiohttp.ClientTimeout(total=CONFIG_SERVICE_TIMEOUT)
|
|
58
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
59
|
+
async with session.get(CONFIG_SERVICE_URL) as response:
|
|
60
|
+
if response.status != 200:
|
|
61
|
+
raise RuntimeError(f"config service returned {response.status}")
|
|
62
|
+
body = await response.json(content_type=None)
|
|
63
|
+
environments = body.get("environments") if isinstance(body, dict) else None
|
|
64
|
+
return environments if isinstance(environments, dict) else {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def resolve(environment: str) -> EnvironmentEndpoints:
|
|
68
|
+
"""Endpoints for ``environment``: the config service's, else the built-in preset.
|
|
69
|
+
|
|
70
|
+
Raises ValueError when neither knows the name, since a wrong environment is a
|
|
71
|
+
caller mistake worth surfacing rather than silently defaulting elsewhere.
|
|
72
|
+
"""
|
|
73
|
+
global _cache
|
|
74
|
+
|
|
75
|
+
if _cache is None:
|
|
76
|
+
async with _lock:
|
|
77
|
+
if _cache is None:
|
|
78
|
+
try:
|
|
79
|
+
_cache = await _fetch()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Not fatal: the presets are exactly what this service would serve
|
|
82
|
+
# today; a session should not fail because config lookup did.
|
|
83
|
+
logger.debug("SpatialReal config service unavailable (%s); using presets", e)
|
|
84
|
+
_cache = {}
|
|
85
|
+
|
|
86
|
+
preset = BUILTIN_ENVIRONMENTS.get(environment)
|
|
87
|
+
remote = _cache.get(environment) or {}
|
|
88
|
+
if not preset and not remote:
|
|
89
|
+
known = sorted(set(BUILTIN_ENVIRONMENTS) | set(_cache))
|
|
90
|
+
raise ValueError(f"Unknown environment {environment!r}; known environments: {known}")
|
|
91
|
+
|
|
92
|
+
return EnvironmentEndpoints(
|
|
93
|
+
api=remote.get("api") or (preset.api if preset else ""),
|
|
94
|
+
driveningress=remote.get("driveningress") or (preset.driveningress if preset else ""),
|
|
95
|
+
)
|
|
@@ -21,6 +21,7 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
|
21
21
|
import aiohttp
|
|
22
22
|
import websockets
|
|
23
23
|
|
|
24
|
+
from . import environments
|
|
24
25
|
from .config import AudioFormat, SessionConfig
|
|
25
26
|
from .errors import (
|
|
26
27
|
AvatarSDKError,
|
|
@@ -37,6 +38,7 @@ from .proto.generated import message_pb2
|
|
|
37
38
|
SESSION_TOKEN_PATH = "/v1/auth/session-token"
|
|
38
39
|
# Accepted for back-compat: callers used to pass the cp root with this suffix already on it.
|
|
39
40
|
LEGACY_CONSOLE_SUFFIX = "/v1/console"
|
|
41
|
+
DRIVENINGRESS_PATH = "/v2/driveningress"
|
|
40
42
|
INGRESS_WEBSOCKET_PATH = "/websocket"
|
|
41
43
|
|
|
42
44
|
logger = logging.getLogger(__name__)
|
|
@@ -62,6 +64,7 @@ class AvatarSession:
|
|
|
62
64
|
self._last_req_id: str | None = None
|
|
63
65
|
self._read_task: asyncio.Task | None = None
|
|
64
66
|
self._close_notified = False
|
|
67
|
+
self._resolved_endpoints: environments.EnvironmentEndpoints | None = None
|
|
65
68
|
|
|
66
69
|
# ------------------------------------------------------------------ props
|
|
67
70
|
|
|
@@ -83,18 +86,36 @@ class AvatarSession:
|
|
|
83
86
|
"""
|
|
84
87
|
return self._capabilities
|
|
85
88
|
|
|
89
|
+
# ------------------------------------------------------------- endpoints
|
|
90
|
+
|
|
91
|
+
async def _endpoints(self) -> environments.EnvironmentEndpoints:
|
|
92
|
+
if self._resolved_endpoints is None:
|
|
93
|
+
self._resolved_endpoints = await environments.resolve(self._config.environment)
|
|
94
|
+
return self._resolved_endpoints
|
|
95
|
+
|
|
96
|
+
async def _console_endpoint(self) -> str:
|
|
97
|
+
"""The OpenAPI root: the caller's if given, else the environment's."""
|
|
98
|
+
if self._config.console_endpoint_url:
|
|
99
|
+
return self._config.console_endpoint_url
|
|
100
|
+
return (await self._endpoints()).api
|
|
101
|
+
|
|
102
|
+
async def _ingress_endpoint(self) -> str:
|
|
103
|
+
"""The driven-ingress WebSocket URL, built from the environment's origin."""
|
|
104
|
+
if self._config.ingress_endpoint_url:
|
|
105
|
+
return self._config.ingress_endpoint_url
|
|
106
|
+
origin = (await self._endpoints()).driveningress.rstrip("/")
|
|
107
|
+
return origin + DRIVENINGRESS_PATH
|
|
108
|
+
|
|
86
109
|
# ------------------------------------------------------------------ init
|
|
87
110
|
|
|
88
111
|
async def init(self) -> None:
|
|
89
112
|
"""Exchange the API key for a session token via the console API."""
|
|
90
113
|
if not self._config.api_key:
|
|
91
114
|
raise ValueError("Missing API key")
|
|
92
|
-
if not self._config.console_endpoint_url:
|
|
93
|
-
raise ValueError("Missing console endpoint URL")
|
|
94
115
|
if not self._config.expire_at:
|
|
95
116
|
raise ValueError("Missing expire_at")
|
|
96
117
|
|
|
97
|
-
endpoint = _session_token_endpoint(self.
|
|
118
|
+
endpoint = _session_token_endpoint(await self._console_endpoint())
|
|
98
119
|
payload = {"expireAt": int(self._config.expire_at.timestamp())}
|
|
99
120
|
headers = {"X-Api-Key": self._config.api_key, "Content-Type": "application/json"}
|
|
100
121
|
timeout = aiohttp.ClientTimeout(total=self._config.token_request_timeout)
|
|
@@ -132,8 +153,6 @@ class AvatarSession:
|
|
|
132
153
|
raise ValueError("Session already started")
|
|
133
154
|
if not self._session_token:
|
|
134
155
|
raise ValueError("Session not initialized (call init() first)")
|
|
135
|
-
if not self._config.ingress_endpoint_url:
|
|
136
|
-
raise ValueError("Missing ingress endpoint URL")
|
|
137
156
|
if not self._config.avatar_id:
|
|
138
157
|
raise ValueError("Missing avatar ID")
|
|
139
158
|
if not self._config.app_id:
|
|
@@ -141,7 +160,7 @@ class AvatarSession:
|
|
|
141
160
|
if self._config.livekit_egress is not None and self._config.agora_egress is not None:
|
|
142
161
|
raise ValueError("Cannot configure both livekit_egress and agora_egress")
|
|
143
162
|
|
|
144
|
-
ws_url, headers = self._build_ws_target()
|
|
163
|
+
ws_url, headers = self._build_ws_target(await self._ingress_endpoint())
|
|
145
164
|
|
|
146
165
|
try:
|
|
147
166
|
self._connection = await _ws_connect(ws_url, headers)
|
|
@@ -163,8 +182,8 @@ class AvatarSession:
|
|
|
163
182
|
self._read_task = asyncio.create_task(self._read_loop())
|
|
164
183
|
return self._connection_id
|
|
165
184
|
|
|
166
|
-
def _build_ws_target(self) -> tuple[str, dict[str, str]]:
|
|
167
|
-
endpoint =
|
|
185
|
+
def _build_ws_target(self, ingress_endpoint_url: str) -> tuple[str, dict[str, str]]:
|
|
186
|
+
endpoint = ingress_endpoint_url.rstrip("/") + INGRESS_WEBSOCKET_PATH
|
|
168
187
|
parsed = urlparse(endpoint)
|
|
169
188
|
scheme = parsed.scheme.lower()
|
|
170
189
|
ws_scheme = {"http": "ws", "https": "wss", "ws": "ws", "wss": "wss"}.get(scheme)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
40f98c99c79c6a52fda9cac4b578a5bccc3f8542
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|