attenlabs-saa 0.3.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.
- attenlabs_saa-0.3.0.dist-info/METADATA +260 -0
- attenlabs_saa-0.3.0.dist-info/RECORD +9 -0
- attenlabs_saa-0.3.0.dist-info/WHEEL +4 -0
- attenlabs_saa-0.3.0.dist-info/licenses/LICENSE +21 -0
- saa/__init__.py +36 -0
- saa/capture.py +218 -0
- saa/client.py +495 -0
- saa/events.py +77 -0
- saa/ws_protocol.py +8 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attenlabs-saa
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for AttentionLabs real-time attention detection.
|
|
5
|
+
Project-URL: Homepage, https://attentionlabs.ai
|
|
6
|
+
Author: Attention Labs
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: attention,audio,realtime,saa,sd-attention,vad,video,websocket
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Requires-Dist: opencv-python>=4.8
|
|
19
|
+
Requires-Dist: sounddevice>=0.4.6
|
|
20
|
+
Requires-Dist: websocket-client>=1.6
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# attenlabs-saa
|
|
24
|
+
|
|
25
|
+
Python SDK for [Attention Labs](https://attentionlabs.ai) real-time selective auditory attention.
|
|
26
|
+
|
|
27
|
+
Every voice pipeline has the same problem: the microphone hears everything, but your ASR should only process speech directed at the device. Wake words solve this with a rigid trigger phrase. SAA solves it without one — classifying every audio frame as **silent**, **human-directed**, or **device-directed** and routing only what matters.
|
|
28
|
+
|
|
29
|
+
`attenlabs-saa` streams mic and webcam data to the SAA inference server over WebSocket and emits typed events: attention predictions, voice activity, conversation state, and ready-to-forward speech audio. LLM routing is left to you.
|
|
30
|
+
|
|
31
|
+
## Sign up
|
|
32
|
+
|
|
33
|
+
Get your API token at [attentionlabs.ai/dashboard](https://attentionlabs.ai/dashboard).
|
|
34
|
+
|
|
35
|
+
**You need your API Key for this project to work**
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install attenlabs-saa
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.10+. `sounddevice` and `opencv-python` are pulled in automatically for mic and camera access.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import time
|
|
49
|
+
from saa import AttentionClient
|
|
50
|
+
|
|
51
|
+
client = AttentionClient(token="your-token")
|
|
52
|
+
|
|
53
|
+
@client.on_prediction
|
|
54
|
+
def _(event):
|
|
55
|
+
label = {0: "silent", 1: "human", 2: "device"}.get(event.cls, "?")
|
|
56
|
+
print(f"{label} {event.confidence:.0%} faces={event.num_faces} src={event.source}")
|
|
57
|
+
|
|
58
|
+
@client.on_speech_ready
|
|
59
|
+
def _(event):
|
|
60
|
+
# event.audio_base64 — base64 PCM16 @ 16 kHz mono, ready for OpenAI Realtime / any LLM
|
|
61
|
+
# event.audio_pcm16 — same audio as np.int16 array
|
|
62
|
+
print(f"speech ready ({event.duration_sec:.2f}s)")
|
|
63
|
+
|
|
64
|
+
@client.on_error
|
|
65
|
+
def _(event):
|
|
66
|
+
print(f"ERROR: {event.title}: {event.message}")
|
|
67
|
+
|
|
68
|
+
client.start()
|
|
69
|
+
try:
|
|
70
|
+
while True:
|
|
71
|
+
time.sleep(0.1)
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
client.stop()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A full CLI demo wiring SAA + OpenAI Realtime lives at [**saa-py-demo**](https://github.com/attenlabs/saa-py-demo).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### `AttentionClient`
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from saa import AttentionClient, CameraConfig, MicConfig
|
|
86
|
+
|
|
87
|
+
client = AttentionClient(
|
|
88
|
+
token="...", # Auth token — sent as WS subprotocol
|
|
89
|
+
url=None, # Server URL (default: wss://server.attentionlabs.ai/ws)
|
|
90
|
+
video=CameraConfig(), # Webcam config
|
|
91
|
+
audio=MicConfig(), # Mic config
|
|
92
|
+
initial_threshold=0.7, # Device-class confidence threshold (0..1)
|
|
93
|
+
enable_audio=True, # Set False to skip mic capture
|
|
94
|
+
enable_video=True, # Set False to skip webcam capture
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Configuration
|
|
99
|
+
|
|
100
|
+
#### `MicConfig`
|
|
101
|
+
|
|
102
|
+
| field | type | default | notes |
|
|
103
|
+
| ---------- | ----------------------- | ------- | ------------------------------------------ |
|
|
104
|
+
| `device` | `int \| str \| None` | `None` | Device index, name, or `None` for system default |
|
|
105
|
+
| `channels` | `int` | `1` | Number of input channels |
|
|
106
|
+
|
|
107
|
+
#### `CameraConfig`
|
|
108
|
+
|
|
109
|
+
| field | type | default | notes |
|
|
110
|
+
| -------------- | ----- | ------- | ----------------------------- |
|
|
111
|
+
| `device_index` | `int` | `0` | Webcam device index |
|
|
112
|
+
| `width` | `int` | `1920` | Capture width |
|
|
113
|
+
| `height` | `int` | `1080` | Capture height |
|
|
114
|
+
| `jpeg_quality` | `int` | `60` | JPEG compression quality 0–100 |
|
|
115
|
+
|
|
116
|
+
### Methods
|
|
117
|
+
|
|
118
|
+
| method | description |
|
|
119
|
+
| ---------------------------- | ----------- |
|
|
120
|
+
| `start()` | Opens WebSocket, acquires mic + camera, starts capture threads. Non-blocking. Raises on handshake failure. |
|
|
121
|
+
| `stop()` | Tears down capture, joins threads, closes WebSocket. |
|
|
122
|
+
| `mute()` | Pauses upstream audio and signals server to stop VAD. |
|
|
123
|
+
| `unmute()` | Resumes upstream audio. |
|
|
124
|
+
| `mark_responding(bool)` | Tell the server an LLM response is in flight. Server stops emitting predictions while `True`. |
|
|
125
|
+
| `set_threshold(value: float)` | Update device-class confidence threshold (0..1). Server acks via `config` event. |
|
|
126
|
+
|
|
127
|
+
### Events
|
|
128
|
+
|
|
129
|
+
Register handlers with decorators. All callbacks fire on internal threads — keep them fast or hand work off to your own thread.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
@client.on_prediction
|
|
133
|
+
def handle(event):
|
|
134
|
+
...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| decorator | payload | fires when |
|
|
138
|
+
| --------------------- | ------------------------------------------------------------------------ | --------------------------------------- |
|
|
139
|
+
| `@on_connected` | — | WebSocket opens |
|
|
140
|
+
| `@on_started` | — | Server-side warmup complete |
|
|
141
|
+
| `@on_warmup_complete` | — | First non-zero-confidence prediction |
|
|
142
|
+
| `@on_prediction` | `PredictionEvent` | Each attention prediction |
|
|
143
|
+
| `@on_vad` | `VadEvent` | Voice activity update |
|
|
144
|
+
| `@on_state` | `StateEvent` | Conversation state transition |
|
|
145
|
+
| `@on_speech_ready` | `SpeechReadyEvent` | Complete speech segment ready to forward |
|
|
146
|
+
| `@on_config` | `ConfigEvent` | Server acks a threshold change |
|
|
147
|
+
| `@on_stats` | `StatsEvent` | Every ~10s with connection health |
|
|
148
|
+
| `@on_error` | `AttentionErrorEvent` | Connection, auth, or server error |
|
|
149
|
+
| `@on_disconnected` | `DisconnectedEvent` | WebSocket closes |
|
|
150
|
+
|
|
151
|
+
### Event types
|
|
152
|
+
|
|
153
|
+
#### `PredictionEvent`
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
cls: int # 0 = silent, 1 = human-directed, 2 = device-directed
|
|
157
|
+
confidence: float # 0..1
|
|
158
|
+
source: str # "video" or "audio"
|
|
159
|
+
num_faces: int # faces detected in frame
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### `VadEvent`
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
probability: float # VAD probability 0..1
|
|
166
|
+
is_speech: bool # whether speech was detected
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
#### `StateEvent`
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
state: ConversationState # "listening" | "sending" | "cancelled" | "idle"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### `SpeechReadyEvent`
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
audio_pcm16: np.ndarray # int16 array @ 16 kHz mono
|
|
179
|
+
audio_base64: str # same audio as base64 — ready for OpenAI Realtime, etc.
|
|
180
|
+
duration_sec: float # duration in seconds
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### `ConfigEvent`
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
model_class2_threshold: float # server-confirmed threshold
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### `StatsEvent`
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
rtt_ms: float | None # round-trip latency in ms
|
|
193
|
+
sent_video: int # total video frames sent
|
|
194
|
+
skipped_video: int # total video frames skipped
|
|
195
|
+
sent_audio: int # total audio chunks sent
|
|
196
|
+
uptime_s: float # connection uptime in seconds
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
#### `AttentionErrorEvent`
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
title: str # error category ("Auth Failed", "Connection Stalled", etc.)
|
|
203
|
+
message: str # human-readable message
|
|
204
|
+
detail: str | None = None # technical detail
|
|
205
|
+
code: int | None = None # WebSocket close code, if applicable
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### `DisconnectedEvent`
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
code: int # WebSocket close code
|
|
212
|
+
reason: str # close reason
|
|
213
|
+
was_clean: bool # True if code == 1000
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## LLM integration
|
|
219
|
+
|
|
220
|
+
LLM routing is intentionally **not** part of the SDK. The `speech_ready` event hands you PCM16 audio — both as a NumPy array and as base64 — forward it wherever you like.
|
|
221
|
+
|
|
222
|
+
When your LLM starts generating, call `mute()` + `mark_responding(True)` to suppress predictions during playback. When it finishes, `unmute()` + `mark_responding(False)`.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from saa import AttentionClient
|
|
226
|
+
|
|
227
|
+
client = AttentionClient(token="...")
|
|
228
|
+
|
|
229
|
+
@client.on_speech_ready
|
|
230
|
+
def _(event):
|
|
231
|
+
# Forward to your LLM of choice
|
|
232
|
+
your_llm.send(event.audio_base64)
|
|
233
|
+
|
|
234
|
+
def on_llm_speaking():
|
|
235
|
+
client.mute()
|
|
236
|
+
client.mark_responding(True)
|
|
237
|
+
|
|
238
|
+
def on_llm_done():
|
|
239
|
+
client.unmute()
|
|
240
|
+
client.mark_responding(False)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
See [**saa-py-demo**](https://github.com/attenlabs/saa-py-demo) for a full working example with OpenAI Realtime.
|
|
244
|
+
|
|
245
|
+
## Threading model
|
|
246
|
+
|
|
247
|
+
The SDK manages four threads internally:
|
|
248
|
+
|
|
249
|
+
| thread | purpose |
|
|
250
|
+
| ---------------- | ---------------------------------- |
|
|
251
|
+
| `saa-ws` | WebSocket send/receive |
|
|
252
|
+
| `saa-heartbeat` | JSON pings every 5s, stats every 10s |
|
|
253
|
+
| `saa-camera` | JPEG capture at 4 fps (250 ms) |
|
|
254
|
+
| *(sounddevice)* | Audio callback at native sample rate, resampled to 16 kHz |
|
|
255
|
+
|
|
256
|
+
All event callbacks fire on `saa-ws` or `saa-heartbeat`. Don't block them — offload heavy work to your own thread.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
saa/__init__.py,sha256=6sVNTkxUOIOyCzpKyOOk91w8v34o4QutyHFGYiOqkFc,703
|
|
2
|
+
saa/capture.py,sha256=VerHOBnh3GcmcAvJKOy_ZA1_7N67eOznm0AGuIt9zOk,7852
|
|
3
|
+
saa/client.py,sha256=ta-TNIgF-_if1boMgYSjbLXpv_NlotG_apj1ZNxpZ70,18788
|
|
4
|
+
saa/events.py,sha256=rxcQ2AE_YAU9mJeUjWCVaoQFmlgQoPB9K5JmroTK_vA,1541
|
|
5
|
+
saa/ws_protocol.py,sha256=69VVf8gaax6UGpQEaIX8JRMb7sKzSYFuf-zcPuh_cXM,159
|
|
6
|
+
attenlabs_saa-0.3.0.dist-info/METADATA,sha256=LW_OY3eHW2G-4GVtcFC9_f27K869mK9m7DHZaz-vHwQ,9914
|
|
7
|
+
attenlabs_saa-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
attenlabs_saa-0.3.0.dist-info/licenses/LICENSE,sha256=7qfx8NuzUIobqroBD32MmK1qmZE9B88hscm9N7O_XcQ,1071
|
|
9
|
+
attenlabs_saa-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Attention Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
saa/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""saa-py — Python SDK for the SD Attention Server (SAA)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .capture import CameraConfig, MicConfig
|
|
6
|
+
from .client import AttentionClient
|
|
7
|
+
from .events import (
|
|
8
|
+
AttentionErrorEvent,
|
|
9
|
+
ConfigEvent,
|
|
10
|
+
ConversationState,
|
|
11
|
+
DisconnectedEvent,
|
|
12
|
+
PredictionEvent,
|
|
13
|
+
StateEvent,
|
|
14
|
+
StatsEvent,
|
|
15
|
+
TurnFrame,
|
|
16
|
+
TurnReadyEvent,
|
|
17
|
+
VadEvent,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AttentionClient",
|
|
22
|
+
"CameraConfig",
|
|
23
|
+
"MicConfig",
|
|
24
|
+
"PredictionEvent",
|
|
25
|
+
"VadEvent",
|
|
26
|
+
"StateEvent",
|
|
27
|
+
"TurnFrame",
|
|
28
|
+
"TurnReadyEvent",
|
|
29
|
+
"ConfigEvent",
|
|
30
|
+
"StatsEvent",
|
|
31
|
+
"AttentionErrorEvent",
|
|
32
|
+
"DisconnectedEvent",
|
|
33
|
+
"ConversationState",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.3.0"
|
saa/capture.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
TARGET_AUDIO_RATE = 16000
|
|
12
|
+
SEND_INTERVAL_SAMPLES = 1600 # 100 ms @ 16 kHz
|
|
13
|
+
VIDEO_INTERVAL_S = 0.25
|
|
14
|
+
|
|
15
|
+
MicDevice = Union[int, str, None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MicConfig:
|
|
20
|
+
device: MicDevice = None
|
|
21
|
+
channels: int = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CameraConfig:
|
|
26
|
+
device_index: int = 0
|
|
27
|
+
width: int = 1920
|
|
28
|
+
height: int = 1080
|
|
29
|
+
jpeg_quality: int = 50
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MicCapture:
|
|
33
|
+
"""Sounddevice input stream → int16 PCM @ 16 kHz, chunked to 100 ms blocks."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: MicConfig, on_pcm16: Callable[[bytes], None]):
|
|
36
|
+
self.config = config
|
|
37
|
+
self.on_pcm16 = on_pcm16
|
|
38
|
+
self._stream: Optional[Any] = None
|
|
39
|
+
self._muted = False
|
|
40
|
+
self._buffer = np.zeros(0, dtype=np.float32)
|
|
41
|
+
self._src_rate = TARGET_AUDIO_RATE
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
# Imported lazily so `import saa` doesn't fail on systems without
|
|
46
|
+
# PortAudio installed — users who only need video capture shouldn't
|
|
47
|
+
# have to install the audio stack.
|
|
48
|
+
import sounddevice as sd
|
|
49
|
+
|
|
50
|
+
device_info = sd.query_devices(
|
|
51
|
+
self.config.device if self.config.device is not None else None,
|
|
52
|
+
kind="input",
|
|
53
|
+
)
|
|
54
|
+
native_rate = int(device_info["default_samplerate"])
|
|
55
|
+
|
|
56
|
+
# Prefer asking PortAudio for 16 kHz directly — its C-side resampler
|
|
57
|
+
# is higher quality than our linear interpolator. Fall back to the
|
|
58
|
+
# device's native rate + manual downsample if the host API refuses.
|
|
59
|
+
try:
|
|
60
|
+
self._stream = sd.InputStream(
|
|
61
|
+
samplerate=TARGET_AUDIO_RATE,
|
|
62
|
+
channels=self.config.channels,
|
|
63
|
+
dtype="float32",
|
|
64
|
+
device=self.config.device,
|
|
65
|
+
callback=self._audio_callback,
|
|
66
|
+
)
|
|
67
|
+
self._src_rate = TARGET_AUDIO_RATE
|
|
68
|
+
except Exception:
|
|
69
|
+
self._stream = sd.InputStream(
|
|
70
|
+
samplerate=native_rate,
|
|
71
|
+
channels=self.config.channels,
|
|
72
|
+
dtype="float32",
|
|
73
|
+
device=self.config.device,
|
|
74
|
+
callback=self._audio_callback,
|
|
75
|
+
)
|
|
76
|
+
self._src_rate = native_rate
|
|
77
|
+
self._stream.start()
|
|
78
|
+
|
|
79
|
+
def _audio_callback(self, indata, frames, time_info, status):
|
|
80
|
+
# Keep capturing even when muted. The server-side mic_muted flag
|
|
81
|
+
# (set via the "mute" control action) is what gates the LLM speech
|
|
82
|
+
# accumulator; the SD-SAA inference ring buffer must keep receiving
|
|
83
|
+
# live audio every tick or AudioMetricsCalculator computes
|
|
84
|
+
# amplitude=0 during AI response, which is the key divergence vs
|
|
85
|
+
# on-device-debug where the mic is always live.
|
|
86
|
+
samples = indata[:, 0] if indata.ndim > 1 else indata.ravel()
|
|
87
|
+
if self._src_rate != TARGET_AUDIO_RATE:
|
|
88
|
+
samples = _linear_downsample(samples, self._src_rate, TARGET_AUDIO_RATE)
|
|
89
|
+
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._buffer = np.concatenate([self._buffer, samples])
|
|
92
|
+
chunks: list[np.ndarray] = []
|
|
93
|
+
while len(self._buffer) >= SEND_INTERVAL_SAMPLES:
|
|
94
|
+
chunks.append(self._buffer[:SEND_INTERVAL_SAMPLES])
|
|
95
|
+
self._buffer = self._buffer[SEND_INTERVAL_SAMPLES:]
|
|
96
|
+
|
|
97
|
+
for chunk in chunks:
|
|
98
|
+
pcm16 = np.clip(chunk * 32768.0, -32768, 32767).astype(np.int16)
|
|
99
|
+
try:
|
|
100
|
+
self.on_pcm16(pcm16.tobytes())
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def mute(self) -> None:
|
|
105
|
+
self._muted = True
|
|
106
|
+
|
|
107
|
+
def unmute(self) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._buffer = np.zeros(0, dtype=np.float32)
|
|
110
|
+
self._muted = False
|
|
111
|
+
|
|
112
|
+
def stop(self) -> None:
|
|
113
|
+
if self._stream is not None:
|
|
114
|
+
try:
|
|
115
|
+
self._stream.stop()
|
|
116
|
+
self._stream.close()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
self._stream = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CameraCapture:
|
|
123
|
+
"""OpenCV webcam → JPEG bytes at a fixed 250 ms interval.
|
|
124
|
+
|
|
125
|
+
A reader thread continuously drains the camera so the encoder always
|
|
126
|
+
snapshots the freshest available frame; without this, AVFoundation /
|
|
127
|
+
V4L2 / MSMF can hand back frames buffered up to ~150 ms ago.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, config: CameraConfig, on_jpeg: Callable[[bytes], None]):
|
|
131
|
+
self.config = config
|
|
132
|
+
self.on_jpeg = on_jpeg
|
|
133
|
+
self._cap: Optional[cv2.VideoCapture] = None
|
|
134
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
135
|
+
self._sender_thread: Optional[threading.Thread] = None
|
|
136
|
+
self._stop = threading.Event()
|
|
137
|
+
self._latest_frame: Optional[np.ndarray] = None
|
|
138
|
+
self._frame_lock = threading.Lock()
|
|
139
|
+
|
|
140
|
+
def start(self) -> None:
|
|
141
|
+
self._cap = cv2.VideoCapture(self.config.device_index)
|
|
142
|
+
if self._cap.isOpened():
|
|
143
|
+
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width)
|
|
144
|
+
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height)
|
|
145
|
+
self._cap.set(cv2.CAP_PROP_FPS, 30)
|
|
146
|
+
# Best-effort on backends that honour it (V4L2, MSMF). AVFoundation
|
|
147
|
+
# ignores it, but the reader thread keeps the queue drained anyway.
|
|
148
|
+
self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
149
|
+
self._stop.clear()
|
|
150
|
+
self._latest_frame = None
|
|
151
|
+
self._reader_thread = threading.Thread(
|
|
152
|
+
target=self._reader, daemon=True, name="saa-camera-read")
|
|
153
|
+
self._sender_thread = threading.Thread(
|
|
154
|
+
target=self._sender, daemon=True, name="saa-camera-send")
|
|
155
|
+
self._reader_thread.start()
|
|
156
|
+
self._sender_thread.start()
|
|
157
|
+
|
|
158
|
+
def _reader(self) -> None:
|
|
159
|
+
while not self._stop.is_set():
|
|
160
|
+
if self._cap is None or not self._cap.isOpened():
|
|
161
|
+
time.sleep(0.01)
|
|
162
|
+
continue
|
|
163
|
+
ok, frame = self._cap.read()
|
|
164
|
+
if not ok or frame is None:
|
|
165
|
+
time.sleep(0.005)
|
|
166
|
+
continue
|
|
167
|
+
with self._frame_lock:
|
|
168
|
+
self._latest_frame = frame
|
|
169
|
+
|
|
170
|
+
def _sender(self) -> None:
|
|
171
|
+
jpeg_params = [int(cv2.IMWRITE_JPEG_QUALITY), int(self.config.jpeg_quality)]
|
|
172
|
+
next_deadline = time.monotonic()
|
|
173
|
+
while not self._stop.is_set():
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
if now < next_deadline:
|
|
176
|
+
time.sleep(min(0.05, next_deadline - now))
|
|
177
|
+
continue
|
|
178
|
+
next_deadline = now + VIDEO_INTERVAL_S
|
|
179
|
+
|
|
180
|
+
with self._frame_lock:
|
|
181
|
+
frame = self._latest_frame
|
|
182
|
+
if frame is None:
|
|
183
|
+
continue
|
|
184
|
+
ok, buf = cv2.imencode(".jpg", frame, jpeg_params)
|
|
185
|
+
if not ok:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
self.on_jpeg(buf.tobytes())
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
def stop(self) -> None:
|
|
193
|
+
self._stop.set()
|
|
194
|
+
for t in (self._reader_thread, self._sender_thread):
|
|
195
|
+
if t is not None:
|
|
196
|
+
t.join(timeout=2.0)
|
|
197
|
+
self._reader_thread = None
|
|
198
|
+
self._sender_thread = None
|
|
199
|
+
if self._cap is not None:
|
|
200
|
+
try:
|
|
201
|
+
self._cap.release()
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
self._cap = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _linear_downsample(samples: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
|
208
|
+
if src_rate == dst_rate or len(samples) == 0:
|
|
209
|
+
return samples
|
|
210
|
+
ratio = src_rate / dst_rate
|
|
211
|
+
out_len = int(len(samples) / ratio)
|
|
212
|
+
if out_len == 0:
|
|
213
|
+
return np.zeros(0, dtype=samples.dtype)
|
|
214
|
+
indices = np.arange(out_len) * ratio
|
|
215
|
+
lo = np.floor(indices).astype(np.int64)
|
|
216
|
+
hi = np.clip(lo + 1, 0, len(samples) - 1)
|
|
217
|
+
frac = (indices - lo).astype(samples.dtype)
|
|
218
|
+
return samples[lo] * (1 - frac) + samples[hi] * frac
|
saa/client.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import websocket
|
|
14
|
+
|
|
15
|
+
from .capture import CameraCapture, CameraConfig, MicCapture, MicConfig
|
|
16
|
+
from .events import (
|
|
17
|
+
AttentionErrorEvent,
|
|
18
|
+
ConfigEvent,
|
|
19
|
+
DisconnectedEvent,
|
|
20
|
+
PredictionEvent,
|
|
21
|
+
StateEvent,
|
|
22
|
+
StatsEvent,
|
|
23
|
+
TurnFrame,
|
|
24
|
+
TurnReadyEvent,
|
|
25
|
+
VadEvent,
|
|
26
|
+
)
|
|
27
|
+
from .ws_protocol import MSG_AUDIO, MSG_VIDEO, frame_binary
|
|
28
|
+
|
|
29
|
+
# Default URL is the broker. The SDK auto-detects mode from the URL scheme:
|
|
30
|
+
# http(s)://… → POST /allocate (Bearer token), then connect to the
|
|
31
|
+
# wss URL the broker returns. Recommended default — sticky
|
|
32
|
+
# least-loaded routing across multiple backends.
|
|
33
|
+
# ws(s)://… → connect WS directly (legacy / debugging). Bypasses the
|
|
34
|
+
# broker. Useful for pinning a session to a specific
|
|
35
|
+
# backend during tests.
|
|
36
|
+
DEFAULT_SERVER_URL = "https://broker.attentionlabs.ai"
|
|
37
|
+
BROKER_ALLOCATE_TIMEOUT_S = 5.0
|
|
38
|
+
DEFAULT_THRESHOLD = 0.7
|
|
39
|
+
|
|
40
|
+
WS_PING_INTERVAL_S = 5.0
|
|
41
|
+
WS_PONG_TIMEOUT_S = 15.0
|
|
42
|
+
WS_STATS_INTERVAL_S = 10.0
|
|
43
|
+
WS_HANDSHAKE_TIMEOUT_S = 10.0
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("saa")
|
|
46
|
+
|
|
47
|
+
Listener = Callable[..., Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AttentionClient:
|
|
51
|
+
"""Streams mic + webcam to the SAA inference server and emits typed events.
|
|
52
|
+
|
|
53
|
+
Callbacks fire on the WebSocket receive thread (or on the heartbeat thread
|
|
54
|
+
for `stats`/`error`). Keep listeners fast or hand work off to your own
|
|
55
|
+
thread.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
url: Optional[str] = None,
|
|
61
|
+
token: Optional[str] = None,
|
|
62
|
+
*,
|
|
63
|
+
video: Optional[CameraConfig] = None,
|
|
64
|
+
audio: Optional[MicConfig] = None,
|
|
65
|
+
initial_threshold: float = DEFAULT_THRESHOLD,
|
|
66
|
+
enable_audio: bool = True,
|
|
67
|
+
enable_video: bool = True,
|
|
68
|
+
):
|
|
69
|
+
self.url = url or DEFAULT_SERVER_URL
|
|
70
|
+
self.token = token
|
|
71
|
+
self.video_config = video or CameraConfig()
|
|
72
|
+
self.audio_config = audio or MicConfig()
|
|
73
|
+
self.enable_audio = enable_audio
|
|
74
|
+
self.enable_video = enable_video
|
|
75
|
+
self.threshold = _clamp01(initial_threshold)
|
|
76
|
+
|
|
77
|
+
self._listeners: dict[str, list[Listener]] = {}
|
|
78
|
+
self._ws: Optional[websocket.WebSocketApp] = None
|
|
79
|
+
self._ws_thread: Optional[threading.Thread] = None
|
|
80
|
+
self._ws_open = threading.Event()
|
|
81
|
+
self._ws_closed = threading.Event()
|
|
82
|
+
self._close_info: dict = {}
|
|
83
|
+
self._mic: Optional[MicCapture] = None
|
|
84
|
+
self._cam: Optional[CameraCapture] = None
|
|
85
|
+
self._stats_thread: Optional[threading.Thread] = None
|
|
86
|
+
self._stats_stop = threading.Event()
|
|
87
|
+
self._started = False
|
|
88
|
+
|
|
89
|
+
self._sent_audio = 0
|
|
90
|
+
self._sent_video = 0
|
|
91
|
+
self._skipped_video = 0
|
|
92
|
+
self._last_rtt_ms: Optional[float] = None
|
|
93
|
+
self._last_pong_at: float = 0.0
|
|
94
|
+
self._ws_opened_at: float = 0.0
|
|
95
|
+
self._warmed_up = False
|
|
96
|
+
self._muted = False
|
|
97
|
+
|
|
98
|
+
# ── event registration (decorator style) ───────────────────────
|
|
99
|
+
|
|
100
|
+
def on_connected(self, func: Listener) -> Listener: return self._register("connected", func)
|
|
101
|
+
def on_started(self, func: Listener) -> Listener: return self._register("started", func)
|
|
102
|
+
def on_warmup_complete(self, func: Listener) -> Listener: return self._register("warmup_complete", func)
|
|
103
|
+
def on_prediction(self, func: Listener) -> Listener: return self._register("prediction", func)
|
|
104
|
+
def on_vad(self, func: Listener) -> Listener: return self._register("vad", func)
|
|
105
|
+
def on_state(self, func: Listener) -> Listener: return self._register("state", func)
|
|
106
|
+
def on_turn_ready(self, func: Listener) -> Listener: return self._register("turn_ready", func)
|
|
107
|
+
def on_config(self, func: Listener) -> Listener: return self._register("config", func)
|
|
108
|
+
def on_stats(self, func: Listener) -> Listener: return self._register("stats", func)
|
|
109
|
+
def on_error(self, func: Listener) -> Listener: return self._register("error", func)
|
|
110
|
+
def on_disconnected(self, func: Listener) -> Listener: return self._register("disconnected", func)
|
|
111
|
+
|
|
112
|
+
def _register(self, event: str, func: Listener) -> Listener:
|
|
113
|
+
self._listeners.setdefault(event, []).append(func)
|
|
114
|
+
return func
|
|
115
|
+
|
|
116
|
+
def _emit(self, event: str, *args) -> None:
|
|
117
|
+
for func in self._listeners.get(event, []):
|
|
118
|
+
try:
|
|
119
|
+
func(*args)
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.exception("saa listener for '%s' raised", event)
|
|
122
|
+
|
|
123
|
+
# ── lifecycle ─────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def start(self) -> None:
|
|
126
|
+
if self._started:
|
|
127
|
+
raise RuntimeError("AttentionClient already started")
|
|
128
|
+
self._started = True
|
|
129
|
+
try:
|
|
130
|
+
self._open_ws_blocking()
|
|
131
|
+
if self.enable_audio:
|
|
132
|
+
self._mic = MicCapture(self.audio_config, on_pcm16=self._on_mic_pcm16)
|
|
133
|
+
self._mic.start()
|
|
134
|
+
if self.enable_video:
|
|
135
|
+
self._cam = CameraCapture(self.video_config, on_jpeg=self._on_cam_jpeg)
|
|
136
|
+
self._cam.start()
|
|
137
|
+
self._stats_stop.clear()
|
|
138
|
+
self._stats_thread = threading.Thread(
|
|
139
|
+
target=self._heartbeat_loop, daemon=True, name="saa-heartbeat",
|
|
140
|
+
)
|
|
141
|
+
self._stats_thread.start()
|
|
142
|
+
except Exception:
|
|
143
|
+
self.stop()
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
def stop(self) -> None:
|
|
147
|
+
if not self._started:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
self._stats_stop.set()
|
|
151
|
+
if self._stats_thread is not None:
|
|
152
|
+
self._stats_thread.join(timeout=2.0)
|
|
153
|
+
self._stats_thread = None
|
|
154
|
+
|
|
155
|
+
if self._mic is not None:
|
|
156
|
+
self._mic.stop()
|
|
157
|
+
self._mic = None
|
|
158
|
+
if self._cam is not None:
|
|
159
|
+
self._cam.stop()
|
|
160
|
+
self._cam = None
|
|
161
|
+
|
|
162
|
+
if self._ws is not None:
|
|
163
|
+
try:
|
|
164
|
+
self._ws.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
if self._ws_thread is not None:
|
|
168
|
+
self._ws_thread.join(timeout=2.0)
|
|
169
|
+
self._ws_thread = None
|
|
170
|
+
self._ws = None
|
|
171
|
+
|
|
172
|
+
self._started = False
|
|
173
|
+
self._warmed_up = False
|
|
174
|
+
self._muted = False
|
|
175
|
+
|
|
176
|
+
# ── control ───────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def mute(self) -> None:
|
|
179
|
+
self._muted = True
|
|
180
|
+
if self._mic is not None:
|
|
181
|
+
self._mic.mute()
|
|
182
|
+
self._send_control({"action": "mute"})
|
|
183
|
+
|
|
184
|
+
def unmute(self) -> None:
|
|
185
|
+
self._muted = False
|
|
186
|
+
if self._mic is not None:
|
|
187
|
+
self._mic.unmute()
|
|
188
|
+
self._send_control({"action": "unmute"})
|
|
189
|
+
|
|
190
|
+
def mark_responding(self, responding: bool) -> None:
|
|
191
|
+
self._send_control({
|
|
192
|
+
"action": "responding_start" if responding else "responding_stop",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
def set_threshold(self, value: float) -> None:
|
|
196
|
+
value = _clamp01(value)
|
|
197
|
+
self.threshold = value
|
|
198
|
+
self._send_control({"action": "set_threshold", "value": value})
|
|
199
|
+
|
|
200
|
+
# ── WS ────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def _resolve_ws_url(self) -> str:
|
|
203
|
+
"""Resolve self.url to a concrete wss://…/ws URL.
|
|
204
|
+
|
|
205
|
+
- ws(s)://… is treated as a direct backend URL — returned as-is.
|
|
206
|
+
- http(s)://… is treated as a broker base URL — POST /allocate
|
|
207
|
+
with the bearer token, return the wss URL the broker returns.
|
|
208
|
+
|
|
209
|
+
Called once per connect, so reconnects pick a fresh least-loaded
|
|
210
|
+
backend each time. Uses urllib (stdlib) — no new deps.
|
|
211
|
+
"""
|
|
212
|
+
url = self.url
|
|
213
|
+
if url.startswith("ws://") or url.startswith("wss://"):
|
|
214
|
+
return url
|
|
215
|
+
allocate_url = url.rstrip("/") + "/allocate"
|
|
216
|
+
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
217
|
+
req = urllib.request.Request(
|
|
218
|
+
allocate_url, method="POST", headers=headers,
|
|
219
|
+
data=b"", # POST with empty body — broker doesn't read it
|
|
220
|
+
)
|
|
221
|
+
try:
|
|
222
|
+
with urllib.request.urlopen(req, timeout=BROKER_ALLOCATE_TIMEOUT_S) as resp:
|
|
223
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
224
|
+
except urllib.error.HTTPError as e:
|
|
225
|
+
body = ""
|
|
226
|
+
try:
|
|
227
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
raise ConnectionError(
|
|
231
|
+
f"broker /allocate failed: HTTP {e.code} {body or e.reason}"
|
|
232
|
+
) from e
|
|
233
|
+
except urllib.error.URLError as e:
|
|
234
|
+
raise ConnectionError(f"broker /allocate request failed: {e.reason}") from e
|
|
235
|
+
ws_url = payload.get("url") if isinstance(payload, dict) else None
|
|
236
|
+
if not ws_url:
|
|
237
|
+
raise ConnectionError(f"broker /allocate returned no url: {payload!r}")
|
|
238
|
+
return ws_url
|
|
239
|
+
|
|
240
|
+
def _open_ws_blocking(self) -> None:
|
|
241
|
+
self._ws_open.clear()
|
|
242
|
+
self._ws_closed.clear()
|
|
243
|
+
self._close_info = {}
|
|
244
|
+
self._sent_audio = 0
|
|
245
|
+
self._sent_video = 0
|
|
246
|
+
self._skipped_video = 0
|
|
247
|
+
self._last_rtt_ms = None
|
|
248
|
+
self._ws_opened_at = 0.0
|
|
249
|
+
self._warmed_up = False
|
|
250
|
+
|
|
251
|
+
ws_url = self._resolve_ws_url()
|
|
252
|
+
subprotocols = [self.token] if self.token else None
|
|
253
|
+
self._ws = websocket.WebSocketApp(
|
|
254
|
+
ws_url,
|
|
255
|
+
subprotocols=subprotocols,
|
|
256
|
+
on_open=self._on_ws_open,
|
|
257
|
+
on_message=self._on_ws_message,
|
|
258
|
+
on_close=self._on_ws_close,
|
|
259
|
+
on_error=self._on_ws_error,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def run_ws():
|
|
263
|
+
try:
|
|
264
|
+
# ping_interval=0 disables websocket-client's automatic WS-level
|
|
265
|
+
# pings — we send our own JSON pings so the server sees them.
|
|
266
|
+
self._ws.run_forever(ping_interval=0)
|
|
267
|
+
except Exception:
|
|
268
|
+
logger.exception("ws run_forever raised")
|
|
269
|
+
|
|
270
|
+
self._ws_thread = threading.Thread(target=run_ws, daemon=True, name="saa-ws")
|
|
271
|
+
self._ws_thread.start()
|
|
272
|
+
|
|
273
|
+
opened = self._ws_open.wait(timeout=WS_HANDSHAKE_TIMEOUT_S)
|
|
274
|
+
if not opened:
|
|
275
|
+
# If closed during handshake, report the close reason.
|
|
276
|
+
if self._ws_closed.is_set():
|
|
277
|
+
info = self._close_info
|
|
278
|
+
raise ConnectionError(
|
|
279
|
+
f"WebSocket closed during handshake: "
|
|
280
|
+
f"code={info.get('code')} reason={info.get('reason') or 'none'}"
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
self._ws.close()
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
raise TimeoutError(
|
|
287
|
+
f"WebSocket handshake timed out after {WS_HANDSHAKE_TIMEOUT_S}s (url={self.url})"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _on_ws_open(self, ws) -> None:
|
|
291
|
+
self._ws_opened_at = time.monotonic()
|
|
292
|
+
self._last_pong_at = self._ws_opened_at
|
|
293
|
+
self._ws_open.set()
|
|
294
|
+
self._emit("connected")
|
|
295
|
+
|
|
296
|
+
def _on_ws_message(self, ws, message) -> None:
|
|
297
|
+
if not isinstance(message, str):
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
msg = json.loads(message)
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
return
|
|
303
|
+
self._handle_msg(msg)
|
|
304
|
+
|
|
305
|
+
def _on_ws_close(self, ws, code, reason) -> None:
|
|
306
|
+
code = code or 0
|
|
307
|
+
reason = reason or ""
|
|
308
|
+
was_clean = code == 1000
|
|
309
|
+
self._close_info = {"code": code, "reason": reason, "was_clean": was_clean}
|
|
310
|
+
self._ws_closed.set()
|
|
311
|
+
# Unblock handshake waiter if we closed before opening.
|
|
312
|
+
self._ws_open.set()
|
|
313
|
+
self._emit("disconnected", DisconnectedEvent(
|
|
314
|
+
code=code, reason=reason, was_clean=was_clean,
|
|
315
|
+
))
|
|
316
|
+
err = _close_to_error(code, reason, was_clean)
|
|
317
|
+
if err is not None:
|
|
318
|
+
self._emit("error", err)
|
|
319
|
+
|
|
320
|
+
def _on_ws_error(self, ws, error) -> None:
|
|
321
|
+
logger.debug("ws error: %s", error)
|
|
322
|
+
|
|
323
|
+
def _handle_msg(self, msg: dict) -> None:
|
|
324
|
+
t = msg.get("type")
|
|
325
|
+
|
|
326
|
+
if t == "pong":
|
|
327
|
+
self._last_pong_at = time.monotonic()
|
|
328
|
+
client_ts = msg.get("client_ts")
|
|
329
|
+
if isinstance(client_ts, (int, float)):
|
|
330
|
+
self._last_rtt_ms = (time.monotonic() * 1000.0) - float(client_ts)
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
if t == "prediction":
|
|
334
|
+
# Prefer the server's display_class (e.g. low-conf class-2 relabelled
|
|
335
|
+
# to class-1). Falls back to raw `class` for older servers.
|
|
336
|
+
cls = msg.get("display_class")
|
|
337
|
+
if cls is None:
|
|
338
|
+
cls = msg.get("class")
|
|
339
|
+
if cls is None:
|
|
340
|
+
cls = 0
|
|
341
|
+
conf = msg.get("confidence") or 0.0
|
|
342
|
+
if not self._warmed_up and conf > 0:
|
|
343
|
+
self._warmed_up = True
|
|
344
|
+
self._emit("warmup_complete")
|
|
345
|
+
self._emit("prediction", PredictionEvent(
|
|
346
|
+
cls=int(cls),
|
|
347
|
+
confidence=float(conf),
|
|
348
|
+
source=msg.get("source") or "",
|
|
349
|
+
num_faces=int(msg.get("num_faces") or 0),
|
|
350
|
+
))
|
|
351
|
+
elif t == "vad":
|
|
352
|
+
self._emit("vad", VadEvent(
|
|
353
|
+
probability=float(msg.get("probability") or 0.0),
|
|
354
|
+
is_speech=bool(msg.get("is_speech")),
|
|
355
|
+
))
|
|
356
|
+
elif t == "state":
|
|
357
|
+
self._emit("state", StateEvent(state=msg.get("state") or "idle"))
|
|
358
|
+
elif t == "turn_ready":
|
|
359
|
+
b64 = msg.get("audio_base64") or ""
|
|
360
|
+
raw_frames = msg.get("frames") or []
|
|
361
|
+
frames = [
|
|
362
|
+
TurnFrame(
|
|
363
|
+
ts_offset_s=float(f.get("ts_offset_s") or 0.0),
|
|
364
|
+
image_base64=str(f.get("image_base64") or ""),
|
|
365
|
+
)
|
|
366
|
+
for f in raw_frames
|
|
367
|
+
if isinstance(f, dict) and f.get("image_base64")
|
|
368
|
+
]
|
|
369
|
+
self._emit("turn_ready", TurnReadyEvent(
|
|
370
|
+
audio_pcm16=_b64_to_int16(b64),
|
|
371
|
+
audio_base64=b64,
|
|
372
|
+
duration_sec=float(msg.get("duration") or 0.0),
|
|
373
|
+
frames=frames,
|
|
374
|
+
))
|
|
375
|
+
elif t == "started":
|
|
376
|
+
self._emit("started")
|
|
377
|
+
self._send_control({"action": "set_threshold", "value": self.threshold})
|
|
378
|
+
elif t == "config":
|
|
379
|
+
thr = msg.get("model_class2_threshold")
|
|
380
|
+
if isinstance(thr, (int, float)):
|
|
381
|
+
self.threshold = float(thr)
|
|
382
|
+
self._emit("config", ConfigEvent(model_class2_threshold=float(thr)))
|
|
383
|
+
elif t == "error":
|
|
384
|
+
self._emit("error", AttentionErrorEvent(
|
|
385
|
+
title="Server Error",
|
|
386
|
+
message=msg.get("message") or "",
|
|
387
|
+
detail=msg.get("detail"),
|
|
388
|
+
))
|
|
389
|
+
|
|
390
|
+
def _send_control(self, data: dict) -> None:
|
|
391
|
+
if self._ws is None or not self._ws_open.is_set():
|
|
392
|
+
return
|
|
393
|
+
try:
|
|
394
|
+
self._ws.send(json.dumps(data))
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
def _on_mic_pcm16(self, pcm16_bytes: bytes) -> None:
|
|
399
|
+
# Don't gate on self._muted — keep streaming PCM during mute. The
|
|
400
|
+
# "mute" control action already informs the server, which only blocks
|
|
401
|
+
# the LLM chunk accumulator (so TTS isn't fed back to OpenAI as user
|
|
402
|
+
# speech). The inference ring buffer needs live audio every tick.
|
|
403
|
+
if self._ws is None or not self._ws_open.is_set():
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
self._ws.send(
|
|
407
|
+
frame_binary(MSG_AUDIO, pcm16_bytes),
|
|
408
|
+
opcode=websocket.ABNF.OPCODE_BINARY,
|
|
409
|
+
)
|
|
410
|
+
self._sent_audio += 1
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
def _on_cam_jpeg(self, jpeg_bytes: bytes) -> None:
|
|
415
|
+
if self._ws is None or not self._ws_open.is_set():
|
|
416
|
+
self._skipped_video += 1
|
|
417
|
+
return
|
|
418
|
+
try:
|
|
419
|
+
self._ws.send(
|
|
420
|
+
frame_binary(MSG_VIDEO, jpeg_bytes),
|
|
421
|
+
opcode=websocket.ABNF.OPCODE_BINARY,
|
|
422
|
+
)
|
|
423
|
+
self._sent_video += 1
|
|
424
|
+
except Exception:
|
|
425
|
+
self._skipped_video += 1
|
|
426
|
+
|
|
427
|
+
def _heartbeat_loop(self) -> None:
|
|
428
|
+
last_stats_at = time.monotonic()
|
|
429
|
+
while not self._stats_stop.wait(WS_PING_INTERVAL_S):
|
|
430
|
+
if self._ws is None or not self._ws_open.is_set():
|
|
431
|
+
continue
|
|
432
|
+
now = time.monotonic()
|
|
433
|
+
if now - self._last_pong_at > WS_PONG_TIMEOUT_S:
|
|
434
|
+
self._emit("error", AttentionErrorEvent(
|
|
435
|
+
title="Connection Stalled",
|
|
436
|
+
message="No pong received within timeout window.",
|
|
437
|
+
detail=f"{now - self._last_pong_at:.1f}s since last pong",
|
|
438
|
+
))
|
|
439
|
+
self._send_control({"action": "ping", "ts": time.monotonic() * 1000.0})
|
|
440
|
+
if now - last_stats_at >= WS_STATS_INTERVAL_S:
|
|
441
|
+
self._emit("stats", StatsEvent(
|
|
442
|
+
rtt_ms=self._last_rtt_ms,
|
|
443
|
+
sent_video=self._sent_video,
|
|
444
|
+
skipped_video=self._skipped_video,
|
|
445
|
+
sent_audio=self._sent_audio,
|
|
446
|
+
uptime_s=now - self._ws_opened_at if self._ws_opened_at else 0.0,
|
|
447
|
+
))
|
|
448
|
+
last_stats_at = now
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _clamp01(v: float) -> float:
|
|
452
|
+
if v != v: # NaN
|
|
453
|
+
return 0.0
|
|
454
|
+
return max(0.0, min(1.0, v))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _b64_to_int16(b64: str) -> np.ndarray:
|
|
458
|
+
if not b64:
|
|
459
|
+
return np.zeros(0, dtype=np.int16)
|
|
460
|
+
raw = base64.b64decode(b64)
|
|
461
|
+
return np.frombuffer(raw, dtype=np.int16).copy()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _close_to_error(code: int, reason: str, was_clean: bool) -> Optional[AttentionErrorEvent]:
|
|
465
|
+
if code == 1000:
|
|
466
|
+
return None
|
|
467
|
+
if code == 1008:
|
|
468
|
+
return AttentionErrorEvent(
|
|
469
|
+
title="Auth Failed",
|
|
470
|
+
message="Server rejected the auth token.",
|
|
471
|
+
detail=reason or f"close code {code}",
|
|
472
|
+
code=code,
|
|
473
|
+
)
|
|
474
|
+
if code == 1013:
|
|
475
|
+
return AttentionErrorEvent(
|
|
476
|
+
title="Rate Limited",
|
|
477
|
+
message="Throttled by server — try again shortly.",
|
|
478
|
+
detail=reason or f"close code {code}",
|
|
479
|
+
code=code,
|
|
480
|
+
)
|
|
481
|
+
if code == 1006:
|
|
482
|
+
return AttentionErrorEvent(
|
|
483
|
+
title="Connection Failed",
|
|
484
|
+
message="Could not reach the server.",
|
|
485
|
+
detail=f"The server may be down or unreachable. (close code {code})",
|
|
486
|
+
code=code,
|
|
487
|
+
)
|
|
488
|
+
if not was_clean:
|
|
489
|
+
return AttentionErrorEvent(
|
|
490
|
+
title="Disconnected",
|
|
491
|
+
message="Connection lost unexpectedly.",
|
|
492
|
+
detail=f"code={code} reason={reason or 'none'}",
|
|
493
|
+
code=code,
|
|
494
|
+
)
|
|
495
|
+
return None
|
saa/events.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
ConversationState = Literal["listening", "sending", "cancelled", "idle"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PredictionEvent:
|
|
13
|
+
cls: int
|
|
14
|
+
confidence: float
|
|
15
|
+
source: str
|
|
16
|
+
num_faces: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VadEvent:
|
|
21
|
+
probability: float
|
|
22
|
+
is_speech: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class StateEvent:
|
|
27
|
+
state: ConversationState
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TurnFrame:
|
|
32
|
+
"""One still captured from the conversation turn.
|
|
33
|
+
|
|
34
|
+
LLM-agnostic: SDK delivers raw base64-encoded JPEG. Callers wrap it for
|
|
35
|
+
whatever LLM they target (OpenAI input_image, Anthropic image, Gemini
|
|
36
|
+
inlineData, …).
|
|
37
|
+
"""
|
|
38
|
+
ts_offset_s: float # seconds from listening-start; negative = pre-context
|
|
39
|
+
image_base64: str # JPEG bytes, base64-encoded (no data: prefix)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TurnReadyEvent:
|
|
44
|
+
audio_pcm16: np.ndarray # int16, 16 kHz mono
|
|
45
|
+
audio_base64: str
|
|
46
|
+
duration_sec: float
|
|
47
|
+
frames: list[TurnFrame] = field(default_factory=list)
|
|
48
|
+
"""Empty unless the server has frames_per_turn > 0."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ConfigEvent:
|
|
53
|
+
model_class2_threshold: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class StatsEvent:
|
|
58
|
+
rtt_ms: Optional[float]
|
|
59
|
+
sent_video: int
|
|
60
|
+
skipped_video: int
|
|
61
|
+
sent_audio: int
|
|
62
|
+
uptime_s: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class AttentionErrorEvent:
|
|
67
|
+
title: str
|
|
68
|
+
message: str
|
|
69
|
+
detail: Optional[str] = None
|
|
70
|
+
code: Optional[int] = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class DisconnectedEvent:
|
|
75
|
+
code: int
|
|
76
|
+
reason: str
|
|
77
|
+
was_clean: bool
|