dominus-sdk-python-dev 2.4.0__py3-none-any.whl → 2.5.1__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,230 @@
1
+ """
2
+ OracleWebSocket - WebSocket wrapper for Oracle streaming (INTERNAL)
3
+
4
+ Handles:
5
+ - First-message authentication (browser WS doesn't support headers)
6
+ - Binary frames for audio (NOT base64)
7
+ - JSON frames for control messages
8
+ - Ping/pong keepalive during IDLE
9
+ - Transcript parsing and callback firing
10
+
11
+ This module is INTERNAL and should NOT be exported publicly.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ from typing import Callable, Optional
17
+ from dataclasses import dataclass
18
+
19
+ try:
20
+ import websockets
21
+ from websockets.client import WebSocketClientProtocol
22
+ WEBSOCKETS_AVAILABLE = True
23
+ except ImportError:
24
+ WEBSOCKETS_AVAILABLE = False
25
+ WebSocketClientProtocol = None
26
+
27
+
28
+ @dataclass
29
+ class OracleWebSocketConfig:
30
+ """Configuration for OracleWebSocket."""
31
+ ping_interval_ms: int = 10000
32
+
33
+
34
+ class OracleWebSocket:
35
+ """
36
+ OracleWebSocket - Manages WebSocket connection to Oracle service.
37
+ """
38
+
39
+ def __init__(self, base_url: str, config: Optional[OracleWebSocketConfig] = None):
40
+ if not WEBSOCKETS_AVAILABLE:
41
+ raise ImportError(
42
+ "websockets package is required for Oracle streaming. "
43
+ "Install with: pip install websockets"
44
+ )
45
+
46
+ self._base_url = base_url
47
+ self._config = config or OracleWebSocketConfig()
48
+ self._ws: Optional[WebSocketClientProtocol] = None
49
+ self._authenticated = False
50
+ self._ping_task: Optional[asyncio.Task] = None
51
+ self._receive_task: Optional[asyncio.Task] = None
52
+
53
+ # Callbacks
54
+ self.on_transcript: Optional[Callable[[str, bool, bool], None]] = None
55
+ self.on_error: Optional[Callable[[Exception], None]] = None
56
+ self.on_close: Optional[Callable[[], None]] = None
57
+
58
+ @property
59
+ def is_connected(self) -> bool:
60
+ """Check if WebSocket is connected and authenticated."""
61
+ return (
62
+ self._ws is not None
63
+ and self._ws.open
64
+ and self._authenticated
65
+ )
66
+
67
+ async def connect(self, user_token: str) -> None:
68
+ """
69
+ Connect to Oracle WebSocket endpoint with first-message auth.
70
+
71
+ Args:
72
+ user_token: User JWT for authentication
73
+
74
+ Raises:
75
+ Exception: If connection or authentication fails
76
+ """
77
+ # Convert HTTP URL to WebSocket URL
78
+ ws_url = self._build_websocket_url()
79
+
80
+ try:
81
+ self._ws = await websockets.connect(ws_url)
82
+ except Exception as e:
83
+ raise Exception(f"Failed to create WebSocket: {e}")
84
+
85
+ # Send first-message auth
86
+ auth_message = json.dumps({
87
+ "type": "auth",
88
+ "token": user_token,
89
+ })
90
+ await self._ws.send(auth_message)
91
+
92
+ # Wait for ready message
93
+ try:
94
+ response = await asyncio.wait_for(self._ws.recv(), timeout=10.0)
95
+ data = json.loads(response)
96
+
97
+ if data.get("type") == "ready":
98
+ self._authenticated = True
99
+ self._start_ping_interval()
100
+ self._start_receive_loop()
101
+ elif data.get("type") == "error":
102
+ raise Exception(f"Authentication failed: {data.get('message')}")
103
+ else:
104
+ raise Exception(f"Unexpected response type: {data.get('type')}")
105
+
106
+ except asyncio.TimeoutError:
107
+ await self._ws.close()
108
+ raise Exception("Authentication timeout")
109
+
110
+ def send_binary(self, pcm_frame: bytes) -> None:
111
+ """
112
+ Send binary PCM audio frame.
113
+ Only called by VADGate - NOT exposed publicly.
114
+
115
+ Args:
116
+ pcm_frame: Raw PCM audio data
117
+ """
118
+ if self._ws and self._ws.open and self._authenticated:
119
+ asyncio.create_task(self._ws.send(pcm_frame))
120
+
121
+ async def send_ping(self) -> None:
122
+ """Send a ping message for keepalive."""
123
+ if self._ws and self._ws.open and self._authenticated:
124
+ await self._ws.send(json.dumps({"type": "ping"}))
125
+
126
+ async def close(self) -> None:
127
+ """Close the WebSocket connection gracefully."""
128
+ self._stop_ping_interval()
129
+ self._stop_receive_loop()
130
+
131
+ if self._ws:
132
+ # Send close message before closing
133
+ if self._ws.open:
134
+ try:
135
+ await self._ws.send(json.dumps({"type": "close"}))
136
+ except Exception:
137
+ pass # Ignore send errors during close
138
+
139
+ await self._ws.close()
140
+ self._ws = None
141
+
142
+ self._authenticated = False
143
+
144
+ def _build_websocket_url(self) -> str:
145
+ """Build WebSocket URL from base URL."""
146
+ # Replace http(s) with ws(s)
147
+ if self._base_url.startswith("https"):
148
+ ws_protocol = "wss"
149
+ http_protocol = "https"
150
+ else:
151
+ ws_protocol = "ws"
152
+ http_protocol = "http"
153
+
154
+ return self._base_url.replace(http_protocol, ws_protocol) + "/api/oracle/stream"
155
+
156
+ def _start_receive_loop(self) -> None:
157
+ """Start the receive loop for incoming messages."""
158
+ async def receive_loop():
159
+ try:
160
+ while self._ws and self._ws.open:
161
+ try:
162
+ message = await self._ws.recv()
163
+ await self._handle_message(message)
164
+ except websockets.exceptions.ConnectionClosed:
165
+ break
166
+ finally:
167
+ self._authenticated = False
168
+ if self.on_close:
169
+ self.on_close()
170
+
171
+ self._receive_task = asyncio.create_task(receive_loop())
172
+
173
+ def _stop_receive_loop(self) -> None:
174
+ """Stop the receive loop."""
175
+ if self._receive_task:
176
+ self._receive_task.cancel()
177
+ self._receive_task = None
178
+
179
+ async def _handle_message(self, message) -> None:
180
+ """Handle incoming WebSocket message."""
181
+ # Binary messages are not expected from server (audio is one-way)
182
+ if isinstance(message, bytes):
183
+ return
184
+
185
+ # Parse JSON message
186
+ try:
187
+ data = json.loads(message)
188
+ except json.JSONDecodeError:
189
+ print("[OracleSDK] Received invalid JSON from server")
190
+ return
191
+
192
+ msg_type = data.get("type")
193
+
194
+ if msg_type == "transcript":
195
+ text = data.get("text", "")
196
+ is_final = data.get("is_final", False)
197
+ speech_final = data.get("speech_final", False)
198
+
199
+ print(f"[OracleSDK] Transcript received: text={text[:40] if text else ''!r}, "
200
+ f"is_final={is_final}, speech_final={speech_final}")
201
+
202
+ if self.on_transcript:
203
+ self.on_transcript(text, is_final, speech_final)
204
+
205
+ elif msg_type == "pong":
206
+ # Keepalive response - no action needed
207
+ pass
208
+
209
+ elif msg_type == "error":
210
+ error = Exception(data.get("message", "Unknown error"))
211
+ if self.on_error:
212
+ self.on_error(error)
213
+
214
+ else:
215
+ print(f"[OracleSDK] Unknown message type: {msg_type}")
216
+
217
+ def _start_ping_interval(self) -> None:
218
+ """Start ping interval for keepalive."""
219
+ async def ping_loop():
220
+ while True:
221
+ await asyncio.sleep(self._config.ping_interval_ms / 1000.0)
222
+ await self.send_ping()
223
+
224
+ self._ping_task = asyncio.create_task(ping_loop())
225
+
226
+ def _stop_ping_interval(self) -> None:
227
+ """Stop ping interval."""
228
+ if self._ping_task:
229
+ self._ping_task.cancel()
230
+ self._ping_task = None
@@ -0,0 +1,252 @@
1
+ """
2
+ OracleSession - Main session class for streaming transcription.
3
+
4
+ Provides the public API for Oracle streaming:
5
+ - start() / stop() lifecycle
6
+ - Event callbacks for transcripts, VAD state, errors
7
+ - NO send_audio() exposed - VAD gates all audio automatically
8
+
9
+ The session handles everything internally:
10
+ - Microphone access and audio capture
11
+ - Resampling to 16kHz mono PCM
12
+ - VAD gating (only sends speech, not silence)
13
+ - WebSocket connection to Oracle
14
+ - Ping/pong keepalive during IDLE
15
+ """
16
+
17
+ import asyncio
18
+ from typing import Callable, Optional, List
19
+
20
+ from .oracle_websocket import OracleWebSocket, OracleWebSocketConfig
21
+ from .vad_gate import VADGate
22
+ from .audio_capture import AudioCapture
23
+ from .types import VADState, ResolvedOracleSessionOptions
24
+
25
+
26
+ class OracleSession:
27
+ """
28
+ OracleSession - Real-time speech-to-text with VAD gating.
29
+
30
+ Usage:
31
+ session = dominus.oracle.create_session(user_jwt)
32
+
33
+ session.on_ready = lambda: print('Ready')
34
+ session.on_interim = lambda text: set_live_text(text)
35
+ session.on_utterance = lambda text: send_to_curator(text)
36
+ session.on_vad_state_change = lambda state: update_mic_icon(state)
37
+
38
+ await session.start()
39
+ # ... user speaks ...
40
+ await session.stop()
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ base_url: str,
46
+ user_token: str,
47
+ options: ResolvedOracleSessionOptions
48
+ ):
49
+ self._user_token = user_token
50
+ self._is_active = False
51
+ self._vad_state: VADState = "idle"
52
+
53
+ # Track accumulated transcript for utterance detection
54
+ # Since VAD stops sending audio before Deepgram's utterance_end_ms,
55
+ # we use VAD TRAILING->IDLE transition as utterance boundary
56
+ self._pending_utterance = ""
57
+
58
+ # Initialize internal components
59
+ self._ws = OracleWebSocket(
60
+ base_url,
61
+ OracleWebSocketConfig(ping_interval_ms=options.ping_interval_ms)
62
+ )
63
+ self._vad = VADGate(options)
64
+ self._audio = AudioCapture()
65
+
66
+ # ========== EVENT CALLBACKS ==========
67
+
68
+ # Called when session is ready to receive speech
69
+ self.on_ready: Optional[Callable[[], None]] = None
70
+
71
+ # Called with interim transcripts (may change)
72
+ self.on_interim: Optional[Callable[[str], None]] = None
73
+
74
+ # Called when a transcript segment is finalized
75
+ self.on_final: Optional[Callable[[str], None]] = None
76
+
77
+ # Called when user finishes an utterance (trigger for Curator)
78
+ self.on_utterance: Optional[Callable[[str], None]] = None
79
+
80
+ # Called on any error
81
+ self.on_error: Optional[Callable[[Exception], None]] = None
82
+
83
+ # Called when session closes
84
+ self.on_close: Optional[Callable[[], None]] = None
85
+
86
+ # Called when VAD state changes (for UI indicators)
87
+ self.on_vad_state_change: Optional[Callable[[VADState], None]] = None
88
+
89
+ # ========== PUBLIC GETTERS ==========
90
+
91
+ @property
92
+ def is_active(self) -> bool:
93
+ """Check if session is currently active."""
94
+ return self._is_active
95
+
96
+ @property
97
+ def vad_state(self) -> VADState:
98
+ """Get current VAD state."""
99
+ return self._vad_state
100
+
101
+ # ========== LIFECYCLE METHODS ==========
102
+
103
+ async def start(self) -> None:
104
+ """
105
+ Start the transcription session.
106
+
107
+ - Requests microphone permission
108
+ - Connects WebSocket to Oracle
109
+ - Begins VAD-gated audio streaming
110
+
111
+ Raises:
112
+ Exception: If mic permission denied or connection fails
113
+ """
114
+ if self._is_active:
115
+ return
116
+
117
+ try:
118
+ # 1. Initialize VAD
119
+ await self._vad.initialize()
120
+
121
+ # 2. Connect WebSocket with first-message auth
122
+ await self._ws.connect(self._user_token)
123
+
124
+ # 3. Wire up transcript events
125
+ def on_transcript(text: str, is_final: bool, speech_final: bool):
126
+ if not is_final:
127
+ # Interim: show in live view (may change)
128
+ if self.on_interim:
129
+ self.on_interim(text)
130
+ else:
131
+ # Final: accumulate for utterance
132
+ if self.on_final:
133
+ self.on_final(text)
134
+ # Accumulate finals - space-separated
135
+ if self._pending_utterance:
136
+ self._pending_utterance += " " + text
137
+ else:
138
+ self._pending_utterance = text
139
+
140
+ # If Deepgram does send speech_final, honor it
141
+ if speech_final and text:
142
+ self._pending_utterance = "" # Clear since we're emitting
143
+ if self.on_utterance:
144
+ self.on_utterance(text)
145
+
146
+ self._ws.on_transcript = on_transcript
147
+
148
+ self._ws.on_error = lambda error: (
149
+ self.on_error(error) if self.on_error else None
150
+ )
151
+
152
+ def on_ws_close():
153
+ self._is_active = False
154
+ if self.on_close:
155
+ self.on_close()
156
+
157
+ self._ws.on_close = on_ws_close
158
+
159
+ # 4. Start audio capture
160
+ await self._audio.start()
161
+
162
+ # 5. Wire audio through VAD gate
163
+ def on_audio_frame(pcm_frame: bytes):
164
+ self._vad.process_frame(pcm_frame)
165
+
166
+ self._audio.on_frame = on_audio_frame
167
+
168
+ # 6. VAD gate controls what gets sent
169
+ def on_send_audio(frames: List[bytes]):
170
+ for frame in frames:
171
+ self._ws.send_binary(frame)
172
+
173
+ self._vad.on_send_audio = on_send_audio
174
+
175
+ def on_vad_state_change(state: VADState):
176
+ prev_state = self._vad_state
177
+ self._vad_state = state
178
+
179
+ if self.on_vad_state_change:
180
+ self.on_vad_state_change(state)
181
+
182
+ # When VAD transitions to IDLE from TRAILING, emit accumulated utterance
183
+ if state == "idle" and (prev_state == "trailing" or prev_state == "speaking"):
184
+ if self._pending_utterance.strip():
185
+ print(f"[OracleSDK] VAD->IDLE: emitting utterance: "
186
+ f"{self._pending_utterance[:50]!r}")
187
+ if self.on_utterance:
188
+ self.on_utterance(self._pending_utterance.strip())
189
+ self._pending_utterance = ""
190
+
191
+ self._vad.on_state_change = on_vad_state_change
192
+
193
+ self._is_active = True
194
+
195
+ if self.on_ready:
196
+ self.on_ready()
197
+
198
+ except Exception as e:
199
+ # Clean up on failure
200
+ await self._cleanup()
201
+ raise e
202
+
203
+ async def stop(self) -> None:
204
+ """
205
+ Stop the transcription session.
206
+
207
+ - Stops microphone capture
208
+ - Closes WebSocket connection
209
+ - Cleans up resources
210
+ """
211
+ if not self._is_active:
212
+ return
213
+
214
+ self._is_active = False
215
+ await self._cleanup()
216
+
217
+ if self.on_close:
218
+ self.on_close()
219
+
220
+ # ========== PRIVATE METHODS ==========
221
+
222
+ async def _cleanup(self) -> None:
223
+ """Clean up all resources."""
224
+ # Stop audio capture
225
+ await self._audio.stop()
226
+
227
+ # Close WebSocket
228
+ await self._ws.close()
229
+
230
+ # Reset VAD state
231
+ self._vad.reset()
232
+ self._vad_state = "idle"
233
+
234
+ # Clear pending utterance
235
+ self._pending_utterance = ""
236
+
237
+ def dispose(self) -> None:
238
+ """Dispose of all resources (for cleanup on unmount)."""
239
+ asyncio.create_task(self.stop())
240
+
241
+ # Clear all callbacks
242
+ self.on_ready = None
243
+ self.on_interim = None
244
+ self.on_final = None
245
+ self.on_utterance = None
246
+ self.on_error = None
247
+ self.on_close = None
248
+ self.on_vad_state_change = None
249
+
250
+ # Dispose internal components
251
+ self._audio.dispose()
252
+ self._vad.dispose()
@@ -0,0 +1,127 @@
1
+ """
2
+ Oracle Namespace Types - Public interfaces for streaming transcription.
3
+
4
+ Oracle provides real-time speech-to-text via Deepgram streaming,
5
+ with built-in VAD (Voice Activity Detection) for cost optimization.
6
+ """
7
+
8
+ from typing import Literal, Optional, Callable, TypedDict
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ # VAD (Voice Activity Detection) states
13
+ VADState = Literal["idle", "armed", "speaking", "trailing"]
14
+
15
+
16
+ @dataclass
17
+ class OracleSessionOptions:
18
+ """Configuration options for OracleSession."""
19
+
20
+ # Pre-roll buffer duration in ms (default: 320)
21
+ preroll_ms: int = 320
22
+
23
+ # Post-roll duration in ms (default: 800)
24
+ postroll_ms: int = 800
25
+
26
+ # VAD confirmation time in ms (default: 160)
27
+ armed_confirm_ms: int = 160
28
+
29
+ # VAD speech threshold 0-1 (default: 0.5) - used for energy-based fallback
30
+ vad_threshold: float = 0.5
31
+
32
+ # Energy threshold for fallback VAD (default: 800)
33
+ energy_threshold: int = 800
34
+
35
+ # Ping interval in ms for keepalive (default: 10000)
36
+ ping_interval_ms: int = 10000
37
+
38
+
39
+ @dataclass
40
+ class ResolvedOracleSessionOptions:
41
+ """Resolved session options with all defaults applied."""
42
+
43
+ preroll_ms: int
44
+ postroll_ms: int
45
+ armed_confirm_ms: int
46
+ vad_threshold: float
47
+ energy_threshold: int
48
+ ping_interval_ms: int
49
+
50
+
51
+ # Default configuration values
52
+ DEFAULT_OPTIONS = ResolvedOracleSessionOptions(
53
+ preroll_ms=320, # 16 frames x 20ms
54
+ postroll_ms=800, # Post-roll trailing duration
55
+ armed_confirm_ms=160, # 8 frames x 20ms to confirm speech
56
+ vad_threshold=0.5, # VAD threshold
57
+ energy_threshold=800, # Energy-based fallback threshold
58
+ ping_interval_ms=10000, # 10 second ping interval
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class AudioConfig:
64
+ """Audio configuration constants."""
65
+
66
+ SAMPLE_RATE: int = 16000 # 16kHz
67
+ CHANNELS: int = 1 # Mono
68
+ FRAME_DURATION_MS: int = 20 # 20ms frames
69
+ FRAME_SIZE_BYTES: int = 640 # 320 samples x 2 bytes per sample
70
+ SAMPLES_PER_FRAME: int = 320 # 16kHz x 20ms
71
+
72
+
73
+ AUDIO_CONFIG = AudioConfig()
74
+
75
+
76
+ class ServerReadyMessage(TypedDict):
77
+ """Server ready message."""
78
+ type: Literal["ready"]
79
+
80
+
81
+ class ServerTranscriptMessage(TypedDict):
82
+ """Server transcript message."""
83
+ type: Literal["transcript"]
84
+ text: str
85
+ is_final: bool
86
+ speech_final: bool
87
+
88
+
89
+ class ServerPongMessage(TypedDict):
90
+ """Server pong message."""
91
+ type: Literal["pong"]
92
+
93
+
94
+ class ServerErrorMessage(TypedDict):
95
+ """Server error message."""
96
+ type: Literal["error"]
97
+ message: str
98
+
99
+
100
+ # Union type for server messages
101
+ ServerMessage = ServerReadyMessage | ServerTranscriptMessage | ServerPongMessage | ServerErrorMessage
102
+
103
+
104
+ @dataclass
105
+ class OracleSessionCallbacks:
106
+ """Event callbacks for OracleSession."""
107
+
108
+ # Called when session is ready to receive speech
109
+ on_ready: Optional[Callable[[], None]] = None
110
+
111
+ # Called with interim transcripts (may change)
112
+ on_interim: Optional[Callable[[str], None]] = None
113
+
114
+ # Called when a transcript segment is finalized
115
+ on_final: Optional[Callable[[str], None]] = None
116
+
117
+ # Called when user finishes an utterance (trigger for Curator)
118
+ on_utterance: Optional[Callable[[str], None]] = None
119
+
120
+ # Called on any error
121
+ on_error: Optional[Callable[[Exception], None]] = None
122
+
123
+ # Called when session closes
124
+ on_close: Optional[Callable[[], None]] = None
125
+
126
+ # Called when VAD state changes (for UI indicators)
127
+ on_vad_state_change: Optional[Callable[[VADState], None]] = None