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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrbitalsAI Streaming Protocol
|
|
3
|
+
|
|
4
|
+
Message encoding/decoding for the streaming WebSocket protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("orbitalsai.streaming")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessageType(Enum):
|
|
17
|
+
"""WebSocket message types for the streaming protocol."""
|
|
18
|
+
|
|
19
|
+
# Server → Client
|
|
20
|
+
READY = "ready"
|
|
21
|
+
PARTIAL = "partial"
|
|
22
|
+
FINAL = "final"
|
|
23
|
+
SPEECH_START = "speech_start"
|
|
24
|
+
SPEECH_END = "speech_end"
|
|
25
|
+
LANGUAGE_SET = "language_set"
|
|
26
|
+
SAMPLE_RATE_SET = "sample_rate_set"
|
|
27
|
+
CREDITS_WARNING = "credits_warning"
|
|
28
|
+
CREDITS_CRITICAL = "credits_critical"
|
|
29
|
+
CREDITS_EXHAUSTED = "credits_exhausted"
|
|
30
|
+
FLUSHED = "flushed"
|
|
31
|
+
ERROR = "error"
|
|
32
|
+
|
|
33
|
+
# Client → Server
|
|
34
|
+
CONFIG = "config"
|
|
35
|
+
FLUSH = "flush"
|
|
36
|
+
AUDIO = "audio" # Binary, not JSON
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Message:
|
|
41
|
+
"""
|
|
42
|
+
Represents a parsed WebSocket message.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
type: Message type enum
|
|
46
|
+
data: Message payload data
|
|
47
|
+
raw: Original raw message (for debugging)
|
|
48
|
+
"""
|
|
49
|
+
type: MessageType
|
|
50
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
raw: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def text(self) -> Optional[str]:
|
|
55
|
+
"""Get transcript text (for partial/final messages)."""
|
|
56
|
+
return self.data.get("text")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def session_id(self) -> Optional[str]:
|
|
60
|
+
"""Get session ID (for ready message)."""
|
|
61
|
+
return self.data.get("session_id")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def language(self) -> Optional[str]:
|
|
65
|
+
"""Get language (for ready/language_set messages)."""
|
|
66
|
+
return self.data.get("language")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def sample_rate(self) -> Optional[int]:
|
|
70
|
+
"""Get sample rate (for sample_rate_set message)."""
|
|
71
|
+
return self.data.get("sample_rate")
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def supported_languages(self) -> List[str]:
|
|
75
|
+
"""Get supported languages (for ready message)."""
|
|
76
|
+
return self.data.get("supported_languages", [])
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def cost(self) -> Optional[float]:
|
|
80
|
+
"""Get cost (for final message)."""
|
|
81
|
+
return self.data.get("cost")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def audio_seconds(self) -> Optional[float]:
|
|
85
|
+
"""Get audio duration (for final message)."""
|
|
86
|
+
return self.data.get("audio_seconds")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def remaining_percent(self) -> Optional[int]:
|
|
90
|
+
"""Get remaining credits percent (for final/credits_* messages)."""
|
|
91
|
+
return self.data.get("remaining_percent")
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def capped(self) -> bool:
|
|
95
|
+
"""Check if billing was capped (for final message)."""
|
|
96
|
+
return self.data.get("capped", False)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def error_message(self) -> Optional[str]:
|
|
100
|
+
"""Get error message (for error message)."""
|
|
101
|
+
return self.data.get("message")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MessageParser:
|
|
105
|
+
"""
|
|
106
|
+
Parser for streaming protocol messages.
|
|
107
|
+
|
|
108
|
+
Handles conversion between raw WebSocket messages and Message objects.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def parse(raw: str) -> Message:
|
|
113
|
+
"""
|
|
114
|
+
Parse a raw JSON message from the server.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
raw: Raw JSON string from WebSocket
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Parsed Message object
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If message is invalid or has unknown type
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(raw)
|
|
127
|
+
except json.JSONDecodeError as e:
|
|
128
|
+
raise ValueError(f"Invalid JSON message: {e}")
|
|
129
|
+
|
|
130
|
+
msg_type_str = data.get("type")
|
|
131
|
+
if not msg_type_str:
|
|
132
|
+
raise ValueError("Message missing 'type' field")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
msg_type = MessageType(msg_type_str)
|
|
136
|
+
except ValueError:
|
|
137
|
+
logger.warning(f"Unknown message type: {msg_type_str}")
|
|
138
|
+
raise ValueError(f"Unknown message type: {msg_type_str}")
|
|
139
|
+
|
|
140
|
+
return Message(type=msg_type, data=data, raw=raw)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def encode_config(
|
|
144
|
+
language: Optional[str] = None,
|
|
145
|
+
sample_rate: Optional[int] = None
|
|
146
|
+
) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Encode a config message to send to server.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
language: Optional language to set
|
|
152
|
+
sample_rate: Optional sample rate to set
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
JSON string to send
|
|
156
|
+
"""
|
|
157
|
+
msg = {"type": "config"}
|
|
158
|
+
if language is not None:
|
|
159
|
+
msg["language"] = language
|
|
160
|
+
if sample_rate is not None:
|
|
161
|
+
msg["sample_rate"] = sample_rate
|
|
162
|
+
return json.dumps(msg)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def encode_flush() -> str:
|
|
166
|
+
"""
|
|
167
|
+
Encode a flush message to send to server.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
JSON string to send
|
|
171
|
+
"""
|
|
172
|
+
return json.dumps({"type": "flush"})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_websocket_url(base_url: str, api_key: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Create WebSocket URL with authentication.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
base_url: Base URL (e.g., "wss://api.orbitalsai.com")
|
|
181
|
+
api_key: API key or JWT token
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Full WebSocket URL with token parameter
|
|
185
|
+
"""
|
|
186
|
+
# Ensure we have the right protocol
|
|
187
|
+
if base_url.startswith("https://"):
|
|
188
|
+
base_url = "wss://" + base_url[8:]
|
|
189
|
+
elif base_url.startswith("http://"):
|
|
190
|
+
base_url = "ws://" + base_url[7:]
|
|
191
|
+
elif not base_url.startswith(("ws://", "wss://")):
|
|
192
|
+
base_url = "wss://" + base_url
|
|
193
|
+
|
|
194
|
+
# Remove trailing slash
|
|
195
|
+
base_url = base_url.rstrip("/")
|
|
196
|
+
|
|
197
|
+
# Build URL with endpoint and token
|
|
198
|
+
return f"{base_url}/api/v1/streaming/transcribe?token={api_key}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Close codes and their meanings
|
|
202
|
+
CLOSE_CODES = {
|
|
203
|
+
1000: ("Normal closure", False), # No retry needed
|
|
204
|
+
1001: ("Going away", True), # Server shutdown, retry
|
|
205
|
+
1006: ("Abnormal closure", True), # Network issue, retry
|
|
206
|
+
4001: ("Authentication failed", False), # Don't retry
|
|
207
|
+
4002: ("Service unavailable", True), # Retry with backoff
|
|
208
|
+
4003: ("Server busy", True), # Retry with longer backoff
|
|
209
|
+
4004: ("Insufficient balance", False), # Don't retry
|
|
210
|
+
4005: ("Credits exhausted", False), # Don't retry
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def should_retry(close_code: int) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Determine if connection should be retried based on close code.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
close_code: WebSocket close code
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if connection should be retried, False otherwise
|
|
223
|
+
"""
|
|
224
|
+
if close_code in CLOSE_CODES:
|
|
225
|
+
_, should_retry = CLOSE_CODES[close_code]
|
|
226
|
+
return should_retry
|
|
227
|
+
|
|
228
|
+
# Default: retry for server errors (5xx equivalent)
|
|
229
|
+
return close_code >= 1000 and close_code < 4000
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_close_reason(close_code: int) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Get human-readable reason for close code.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
close_code: WebSocket close code
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Human-readable reason string
|
|
241
|
+
"""
|
|
242
|
+
if close_code in CLOSE_CODES:
|
|
243
|
+
reason, _ = CLOSE_CODES[close_code]
|
|
244
|
+
return reason
|
|
245
|
+
return f"Unknown close code: {close_code}"
|