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.
@@ -0,0 +1,207 @@
1
+ """
2
+ OrbitalsAI Streaming Configuration
3
+
4
+ Configuration dataclass for streaming transcription sessions.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional
9
+ import logging
10
+
11
+ logger = logging.getLogger("orbitalsai.streaming")
12
+
13
+
14
+ # Supported languages for streaming transcription
15
+ STREAMING_SUPPORTED_LANGUAGES: List[str] = [
16
+ "english", "hausa", "igbo", "yoruba"
17
+ ]
18
+
19
+ # Valid sample rate range
20
+ MIN_SAMPLE_RATE = 8000
21
+ MAX_SAMPLE_RATE = 48000
22
+ DEFAULT_SAMPLE_RATE = 16000
23
+
24
+ # Default chunk size (samples at 16kHz = 500ms)
25
+ DEFAULT_CHUNK_SIZE = 8000
26
+
27
+
28
+ @dataclass
29
+ class StreamingConfig:
30
+ """
31
+ Configuration for streaming transcription sessions.
32
+
33
+ Attributes:
34
+ sample_rate: Audio sample rate in Hz (8000-48000, default: 16000)
35
+ chunk_size: Number of samples per chunk (default: 8000 = 500ms at 16kHz)
36
+ encoding: Audio encoding format (default: "pcm_s16le")
37
+ language: Target transcription language (default: "english")
38
+ auto_detect_language: Whether to auto-detect language (default: False)
39
+ max_retries: Maximum reconnection attempts (default: 5)
40
+ retry_delay: Initial retry delay in seconds (default: 1.0)
41
+ keepalive_interval: WebSocket ping interval in seconds (default: 15.0)
42
+ connection_timeout: Connection timeout in seconds (default: 30.0)
43
+ interim_results: Whether to receive partial transcripts (default: True)
44
+ auto_flush: Whether to auto-flush on silence (default: True)
45
+
46
+ Example:
47
+ config = StreamingConfig(
48
+ language="hausa",
49
+ sample_rate=16000,
50
+ interim_results=True
51
+ )
52
+ """
53
+
54
+ # Audio settings
55
+ sample_rate: int = DEFAULT_SAMPLE_RATE
56
+ chunk_size: int = DEFAULT_CHUNK_SIZE
57
+ encoding: str = "pcm_s16le"
58
+
59
+ # Language settings
60
+ language: str = "english"
61
+ auto_detect_language: bool = False
62
+
63
+ # Connection settings
64
+ max_retries: int = 5
65
+ retry_delay: float = 1.0
66
+ max_retry_delay: float = 60.0
67
+ keepalive_interval: float = 15.0
68
+ connection_timeout: float = 30.0
69
+
70
+ # Processing settings
71
+ interim_results: bool = True
72
+ auto_flush: bool = True
73
+
74
+ def __post_init__(self) -> None:
75
+ """Validate configuration after initialization."""
76
+ self.validate()
77
+
78
+ def validate(self) -> None:
79
+ """
80
+ Validate configuration values.
81
+
82
+ Raises:
83
+ ValueError: If any configuration value is invalid
84
+ """
85
+ # Validate sample rate
86
+ if not MIN_SAMPLE_RATE <= self.sample_rate <= MAX_SAMPLE_RATE:
87
+ raise ValueError(
88
+ f"sample_rate must be between {MIN_SAMPLE_RATE} and {MAX_SAMPLE_RATE}, "
89
+ f"got {self.sample_rate}"
90
+ )
91
+
92
+ # Validate chunk size
93
+ if self.chunk_size <= 0:
94
+ raise ValueError(f"chunk_size must be positive, got {self.chunk_size}")
95
+
96
+ # Validate language
97
+ if self.language.lower() not in STREAMING_SUPPORTED_LANGUAGES:
98
+ raise ValueError(
99
+ f"Unsupported language: {self.language}. "
100
+ f"Supported languages: {', '.join(STREAMING_SUPPORTED_LANGUAGES)}"
101
+ )
102
+
103
+ # Validate connection settings
104
+ if self.max_retries < 0:
105
+ raise ValueError(f"max_retries must be non-negative, got {self.max_retries}")
106
+
107
+ if self.retry_delay <= 0:
108
+ raise ValueError(f"retry_delay must be positive, got {self.retry_delay}")
109
+
110
+ if self.keepalive_interval <= 0:
111
+ raise ValueError(f"keepalive_interval must be positive, got {self.keepalive_interval}")
112
+
113
+ if self.connection_timeout <= 0:
114
+ raise ValueError(f"connection_timeout must be positive, got {self.connection_timeout}")
115
+
116
+ # Validate encoding
117
+ if self.encoding != "pcm_s16le":
118
+ logger.warning(
119
+ f"Only 'pcm_s16le' encoding is currently supported. "
120
+ f"Got '{self.encoding}', using 'pcm_s16le' instead."
121
+ )
122
+ self.encoding = "pcm_s16le"
123
+
124
+ @property
125
+ def chunk_duration_ms(self) -> float:
126
+ """
127
+ Calculate chunk duration in milliseconds.
128
+
129
+ Returns:
130
+ Duration of one chunk in milliseconds
131
+ """
132
+ return (self.chunk_size / self.sample_rate) * 1000
133
+
134
+ @property
135
+ def bytes_per_chunk(self) -> int:
136
+ """
137
+ Calculate bytes per chunk for PCM16 audio.
138
+
139
+ Returns:
140
+ Number of bytes per chunk (chunk_size * 2 for int16)
141
+ """
142
+ return self.chunk_size * 2 # 2 bytes per int16 sample
143
+
144
+ @property
145
+ def bytes_per_second(self) -> int:
146
+ """
147
+ Calculate bytes per second for the configured sample rate.
148
+
149
+ Returns:
150
+ Number of bytes per second of audio
151
+ """
152
+ return self.sample_rate * 2 # 2 bytes per int16 sample
153
+
154
+ def copy(self, **updates) -> 'StreamingConfig':
155
+ """
156
+ Create a copy of this config with optional updates.
157
+
158
+ Args:
159
+ **updates: Fields to update in the copy
160
+
161
+ Returns:
162
+ New StreamingConfig with updates applied
163
+ """
164
+ import copy
165
+ new_config = copy.copy(self)
166
+ for key, value in updates.items():
167
+ if hasattr(new_config, key):
168
+ setattr(new_config, key, value)
169
+ else:
170
+ raise ValueError(f"Unknown config field: {key}")
171
+ new_config.validate()
172
+ return new_config
173
+
174
+ def to_dict(self) -> dict:
175
+ """
176
+ Convert config to dictionary.
177
+
178
+ Returns:
179
+ Dictionary representation of config
180
+ """
181
+ return {
182
+ "sample_rate": self.sample_rate,
183
+ "chunk_size": self.chunk_size,
184
+ "encoding": self.encoding,
185
+ "language": self.language,
186
+ "auto_detect_language": self.auto_detect_language,
187
+ "max_retries": self.max_retries,
188
+ "retry_delay": self.retry_delay,
189
+ "max_retry_delay": self.max_retry_delay,
190
+ "keepalive_interval": self.keepalive_interval,
191
+ "connection_timeout": self.connection_timeout,
192
+ "interim_results": self.interim_results,
193
+ "auto_flush": self.auto_flush,
194
+ }
195
+
196
+ @classmethod
197
+ def from_dict(cls, data: dict) -> 'StreamingConfig':
198
+ """
199
+ Create config from dictionary.
200
+
201
+ Args:
202
+ data: Dictionary with config values
203
+
204
+ Returns:
205
+ New StreamingConfig instance
206
+ """
207
+ return cls(**{k: v for k, v in data.items() if hasattr(cls, k)})
@@ -0,0 +1,298 @@
1
+ """
2
+ OrbitalsAI Streaming Connection Manager
3
+
4
+ WebSocket connection management with auto-reconnection.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import random
10
+ import time
11
+ from typing import Callable, Optional
12
+
13
+ from .protocol import should_retry, get_close_reason, CLOSE_CODES
14
+ from .exceptions import (
15
+ ConnectionError,
16
+ ReconnectionFailedError,
17
+ AuthenticationError,
18
+ ServiceUnavailableError,
19
+ ServerBusyError,
20
+ InsufficientCreditsError,
21
+ )
22
+
23
+ logger = logging.getLogger("orbitalsai.streaming")
24
+
25
+
26
+ def calculate_backoff(
27
+ attempt: int,
28
+ base_delay: float = 1.0,
29
+ max_delay: float = 60.0,
30
+ jitter: bool = True
31
+ ) -> float:
32
+ """
33
+ Calculate exponential backoff delay with optional jitter.
34
+
35
+ Args:
36
+ attempt: Current retry attempt (0-indexed)
37
+ base_delay: Initial delay in seconds
38
+ max_delay: Maximum delay in seconds
39
+ jitter: Whether to add random jitter
40
+
41
+ Returns:
42
+ Delay in seconds
43
+ """
44
+ delay = min(base_delay * (2 ** attempt), max_delay)
45
+
46
+ if jitter:
47
+ # Add 0-10% jitter
48
+ jitter_amount = random.uniform(0, delay * 0.1)
49
+ delay += jitter_amount
50
+
51
+ return delay
52
+
53
+
54
+ class ConnectionState:
55
+ """Connection state enumeration."""
56
+ DISCONNECTED = "disconnected"
57
+ CONNECTING = "connecting"
58
+ CONNECTED = "connected"
59
+ RECONNECTING = "reconnecting"
60
+ CLOSED = "closed"
61
+
62
+
63
+ class ConnectionManager:
64
+ """
65
+ Manages WebSocket connection with auto-reconnection.
66
+
67
+ Handles:
68
+ - Initial connection establishment
69
+ - Automatic reconnection on connection loss
70
+ - Exponential backoff with jitter
71
+ - Close code handling
72
+
73
+ Example:
74
+ manager = ConnectionManager(
75
+ max_retries=5,
76
+ base_delay=1.0,
77
+ max_delay=60.0
78
+ )
79
+
80
+ async with manager.connect(ws_url) as ws:
81
+ await ws.send(data)
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ max_retries: int = 5,
87
+ base_delay: float = 1.0,
88
+ max_delay: float = 60.0,
89
+ connection_timeout: float = 30.0,
90
+ on_reconnect: Optional[Callable[[], None]] = None,
91
+ on_reconnect_failed: Optional[Callable[[int], None]] = None,
92
+ ):
93
+ """
94
+ Initialize connection manager.
95
+
96
+ Args:
97
+ max_retries: Maximum number of reconnection attempts
98
+ base_delay: Initial retry delay in seconds
99
+ max_delay: Maximum retry delay in seconds
100
+ connection_timeout: Connection timeout in seconds
101
+ on_reconnect: Callback when reconnection starts
102
+ on_reconnect_failed: Callback when reconnection fails (receives attempt count)
103
+ """
104
+ self.max_retries = max_retries
105
+ self.base_delay = base_delay
106
+ self.max_delay = max_delay
107
+ self.connection_timeout = connection_timeout
108
+ self.on_reconnect = on_reconnect
109
+ self.on_reconnect_failed = on_reconnect_failed
110
+
111
+ self._state = ConnectionState.DISCONNECTED
112
+ self._retry_count = 0
113
+ self._last_close_code: Optional[int] = None
114
+ self._last_close_reason: Optional[str] = None
115
+
116
+ @property
117
+ def state(self) -> str:
118
+ """Get current connection state."""
119
+ return self._state
120
+
121
+ @property
122
+ def is_connected(self) -> bool:
123
+ """Check if currently connected."""
124
+ return self._state == ConnectionState.CONNECTED
125
+
126
+ @property
127
+ def retry_count(self) -> int:
128
+ """Get current retry count."""
129
+ return self._retry_count
130
+
131
+ @property
132
+ def last_close_code(self) -> Optional[int]:
133
+ """Get last WebSocket close code."""
134
+ return self._last_close_code
135
+
136
+ @property
137
+ def last_close_reason(self) -> Optional[str]:
138
+ """Get last WebSocket close reason."""
139
+ return self._last_close_reason
140
+
141
+ def reset(self) -> None:
142
+ """Reset connection state and retry count."""
143
+ self._state = ConnectionState.DISCONNECTED
144
+ self._retry_count = 0
145
+ self._last_close_code = None
146
+ self._last_close_reason = None
147
+
148
+ def record_close(self, code: int, reason: str = "") -> None:
149
+ """
150
+ Record a connection close event.
151
+
152
+ Args:
153
+ code: WebSocket close code
154
+ reason: Close reason string
155
+ """
156
+ self._last_close_code = code
157
+ self._last_close_reason = reason or get_close_reason(code)
158
+ self._state = ConnectionState.DISCONNECTED
159
+
160
+ def should_reconnect(self, close_code: Optional[int] = None) -> bool:
161
+ """
162
+ Determine if reconnection should be attempted.
163
+
164
+ Args:
165
+ close_code: WebSocket close code (uses last recorded if not provided)
166
+
167
+ Returns:
168
+ True if should attempt reconnection
169
+ """
170
+ code = close_code or self._last_close_code
171
+
172
+ if code is None:
173
+ return True # Unknown close, try to reconnect
174
+
175
+ # Check if we've exhausted retries
176
+ if self._retry_count >= self.max_retries:
177
+ return False
178
+
179
+ # Check if this close code allows retry
180
+ return should_retry(code)
181
+
182
+ def get_retry_delay(self) -> float:
183
+ """
184
+ Get delay before next retry attempt.
185
+
186
+ Uses exponential backoff with jitter.
187
+ May use longer delays for certain close codes.
188
+
189
+ Returns:
190
+ Delay in seconds
191
+ """
192
+ base = self.base_delay
193
+
194
+ # Use longer base delay for server busy
195
+ if self._last_close_code == 4003: # Server busy
196
+ base = self.base_delay * 2
197
+
198
+ return calculate_backoff(
199
+ self._retry_count,
200
+ base_delay=base,
201
+ max_delay=self.max_delay,
202
+ jitter=True
203
+ )
204
+
205
+ async def wait_for_retry(self) -> None:
206
+ """Wait before retry attempt."""
207
+ delay = self.get_retry_delay()
208
+ logger.info(f"Waiting {delay:.1f}s before retry attempt {self._retry_count + 1}")
209
+ await asyncio.sleep(delay)
210
+
211
+ def increment_retry(self) -> int:
212
+ """
213
+ Increment retry counter.
214
+
215
+ Returns:
216
+ New retry count
217
+ """
218
+ self._retry_count += 1
219
+ self._state = ConnectionState.RECONNECTING
220
+
221
+ if self.on_reconnect:
222
+ try:
223
+ self.on_reconnect()
224
+ except Exception as e:
225
+ logger.warning(f"on_reconnect callback error: {e}")
226
+
227
+ return self._retry_count
228
+
229
+ def reset_retry_count(self) -> None:
230
+ """Reset retry counter (call after successful connection)."""
231
+ self._retry_count = 0
232
+
233
+ def mark_connected(self) -> None:
234
+ """Mark connection as established."""
235
+ self._state = ConnectionState.CONNECTED
236
+ self.reset_retry_count()
237
+
238
+ def mark_connecting(self) -> None:
239
+ """Mark connection as in progress."""
240
+ self._state = ConnectionState.CONNECTING
241
+
242
+ def mark_closed(self) -> None:
243
+ """Mark connection as permanently closed."""
244
+ self._state = ConnectionState.CLOSED
245
+
246
+ def raise_for_close_code(self, code: int, reason: str = "") -> None:
247
+ """
248
+ Raise appropriate exception for close code.
249
+
250
+ Args:
251
+ code: WebSocket close code
252
+ reason: Close reason string
253
+
254
+ Raises:
255
+ Appropriate exception for the close code
256
+ """
257
+ self.record_close(code, reason)
258
+
259
+ reason = reason or get_close_reason(code)
260
+
261
+ if code == 4001:
262
+ raise AuthenticationError(reason)
263
+ elif code == 4002:
264
+ raise ServiceUnavailableError(reason)
265
+ elif code == 4003:
266
+ raise ServerBusyError(reason)
267
+ elif code in (4004, 4005):
268
+ raise InsufficientCreditsError(reason)
269
+ else:
270
+ raise ConnectionError(reason, code=code)
271
+
272
+ def check_can_reconnect(self) -> None:
273
+ """
274
+ Check if reconnection is possible.
275
+
276
+ Raises:
277
+ ReconnectionFailedError: If max retries exceeded
278
+ Other exceptions: For non-retryable close codes
279
+ """
280
+ if not self.should_reconnect():
281
+ if self._retry_count >= self.max_retries:
282
+ if self.on_reconnect_failed:
283
+ try:
284
+ self.on_reconnect_failed(self._retry_count)
285
+ except Exception as e:
286
+ logger.warning(f"on_reconnect_failed callback error: {e}")
287
+
288
+ raise ReconnectionFailedError(
289
+ f"Failed to reconnect after {self._retry_count} attempts",
290
+ attempts=self._retry_count
291
+ )
292
+
293
+ # Non-retryable close code
294
+ if self._last_close_code:
295
+ self.raise_for_close_code(
296
+ self._last_close_code,
297
+ self._last_close_reason or ""
298
+ )