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 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
+ ]
@@ -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