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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
saa/ws_protocol.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ MSG_AUDIO = 0x01
4
+ MSG_VIDEO = 0x02
5
+
6
+
7
+ def frame_binary(tag: int, payload: bytes) -> bytes:
8
+ return bytes([tag]) + payload