dominus-sdk-python-dev 2.3.0__py3-none-any.whl → 2.5.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.
- dominus/__init__.py +82 -5
- dominus/helpers/cache.py +12 -4
- dominus/namespaces/oracle/__init__.py +120 -0
- dominus/namespaces/oracle/audio_capture.py +382 -0
- dominus/namespaces/oracle/oracle_websocket.py +230 -0
- dominus/namespaces/oracle/session.py +252 -0
- dominus/namespaces/oracle/types.py +127 -0
- dominus/namespaces/oracle/vad_gate.py +276 -0
- dominus/start.py +26 -32
- {dominus_sdk_python_dev-2.3.0.dist-info → dominus_sdk_python_dev-2.5.0.dist-info}/METADATA +21 -6
- {dominus_sdk_python_dev-2.3.0.dist-info → dominus_sdk_python_dev-2.5.0.dist-info}/RECORD +13 -11
- dominus/namespaces/_deprecated_crossover.py +0 -10
- dominus/namespaces/_deprecated_sql.py +0 -1341
- dominus/services/_deprecated_architect.py +0 -323
- dominus/services/_deprecated_sovereign.py +0 -93
- {dominus_sdk_python_dev-2.3.0.dist-info → dominus_sdk_python_dev-2.5.0.dist-info}/WHEEL +0 -0
- {dominus_sdk_python_dev-2.3.0.dist-info → dominus_sdk_python_dev-2.5.0.dist-info}/top_level.txt +0 -0
dominus/__init__.py
CHANGED
|
@@ -8,6 +8,10 @@ Ultra-Flat API:
|
|
|
8
8
|
value = await dominus.get("DB_URL")
|
|
9
9
|
await dominus.upsert("KEY", "value")
|
|
10
10
|
|
|
11
|
+
# Secrets namespace
|
|
12
|
+
value = await dominus.secrets.get("DB_URL")
|
|
13
|
+
await dominus.secrets.upsert("KEY", "value")
|
|
14
|
+
|
|
11
15
|
# Database (namespace-based)
|
|
12
16
|
tables = await dominus.db.tables()
|
|
13
17
|
rows = await dominus.db.query("users", filters={"status": "active"})
|
|
@@ -23,6 +27,16 @@ Ultra-Flat API:
|
|
|
23
27
|
# Files (namespace-based)
|
|
24
28
|
result = await dominus.files.upload(data=file_bytes, filename="report.pdf", category="reports")
|
|
25
29
|
|
|
30
|
+
# Redis caching
|
|
31
|
+
await dominus.redis.set("key", "value", ttl=3600)
|
|
32
|
+
value = await dominus.redis.get("key")
|
|
33
|
+
|
|
34
|
+
# Logging
|
|
35
|
+
await dominus.logs.info("Operation complete", {"user_id": user_id})
|
|
36
|
+
|
|
37
|
+
# Email
|
|
38
|
+
await dominus.courier.send("welcome", to="user@example.com", from_email="hello@app.com", model={})
|
|
39
|
+
|
|
26
40
|
# Secure table access with audit logging
|
|
27
41
|
rows = await dominus.db_secure.query("patients", context=SecureAccessContext(
|
|
28
42
|
reason="Reviewing chart",
|
|
@@ -35,11 +49,14 @@ Ultra-Flat API:
|
|
|
35
49
|
# Portal authentication
|
|
36
50
|
session = await dominus.portal.login("user@example.com", "password", "tenant-id")
|
|
37
51
|
|
|
38
|
-
# Open DSN
|
|
52
|
+
# Open DSN and raw SQL
|
|
39
53
|
dsn = await dominus.open.dsn()
|
|
54
|
+
result = await dominus.open.execute("SELECT * FROM users WHERE id = $1", {"1": user_id})
|
|
40
55
|
|
|
41
56
|
# Health
|
|
42
57
|
status = await dominus.health.check()
|
|
58
|
+
await dominus.health.ping()
|
|
59
|
+
await dominus.health.warmup()
|
|
43
60
|
|
|
44
61
|
Backward Compatible APIs:
|
|
45
62
|
# String-based API
|
|
@@ -76,11 +93,45 @@ from .namespaces.files import FilesNamespace
|
|
|
76
93
|
from .namespaces.portal import PortalNamespace
|
|
77
94
|
from .namespaces.admin import AdminNamespace
|
|
78
95
|
from .namespaces.secure import SecureNamespace, SecureAccessContext
|
|
96
|
+
from .namespaces.secrets import SecretsNamespace
|
|
97
|
+
from .namespaces.redis import RedisNamespace
|
|
98
|
+
from .namespaces.logs import LogsNamespace
|
|
99
|
+
from .namespaces.courier import CourierNamespace
|
|
100
|
+
from .namespaces.health import HealthNamespace
|
|
101
|
+
from .namespaces.open import OpenNamespace
|
|
102
|
+
|
|
103
|
+
# Export Oracle namespace for speech-to-text
|
|
104
|
+
from .namespaces.oracle import (
|
|
105
|
+
OracleNamespace,
|
|
106
|
+
OracleSession,
|
|
107
|
+
OracleSessionOptions,
|
|
108
|
+
VADState,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Export cache and resilience utilities
|
|
112
|
+
from .helpers.cache import (
|
|
113
|
+
dominus_cache,
|
|
114
|
+
CircuitBreaker,
|
|
115
|
+
DominusCache,
|
|
116
|
+
orchestrator_circuit_breaker,
|
|
117
|
+
exponential_backoff_with_jitter,
|
|
118
|
+
)
|
|
79
119
|
|
|
80
|
-
# Export
|
|
81
|
-
from .
|
|
120
|
+
# Export error classes
|
|
121
|
+
from .errors import (
|
|
122
|
+
DominusError,
|
|
123
|
+
AuthenticationError,
|
|
124
|
+
AuthorizationError,
|
|
125
|
+
NotFoundError,
|
|
126
|
+
ValidationError,
|
|
127
|
+
ConflictError,
|
|
128
|
+
ServiceError,
|
|
129
|
+
SecureTableError,
|
|
130
|
+
ConnectionError as DominusConnectionError,
|
|
131
|
+
TimeoutError as DominusTimeoutError,
|
|
132
|
+
)
|
|
82
133
|
|
|
83
|
-
__version__ = "2.
|
|
134
|
+
__version__ = "2.5.0"
|
|
84
135
|
__all__ = [
|
|
85
136
|
# Main SDK instance
|
|
86
137
|
"dominus",
|
|
@@ -103,6 +154,32 @@ __all__ = [
|
|
|
103
154
|
"AdminNamespace",
|
|
104
155
|
"SecureNamespace",
|
|
105
156
|
"SecureAccessContext",
|
|
106
|
-
|
|
157
|
+
"SecretsNamespace",
|
|
158
|
+
"RedisNamespace",
|
|
159
|
+
"LogsNamespace",
|
|
160
|
+
"CourierNamespace",
|
|
161
|
+
"HealthNamespace",
|
|
162
|
+
"OpenNamespace",
|
|
163
|
+
# Oracle namespace for speech-to-text
|
|
164
|
+
"OracleNamespace",
|
|
165
|
+
"OracleSession",
|
|
166
|
+
"OracleSessionOptions",
|
|
167
|
+
"VADState",
|
|
168
|
+
# Cache and resilience utilities
|
|
107
169
|
"dominus_cache",
|
|
170
|
+
"CircuitBreaker",
|
|
171
|
+
"DominusCache",
|
|
172
|
+
"orchestrator_circuit_breaker",
|
|
173
|
+
"exponential_backoff_with_jitter",
|
|
174
|
+
# Error classes
|
|
175
|
+
"DominusError",
|
|
176
|
+
"AuthenticationError",
|
|
177
|
+
"AuthorizationError",
|
|
178
|
+
"NotFoundError",
|
|
179
|
+
"ValidationError",
|
|
180
|
+
"ConflictError",
|
|
181
|
+
"ServiceError",
|
|
182
|
+
"SecureTableError",
|
|
183
|
+
"DominusConnectionError",
|
|
184
|
+
"DominusTimeoutError",
|
|
108
185
|
]
|
dominus/helpers/cache.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Internal cache with automatic encryption and circuit breaker.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Exports:
|
|
5
|
+
- CircuitBreaker: Circuit breaker class for resilience
|
|
6
|
+
- DominusCache: Encrypted cache class
|
|
7
|
+
- dominus_cache: Singleton cache instance
|
|
8
|
+
- orchestrator_circuit_breaker: Circuit breaker for orchestrator
|
|
9
|
+
- exponential_backoff_with_jitter: Backoff calculation utility
|
|
5
10
|
"""
|
|
6
11
|
import time
|
|
7
12
|
import json
|
|
@@ -181,12 +186,15 @@ class DominusCache:
|
|
|
181
186
|
return count
|
|
182
187
|
|
|
183
188
|
|
|
184
|
-
#
|
|
189
|
+
# Singleton instances
|
|
185
190
|
dominus_cache = DominusCache(default_ttl=300)
|
|
186
191
|
|
|
187
|
-
# Circuit
|
|
188
|
-
|
|
192
|
+
# Circuit breaker for orchestrator (prevents retry storms)
|
|
193
|
+
orchestrator_circuit_breaker = CircuitBreaker(
|
|
189
194
|
failure_threshold=5, # Open after 5 consecutive failures
|
|
190
195
|
recovery_timeout=30.0, # Try again after 30 seconds
|
|
191
196
|
half_open_max_calls=1 # Allow 1 test call in half-open state
|
|
192
197
|
)
|
|
198
|
+
|
|
199
|
+
# Backward compatibility alias
|
|
200
|
+
sovereign_circuit_breaker = orchestrator_circuit_breaker
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Oracle Namespace - Real-time streaming speech-to-text.
|
|
3
|
+
|
|
4
|
+
Provides WebSocket-based streaming transcription via Deepgram,
|
|
5
|
+
with built-in VAD (Voice Activity Detection) for cost optimization.
|
|
6
|
+
|
|
7
|
+
Key features:
|
|
8
|
+
- Automatic microphone capture and 16kHz resampling
|
|
9
|
+
- VAD gating: only sends audio when speech is detected
|
|
10
|
+
- 4-state VAD machine: IDLE -> ARMED -> SPEAKING -> TRAILING
|
|
11
|
+
- Pre-roll buffer captures word onsets
|
|
12
|
+
- Ping/pong keepalive during IDLE
|
|
13
|
+
- NO send_audio() exposed - VAD handles everything
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
session = dominus.oracle.create_session(user_jwt)
|
|
17
|
+
|
|
18
|
+
session.on_ready = lambda: set_listening(True)
|
|
19
|
+
session.on_interim = lambda text: set_live_transcript(text)
|
|
20
|
+
session.on_utterance = lambda text: send_to_curator(text)
|
|
21
|
+
session.on_vad_state_change = lambda state: set_mic_state(state)
|
|
22
|
+
session.on_error = lambda error: show_error(error)
|
|
23
|
+
|
|
24
|
+
await session.start()
|
|
25
|
+
# ... user speaks, transcripts flow back ...
|
|
26
|
+
await session.stop()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from typing import Optional, TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from .types import (
|
|
32
|
+
VADState,
|
|
33
|
+
OracleSessionOptions,
|
|
34
|
+
ResolvedOracleSessionOptions,
|
|
35
|
+
DEFAULT_OPTIONS,
|
|
36
|
+
AUDIO_CONFIG,
|
|
37
|
+
)
|
|
38
|
+
from .session import OracleSession
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ...start import Dominus
|
|
42
|
+
|
|
43
|
+
# Re-export public types
|
|
44
|
+
__all__ = [
|
|
45
|
+
"OracleNamespace",
|
|
46
|
+
"OracleSession",
|
|
47
|
+
"OracleSessionOptions",
|
|
48
|
+
"VADState",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OracleNamespace:
|
|
53
|
+
"""
|
|
54
|
+
OracleNamespace - Factory for creating streaming transcription sessions.
|
|
55
|
+
|
|
56
|
+
The Oracle namespace provides a simple API for real-time speech-to-text:
|
|
57
|
+
- create_session() creates a new transcription session
|
|
58
|
+
- Sessions handle mic capture, VAD, WebSocket, and transcripts internally
|
|
59
|
+
- NO raw audio access - VAD is mandatory for cost control
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, client: "Dominus"):
|
|
63
|
+
from ...config.endpoints import BASE_URL
|
|
64
|
+
self._base_url = BASE_URL
|
|
65
|
+
self._client = client
|
|
66
|
+
|
|
67
|
+
def create_session(
|
|
68
|
+
self,
|
|
69
|
+
user_token: str,
|
|
70
|
+
options: Optional[OracleSessionOptions] = None
|
|
71
|
+
) -> OracleSession:
|
|
72
|
+
"""
|
|
73
|
+
Create a streaming transcription session.
|
|
74
|
+
|
|
75
|
+
The session handles everything internally:
|
|
76
|
+
- Microphone access and audio capture
|
|
77
|
+
- Resampling to 16kHz mono PCM
|
|
78
|
+
- VAD gating (only sends speech, not silence)
|
|
79
|
+
- WebSocket connection to Oracle
|
|
80
|
+
- Reconnection on connection loss
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
user_token: User JWT from portal.login()
|
|
84
|
+
options: Optional configuration overrides
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
OracleSession ready to start()
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
session = dominus.oracle.create_session(user_jwt, OracleSessionOptions(
|
|
91
|
+
preroll_ms=320, # Capture 320ms before speech
|
|
92
|
+
postroll_ms=400, # Continue 400ms after speech
|
|
93
|
+
armed_confirm_ms=80, # Require 80ms to confirm speech
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
session.on_utterance = lambda text: send_to_curator(text)
|
|
97
|
+
|
|
98
|
+
await session.start()
|
|
99
|
+
"""
|
|
100
|
+
# Merge options with defaults
|
|
101
|
+
if options:
|
|
102
|
+
resolved_options = ResolvedOracleSessionOptions(
|
|
103
|
+
preroll_ms=options.preroll_ms if options.preroll_ms != DEFAULT_OPTIONS.preroll_ms else DEFAULT_OPTIONS.preroll_ms,
|
|
104
|
+
postroll_ms=options.postroll_ms if options.postroll_ms != DEFAULT_OPTIONS.postroll_ms else DEFAULT_OPTIONS.postroll_ms,
|
|
105
|
+
armed_confirm_ms=options.armed_confirm_ms if options.armed_confirm_ms != DEFAULT_OPTIONS.armed_confirm_ms else DEFAULT_OPTIONS.armed_confirm_ms,
|
|
106
|
+
vad_threshold=options.vad_threshold if options.vad_threshold != DEFAULT_OPTIONS.vad_threshold else DEFAULT_OPTIONS.vad_threshold,
|
|
107
|
+
energy_threshold=options.energy_threshold if options.energy_threshold != DEFAULT_OPTIONS.energy_threshold else DEFAULT_OPTIONS.energy_threshold,
|
|
108
|
+
ping_interval_ms=options.ping_interval_ms if options.ping_interval_ms != DEFAULT_OPTIONS.ping_interval_ms else DEFAULT_OPTIONS.ping_interval_ms,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
resolved_options = ResolvedOracleSessionOptions(
|
|
112
|
+
preroll_ms=DEFAULT_OPTIONS.preroll_ms,
|
|
113
|
+
postroll_ms=DEFAULT_OPTIONS.postroll_ms,
|
|
114
|
+
armed_confirm_ms=DEFAULT_OPTIONS.armed_confirm_ms,
|
|
115
|
+
vad_threshold=DEFAULT_OPTIONS.vad_threshold,
|
|
116
|
+
energy_threshold=DEFAULT_OPTIONS.energy_threshold,
|
|
117
|
+
ping_interval_ms=DEFAULT_OPTIONS.ping_interval_ms,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return OracleSession(self._base_url, user_token, resolved_options)
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AudioCapture - Microphone capture and resampling (INTERNAL)
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Microphone access via sounddevice or pyaudio
|
|
6
|
+
- Resampling to 16kHz mono if needed
|
|
7
|
+
- Output 20ms frames (640 bytes PCM16)
|
|
8
|
+
|
|
9
|
+
This module is INTERNAL and should NOT be exported publicly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import struct
|
|
14
|
+
import math
|
|
15
|
+
from typing import Callable, Optional, List
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .types import AUDIO_CONFIG
|
|
19
|
+
|
|
20
|
+
# Try to import audio libraries
|
|
21
|
+
try:
|
|
22
|
+
import sounddevice as sd
|
|
23
|
+
import numpy as np
|
|
24
|
+
SOUNDDEVICE_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
SOUNDDEVICE_AVAILABLE = False
|
|
27
|
+
sd = None
|
|
28
|
+
np = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import pyaudio
|
|
32
|
+
PYAUDIO_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
PYAUDIO_AVAILABLE = False
|
|
35
|
+
pyaudio = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resample(input_data: List[float], from_rate: int, to_rate: int) -> List[float]:
|
|
39
|
+
"""
|
|
40
|
+
Resample audio data using linear interpolation.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
input_data: Input samples as floats
|
|
44
|
+
from_rate: Source sample rate
|
|
45
|
+
to_rate: Target sample rate
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Resampled audio data
|
|
49
|
+
"""
|
|
50
|
+
if from_rate == to_rate:
|
|
51
|
+
return input_data
|
|
52
|
+
|
|
53
|
+
ratio = from_rate / to_rate
|
|
54
|
+
output_length = math.ceil(len(input_data) / ratio)
|
|
55
|
+
output = []
|
|
56
|
+
|
|
57
|
+
for i in range(output_length):
|
|
58
|
+
src_index = i * ratio
|
|
59
|
+
src_floor = int(src_index)
|
|
60
|
+
src_ceil = min(src_floor + 1, len(input_data) - 1)
|
|
61
|
+
t = src_index - src_floor
|
|
62
|
+
|
|
63
|
+
# Linear interpolation
|
|
64
|
+
value = input_data[src_floor] * (1 - t) + input_data[src_ceil] * t
|
|
65
|
+
output.append(value)
|
|
66
|
+
|
|
67
|
+
return output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def float_to_pcm16(samples: List[float]) -> bytes:
|
|
71
|
+
"""
|
|
72
|
+
Convert float samples [-1, 1] to PCM16 bytes.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
samples: Float samples in range [-1, 1]
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
PCM16 bytes (little-endian)
|
|
79
|
+
"""
|
|
80
|
+
pcm_values = []
|
|
81
|
+
for s in samples:
|
|
82
|
+
# Clamp to [-1, 1]
|
|
83
|
+
s = max(-1.0, min(1.0, s))
|
|
84
|
+
# Convert to int16
|
|
85
|
+
if s < 0:
|
|
86
|
+
pcm_values.append(int(s * 0x8000))
|
|
87
|
+
else:
|
|
88
|
+
pcm_values.append(int(s * 0x7FFF))
|
|
89
|
+
|
|
90
|
+
return struct.pack(f"<{len(pcm_values)}h", *pcm_values)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SoundDeviceCapture:
|
|
94
|
+
"""
|
|
95
|
+
Audio capture using sounddevice library.
|
|
96
|
+
Preferred for cross-platform compatibility.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self):
|
|
100
|
+
if not SOUNDDEVICE_AVAILABLE:
|
|
101
|
+
raise ImportError(
|
|
102
|
+
"sounddevice package is required for audio capture. "
|
|
103
|
+
"Install with: pip install sounddevice numpy"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._stream: Optional[sd.InputStream] = None
|
|
107
|
+
self._buffer: List[float] = []
|
|
108
|
+
self._is_capturing = False
|
|
109
|
+
self._callback_queue: asyncio.Queue = None
|
|
110
|
+
self._process_task: Optional[asyncio.Task] = None
|
|
111
|
+
|
|
112
|
+
self.on_frame: Optional[Callable[[bytes], None]] = None
|
|
113
|
+
|
|
114
|
+
async def start(self) -> None:
|
|
115
|
+
"""Start audio capture from microphone."""
|
|
116
|
+
if self._is_capturing:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self._callback_queue = asyncio.Queue()
|
|
120
|
+
|
|
121
|
+
def audio_callback(indata, frames, time_info, status):
|
|
122
|
+
if status:
|
|
123
|
+
print(f"[OracleSDK] Audio status: {status}")
|
|
124
|
+
|
|
125
|
+
# Convert numpy array to list of floats
|
|
126
|
+
mono_data = indata[:, 0].tolist() if indata.ndim > 1 else indata.flatten().tolist()
|
|
127
|
+
|
|
128
|
+
# Put data in queue for async processing
|
|
129
|
+
try:
|
|
130
|
+
self._callback_queue.put_nowait(mono_data)
|
|
131
|
+
except asyncio.QueueFull:
|
|
132
|
+
pass # Drop frames if queue is full
|
|
133
|
+
|
|
134
|
+
# Open stream
|
|
135
|
+
self._stream = sd.InputStream(
|
|
136
|
+
samplerate=AUDIO_CONFIG.SAMPLE_RATE,
|
|
137
|
+
channels=AUDIO_CONFIG.CHANNELS,
|
|
138
|
+
dtype='float32',
|
|
139
|
+
blocksize=1024, # Good balance of latency vs efficiency
|
|
140
|
+
callback=audio_callback,
|
|
141
|
+
)
|
|
142
|
+
self._stream.start()
|
|
143
|
+
self._is_capturing = True
|
|
144
|
+
|
|
145
|
+
# Start processing task
|
|
146
|
+
self._process_task = asyncio.create_task(self._process_audio())
|
|
147
|
+
|
|
148
|
+
async def stop(self) -> None:
|
|
149
|
+
"""Stop audio capture."""
|
|
150
|
+
if not self._is_capturing:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
self._is_capturing = False
|
|
154
|
+
|
|
155
|
+
if self._process_task:
|
|
156
|
+
self._process_task.cancel()
|
|
157
|
+
try:
|
|
158
|
+
await self._process_task
|
|
159
|
+
except asyncio.CancelledError:
|
|
160
|
+
pass
|
|
161
|
+
self._process_task = None
|
|
162
|
+
|
|
163
|
+
if self._stream:
|
|
164
|
+
self._stream.stop()
|
|
165
|
+
self._stream.close()
|
|
166
|
+
self._stream = None
|
|
167
|
+
|
|
168
|
+
self._buffer = []
|
|
169
|
+
|
|
170
|
+
async def _process_audio(self) -> None:
|
|
171
|
+
"""Process audio from queue and emit frames."""
|
|
172
|
+
while self._is_capturing:
|
|
173
|
+
try:
|
|
174
|
+
# Get audio data from queue
|
|
175
|
+
audio_data = await asyncio.wait_for(
|
|
176
|
+
self._callback_queue.get(),
|
|
177
|
+
timeout=0.1
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Append to buffer
|
|
181
|
+
self._buffer.extend(audio_data)
|
|
182
|
+
|
|
183
|
+
# Extract complete frames
|
|
184
|
+
while len(self._buffer) >= AUDIO_CONFIG.SAMPLES_PER_FRAME:
|
|
185
|
+
frame_samples = self._buffer[:AUDIO_CONFIG.SAMPLES_PER_FRAME]
|
|
186
|
+
self._buffer = self._buffer[AUDIO_CONFIG.SAMPLES_PER_FRAME:]
|
|
187
|
+
|
|
188
|
+
# Convert to PCM16
|
|
189
|
+
pcm_frame = float_to_pcm16(frame_samples)
|
|
190
|
+
|
|
191
|
+
if self.on_frame:
|
|
192
|
+
self.on_frame(pcm_frame)
|
|
193
|
+
|
|
194
|
+
except asyncio.TimeoutError:
|
|
195
|
+
continue
|
|
196
|
+
except asyncio.CancelledError:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class PyAudioCapture:
|
|
201
|
+
"""
|
|
202
|
+
Audio capture using PyAudio library.
|
|
203
|
+
Fallback for environments where sounddevice doesn't work.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self):
|
|
207
|
+
if not PYAUDIO_AVAILABLE:
|
|
208
|
+
raise ImportError(
|
|
209
|
+
"pyaudio package is required for audio capture. "
|
|
210
|
+
"Install with: pip install pyaudio"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self._pa: Optional[pyaudio.PyAudio] = None
|
|
214
|
+
self._stream = None
|
|
215
|
+
self._buffer: List[float] = []
|
|
216
|
+
self._is_capturing = False
|
|
217
|
+
self._callback_queue: asyncio.Queue = None
|
|
218
|
+
self._process_task: Optional[asyncio.Task] = None
|
|
219
|
+
|
|
220
|
+
self.on_frame: Optional[Callable[[bytes], None]] = None
|
|
221
|
+
|
|
222
|
+
async def start(self) -> None:
|
|
223
|
+
"""Start audio capture from microphone."""
|
|
224
|
+
if self._is_capturing:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
self._pa = pyaudio.PyAudio()
|
|
228
|
+
self._callback_queue = asyncio.Queue()
|
|
229
|
+
|
|
230
|
+
def audio_callback(in_data, frame_count, time_info, status):
|
|
231
|
+
# Convert bytes to float samples
|
|
232
|
+
num_samples = len(in_data) // 2
|
|
233
|
+
samples = struct.unpack(f"<{num_samples}h", in_data)
|
|
234
|
+
# Normalize to [-1, 1]
|
|
235
|
+
float_samples = [s / 32768.0 for s in samples]
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
self._callback_queue.put_nowait(float_samples)
|
|
239
|
+
except asyncio.QueueFull:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
return (None, pyaudio.paContinue)
|
|
243
|
+
|
|
244
|
+
# Open stream
|
|
245
|
+
self._stream = self._pa.open(
|
|
246
|
+
format=pyaudio.paInt16,
|
|
247
|
+
channels=AUDIO_CONFIG.CHANNELS,
|
|
248
|
+
rate=AUDIO_CONFIG.SAMPLE_RATE,
|
|
249
|
+
input=True,
|
|
250
|
+
frames_per_buffer=1024,
|
|
251
|
+
stream_callback=audio_callback,
|
|
252
|
+
)
|
|
253
|
+
self._stream.start_stream()
|
|
254
|
+
self._is_capturing = True
|
|
255
|
+
|
|
256
|
+
# Start processing task
|
|
257
|
+
self._process_task = asyncio.create_task(self._process_audio())
|
|
258
|
+
|
|
259
|
+
async def stop(self) -> None:
|
|
260
|
+
"""Stop audio capture."""
|
|
261
|
+
if not self._is_capturing:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
self._is_capturing = False
|
|
265
|
+
|
|
266
|
+
if self._process_task:
|
|
267
|
+
self._process_task.cancel()
|
|
268
|
+
try:
|
|
269
|
+
await self._process_task
|
|
270
|
+
except asyncio.CancelledError:
|
|
271
|
+
pass
|
|
272
|
+
self._process_task = None
|
|
273
|
+
|
|
274
|
+
if self._stream:
|
|
275
|
+
self._stream.stop_stream()
|
|
276
|
+
self._stream.close()
|
|
277
|
+
self._stream = None
|
|
278
|
+
|
|
279
|
+
if self._pa:
|
|
280
|
+
self._pa.terminate()
|
|
281
|
+
self._pa = None
|
|
282
|
+
|
|
283
|
+
self._buffer = []
|
|
284
|
+
|
|
285
|
+
async def _process_audio(self) -> None:
|
|
286
|
+
"""Process audio from queue and emit frames."""
|
|
287
|
+
while self._is_capturing:
|
|
288
|
+
try:
|
|
289
|
+
audio_data = await asyncio.wait_for(
|
|
290
|
+
self._callback_queue.get(),
|
|
291
|
+
timeout=0.1
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
self._buffer.extend(audio_data)
|
|
295
|
+
|
|
296
|
+
while len(self._buffer) >= AUDIO_CONFIG.SAMPLES_PER_FRAME:
|
|
297
|
+
frame_samples = self._buffer[:AUDIO_CONFIG.SAMPLES_PER_FRAME]
|
|
298
|
+
self._buffer = self._buffer[AUDIO_CONFIG.SAMPLES_PER_FRAME:]
|
|
299
|
+
|
|
300
|
+
pcm_frame = float_to_pcm16(frame_samples)
|
|
301
|
+
|
|
302
|
+
if self.on_frame:
|
|
303
|
+
self.on_frame(pcm_frame)
|
|
304
|
+
|
|
305
|
+
except asyncio.TimeoutError:
|
|
306
|
+
continue
|
|
307
|
+
except asyncio.CancelledError:
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class AudioCapture:
|
|
312
|
+
"""
|
|
313
|
+
AudioCapture - Main class for microphone capture.
|
|
314
|
+
|
|
315
|
+
Automatically uses sounddevice if available, falls back to PyAudio.
|
|
316
|
+
Outputs 20ms frames (640 bytes PCM16) at 16kHz mono.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(self):
|
|
320
|
+
self._capture = None
|
|
321
|
+
self._is_capturing = False
|
|
322
|
+
|
|
323
|
+
# Callback for each audio frame
|
|
324
|
+
self.on_frame: Optional[Callable[[bytes], None]] = None
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def is_capturing(self) -> bool:
|
|
328
|
+
"""Check if currently capturing audio."""
|
|
329
|
+
return self._is_capturing
|
|
330
|
+
|
|
331
|
+
async def start(self) -> None:
|
|
332
|
+
"""
|
|
333
|
+
Start audio capture from microphone.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
ImportError: If no audio library is available
|
|
337
|
+
Exception: If microphone access fails
|
|
338
|
+
"""
|
|
339
|
+
if self._is_capturing:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# Choose capture method based on available libraries
|
|
343
|
+
if SOUNDDEVICE_AVAILABLE:
|
|
344
|
+
try:
|
|
345
|
+
self._capture = SoundDeviceCapture()
|
|
346
|
+
await self._capture.start()
|
|
347
|
+
except Exception as e:
|
|
348
|
+
print(f"[OracleSDK] sounddevice failed, trying PyAudio: {e}")
|
|
349
|
+
if PYAUDIO_AVAILABLE:
|
|
350
|
+
self._capture = PyAudioCapture()
|
|
351
|
+
await self._capture.start()
|
|
352
|
+
else:
|
|
353
|
+
raise
|
|
354
|
+
elif PYAUDIO_AVAILABLE:
|
|
355
|
+
self._capture = PyAudioCapture()
|
|
356
|
+
await self._capture.start()
|
|
357
|
+
else:
|
|
358
|
+
raise ImportError(
|
|
359
|
+
"Audio capture requires either sounddevice or pyaudio. "
|
|
360
|
+
"Install with: pip install sounddevice numpy OR pip install pyaudio"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Wire up frame callback
|
|
364
|
+
self._capture.on_frame = lambda pcm_frame: (
|
|
365
|
+
self.on_frame(pcm_frame) if self.on_frame else None
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
self._is_capturing = True
|
|
369
|
+
|
|
370
|
+
async def stop(self) -> None:
|
|
371
|
+
"""Stop audio capture."""
|
|
372
|
+
if not self._is_capturing or not self._capture:
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
await self._capture.stop()
|
|
376
|
+
self._capture = None
|
|
377
|
+
self._is_capturing = False
|
|
378
|
+
|
|
379
|
+
def dispose(self) -> None:
|
|
380
|
+
"""Clean up resources."""
|
|
381
|
+
asyncio.create_task(self.stop())
|
|
382
|
+
self.on_frame = None
|