orbitalsai 1.1.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 +24 -2
- 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.1.0.dist-info → orbitalsai-1.2.0.dist-info}/WHEEL +1 -1
- orbitalsai-1.1.0.dist-info/METADATA +0 -491
- orbitalsai-1.1.0.dist-info/RECORD +0 -11
- {orbitalsai-1.1.0.dist-info → orbitalsai-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {orbitalsai-1.1.0.dist-info → orbitalsai-1.2.0.dist-info}/top_level.txt +0 -0
orbitalsai/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@ OrbitalsAI Python SDK
|
|
|
3
3
|
|
|
4
4
|
A simple and powerful Python SDK for the OrbitalsAI API.
|
|
5
5
|
|
|
6
|
-
Example:
|
|
6
|
+
Example (Batch Transcription):
|
|
7
7
|
import orbitalsai
|
|
8
8
|
|
|
9
9
|
# Synchronous usage
|
|
@@ -15,6 +15,22 @@ Example:
|
|
|
15
15
|
async with orbitalsai.AsyncClient(api_key="your_api_key_here") as client:
|
|
16
16
|
transcript = await client.transcribe("audio.mp3")
|
|
17
17
|
print(transcript.text)
|
|
18
|
+
|
|
19
|
+
Example (Real-time Streaming):
|
|
20
|
+
from orbitalsai.streaming import AsyncStreamingClient, PrintingEventHandlers
|
|
21
|
+
import asyncio
|
|
22
|
+
|
|
23
|
+
async def main():
|
|
24
|
+
async with AsyncStreamingClient(api_key="your_key") as client:
|
|
25
|
+
await client.connect(PrintingEventHandlers())
|
|
26
|
+
|
|
27
|
+
with open("audio.pcm", "rb") as f:
|
|
28
|
+
while chunk := f.read(16000):
|
|
29
|
+
await client.send_audio(chunk)
|
|
30
|
+
|
|
31
|
+
await client.flush()
|
|
32
|
+
|
|
33
|
+
asyncio.run(main())
|
|
18
34
|
"""
|
|
19
35
|
|
|
20
36
|
from .client import Client
|
|
@@ -29,7 +45,10 @@ from .exceptions import (
|
|
|
29
45
|
TaskNotFoundError, TranscriptionError, TimeoutError, APIError
|
|
30
46
|
)
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
# Streaming module (imported as submodule)
|
|
49
|
+
from . import streaming
|
|
50
|
+
|
|
51
|
+
__version__ = "2.0.0"
|
|
33
52
|
__author__ = "OrbitalsAI"
|
|
34
53
|
__email__ = "support@orbitalsai.com"
|
|
35
54
|
|
|
@@ -38,6 +57,9 @@ __all__ = [
|
|
|
38
57
|
"Client",
|
|
39
58
|
"AsyncClient",
|
|
40
59
|
|
|
60
|
+
# Streaming Module
|
|
61
|
+
"streaming",
|
|
62
|
+
|
|
41
63
|
# Models
|
|
42
64
|
"TranscriptTask",
|
|
43
65
|
"Transcript",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrbitalsAI Streaming Module
|
|
3
|
+
|
|
4
|
+
Real-time streaming transcription via WebSocket.
|
|
5
|
+
|
|
6
|
+
Example (Async - Recommended):
|
|
7
|
+
import asyncio
|
|
8
|
+
from orbitalsai.streaming import AsyncStreamingClient, PrintingEventHandlers
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
async with AsyncStreamingClient(api_key="your_key") as client:
|
|
12
|
+
await client.connect(PrintingEventHandlers())
|
|
13
|
+
|
|
14
|
+
# Stream audio file
|
|
15
|
+
with open("audio.pcm", "rb") as f:
|
|
16
|
+
while chunk := f.read(16000):
|
|
17
|
+
await client.send_audio(chunk)
|
|
18
|
+
|
|
19
|
+
await client.flush()
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
22
|
+
|
|
23
|
+
Example (Sync):
|
|
24
|
+
import time
|
|
25
|
+
from orbitalsai.streaming import StreamingClient, StreamingEventHandlers
|
|
26
|
+
|
|
27
|
+
class MyHandlers(StreamingEventHandlers):
|
|
28
|
+
def on_transcript_final(self, transcript, metadata):
|
|
29
|
+
print(f"Final: {transcript}")
|
|
30
|
+
print(f"Cost: ${metadata['cost']:.4f}")
|
|
31
|
+
|
|
32
|
+
with StreamingClient(api_key="your_key") as client:
|
|
33
|
+
client.connect(MyHandlers())
|
|
34
|
+
|
|
35
|
+
with open("audio.pcm", "rb") as f:
|
|
36
|
+
while chunk := f.read(16000):
|
|
37
|
+
client.send_audio(chunk)
|
|
38
|
+
time.sleep(0.5) # Real-time pacing
|
|
39
|
+
|
|
40
|
+
client.flush()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from .config import (
|
|
44
|
+
StreamingConfig,
|
|
45
|
+
STREAMING_SUPPORTED_LANGUAGES,
|
|
46
|
+
DEFAULT_SAMPLE_RATE,
|
|
47
|
+
DEFAULT_CHUNK_SIZE,
|
|
48
|
+
)
|
|
49
|
+
from .events import (
|
|
50
|
+
StreamingEventHandlers,
|
|
51
|
+
PrintingEventHandlers,
|
|
52
|
+
CallbackEventHandlers,
|
|
53
|
+
)
|
|
54
|
+
from .exceptions import (
|
|
55
|
+
StreamingError,
|
|
56
|
+
ConnectionError,
|
|
57
|
+
AuthenticationError,
|
|
58
|
+
AudioFormatError,
|
|
59
|
+
InsufficientCreditsError,
|
|
60
|
+
ReconnectionFailedError,
|
|
61
|
+
ServiceUnavailableError,
|
|
62
|
+
ServerBusyError,
|
|
63
|
+
SessionClosedError,
|
|
64
|
+
ProtocolError,
|
|
65
|
+
)
|
|
66
|
+
from .async_client import AsyncStreamingClient
|
|
67
|
+
from .client import StreamingClient, StreamingTranscriptAccumulator
|
|
68
|
+
from .audio import (
|
|
69
|
+
AudioBuffer,
|
|
70
|
+
AudioConverter,
|
|
71
|
+
AudioFormat,
|
|
72
|
+
PCM16_MONO,
|
|
73
|
+
AudioSource,
|
|
74
|
+
FileAudioSource,
|
|
75
|
+
RawPCMFileSource,
|
|
76
|
+
MicrophoneSource,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
# Clients
|
|
81
|
+
"AsyncStreamingClient",
|
|
82
|
+
"StreamingClient",
|
|
83
|
+
|
|
84
|
+
# Configuration
|
|
85
|
+
"StreamingConfig",
|
|
86
|
+
"STREAMING_SUPPORTED_LANGUAGES",
|
|
87
|
+
"DEFAULT_SAMPLE_RATE",
|
|
88
|
+
"DEFAULT_CHUNK_SIZE",
|
|
89
|
+
|
|
90
|
+
# Event Handlers
|
|
91
|
+
"StreamingEventHandlers",
|
|
92
|
+
"PrintingEventHandlers",
|
|
93
|
+
"CallbackEventHandlers",
|
|
94
|
+
"StreamingTranscriptAccumulator",
|
|
95
|
+
|
|
96
|
+
# Exceptions
|
|
97
|
+
"StreamingError",
|
|
98
|
+
"ConnectionError",
|
|
99
|
+
"AuthenticationError",
|
|
100
|
+
"AudioFormatError",
|
|
101
|
+
"InsufficientCreditsError",
|
|
102
|
+
"ReconnectionFailedError",
|
|
103
|
+
"ServiceUnavailableError",
|
|
104
|
+
"ServerBusyError",
|
|
105
|
+
"SessionClosedError",
|
|
106
|
+
"ProtocolError",
|
|
107
|
+
|
|
108
|
+
# Audio Utilities
|
|
109
|
+
"AudioBuffer",
|
|
110
|
+
"AudioConverter",
|
|
111
|
+
"AudioFormat",
|
|
112
|
+
"PCM16_MONO",
|
|
113
|
+
"AudioSource",
|
|
114
|
+
"FileAudioSource",
|
|
115
|
+
"RawPCMFileSource",
|
|
116
|
+
"MicrophoneSource",
|
|
117
|
+
]
|
|
@@ -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
|
+
]
|