Skip to content

Commit 0021cbd

Browse files
authored
Merge pull request #8 from ofspectrum/sync-sdk-v1.3.1
Sync SDK v1.3.1 from neo
2 parents a15ca89 + af4d84c commit 0021cbd

10 files changed

Lines changed: 221 additions & 14 deletions

File tree

‎AGENT_GUIDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ The public SDK repository is:
1212
https://github.com/ofspectrum/python-sdk
1313
```
1414

15-
Customer applications should normally install SDK `1.2.1` with
16-
`pip install "ofspectrum==1.2.1"`; they should not need the private Neo
15+
Customer applications should normally install SDK `1.3.1` with
16+
`pip install "ofspectrum==1.3.1"`; they should not need the private Neo
1717
monorepo source.
1818

1919
If an agent needs SDK source orientation, use the public SDK repository above. From that repository root, inspect these files first:

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 1.3.1 - 2026-08-24
4+
5+
- `open_stream_pool(..., keepalive_interval_seconds=120)` heartbeats every pooled connection so idle Neo model sessions are not retired.
6+
- `StreamEncodePool.heartbeat()` sends `{"type": "heartbeat"}` on all slots, silently replaces dead connections, and never raises into the encode path.
7+
- Opening a stream connection through `admitted` / `ready` is the warmup. Do not send dummy audio just to keep a session hot.
8+
- Neo-to-model sessions stay warm from real encode/decode work. Heartbeats only refresh idle tracking; they do not bill quota.
9+
310
## 1.3.0 - 2026-08-23
411

512
- `audio.stream_encode()` reuses a process-local persistent stream pool when `interval` is 0, so repeated TTS-style calls do not open a new WebSocket each time.

‎README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# OfSpectrum Python SDK
22

33
Official Python SDK for the OfSpectrum audio watermarking API. This guide
4-
describes the SDK `1.2.1` contract.
4+
describes the SDK `1.3.1` contract.
55

66
## Installation
77

88
```bash
9-
pip install "ofspectrum==1.2.1"
9+
pip install "ofspectrum==1.3.1"
1010
```
1111

1212
Or install from source:
@@ -294,6 +294,7 @@ with client.audio.open_stream_pool(
294294
strength=1.0,
295295
smooth=True,
296296
verify_and_reencode=True,
297+
keepalive_interval_seconds=120,
297298
) as pool:
298299
result = pool.encode(tts_wav_bytes, timeout=120.0)
299300
result.save("watermarked.wav")
@@ -302,12 +303,22 @@ with client.audio.open_stream_pool(
302303
Pool configuration is fixed when it is opened. `pool.encode()` accepts a file
303304
path, file object, or bytes and returns the same `EncodeResult` as
304305
`stream_encode()`. The input channel count must match `channels`; use the
305-
default mono configuration for EthoVox output. Before sending an operation, the
306-
SDK probes an existing pooled connection and replaces it if it is stale. Once
306+
default mono configuration for EthoVox output. Set
307+
`keepalive_interval_seconds` below Neo's idle window (300 seconds by default)
308+
so every pooled connection stays warm without dummy audio. Completing
309+
`admitted` / `ready` is the warmup. Before sending an operation, the SDK
310+
probes an existing pooled connection and replaces it if it is stale. Once
307311
operation input starts, the SDK never replays that operation automatically;
308312
retry at the application level only when the workflow has an idempotency
309313
boundary.
310314

315+
Real encode and decode traffic keeps Neo-to-model sessions alive. Heartbeats
316+
only refresh idle tracking and do not consume encode quota. They cannot skip
317+
hourly connection max-age rotation (`AUDIO_ENCODE_V2_CONNECTION_MAX_AGE_SECONDS`,
318+
default 3600s) or the 40-minute persistent WebSocket rotation. For a long-lived
319+
TTS worker, raise max-age (for example 86400) or accept a reconnect about once
320+
an hour.
321+
311322
## Notebook Management
312323

313324
Attach notes and media files to tokens. Private notebooks require a credential, and limits depend on your account and token configuration.

‎ofspectrum/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
print(f"Remaining: {quota.remaining}/{quota.limit}")
3030
"""
3131

32-
__version__ = "1.3.0"
32+
__version__ = "1.3.1"
3333
__author__ = "OfSpectrum"
3434

3535
from .client import AsyncOfSpectrum, OfSpectrum

‎ofspectrum/client.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def _default_headers(self) -> Dict[str, str]:
104104
"""Get default request headers"""
105105
return {
106106
"Authorization": f"Bearer {self._api_key}",
107-
"User-Agent": "OfSpectrum-Python-SDK/1.3.0",
107+
"User-Agent": "OfSpectrum-Python-SDK/1.3.1",
108108
"Accept": "application/json",
109109
}
110110

@@ -236,7 +236,7 @@ def __init__(
236236
def _default_headers(self) -> Dict[str, str]:
237237
return {
238238
"Authorization": f"Bearer {self._api_key}",
239-
"User-Agent": "OfSpectrum-Python-SDK/1.3.0",
239+
"User-Agent": "OfSpectrum-Python-SDK/1.3.1",
240240
"Accept": "application/json",
241241
}
242242

‎ofspectrum/resources/audio.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def __init__(
9999
smooth: bool,
100100
verify_and_reencode: bool,
101101
timeout: Optional[float],
102+
keepalive_interval_seconds: Optional[float] = None,
102103
) -> None:
103104
self._resource = resource
104105
self._token_id = token_id
@@ -108,12 +109,22 @@ def __init__(
108109
self._smooth = smooth
109110
self._verify_and_reencode = verify_and_reencode
110111
self._timeout = timeout
112+
self._keepalive_interval = keepalive_interval_seconds
111113
self._slots = [_StreamPoolSlot() for _ in range(connections)]
112114
self._idle = deque(self._slots)
113115
self._idle_lock = threading.Lock()
114116
self._idle_ready = threading.Condition(self._idle_lock)
115117
self._state_lock = threading.Lock()
116118
self._closed = False
119+
self._keepalive_stop = threading.Event()
120+
self._keepalive_thread: Optional[threading.Thread] = None
121+
if keepalive_interval_seconds is not None:
122+
self._keepalive_thread = threading.Thread(
123+
target=self._keepalive_loop,
124+
name="ofspectrum-stream-keepalive",
125+
daemon=True,
126+
)
127+
self._keepalive_thread.start()
117128

118129
def __enter__(self) -> "StreamEncodePool":
119130
return self
@@ -132,6 +143,10 @@ def close(self) -> None:
132143
if self._closed:
133144
return
134145
self._closed = True
146+
self._keepalive_stop.set()
147+
thread = self._keepalive_thread
148+
if thread is not None and thread is not threading.current_thread():
149+
thread.join(timeout=1.0)
135150
with self._idle_ready:
136151
self._idle_ready.notify_all()
137152

@@ -166,6 +181,77 @@ def close(self) -> None:
166181
finally:
167182
slot.lock.release()
168183

184+
def heartbeat(self, *, timeout: Optional[float] = None) -> bool:
185+
"""Refresh every pooled stream connection without encoding audio.
186+
187+
Busy slots are skipped because an in-flight encode already keeps them
188+
alive. Dead connections are replaced silently. This method never raises;
189+
a ``False`` result means no slot confirmed an ack, but later ``encode()``
190+
still uses the existing fail-open reconnect path.
191+
"""
192+
if self.closed:
193+
return False
194+
deadline = None
195+
if timeout is not None:
196+
deadline = time.monotonic() + float(timeout)
197+
elif self._timeout is not None:
198+
deadline = time.monotonic() + float(self._timeout)
199+
confirmed = False
200+
for slot in self._slots:
201+
if self._heartbeat_slot(slot, deadline):
202+
confirmed = True
203+
return confirmed
204+
205+
def _keepalive_loop(self) -> None:
206+
interval = self._keepalive_interval
207+
if interval is None:
208+
return
209+
while not self._keepalive_stop.wait(timeout=interval):
210+
if self.closed:
211+
return
212+
self.heartbeat(timeout=self._CONNECT_TIMEOUT_SECONDS)
213+
214+
def _heartbeat_slot(
215+
self,
216+
slot: _StreamPoolSlot,
217+
deadline: Optional[float],
218+
) -> bool:
219+
if not slot.lock.acquire(blocking=False):
220+
return True
221+
try:
222+
if self.closed:
223+
return False
224+
try:
225+
return self._heartbeat_once(slot, deadline)
226+
except Exception:
227+
self._discard_connection(slot)
228+
try:
229+
return self._heartbeat_once(slot, deadline)
230+
except Exception:
231+
self._discard_connection(slot)
232+
return False
233+
finally:
234+
slot.lock.release()
235+
236+
def _heartbeat_once(
237+
self,
238+
slot: _StreamPoolSlot,
239+
deadline: Optional[float],
240+
) -> bool:
241+
self._ensure_connection(slot, deadline)
242+
websocket = slot.websocket
243+
if websocket is None:
244+
return False
245+
websocket.send(json.dumps({"type": "heartbeat"}))
246+
event = self._control_event(self._recv(websocket, deadline))
247+
if event.get("type") != "heartbeat_ack":
248+
raise OfSpectrumError(
249+
message="Streaming pool received an invalid heartbeat response",
250+
code="StreamingPoolProtocol",
251+
)
252+
slot.last_ok_monotonic = time.monotonic()
253+
return True
254+
169255
def encode(
170256
self,
171257
audio: Union[str, Path, BinaryIO, bytes],
@@ -754,12 +840,19 @@ def open_stream_pool(
754840
smooth: bool = True,
755841
verify_and_reencode: bool = True,
756842
timeout: Optional[float] = None,
843+
keepalive_interval_seconds: Optional[float] = None,
757844
) -> StreamEncodePool:
758845
"""Create a thread-safe persistent pool for repeated stream encodes.
759846
760847
Each connection has a stable opaque slot identifier. The pool config is
761848
fixed at creation time; ``encode()`` accepts file paths, file objects,
762849
or bytes with the same number of audio channels as the pool.
850+
851+
``keepalive_interval_seconds`` sends ``{"type": "heartbeat"}`` on every
852+
pooled connection so Neo does not idle-retire the model session. Use a
853+
value below the server idle window (default 300s), typically 120. Opening
854+
the pool and completing ``admitted`` / ``ready`` warms the connection;
855+
do not send dummy audio just to warm up. ``None`` disables keepalive.
763856
"""
764857
if not token_id:
765858
raise ValueError("token_id is required")
@@ -783,6 +876,12 @@ def open_stream_pool(
783876
isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0.0
784877
):
785878
raise ValueError("timeout must be a finite positive number or None")
879+
if keepalive_interval_seconds is not None and (
880+
isinstance(keepalive_interval_seconds, bool)
881+
or not math.isfinite(keepalive_interval_seconds)
882+
or keepalive_interval_seconds <= 0.0
883+
):
884+
raise ValueError("keepalive_interval_seconds must be a positive number or None")
786885

787886
pool = StreamEncodePool(
788887
self,
@@ -794,6 +893,7 @@ def open_stream_pool(
794893
smooth=smooth,
795894
verify_and_reencode=verify_and_reencode,
796895
timeout=timeout,
896+
keepalive_interval_seconds=keepalive_interval_seconds,
797897
)
798898
with self._stream_pools_lock:
799899
self._stream_pools.add(pool)

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ofspectrum"
7-
version = "1.3.0"
7+
version = "1.3.1"
88
description = "OfSpectrum Audio Watermarking SDK"
99
readme = "README.md"
1010
license = {text = "MIT"}

‎tests/test_audio_stream_pool.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import threading
3+
import time
34
import uuid
45
from collections import deque
56
from concurrent.futures import ThreadPoolExecutor
@@ -50,6 +51,8 @@ def send(self, value):
5051
with self._condition:
5152
self._responses.extend(self._flush_responses.popleft())
5253
self._condition.notify_all()
54+
elif event_type == "heartbeat":
55+
self._queue_response('{"type":"heartbeat_ack"}')
5356
elif event_type == "close":
5457
self._queue_response('{"type":"closed"}')
5558

@@ -333,6 +336,92 @@ def test_stream_pool_reconnects_stale_socket_before_sending_next_operation(monke
333336
assert factory.connections[0].closed is True
334337

335338

339+
def test_stream_pool_heartbeat_keeps_every_slot_alive(monkeypatch):
340+
factory = _PoolFactory([_success_scenario(), _success_scenario()])
341+
monkeypatch.setattr(websockets.sync.client, "connect", factory)
342+
client = OfSpectrum(api_key="test-key")
343+
pool = client.audio.open_stream_pool("token-1", connections=2)
344+
try:
345+
assert pool.heartbeat(timeout=2.0) is True
346+
assert len(factory.connections) == 2
347+
for socket in factory.connections:
348+
types = [
349+
json.loads(message).get("type")
350+
for message in socket.sent
351+
if isinstance(message, str)
352+
]
353+
assert "heartbeat" in types
354+
assert "config" in types
355+
finally:
356+
client.close()
357+
358+
359+
def test_stream_pool_heartbeat_reconnects_dead_socket_without_raising(monkeypatch):
360+
factory = _PoolFactory([_success_scenario(), _success_scenario()])
361+
original = factory.__call__
362+
363+
def connect_with_dead_first(_url, **kwargs):
364+
socket = original(_url, **kwargs)
365+
if len(factory.connections) == 1:
366+
inner_send = socket.send
367+
368+
def send(value):
369+
if isinstance(value, str) and json.loads(value).get("type") == "heartbeat":
370+
raise RuntimeError("socket dead")
371+
return inner_send(value)
372+
373+
socket.send = send
374+
return socket
375+
376+
monkeypatch.setattr(websockets.sync.client, "connect", connect_with_dead_first)
377+
client = OfSpectrum(api_key="test-key")
378+
pool = client.audio.open_stream_pool("token-1", connections=1)
379+
try:
380+
assert pool.heartbeat(timeout=2.0) is True
381+
assert len(factory.connections) == 2
382+
assert any(
383+
isinstance(message, str) and json.loads(message).get("type") == "heartbeat"
384+
for message in factory.connections[1].sent
385+
)
386+
finally:
387+
client.close()
388+
389+
390+
def test_stream_pool_keepalive_thread_heartbeats_all_slots(monkeypatch):
391+
factory = _PoolFactory([_success_scenario(), _success_scenario()])
392+
monkeypatch.setattr(websockets.sync.client, "connect", factory)
393+
client = OfSpectrum(api_key="test-key")
394+
pool = client.audio.open_stream_pool(
395+
"token-1",
396+
connections=2,
397+
keepalive_interval_seconds=0.05,
398+
)
399+
try:
400+
deadline = time.monotonic() + 2.0
401+
while time.monotonic() < deadline:
402+
if len(factory.connections) == 2 and all(
403+
any(
404+
isinstance(message, str)
405+
and json.loads(message).get("type") == "heartbeat"
406+
for message in socket.sent
407+
)
408+
for socket in factory.connections
409+
):
410+
break
411+
time.sleep(0.02)
412+
assert len(factory.connections) == 2
413+
assert all(
414+
any(
415+
isinstance(message, str)
416+
and json.loads(message).get("type") == "heartbeat"
417+
for message in socket.sent
418+
)
419+
for socket in factory.connections
420+
)
421+
finally:
422+
client.close()
423+
424+
336425
def test_stream_pool_does_not_replay_after_operation_input_starts(monkeypatch):
337426
factory = _PoolFactory(
338427
[{"responses": [[RuntimeError("connection closed before output")]]}]

‎tests/test_client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
def test_client_version_headers_match_release():
1313
client = OfSpectrum(api_key="test-key")
1414
try:
15-
assert client._default_headers()["User-Agent"] == "OfSpectrum-Python-SDK/1.3.0"
15+
assert client._default_headers()["User-Agent"] == "OfSpectrum-Python-SDK/1.3.1"
1616
finally:
1717
client.close()
1818

0 commit comments

Comments
 (0)