orbitalsai 1.0.0__py3-none-any.whl → 1.2.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.
- orbitalsai/__init__.py +26 -3
- orbitalsai/async_client.py +25 -1
- orbitalsai/client.py +26 -50
- orbitalsai/models.py +10 -0
- orbitalsai/streaming/__init__.py +117 -0
- orbitalsai/streaming/async_client.py +507 -0
- orbitalsai/streaming/audio/__init__.py +33 -0
- orbitalsai/streaming/audio/buffer.py +171 -0
- orbitalsai/streaming/audio/converter.py +327 -0
- orbitalsai/streaming/audio/formats.py +112 -0
- orbitalsai/streaming/audio/source.py +317 -0
- orbitalsai/streaming/client.py +384 -0
- orbitalsai/streaming/config.py +207 -0
- orbitalsai/streaming/connection.py +298 -0
- orbitalsai/streaming/events.py +360 -0
- orbitalsai/streaming/exceptions.py +179 -0
- orbitalsai/streaming/protocol.py +245 -0
- orbitalsai-1.2.0.dist-info/METADATA +850 -0
- orbitalsai-1.2.0.dist-info/RECORD +24 -0
- {orbitalsai-1.0.0.dist-info → orbitalsai-1.2.0.dist-info}/WHEEL +1 -1
- orbitalsai-1.0.0.dist-info/METADATA +0 -439
- orbitalsai-1.0.0.dist-info/RECORD +0 -11
- {orbitalsai-1.0.0.dist-info → orbitalsai-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {orbitalsai-1.0.0.dist-info → orbitalsai-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrbitalsAI Async Streaming Client
|
|
3
|
+
|
|
4
|
+
Asynchronous WebSocket client for real-time streaming transcription.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import websockets
|
|
14
|
+
from websockets.exceptions import (
|
|
15
|
+
ConnectionClosed,
|
|
16
|
+
ConnectionClosedOK,
|
|
17
|
+
ConnectionClosedError,
|
|
18
|
+
InvalidStatusCode,
|
|
19
|
+
)
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"websockets is required for streaming. "
|
|
23
|
+
"Install it with: pip install websockets>=11.0.0"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .config import StreamingConfig
|
|
27
|
+
from .events import StreamingEventHandlers
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
StreamingError,
|
|
30
|
+
ConnectionError,
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
AudioFormatError,
|
|
33
|
+
ReconnectionFailedError,
|
|
34
|
+
SessionClosedError,
|
|
35
|
+
ProtocolError,
|
|
36
|
+
exception_from_close_code,
|
|
37
|
+
)
|
|
38
|
+
from .protocol import (
|
|
39
|
+
MessageType,
|
|
40
|
+
MessageParser,
|
|
41
|
+
create_websocket_url,
|
|
42
|
+
should_retry,
|
|
43
|
+
get_close_reason,
|
|
44
|
+
)
|
|
45
|
+
from .connection import ConnectionManager, ConnectionState
|
|
46
|
+
from .audio import AudioBuffer
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("orbitalsai.streaming")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AsyncStreamingClient:
|
|
52
|
+
"""
|
|
53
|
+
Asynchronous WebSocket client for streaming transcription.
|
|
54
|
+
|
|
55
|
+
Provides real-time audio streaming and transcription via WebSocket.
|
|
56
|
+
Supports automatic reconnection, event callbacks, and graceful shutdown.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
async with AsyncStreamingClient(api_key="your_key") as client:
|
|
60
|
+
await client.connect(PrintingEventHandlers())
|
|
61
|
+
|
|
62
|
+
# Stream audio file
|
|
63
|
+
with open("audio.pcm", "rb") as f:
|
|
64
|
+
while chunk := f.read(16000):
|
|
65
|
+
await client.send_audio(chunk)
|
|
66
|
+
|
|
67
|
+
await client.flush()
|
|
68
|
+
|
|
69
|
+
Example with custom handlers:
|
|
70
|
+
class MyHandlers(StreamingEventHandlers):
|
|
71
|
+
def on_transcript_final(self, text, metadata):
|
|
72
|
+
print(f"Transcription: {text}")
|
|
73
|
+
|
|
74
|
+
client = AsyncStreamingClient(api_key="your_key")
|
|
75
|
+
await client.connect(MyHandlers())
|
|
76
|
+
# ... send audio ...
|
|
77
|
+
await client.disconnect()
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
api_key: str,
|
|
83
|
+
config: Optional[StreamingConfig] = None,
|
|
84
|
+
base_url: str = "wss://api.orbitalsai.com"
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Initialize the async streaming client.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
api_key: OrbitalsAI API key or JWT token
|
|
91
|
+
config: Streaming configuration (optional)
|
|
92
|
+
base_url: WebSocket base URL (default: wss://api.orbitalsai.com)
|
|
93
|
+
"""
|
|
94
|
+
self.api_key = api_key
|
|
95
|
+
self.config = config or StreamingConfig()
|
|
96
|
+
self.base_url = base_url
|
|
97
|
+
|
|
98
|
+
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
99
|
+
self._handlers: Optional[StreamingEventHandlers] = None
|
|
100
|
+
self._receiver_task: Optional[asyncio.Task] = None
|
|
101
|
+
self._keepalive_task: Optional[asyncio.Task] = None
|
|
102
|
+
self._connected = False
|
|
103
|
+
self._session_id: Optional[str] = None
|
|
104
|
+
self._audio_buffer: Optional[AudioBuffer] = None
|
|
105
|
+
|
|
106
|
+
# Connection management
|
|
107
|
+
self._connection_manager = ConnectionManager(
|
|
108
|
+
max_retries=self.config.max_retries,
|
|
109
|
+
base_delay=self.config.retry_delay,
|
|
110
|
+
max_delay=self.config.max_retry_delay,
|
|
111
|
+
connection_timeout=self.config.connection_timeout,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Reconnection state
|
|
115
|
+
self._should_reconnect = True
|
|
116
|
+
self._reconnect_lock = asyncio.Lock()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_connected(self) -> bool:
|
|
120
|
+
"""Check if WebSocket is connected."""
|
|
121
|
+
return self._connected and self._ws is not None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def session_id(self) -> Optional[str]:
|
|
125
|
+
"""Get current session ID."""
|
|
126
|
+
return self._session_id
|
|
127
|
+
|
|
128
|
+
async def connect(self, handlers: StreamingEventHandlers) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Establish WebSocket connection and start receiver loop.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
handlers: Event handlers for callbacks
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ConnectionError: If connection fails
|
|
137
|
+
AuthenticationError: If authentication fails
|
|
138
|
+
"""
|
|
139
|
+
self._handlers = handlers
|
|
140
|
+
self._should_reconnect = True
|
|
141
|
+
|
|
142
|
+
await self._connect_internal()
|
|
143
|
+
|
|
144
|
+
async def _connect_internal(self) -> None:
|
|
145
|
+
"""Internal connection method."""
|
|
146
|
+
self._connection_manager.mark_connecting()
|
|
147
|
+
|
|
148
|
+
# Build WebSocket URL with API key
|
|
149
|
+
ws_url = create_websocket_url(self.base_url, self.api_key)
|
|
150
|
+
|
|
151
|
+
logger.info(f"Connecting to {self.base_url}...")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# Connect with timeout
|
|
155
|
+
self._ws = await asyncio.wait_for(
|
|
156
|
+
websockets.connect(
|
|
157
|
+
ws_url,
|
|
158
|
+
ping_interval=self.config.keepalive_interval,
|
|
159
|
+
ping_timeout=20,
|
|
160
|
+
close_timeout=10,
|
|
161
|
+
max_size=10 * 1024 * 1024, # 10MB max message
|
|
162
|
+
),
|
|
163
|
+
timeout=self.config.connection_timeout
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Wait for ready message
|
|
167
|
+
ready_msg = await asyncio.wait_for(
|
|
168
|
+
self._ws.recv(),
|
|
169
|
+
timeout=self.config.connection_timeout
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Parse ready message
|
|
173
|
+
try:
|
|
174
|
+
message = MessageParser.parse(ready_msg)
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
raise ProtocolError(f"Invalid ready message: {e}")
|
|
177
|
+
|
|
178
|
+
if message.type == MessageType.ERROR:
|
|
179
|
+
error_msg = message.error_message or "Connection error"
|
|
180
|
+
raise ConnectionError(error_msg)
|
|
181
|
+
|
|
182
|
+
if message.type != MessageType.READY:
|
|
183
|
+
raise ProtocolError(f"Expected 'ready' message, got '{message.type.value}'")
|
|
184
|
+
|
|
185
|
+
# Extract session info
|
|
186
|
+
self._session_id = message.session_id
|
|
187
|
+
self._connected = True
|
|
188
|
+
self._connection_manager.mark_connected()
|
|
189
|
+
|
|
190
|
+
# Initialize audio buffer
|
|
191
|
+
self._audio_buffer = AudioBuffer(
|
|
192
|
+
chunk_size=self.config.chunk_size,
|
|
193
|
+
sample_rate=self.config.sample_rate
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
logger.info(f"Connected: session_id={self._session_id}")
|
|
197
|
+
|
|
198
|
+
# Call on_open handler
|
|
199
|
+
self._safe_call_handler(
|
|
200
|
+
"on_open",
|
|
201
|
+
{
|
|
202
|
+
"session_id": message.session_id,
|
|
203
|
+
"language": message.language,
|
|
204
|
+
"supported_languages": message.supported_languages,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Start receiver loop
|
|
209
|
+
self._receiver_task = asyncio.create_task(self._receiver_loop())
|
|
210
|
+
|
|
211
|
+
except asyncio.TimeoutError:
|
|
212
|
+
raise ConnectionError("Connection timeout")
|
|
213
|
+
except InvalidStatusCode as e:
|
|
214
|
+
if e.status_code == 401:
|
|
215
|
+
raise AuthenticationError("Invalid API key")
|
|
216
|
+
raise ConnectionError(f"Connection failed: HTTP {e.status_code}")
|
|
217
|
+
except ConnectionClosedError as e:
|
|
218
|
+
self._connection_manager.record_close(e.code, e.reason)
|
|
219
|
+
raise exception_from_close_code(e.code, e.reason)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
if not isinstance(e, StreamingError):
|
|
222
|
+
raise ConnectionError(f"Connection failed: {e}")
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
async def send_audio(self, audio_data: bytes) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Send PCM16 audio chunk.
|
|
228
|
+
|
|
229
|
+
Audio should be PCM16 mono little-endian format. The audio will be
|
|
230
|
+
buffered and sent in optimal chunk sizes.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
audio_data: Raw PCM16 mono little-endian bytes
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
SessionClosedError: If session is closed
|
|
237
|
+
AudioFormatError: If audio format is invalid
|
|
238
|
+
"""
|
|
239
|
+
if not self.is_connected:
|
|
240
|
+
raise SessionClosedError("Session is not connected")
|
|
241
|
+
|
|
242
|
+
# Validate audio format
|
|
243
|
+
if len(audio_data) % 2 != 0:
|
|
244
|
+
raise AudioFormatError(
|
|
245
|
+
f"Audio data must have even length (PCM16), got {len(audio_data)}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
# Send directly without additional buffering
|
|
250
|
+
# The server handles its own buffering
|
|
251
|
+
await self._ws.send(audio_data)
|
|
252
|
+
except ConnectionClosed as e:
|
|
253
|
+
self._handle_connection_closed(e)
|
|
254
|
+
raise SessionClosedError("Connection closed while sending audio")
|
|
255
|
+
|
|
256
|
+
async def configure(
|
|
257
|
+
self,
|
|
258
|
+
language: Optional[str] = None,
|
|
259
|
+
sample_rate: Optional[int] = None
|
|
260
|
+
) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Update session configuration dynamically.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
language: New transcription language (optional)
|
|
266
|
+
sample_rate: New sample rate in Hz (optional)
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
SessionClosedError: If session is closed
|
|
270
|
+
"""
|
|
271
|
+
if not self.is_connected:
|
|
272
|
+
raise SessionClosedError("Session is not connected")
|
|
273
|
+
|
|
274
|
+
config_msg = MessageParser.encode_config(
|
|
275
|
+
language=language,
|
|
276
|
+
sample_rate=sample_rate
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
await self._ws.send(config_msg)
|
|
281
|
+
except ConnectionClosed as e:
|
|
282
|
+
self._handle_connection_closed(e)
|
|
283
|
+
raise SessionClosedError("Connection closed while configuring")
|
|
284
|
+
|
|
285
|
+
async def flush(self) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Force transcription of remaining audio buffer.
|
|
288
|
+
|
|
289
|
+
Sends a flush command to process any buffered audio on the server.
|
|
290
|
+
The server will emit final transcripts for any remaining audio.
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
SessionClosedError: If session is closed
|
|
294
|
+
"""
|
|
295
|
+
if not self.is_connected:
|
|
296
|
+
raise SessionClosedError("Session is not connected")
|
|
297
|
+
|
|
298
|
+
# Flush local buffer first
|
|
299
|
+
if self._audio_buffer:
|
|
300
|
+
remaining = self._audio_buffer.flush()
|
|
301
|
+
if remaining:
|
|
302
|
+
try:
|
|
303
|
+
await self._ws.send(remaining)
|
|
304
|
+
except ConnectionClosed:
|
|
305
|
+
pass # Will be handled by server flush
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
flush_msg = MessageParser.encode_flush()
|
|
309
|
+
await self._ws.send(flush_msg)
|
|
310
|
+
except ConnectionClosed as e:
|
|
311
|
+
self._handle_connection_closed(e)
|
|
312
|
+
raise SessionClosedError("Connection closed while flushing")
|
|
313
|
+
|
|
314
|
+
async def disconnect(self) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Close connection gracefully.
|
|
317
|
+
|
|
318
|
+
Stops the receiver loop and closes the WebSocket connection.
|
|
319
|
+
"""
|
|
320
|
+
self._should_reconnect = False
|
|
321
|
+
self._connected = False
|
|
322
|
+
|
|
323
|
+
# Cancel receiver task
|
|
324
|
+
if self._receiver_task and not self._receiver_task.done():
|
|
325
|
+
self._receiver_task.cancel()
|
|
326
|
+
try:
|
|
327
|
+
await self._receiver_task
|
|
328
|
+
except asyncio.CancelledError:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# Cancel keepalive task
|
|
332
|
+
if self._keepalive_task and not self._keepalive_task.done():
|
|
333
|
+
self._keepalive_task.cancel()
|
|
334
|
+
try:
|
|
335
|
+
await self._keepalive_task
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Close WebSocket
|
|
340
|
+
if self._ws:
|
|
341
|
+
try:
|
|
342
|
+
await self._ws.close(code=1000, reason="Client disconnect")
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.debug(f"Error closing WebSocket: {e}")
|
|
345
|
+
self._ws = None
|
|
346
|
+
|
|
347
|
+
self._connection_manager.mark_closed()
|
|
348
|
+
logger.info("Disconnected")
|
|
349
|
+
|
|
350
|
+
async def _receiver_loop(self) -> None:
|
|
351
|
+
"""Background task to receive and dispatch messages."""
|
|
352
|
+
try:
|
|
353
|
+
async for message in self._ws:
|
|
354
|
+
await self._handle_message(message)
|
|
355
|
+
except ConnectionClosedOK:
|
|
356
|
+
logger.info("Connection closed normally")
|
|
357
|
+
self._safe_call_handler("on_close", 1000, "Normal closure")
|
|
358
|
+
except ConnectionClosedError as e:
|
|
359
|
+
logger.warning(f"Connection closed: code={e.code}, reason={e.reason}")
|
|
360
|
+
self._handle_connection_closed(e)
|
|
361
|
+
except asyncio.CancelledError:
|
|
362
|
+
logger.debug("Receiver loop cancelled")
|
|
363
|
+
raise
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.exception(f"Receiver loop error: {e}")
|
|
366
|
+
self._safe_call_handler("on_error", e)
|
|
367
|
+
finally:
|
|
368
|
+
self._connected = False
|
|
369
|
+
|
|
370
|
+
async def _handle_message(self, raw_message: str) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Handle a received WebSocket message.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
raw_message: Raw message string from WebSocket
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
message = MessageParser.parse(raw_message)
|
|
379
|
+
except ValueError as e:
|
|
380
|
+
logger.warning(f"Invalid message: {e}")
|
|
381
|
+
self._safe_call_handler("on_error", ProtocolError(str(e)))
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Dispatch based on message type
|
|
385
|
+
msg_type = message.type
|
|
386
|
+
|
|
387
|
+
if msg_type == MessageType.PARTIAL:
|
|
388
|
+
self._safe_call_handler("on_transcript_partial", message.text or "")
|
|
389
|
+
|
|
390
|
+
elif msg_type == MessageType.FINAL:
|
|
391
|
+
metadata = {
|
|
392
|
+
"cost": message.cost or 0,
|
|
393
|
+
"audio_seconds": message.audio_seconds or 0,
|
|
394
|
+
"remaining_percent": message.remaining_percent or 100,
|
|
395
|
+
"capped": message.capped,
|
|
396
|
+
}
|
|
397
|
+
self._safe_call_handler("on_transcript_final", message.text or "", metadata)
|
|
398
|
+
|
|
399
|
+
elif msg_type == MessageType.SPEECH_START:
|
|
400
|
+
self._safe_call_handler("on_speech_start")
|
|
401
|
+
|
|
402
|
+
elif msg_type == MessageType.SPEECH_END:
|
|
403
|
+
self._safe_call_handler("on_speech_end")
|
|
404
|
+
|
|
405
|
+
elif msg_type == MessageType.LANGUAGE_SET:
|
|
406
|
+
self._safe_call_handler("on_language_detected", message.language or "")
|
|
407
|
+
|
|
408
|
+
elif msg_type == MessageType.SAMPLE_RATE_SET:
|
|
409
|
+
self._safe_call_handler("on_sample_rate_changed", message.sample_rate or 16000)
|
|
410
|
+
|
|
411
|
+
elif msg_type == MessageType.FLUSHED:
|
|
412
|
+
self._safe_call_handler("on_flushed")
|
|
413
|
+
|
|
414
|
+
elif msg_type == MessageType.CREDITS_WARNING:
|
|
415
|
+
self._safe_call_handler("on_credits_warning", message.remaining_percent or 0)
|
|
416
|
+
|
|
417
|
+
elif msg_type == MessageType.CREDITS_CRITICAL:
|
|
418
|
+
self._safe_call_handler("on_credits_critical", message.remaining_percent or 0)
|
|
419
|
+
|
|
420
|
+
elif msg_type == MessageType.CREDITS_EXHAUSTED:
|
|
421
|
+
self._safe_call_handler("on_credits_exhausted")
|
|
422
|
+
|
|
423
|
+
elif msg_type == MessageType.ERROR:
|
|
424
|
+
error = StreamingError(message.error_message or "Unknown error")
|
|
425
|
+
self._safe_call_handler("on_error", error)
|
|
426
|
+
|
|
427
|
+
def _safe_call_handler(self, handler_name: str, *args) -> None:
|
|
428
|
+
"""
|
|
429
|
+
Safely call an event handler.
|
|
430
|
+
|
|
431
|
+
Wraps handler calls in try/except to prevent user code from
|
|
432
|
+
crashing the SDK.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
handler_name: Name of handler method
|
|
436
|
+
*args: Arguments to pass to handler
|
|
437
|
+
"""
|
|
438
|
+
if not self._handlers:
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
handler = getattr(self._handlers, handler_name, None)
|
|
442
|
+
if not handler:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
handler(*args)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.warning(f"Handler {handler_name} raised exception: {e}")
|
|
449
|
+
|
|
450
|
+
def _handle_connection_closed(self, error: ConnectionClosed) -> None:
|
|
451
|
+
"""
|
|
452
|
+
Handle connection closure.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
error: The connection closed error
|
|
456
|
+
"""
|
|
457
|
+
self._connected = False
|
|
458
|
+
self._connection_manager.record_close(error.code, error.reason)
|
|
459
|
+
|
|
460
|
+
# Call on_close handler
|
|
461
|
+
self._safe_call_handler("on_close", error.code, error.reason)
|
|
462
|
+
|
|
463
|
+
# Check for non-retryable errors
|
|
464
|
+
if not should_retry(error.code):
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# Attempt reconnection if enabled
|
|
468
|
+
if self._should_reconnect:
|
|
469
|
+
asyncio.create_task(self._attempt_reconnect())
|
|
470
|
+
|
|
471
|
+
async def _attempt_reconnect(self) -> None:
|
|
472
|
+
"""Attempt to reconnect after connection loss."""
|
|
473
|
+
async with self._reconnect_lock:
|
|
474
|
+
if not self._should_reconnect:
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
while self._should_reconnect and self._connection_manager.should_reconnect():
|
|
478
|
+
self._connection_manager.increment_retry()
|
|
479
|
+
logger.info(
|
|
480
|
+
f"Reconnection attempt {self._connection_manager.retry_count}/"
|
|
481
|
+
f"{self.config.max_retries}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
await self._connection_manager.wait_for_retry()
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
await self._connect_internal()
|
|
488
|
+
logger.info("Reconnected successfully")
|
|
489
|
+
return
|
|
490
|
+
except Exception as e:
|
|
491
|
+
logger.warning(f"Reconnection failed: {e}")
|
|
492
|
+
|
|
493
|
+
# Failed to reconnect
|
|
494
|
+
if self._should_reconnect:
|
|
495
|
+
error = ReconnectionFailedError(
|
|
496
|
+
f"Failed to reconnect after {self._connection_manager.retry_count} attempts",
|
|
497
|
+
attempts=self._connection_manager.retry_count
|
|
498
|
+
)
|
|
499
|
+
self._safe_call_handler("on_error", error)
|
|
500
|
+
|
|
501
|
+
async def __aenter__(self) -> 'AsyncStreamingClient':
|
|
502
|
+
"""Async context manager entry."""
|
|
503
|
+
return self
|
|
504
|
+
|
|
505
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
506
|
+
"""Async context manager exit."""
|
|
507
|
+
await self.disconnect()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrbitalsAI Streaming Audio Utilities
|
|
3
|
+
|
|
4
|
+
Audio processing utilities for streaming transcription.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .buffer import AudioBuffer
|
|
8
|
+
from .converter import AudioConverter
|
|
9
|
+
from .formats import (
|
|
10
|
+
AudioFormat,
|
|
11
|
+
PCM16_MONO,
|
|
12
|
+
get_format_for_file,
|
|
13
|
+
SUPPORTED_AUDIO_EXTENSIONS,
|
|
14
|
+
)
|
|
15
|
+
from .source import (
|
|
16
|
+
AudioSource,
|
|
17
|
+
FileAudioSource,
|
|
18
|
+
RawPCMFileSource,
|
|
19
|
+
MicrophoneSource,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AudioBuffer",
|
|
24
|
+
"AudioConverter",
|
|
25
|
+
"AudioFormat",
|
|
26
|
+
"PCM16_MONO",
|
|
27
|
+
"get_format_for_file",
|
|
28
|
+
"SUPPORTED_AUDIO_EXTENSIONS",
|
|
29
|
+
"AudioSource",
|
|
30
|
+
"FileAudioSource",
|
|
31
|
+
"RawPCMFileSource",
|
|
32
|
+
"MicrophoneSource",
|
|
33
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrbitalsAI Streaming Audio Buffer
|
|
3
|
+
|
|
4
|
+
Audio chunking buffer for streaming transcription.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("orbitalsai.streaming")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AudioBuffer:
|
|
14
|
+
"""
|
|
15
|
+
Manages audio chunking for streaming.
|
|
16
|
+
|
|
17
|
+
Accumulates audio data and returns complete chunks of the specified size.
|
|
18
|
+
Any remaining data is kept in the buffer until more audio is added or
|
|
19
|
+
the buffer is flushed.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
buffer = AudioBuffer(chunk_size=8000, sample_rate=16000)
|
|
23
|
+
|
|
24
|
+
# Add audio and get complete chunks
|
|
25
|
+
chunks = buffer.add(audio_bytes)
|
|
26
|
+
for chunk in chunks:
|
|
27
|
+
await client.send_audio(chunk)
|
|
28
|
+
|
|
29
|
+
# Flush remaining audio at the end
|
|
30
|
+
remaining = buffer.flush()
|
|
31
|
+
if remaining:
|
|
32
|
+
await client.send_audio(remaining)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, chunk_size: int, sample_rate: int):
|
|
36
|
+
"""
|
|
37
|
+
Initialize audio buffer.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
chunk_size: Number of samples per chunk
|
|
41
|
+
sample_rate: Audio sample rate in Hz
|
|
42
|
+
"""
|
|
43
|
+
self.chunk_size = chunk_size
|
|
44
|
+
self.sample_rate = sample_rate
|
|
45
|
+
self._buffer = bytearray()
|
|
46
|
+
self._bytes_per_chunk = chunk_size * 2 # 2 bytes per int16 sample
|
|
47
|
+
self._total_samples_processed = 0
|
|
48
|
+
|
|
49
|
+
def add(self, audio_data: bytes) -> List[bytes]:
|
|
50
|
+
"""
|
|
51
|
+
Add audio data and return complete chunks.
|
|
52
|
+
|
|
53
|
+
Adds audio to the internal buffer and extracts any complete chunks.
|
|
54
|
+
Remaining data stays in the buffer for the next call.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
audio_data: Raw PCM16 mono little-endian bytes
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of complete chunk bytes (each bytes_per_chunk size)
|
|
61
|
+
"""
|
|
62
|
+
if not audio_data:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
# Ensure even length (PCM16 = 2 bytes per sample)
|
|
66
|
+
if len(audio_data) % 2 != 0:
|
|
67
|
+
logger.warning(
|
|
68
|
+
f"Audio data has odd length ({len(audio_data)}), "
|
|
69
|
+
f"truncating last byte"
|
|
70
|
+
)
|
|
71
|
+
audio_data = audio_data[:-1]
|
|
72
|
+
|
|
73
|
+
self._buffer.extend(audio_data)
|
|
74
|
+
|
|
75
|
+
chunks = []
|
|
76
|
+
while len(self._buffer) >= self._bytes_per_chunk:
|
|
77
|
+
chunk = bytes(self._buffer[:self._bytes_per_chunk])
|
|
78
|
+
self._buffer = self._buffer[self._bytes_per_chunk:]
|
|
79
|
+
chunks.append(chunk)
|
|
80
|
+
self._total_samples_processed += self.chunk_size
|
|
81
|
+
|
|
82
|
+
return chunks
|
|
83
|
+
|
|
84
|
+
def flush(self) -> Optional[bytes]:
|
|
85
|
+
"""
|
|
86
|
+
Flush any remaining audio in the buffer.
|
|
87
|
+
|
|
88
|
+
Returns the remaining audio data (may be smaller than chunk_size).
|
|
89
|
+
After flushing, the buffer is empty.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Remaining audio bytes, or None if buffer is empty
|
|
93
|
+
"""
|
|
94
|
+
if not self._buffer:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
remaining = bytes(self._buffer)
|
|
98
|
+
samples = len(remaining) // 2
|
|
99
|
+
self._total_samples_processed += samples
|
|
100
|
+
self._buffer = bytearray()
|
|
101
|
+
|
|
102
|
+
return remaining
|
|
103
|
+
|
|
104
|
+
def clear(self) -> None:
|
|
105
|
+
"""Clear the buffer without returning data."""
|
|
106
|
+
self._buffer = bytearray()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def buffered_duration_ms(self) -> float:
|
|
110
|
+
"""
|
|
111
|
+
Get duration of buffered audio in milliseconds.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Buffered audio duration in milliseconds
|
|
115
|
+
"""
|
|
116
|
+
samples = len(self._buffer) // 2
|
|
117
|
+
return (samples / self.sample_rate) * 1000
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def buffered_samples(self) -> int:
|
|
121
|
+
"""
|
|
122
|
+
Get number of buffered samples.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Number of samples in buffer
|
|
126
|
+
"""
|
|
127
|
+
return len(self._buffer) // 2
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def buffered_bytes(self) -> int:
|
|
131
|
+
"""
|
|
132
|
+
Get number of buffered bytes.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Number of bytes in buffer
|
|
136
|
+
"""
|
|
137
|
+
return len(self._buffer)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def is_empty(self) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Check if buffer is empty.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if buffer is empty, False otherwise
|
|
146
|
+
"""
|
|
147
|
+
return len(self._buffer) == 0
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def total_duration_ms(self) -> float:
|
|
151
|
+
"""
|
|
152
|
+
Get total duration of audio processed (including flushed).
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Total processed duration in milliseconds
|
|
156
|
+
"""
|
|
157
|
+
return (self._total_samples_processed / self.sample_rate) * 1000
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def total_samples(self) -> int:
|
|
161
|
+
"""
|
|
162
|
+
Get total number of samples processed.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Total number of samples processed
|
|
166
|
+
"""
|
|
167
|
+
return self._total_samples_processed
|
|
168
|
+
|
|
169
|
+
def reset_stats(self) -> None:
|
|
170
|
+
"""Reset statistics (total_samples_processed)."""
|
|
171
|
+
self._total_samples_processed = 0
|