spatius 1.0.0__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.
- spatius/__init__.py +34 -0
- spatius/audio_encoder.py +304 -0
- spatius/avatar_session.py +1086 -0
- spatius/errors.py +130 -0
- spatius/logid.py +30 -0
- spatius/proto/__init__.py +1 -0
- spatius/proto/generated/__init__.py +1 -0
- spatius/proto/generated/message_pb2.py +65 -0
- spatius/py.typed +0 -0
- spatius/session_config.py +274 -0
- spatius-1.0.0.dist-info/METADATA +86 -0
- spatius-1.0.0.dist-info/RECORD +14 -0
- spatius-1.0.0.dist-info/WHEEL +4 -0
- spatius-1.0.0.dist-info/licenses/LICENSE +21 -0
spatius/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Spatius Python SDK for avatar sessions."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .avatar_session import AvatarSession
|
|
6
|
+
from .errors import AvatarSDKError, AvatarSDKErrorCode, SessionTokenError
|
|
7
|
+
from .session_config import (
|
|
8
|
+
AudioFormat,
|
|
9
|
+
OggOpusEncoderConfig,
|
|
10
|
+
SessionConfig,
|
|
11
|
+
LiveKitEgressConfig,
|
|
12
|
+
AgoraEgressConfig,
|
|
13
|
+
new_avatar_session,
|
|
14
|
+
)
|
|
15
|
+
from .logid import generate_log_id
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version("spatius")
|
|
19
|
+
except PackageNotFoundError: # pragma: no cover - only when imported without install
|
|
20
|
+
__version__ = "0+unknown"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AvatarSession",
|
|
24
|
+
"SessionTokenError",
|
|
25
|
+
"AvatarSDKError",
|
|
26
|
+
"AvatarSDKErrorCode",
|
|
27
|
+
"new_avatar_session",
|
|
28
|
+
"AudioFormat",
|
|
29
|
+
"OggOpusEncoderConfig",
|
|
30
|
+
"SessionConfig",
|
|
31
|
+
"LiveKitEgressConfig",
|
|
32
|
+
"AgoraEgressConfig",
|
|
33
|
+
"generate_log_id",
|
|
34
|
+
]
|
spatius/audio_encoder.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Helpers for optional client-side Ogg Opus encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
import struct
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class EncodedAudioChunk:
|
|
17
|
+
"""
|
|
18
|
+
Result of one internal Ogg Opus encoder step.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
payload: Encoded bytes ready to send to the service.
|
|
22
|
+
completed_stream: Full encoded stream when collection is enabled and the request
|
|
23
|
+
has ended. Otherwise ``None``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
payload: bytes
|
|
27
|
+
completed_stream: Optional[bytes] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OggOpusStreamEncoder:
|
|
31
|
+
"""
|
|
32
|
+
Streaming mono PCM to Ogg Opus encoder used by ``AvatarSession``.
|
|
33
|
+
|
|
34
|
+
This class is an internal implementation detail behind
|
|
35
|
+
``OggOpusEncoderConfig``. It accepts 16-bit little-endian mono PCM and emits Ogg
|
|
36
|
+
pages that can be sent incrementally to the avatar service.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_ALLOWED_SAMPLE_RATES = {8000, 12000, 16000, 24000, 48000}
|
|
40
|
+
_ALLOWED_FRAME_DURATIONS_MS = {10, 20, 40, 60}
|
|
41
|
+
_APPLICATIONS = {"audio", "voip", "restricted_lowdelay"}
|
|
42
|
+
_DEFAULT_PRE_SKIP = 312
|
|
43
|
+
_VENDOR = b"spatiussdk"
|
|
44
|
+
_CRC_POLY = 0x04C11DB7
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
sample_rate: int,
|
|
50
|
+
bitrate: int,
|
|
51
|
+
frame_duration_ms: int,
|
|
52
|
+
application: str,
|
|
53
|
+
collect_encoded_output: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._validate_config(sample_rate, frame_duration_ms, application)
|
|
56
|
+
|
|
57
|
+
self._sample_rate = sample_rate
|
|
58
|
+
self._frame_duration_ms = frame_duration_ms
|
|
59
|
+
self._application = application
|
|
60
|
+
self._frame_size = sample_rate * frame_duration_ms // 1000
|
|
61
|
+
self._frame_bytes = self._frame_size * 2
|
|
62
|
+
self._sample_scale = 48000 // sample_rate
|
|
63
|
+
self._pre_skip = self._DEFAULT_PRE_SKIP
|
|
64
|
+
self._pcm_buffer = bytearray()
|
|
65
|
+
self._pending_packet: Optional[bytes] = None
|
|
66
|
+
self._pending_granule = 0
|
|
67
|
+
self._headers_emitted = False
|
|
68
|
+
self._page_sequence = 0
|
|
69
|
+
self._stream_serial = secrets.randbits(32)
|
|
70
|
+
self._total_input_samples = 0
|
|
71
|
+
self._encoded_output: Optional[bytearray] = (
|
|
72
|
+
bytearray() if collect_encoded_output else None
|
|
73
|
+
)
|
|
74
|
+
self._encoder = self._create_encoder(sample_rate, bitrate, application)
|
|
75
|
+
|
|
76
|
+
def encode(self, pcm_data: bytes, *, end: bool) -> EncodedAudioChunk:
|
|
77
|
+
if len(pcm_data) % 2 != 0:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"PCM input for internal Ogg Opus encoder must be 16-bit aligned"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if pcm_data:
|
|
83
|
+
self._pcm_buffer.extend(pcm_data)
|
|
84
|
+
|
|
85
|
+
payload = bytearray()
|
|
86
|
+
self._encode_full_frames(payload)
|
|
87
|
+
|
|
88
|
+
if end:
|
|
89
|
+
self._flush_final_frame(payload)
|
|
90
|
+
self._finalize_stream(payload)
|
|
91
|
+
|
|
92
|
+
completed_stream = None
|
|
93
|
+
if end and self._encoded_output:
|
|
94
|
+
completed_stream = bytes(self._encoded_output)
|
|
95
|
+
|
|
96
|
+
return EncodedAudioChunk(
|
|
97
|
+
payload=bytes(payload), completed_stream=completed_stream
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _encode_full_frames(self, payload: bytearray) -> None:
|
|
101
|
+
while len(self._pcm_buffer) >= self._frame_bytes:
|
|
102
|
+
frame = bytes(self._pcm_buffer[: self._frame_bytes])
|
|
103
|
+
del self._pcm_buffer[: self._frame_bytes]
|
|
104
|
+
|
|
105
|
+
self._queue_audio_packet(payload, frame, self._frame_size)
|
|
106
|
+
|
|
107
|
+
def _flush_final_frame(self, payload: bytearray) -> None:
|
|
108
|
+
if not self._pcm_buffer:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
actual_samples = len(self._pcm_buffer) // 2
|
|
112
|
+
frame = bytes(self._pcm_buffer)
|
|
113
|
+
frame += b"\x00" * (self._frame_bytes - len(frame))
|
|
114
|
+
self._pcm_buffer.clear()
|
|
115
|
+
|
|
116
|
+
self._queue_audio_packet(payload, frame, actual_samples)
|
|
117
|
+
|
|
118
|
+
def _queue_audio_packet(
|
|
119
|
+
self, payload: bytearray, pcm_frame: bytes, actual_samples: int
|
|
120
|
+
) -> None:
|
|
121
|
+
if not self._headers_emitted:
|
|
122
|
+
self._emit_headers(payload)
|
|
123
|
+
|
|
124
|
+
packet = self._encoder.encode(pcm_frame, self._frame_size)
|
|
125
|
+
self._total_input_samples += actual_samples
|
|
126
|
+
granule = self._pre_skip + self._total_input_samples * self._sample_scale
|
|
127
|
+
|
|
128
|
+
if self._pending_packet is not None:
|
|
129
|
+
self._write_page(payload, self._pending_packet, self._pending_granule)
|
|
130
|
+
|
|
131
|
+
self._pending_packet = packet
|
|
132
|
+
self._pending_granule = granule
|
|
133
|
+
|
|
134
|
+
def _finalize_stream(self, payload: bytearray) -> None:
|
|
135
|
+
if self._pending_packet is not None:
|
|
136
|
+
self._write_page(
|
|
137
|
+
payload,
|
|
138
|
+
self._pending_packet,
|
|
139
|
+
self._pending_granule,
|
|
140
|
+
end_of_stream=True,
|
|
141
|
+
)
|
|
142
|
+
self._pending_packet = None
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if self._headers_emitted:
|
|
146
|
+
self._write_page(payload, b"", self._pre_skip, end_of_stream=True)
|
|
147
|
+
|
|
148
|
+
def _emit_headers(self, payload: bytearray) -> None:
|
|
149
|
+
self._headers_emitted = True
|
|
150
|
+
self._write_page(payload, self._build_opus_head(), 0, begin_of_stream=True)
|
|
151
|
+
self._write_page(payload, self._build_opus_tags(), 0)
|
|
152
|
+
|
|
153
|
+
def _write_page(
|
|
154
|
+
self,
|
|
155
|
+
payload: bytearray,
|
|
156
|
+
packet: bytes,
|
|
157
|
+
granule_position: int,
|
|
158
|
+
*,
|
|
159
|
+
begin_of_stream: bool = False,
|
|
160
|
+
end_of_stream: bool = False,
|
|
161
|
+
) -> None:
|
|
162
|
+
page = self._build_ogg_page(
|
|
163
|
+
packet,
|
|
164
|
+
granule_position,
|
|
165
|
+
begin_of_stream=begin_of_stream,
|
|
166
|
+
end_of_stream=end_of_stream,
|
|
167
|
+
)
|
|
168
|
+
payload.extend(page)
|
|
169
|
+
if self._encoded_output is not None:
|
|
170
|
+
self._encoded_output.extend(page)
|
|
171
|
+
|
|
172
|
+
def _build_ogg_page(
|
|
173
|
+
self,
|
|
174
|
+
packet: bytes,
|
|
175
|
+
granule_position: int,
|
|
176
|
+
*,
|
|
177
|
+
begin_of_stream: bool = False,
|
|
178
|
+
end_of_stream: bool = False,
|
|
179
|
+
) -> bytes:
|
|
180
|
+
header_type = 0
|
|
181
|
+
if begin_of_stream:
|
|
182
|
+
header_type |= 0x02
|
|
183
|
+
if end_of_stream:
|
|
184
|
+
header_type |= 0x04
|
|
185
|
+
|
|
186
|
+
lacing_values = self._build_lacing_values(packet)
|
|
187
|
+
header = bytearray()
|
|
188
|
+
header.extend(b"OggS")
|
|
189
|
+
header.append(0)
|
|
190
|
+
header.append(header_type)
|
|
191
|
+
header.extend(struct.pack("<Q", granule_position))
|
|
192
|
+
header.extend(struct.pack("<I", self._stream_serial))
|
|
193
|
+
header.extend(struct.pack("<I", self._page_sequence))
|
|
194
|
+
header.extend(b"\x00\x00\x00\x00")
|
|
195
|
+
header.append(len(lacing_values))
|
|
196
|
+
header.extend(lacing_values)
|
|
197
|
+
|
|
198
|
+
page = bytes(header) + packet
|
|
199
|
+
checksum = self._ogg_crc(page)
|
|
200
|
+
|
|
201
|
+
header[22:26] = struct.pack("<I", checksum)
|
|
202
|
+
self._page_sequence += 1
|
|
203
|
+
|
|
204
|
+
return bytes(header) + packet
|
|
205
|
+
|
|
206
|
+
def _build_opus_head(self) -> bytes:
|
|
207
|
+
packet = bytearray()
|
|
208
|
+
packet.extend(b"OpusHead")
|
|
209
|
+
packet.append(1)
|
|
210
|
+
packet.append(1)
|
|
211
|
+
packet.extend(struct.pack("<H", self._pre_skip))
|
|
212
|
+
packet.extend(struct.pack("<I", self._sample_rate))
|
|
213
|
+
packet.extend(struct.pack("<h", 0))
|
|
214
|
+
packet.append(0)
|
|
215
|
+
|
|
216
|
+
return bytes(packet)
|
|
217
|
+
|
|
218
|
+
def _build_opus_tags(self) -> bytes:
|
|
219
|
+
packet = bytearray()
|
|
220
|
+
packet.extend(b"OpusTags")
|
|
221
|
+
packet.extend(struct.pack("<I", len(self._VENDOR)))
|
|
222
|
+
packet.extend(self._VENDOR)
|
|
223
|
+
packet.extend(struct.pack("<I", 0))
|
|
224
|
+
|
|
225
|
+
return bytes(packet)
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def _build_lacing_values(cls, packet: bytes) -> bytes:
|
|
229
|
+
if not packet:
|
|
230
|
+
return b""
|
|
231
|
+
|
|
232
|
+
size = len(packet)
|
|
233
|
+
segments = bytearray()
|
|
234
|
+
while size >= 255:
|
|
235
|
+
segments.append(255)
|
|
236
|
+
size -= 255
|
|
237
|
+
|
|
238
|
+
segments.append(size)
|
|
239
|
+
if len(packet) % 255 == 0:
|
|
240
|
+
segments.append(0)
|
|
241
|
+
|
|
242
|
+
return bytes(segments)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def _validate_config(
|
|
246
|
+
cls, sample_rate: int, frame_duration_ms: int, application: str
|
|
247
|
+
) -> None:
|
|
248
|
+
if sample_rate not in cls._ALLOWED_SAMPLE_RATES:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
"Internal Ogg Opus encoder supports sample rates: "
|
|
251
|
+
+ ", ".join(str(rate) for rate in sorted(cls._ALLOWED_SAMPLE_RATES))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if frame_duration_ms not in cls._ALLOWED_FRAME_DURATIONS_MS:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
"Internal Ogg Opus encoder supports frame durations: "
|
|
257
|
+
+ ", ".join(
|
|
258
|
+
str(duration)
|
|
259
|
+
for duration in sorted(cls._ALLOWED_FRAME_DURATIONS_MS)
|
|
260
|
+
)
|
|
261
|
+
+ " ms"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if application not in cls._APPLICATIONS:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"Internal Ogg Opus encoder application must be one of: "
|
|
267
|
+
+ ", ".join(sorted(cls._APPLICATIONS))
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _create_encoder(sample_rate: int, bitrate: int, application: str):
|
|
272
|
+
try:
|
|
273
|
+
import opuslib
|
|
274
|
+
except ImportError as exc: # pragma: no cover - exercised by runtime users
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
"Internal Ogg Opus encoding requires the optional opus dependency. "
|
|
277
|
+
"Install spatius[opus] to enable it."
|
|
278
|
+
) from exc
|
|
279
|
+
|
|
280
|
+
encoder = opuslib.Encoder(sample_rate, 1, application)
|
|
281
|
+
if bitrate > 0:
|
|
282
|
+
try:
|
|
283
|
+
encoder.bitrate = bitrate
|
|
284
|
+
except opuslib.exceptions.OpusError as exc:
|
|
285
|
+
logger.warning(
|
|
286
|
+
"Failed to set Opus encoder bitrate; using encoder default instead",
|
|
287
|
+
extra={"bitrate": bitrate},
|
|
288
|
+
exc_info=exc,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return encoder
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def _ogg_crc(cls, data: bytes) -> int:
|
|
295
|
+
crc = 0
|
|
296
|
+
for byte in data:
|
|
297
|
+
crc ^= byte << 24
|
|
298
|
+
for _ in range(8):
|
|
299
|
+
if crc & 0x80000000:
|
|
300
|
+
crc = ((crc << 1) ^ cls._CRC_POLY) & 0xFFFFFFFF
|
|
301
|
+
else:
|
|
302
|
+
crc = (crc << 1) & 0xFFFFFFFF
|
|
303
|
+
|
|
304
|
+
return crc
|