hapbeat-python-sdk 0.2.0__py3-none-any.whl → 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.
- hapbeat/__init__.py +14 -4
- hapbeat/cli.py +14 -2
- hapbeat/client.py +225 -4
- hapbeat/hapbeat.py +293 -78
- hapbeat/launchpad.py +2 -1
- hapbeat/netif.py +270 -0
- hapbeat/osc.py +4 -2
- hapbeat/protocol.py +165 -23
- hapbeat/stream.py +766 -0
- hapbeat/stream_lease.py +182 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/METADATA +17 -9
- hapbeat_python_sdk-0.3.0.dist-info/RECORD +20 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/WHEEL +1 -1
- hapbeat/clip.py +0 -121
- hapbeat_python_sdk-0.2.0.dist-info/RECORD +0 -18
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/entry_points.txt +0 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/top_level.txt +0 -0
hapbeat/stream.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""Endpoint-scoped multi-stream mixing for WifiUdp.
|
|
2
|
+
|
|
3
|
+
Logical PCM sources are independent to callers, but Hapbeat devices expose one
|
|
4
|
+
wire stream each. StreamHub therefore resolves targets to exact PONG-reported
|
|
5
|
+
device endpoints, mixes every matching source per endpoint, and owns the
|
|
6
|
+
BEGIN/DATA/END lifecycle. No stream packet is ever broadcast.
|
|
7
|
+
|
|
8
|
+
Each endpoint session is fixed to one wire format when it begins
|
|
9
|
+
(contracts stream-session-v2.md): v2 receivers get the device-issued lease
|
|
10
|
+
plus a fresh generation on every packet and can restart immediately after END;
|
|
11
|
+
pre-v2 receivers get the v1 stream and keep the 300 ms END -> BEGIN guard,
|
|
12
|
+
for that endpoint only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from array import array
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Callable, Optional, Protocol
|
|
28
|
+
|
|
29
|
+
from . import protocol
|
|
30
|
+
from .stream_lease import StreamLease, StreamSessionIdentity
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("hapbeat")
|
|
33
|
+
|
|
34
|
+
SAMPLE_RATE = 16_000
|
|
35
|
+
CHANNELS = 2
|
|
36
|
+
CHUNK_FRAMES = 256
|
|
37
|
+
MAX_DATA_BYTES = CHUNK_FRAMES * CHANNELS * 2
|
|
38
|
+
DEFAULT_SEND_AHEAD = 0.15
|
|
39
|
+
FULL_SCALE = 32767.0
|
|
40
|
+
# Mix bus: overlapping sources are summed. A sum whose peak is within
|
|
41
|
+
# LIMIT_THRESHOLD (the PCM16 range including -32768, so one full-scale clip
|
|
42
|
+
# alone never triggers it) passes unchanged; one that exceeds it is scaled
|
|
43
|
+
# down to LIMIT_TARGET (gain
|
|
44
|
+
# reduction ramped per sample: attack within the chunk, ~0.3 s release), and a
|
|
45
|
+
# soft knee above LIMIT_KNEE replaces the hard clamp for what is left over.
|
|
46
|
+
LIMIT_THRESHOLD = 32768.0
|
|
47
|
+
LIMIT_TARGET = 0.9 * FULL_SCALE
|
|
48
|
+
LIMIT_KNEE = 0.95 * FULL_SCALE
|
|
49
|
+
LIMIT_RELEASE_PER_CHUNK = 0.05
|
|
50
|
+
# Keep an empty endpoint session alive for one AP beacon interval. A new source
|
|
51
|
+
# can then join without END -> BEGIN, which may reorder/flush the previous tail.
|
|
52
|
+
SESSION_END_LINGER = 0.31
|
|
53
|
+
# Pre-v2 firmware cannot tell a delayed old END from a new one, so after a
|
|
54
|
+
# legacy END the next BEGIN to that endpoint waits this long. v2 endpoints
|
|
55
|
+
# order sessions by (lease, generation) and restart immediately.
|
|
56
|
+
LEGACY_END_TO_BEGIN_GUARD = 0.3
|
|
57
|
+
MAX_GENERATION = 0xFFFFFFFF
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PlaybackStatus(enum.Enum):
|
|
61
|
+
DEFERRED = "Deferred"
|
|
62
|
+
ACTIVE = "Active"
|
|
63
|
+
STOPPED = "Stopped"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DeferredReason(enum.Enum):
|
|
67
|
+
NO_RESOLVED_ENDPOINT = "NoResolvedEndpoint"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class StreamEndpoint:
|
|
72
|
+
ip: str
|
|
73
|
+
address: str
|
|
74
|
+
port: int = 7700
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class EndpointStreamMode:
|
|
79
|
+
"""How an endpoint may be streamed now: ``lease=None`` is the legacy v1
|
|
80
|
+
stream (pre-v2 firmware), otherwise v2 under that device-issued lease."""
|
|
81
|
+
|
|
82
|
+
lease: Optional[StreamLease] = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
LEGACY_STREAM_MODE = EndpointStreamMode()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EndpointStreamSink(Protocol):
|
|
89
|
+
# v1 stream, for endpoints in LEGACY_STREAM_MODE
|
|
90
|
+
def stream_begin_endpoint(self, endpoint: StreamEndpoint) -> None: ...
|
|
91
|
+
|
|
92
|
+
def stream_data_endpoint(
|
|
93
|
+
self, endpoint: StreamEndpoint, offset: int, data: bytes
|
|
94
|
+
) -> None: ...
|
|
95
|
+
|
|
96
|
+
def stream_end_endpoint(self, endpoint: StreamEndpoint) -> None: ...
|
|
97
|
+
|
|
98
|
+
# stream session v2: every packet carries the session identity
|
|
99
|
+
def stream_begin_v2(
|
|
100
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity
|
|
101
|
+
) -> None: ...
|
|
102
|
+
|
|
103
|
+
def stream_data_v2(
|
|
104
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity,
|
|
105
|
+
offset: int, data: bytes,
|
|
106
|
+
) -> None: ...
|
|
107
|
+
|
|
108
|
+
def stream_end_v2(
|
|
109
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity
|
|
110
|
+
) -> None: ...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class _Source:
|
|
115
|
+
id: str
|
|
116
|
+
pcm: tuple[int, ...]
|
|
117
|
+
target: str
|
|
118
|
+
gain: float
|
|
119
|
+
pan: float
|
|
120
|
+
loop: bool
|
|
121
|
+
key: Optional[str]
|
|
122
|
+
playback: "StreamPlayback"
|
|
123
|
+
completed_endpoints: set[StreamEndpoint] = field(default_factory=set)
|
|
124
|
+
# matched endpoints whose session cannot begin yet (legacy guard): a finite
|
|
125
|
+
# source that completes elsewhere meanwhile must still play there
|
|
126
|
+
waiting_endpoints: set[StreamEndpoint] = field(default_factory=set)
|
|
127
|
+
# played to the end on at least one endpoint (finite source)
|
|
128
|
+
played: bool = False
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def frames(self) -> int:
|
|
132
|
+
return len(self.pcm) // CHANNELS
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class _EndpointSession:
|
|
137
|
+
endpoint: StreamEndpoint
|
|
138
|
+
# fixed for the session's whole life; None = legacy v1 stream
|
|
139
|
+
identity: Optional[StreamSessionIdentity] = None
|
|
140
|
+
cursors: dict[str, int] = field(default_factory=dict)
|
|
141
|
+
offset: int = 0
|
|
142
|
+
started_at: float = field(default_factory=time.perf_counter)
|
|
143
|
+
frames_sent: int = 0
|
|
144
|
+
empty_since: Optional[float] = None
|
|
145
|
+
# (L, R) channel gains each source was last mixed with: a gain / pan change
|
|
146
|
+
# is ramped across the next chunk instead of stepping at its boundary
|
|
147
|
+
gains: dict[str, tuple[float, float]] = field(default_factory=dict)
|
|
148
|
+
limit: float = 1.0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class StreamPlayback:
|
|
152
|
+
"""Handle for one logical source registered with a :class:`StreamHub`."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
hub: "StreamHub",
|
|
157
|
+
source_id: str,
|
|
158
|
+
*,
|
|
159
|
+
gain: float,
|
|
160
|
+
pan: float,
|
|
161
|
+
loop: bool,
|
|
162
|
+
) -> None:
|
|
163
|
+
self._hub = hub
|
|
164
|
+
self.id = source_id
|
|
165
|
+
self._status = PlaybackStatus.DEFERRED
|
|
166
|
+
self._deferred_reason: Optional[DeferredReason] = (
|
|
167
|
+
DeferredReason.NO_RESOLVED_ENDPOINT
|
|
168
|
+
)
|
|
169
|
+
self._gain = gain
|
|
170
|
+
self._pan = pan
|
|
171
|
+
self._loop = loop
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def status(self) -> PlaybackStatus:
|
|
175
|
+
return self._status
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def deferred_reason(self) -> Optional[DeferredReason]:
|
|
179
|
+
return self._deferred_reason
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def gain(self) -> float:
|
|
183
|
+
return self._gain
|
|
184
|
+
|
|
185
|
+
@gain.setter
|
|
186
|
+
def gain(self, value: float) -> None:
|
|
187
|
+
self._gain = _clamp(float(value), 0.0, 1.0)
|
|
188
|
+
self._hub._set_source_property(self.id, "gain", self._gain)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def pan(self) -> float:
|
|
192
|
+
return self._pan
|
|
193
|
+
|
|
194
|
+
@pan.setter
|
|
195
|
+
def pan(self, value: float) -> None:
|
|
196
|
+
self._pan = _clamp(float(value), -1.0, 1.0)
|
|
197
|
+
self._hub._set_source_property(self.id, "pan", self._pan)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def loop(self) -> bool:
|
|
201
|
+
return self._loop
|
|
202
|
+
|
|
203
|
+
@loop.setter
|
|
204
|
+
def loop(self, value: bool) -> None:
|
|
205
|
+
self._loop = bool(value)
|
|
206
|
+
self._hub._set_source_property(self.id, "loop", self._loop)
|
|
207
|
+
|
|
208
|
+
def stop(self) -> None:
|
|
209
|
+
"""Stop only this source. Repeated calls are harmless."""
|
|
210
|
+
self._hub.stop(self.id)
|
|
211
|
+
|
|
212
|
+
def _set_state(
|
|
213
|
+
self,
|
|
214
|
+
status: PlaybackStatus,
|
|
215
|
+
reason: Optional[DeferredReason] = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
self._status = status
|
|
218
|
+
self._deferred_reason = reason
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class StreamHub:
|
|
222
|
+
"""Own all logical sources and endpoint wire sessions for one SDK client."""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
sink: EndpointStreamSink,
|
|
227
|
+
endpoint_provider: Callable[[], list[StreamEndpoint]],
|
|
228
|
+
*,
|
|
229
|
+
send_ahead_sec: float = DEFAULT_SEND_AHEAD,
|
|
230
|
+
mode_provider: Optional[
|
|
231
|
+
Callable[[StreamEndpoint], Optional[EndpointStreamMode]]
|
|
232
|
+
] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""``mode_provider`` returns each endpoint's current stream mode, or
|
|
235
|
+
None while it may not be streamed (class unknown, lease invalid or
|
|
236
|
+
superseded): its sources stay Deferred. Without one every endpoint is
|
|
237
|
+
streamed in LEGACY_STREAM_MODE."""
|
|
238
|
+
self._sink = sink
|
|
239
|
+
self._endpoint_provider = endpoint_provider
|
|
240
|
+
self._mode_provider = mode_provider or (lambda _endpoint: LEGACY_STREAM_MODE)
|
|
241
|
+
self._send_ahead = max(0.0, float(send_ahead_sec))
|
|
242
|
+
self._lock = threading.RLock()
|
|
243
|
+
self._wake = threading.Event()
|
|
244
|
+
self._closed = False
|
|
245
|
+
self._sources: dict[str, _Source] = {}
|
|
246
|
+
self._sessions: dict[StreamEndpoint, _EndpointSession] = {}
|
|
247
|
+
# keyed per endpoint (ip, port, address): another device sharing the
|
|
248
|
+
# address or IP is not delayed
|
|
249
|
+
self._last_legacy_end_at: dict[StreamEndpoint, float] = {}
|
|
250
|
+
# last generation handed out per lease; never reused
|
|
251
|
+
self._last_generation: dict[StreamLease, int] = {}
|
|
252
|
+
self._thread: Optional[threading.Thread] = None
|
|
253
|
+
|
|
254
|
+
def play_pcm(
|
|
255
|
+
self,
|
|
256
|
+
pcm: bytes,
|
|
257
|
+
*,
|
|
258
|
+
sample_rate: int = SAMPLE_RATE,
|
|
259
|
+
channels: int = 1,
|
|
260
|
+
gain: float = 1.0,
|
|
261
|
+
pan: float = 0.0,
|
|
262
|
+
loop: bool = False,
|
|
263
|
+
target: str = "",
|
|
264
|
+
key: Optional[str] = None,
|
|
265
|
+
) -> StreamPlayback:
|
|
266
|
+
"""Register a finite or looping PCM16 source and return its handle."""
|
|
267
|
+
normalized = _normalize_pcm16(pcm, sample_rate=sample_rate, channels=channels)
|
|
268
|
+
if not normalized:
|
|
269
|
+
raise ValueError("pcm must contain at least one complete frame")
|
|
270
|
+
source_id = uuid.uuid4().hex
|
|
271
|
+
gain_value = _clamp(float(gain), 0.0, 1.0)
|
|
272
|
+
pan_value = _clamp(float(pan), -1.0, 1.0)
|
|
273
|
+
loop_value = bool(loop)
|
|
274
|
+
playback = StreamPlayback(
|
|
275
|
+
self,
|
|
276
|
+
source_id,
|
|
277
|
+
gain=gain_value,
|
|
278
|
+
pan=pan_value,
|
|
279
|
+
loop=loop_value,
|
|
280
|
+
)
|
|
281
|
+
source = _Source(
|
|
282
|
+
id=source_id,
|
|
283
|
+
pcm=normalized,
|
|
284
|
+
target=target,
|
|
285
|
+
gain=gain_value,
|
|
286
|
+
pan=pan_value,
|
|
287
|
+
loop=loop_value,
|
|
288
|
+
key=key,
|
|
289
|
+
playback=playback,
|
|
290
|
+
)
|
|
291
|
+
with self._lock:
|
|
292
|
+
if self._closed:
|
|
293
|
+
raise RuntimeError("StreamHub is closed")
|
|
294
|
+
self._sources[source_id] = source
|
|
295
|
+
self._reconcile_locked()
|
|
296
|
+
self._ensure_thread_locked()
|
|
297
|
+
self._wake.set()
|
|
298
|
+
return playback
|
|
299
|
+
|
|
300
|
+
def stop(self, source_id: str) -> None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
source = self._sources.pop(source_id, None)
|
|
303
|
+
if source is None:
|
|
304
|
+
return
|
|
305
|
+
source.playback._set_state(PlaybackStatus.STOPPED)
|
|
306
|
+
self._remove_source_from_sessions_locked(source_id)
|
|
307
|
+
self._wake.set()
|
|
308
|
+
|
|
309
|
+
def stop_key(self, key: str, *, target: Optional[str] = None) -> int:
|
|
310
|
+
with self._lock:
|
|
311
|
+
ids = [
|
|
312
|
+
source.id
|
|
313
|
+
for source in self._sources.values()
|
|
314
|
+
if source.key == key and (target is None or source.target == target)
|
|
315
|
+
]
|
|
316
|
+
for source_id in ids:
|
|
317
|
+
self.stop(source_id)
|
|
318
|
+
return len(ids)
|
|
319
|
+
|
|
320
|
+
def stop_all(self) -> None:
|
|
321
|
+
with self._lock:
|
|
322
|
+
ids = list(self._sources)
|
|
323
|
+
for source_id in ids:
|
|
324
|
+
self.stop(source_id)
|
|
325
|
+
|
|
326
|
+
def stop_target(self, target: str) -> int:
|
|
327
|
+
with self._lock:
|
|
328
|
+
ids = [
|
|
329
|
+
source.id for source in self._sources.values()
|
|
330
|
+
if source.target == target
|
|
331
|
+
]
|
|
332
|
+
for source_id in ids:
|
|
333
|
+
self.stop(source_id)
|
|
334
|
+
return len(ids)
|
|
335
|
+
|
|
336
|
+
def open(self) -> None:
|
|
337
|
+
"""Allow a client that was closed to be opened and streamed again."""
|
|
338
|
+
with self._lock:
|
|
339
|
+
if not self._closed:
|
|
340
|
+
return
|
|
341
|
+
self._closed = False
|
|
342
|
+
self._thread = None
|
|
343
|
+
|
|
344
|
+
def reconcile(self) -> None:
|
|
345
|
+
"""Apply the latest PONG/TTL endpoint set without crossing routes."""
|
|
346
|
+
with self._lock:
|
|
347
|
+
if not self._closed:
|
|
348
|
+
self._reconcile_locked()
|
|
349
|
+
self._wake.set()
|
|
350
|
+
|
|
351
|
+
def close(self) -> None:
|
|
352
|
+
with self._lock:
|
|
353
|
+
if self._closed:
|
|
354
|
+
return
|
|
355
|
+
self._closed = True
|
|
356
|
+
for source in self._sources.values():
|
|
357
|
+
source.playback._set_state(PlaybackStatus.STOPPED)
|
|
358
|
+
self._sources.clear()
|
|
359
|
+
for session in list(self._sessions.values()):
|
|
360
|
+
self._end_session_locked(session)
|
|
361
|
+
self._sessions.clear()
|
|
362
|
+
thread = self._thread
|
|
363
|
+
self._wake.set()
|
|
364
|
+
if thread is not None and thread is not threading.current_thread():
|
|
365
|
+
thread.join(timeout=1.0)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def active_playbacks(self) -> tuple[StreamPlayback, ...]:
|
|
369
|
+
with self._lock:
|
|
370
|
+
return tuple(source.playback for source in self._sources.values())
|
|
371
|
+
|
|
372
|
+
def _ensure_thread_locked(self) -> None:
|
|
373
|
+
if self._thread is not None and self._thread.is_alive():
|
|
374
|
+
return
|
|
375
|
+
self._thread = threading.Thread(
|
|
376
|
+
target=self._run, name="hapbeat-stream-hub", daemon=True
|
|
377
|
+
)
|
|
378
|
+
self._thread.start()
|
|
379
|
+
|
|
380
|
+
def _run(self) -> None:
|
|
381
|
+
while True:
|
|
382
|
+
with self._lock:
|
|
383
|
+
if self._closed:
|
|
384
|
+
return
|
|
385
|
+
self._reconcile_locked()
|
|
386
|
+
self._pump_locked()
|
|
387
|
+
if not self._sources and not self._sessions:
|
|
388
|
+
self._thread = None
|
|
389
|
+
return
|
|
390
|
+
wait = 0.005 if self._sessions else 0.25
|
|
391
|
+
self._wake.wait(wait)
|
|
392
|
+
self._wake.clear()
|
|
393
|
+
|
|
394
|
+
def _reconcile_locked(self) -> None:
|
|
395
|
+
cutoff = time.perf_counter() - LEGACY_END_TO_BEGIN_GUARD * 2
|
|
396
|
+
self._last_legacy_end_at = {
|
|
397
|
+
key: ended_at for key, ended_at in self._last_legacy_end_at.items()
|
|
398
|
+
if ended_at >= cutoff
|
|
399
|
+
}
|
|
400
|
+
endpoints = {
|
|
401
|
+
endpoint
|
|
402
|
+
for endpoint in self._endpoint_provider()
|
|
403
|
+
if endpoint.ip and endpoint.address
|
|
404
|
+
}
|
|
405
|
+
modes = {endpoint: self._mode_provider(endpoint) for endpoint in endpoints}
|
|
406
|
+
|
|
407
|
+
# Migrate an established session when either its exact device address or
|
|
408
|
+
# its IP still identifies one unambiguous new endpoint. Route-only
|
|
409
|
+
# changes continue DATA with the existing offset: END(old)->BEGIN(new)
|
|
410
|
+
# can reorder across an AP's DTIM buffer and kill the new session.
|
|
411
|
+
# Only under the identity the session began with (same lease, or legacy
|
|
412
|
+
# on both sides); otherwise the old session is forgotten like a
|
|
413
|
+
# vanished endpoint.
|
|
414
|
+
missing = [endpoint for endpoint in self._sessions if endpoint not in endpoints]
|
|
415
|
+
unclaimed = set(endpoints) - set(self._sessions)
|
|
416
|
+
for old_endpoint in missing:
|
|
417
|
+
candidates = [
|
|
418
|
+
endpoint for endpoint in unclaimed
|
|
419
|
+
if endpoint.address == old_endpoint.address
|
|
420
|
+
or endpoint.ip == old_endpoint.ip
|
|
421
|
+
]
|
|
422
|
+
if len(candidates) != 1:
|
|
423
|
+
continue
|
|
424
|
+
new_endpoint = candidates[0]
|
|
425
|
+
if not _session_current(self._sessions[old_endpoint], modes[new_endpoint]):
|
|
426
|
+
continue
|
|
427
|
+
session = self._sessions.pop(old_endpoint)
|
|
428
|
+
session.endpoint = new_endpoint
|
|
429
|
+
self._sessions[new_endpoint] = session
|
|
430
|
+
unclaimed.remove(new_endpoint)
|
|
431
|
+
for source in self._sources.values():
|
|
432
|
+
if old_endpoint in source.completed_endpoints:
|
|
433
|
+
source.completed_endpoints.remove(old_endpoint)
|
|
434
|
+
source.completed_endpoints.add(new_endpoint)
|
|
435
|
+
|
|
436
|
+
# A genuinely vanished endpoint is forgotten without END. If it later
|
|
437
|
+
# returns as a new endpoint, finite sources start there from frame zero.
|
|
438
|
+
for endpoint in tuple(self._sessions):
|
|
439
|
+
if endpoint not in endpoints:
|
|
440
|
+
self._sessions.pop(endpoint, None)
|
|
441
|
+
for source in self._sources.values():
|
|
442
|
+
source.completed_endpoints.discard(endpoint)
|
|
443
|
+
|
|
444
|
+
# A matched PONG may have changed an endpoint's class (legacy <-> v2),
|
|
445
|
+
# its lease (device reboot / lease renewal / new incarnation) or made
|
|
446
|
+
# it unusable (invalid / superseded). End the running session NOW, in
|
|
447
|
+
# the format and identity it began with (no linger), before a session
|
|
448
|
+
# in the new mode can start. A superseded endpoint is not re-taken.
|
|
449
|
+
for endpoint, session in tuple(self._sessions.items()):
|
|
450
|
+
if not _session_current(session, modes[endpoint]):
|
|
451
|
+
self._sessions.pop(endpoint)
|
|
452
|
+
self._end_session_locked(session)
|
|
453
|
+
|
|
454
|
+
now = time.perf_counter()
|
|
455
|
+
for session in self._sessions.values():
|
|
456
|
+
for source_id in tuple(session.cursors):
|
|
457
|
+
source = self._sources.get(source_id)
|
|
458
|
+
if source is None or not protocol.address_matches(
|
|
459
|
+
source.target, session.endpoint.address
|
|
460
|
+
):
|
|
461
|
+
session.cursors.pop(source_id, None)
|
|
462
|
+
if not session.cursors:
|
|
463
|
+
session.empty_since = session.empty_since or now
|
|
464
|
+
|
|
465
|
+
for source in self._sources.values():
|
|
466
|
+
matched = [
|
|
467
|
+
endpoint for endpoint in endpoints
|
|
468
|
+
if modes[endpoint] is not None
|
|
469
|
+
and protocol.address_matches(source.target, endpoint.address)
|
|
470
|
+
]
|
|
471
|
+
source.waiting_endpoints.clear()
|
|
472
|
+
for endpoint in matched:
|
|
473
|
+
if endpoint in source.completed_endpoints:
|
|
474
|
+
continue
|
|
475
|
+
session = self._sessions.get(endpoint)
|
|
476
|
+
if session is None:
|
|
477
|
+
session = self._begin_session_locked(endpoint, modes[endpoint])
|
|
478
|
+
if session is None:
|
|
479
|
+
source.waiting_endpoints.add(endpoint)
|
|
480
|
+
continue
|
|
481
|
+
if source.id not in session.cursors:
|
|
482
|
+
if session.empty_since is not None:
|
|
483
|
+
session.started_at = (
|
|
484
|
+
time.perf_counter() - session.frames_sent / SAMPLE_RATE
|
|
485
|
+
)
|
|
486
|
+
session.empty_since = None
|
|
487
|
+
session.cursors[source.id] = 0
|
|
488
|
+
assigned = any(
|
|
489
|
+
source.id in session.cursors for session in self._sessions.values()
|
|
490
|
+
)
|
|
491
|
+
source.playback._set_state(
|
|
492
|
+
PlaybackStatus.ACTIVE if assigned else PlaybackStatus.DEFERRED,
|
|
493
|
+
None if assigned else DeferredReason.NO_RESOLVED_ENDPOINT,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# A pending (guarded) endpoint that expired or stopped matching left
|
|
497
|
+
# waiting_endpoints above: a finite source already played elsewhere
|
|
498
|
+
# completes now, not on that endpoint's next pump (§6.10).
|
|
499
|
+
for source_id, source in tuple(self._sources.items()):
|
|
500
|
+
if source.played and not source.loop:
|
|
501
|
+
self._complete_if_unreferenced_locked(source_id)
|
|
502
|
+
|
|
503
|
+
def _begin_session_locked(
|
|
504
|
+
self, endpoint: StreamEndpoint, mode: EndpointStreamMode
|
|
505
|
+
) -> Optional[_EndpointSession]:
|
|
506
|
+
"""Open an endpoint session in ``mode``, or None while it must wait
|
|
507
|
+
(legacy guard after a legacy END, or generations exhausted)."""
|
|
508
|
+
if mode.lease is None:
|
|
509
|
+
if self._legacy_guard_locked(endpoint):
|
|
510
|
+
return None
|
|
511
|
+
identity = None
|
|
512
|
+
else:
|
|
513
|
+
identity = self._allocate_identity_locked(mode.lease)
|
|
514
|
+
if identity is None:
|
|
515
|
+
return None
|
|
516
|
+
session = _EndpointSession(endpoint=endpoint, identity=identity)
|
|
517
|
+
self._sessions[endpoint] = session
|
|
518
|
+
if identity is None:
|
|
519
|
+
self._sink.stream_begin_endpoint(endpoint)
|
|
520
|
+
else:
|
|
521
|
+
self._sink.stream_begin_v2(endpoint, identity)
|
|
522
|
+
return session
|
|
523
|
+
|
|
524
|
+
def _allocate_identity_locked(
|
|
525
|
+
self, lease: StreamLease
|
|
526
|
+
) -> Optional[StreamSessionIdentity]:
|
|
527
|
+
"""Next generation under ``lease``, monotonic and never reused.
|
|
528
|
+
Exhaustion fails closed until the device issues a new lease."""
|
|
529
|
+
last = self._last_generation.get(lease, 0)
|
|
530
|
+
if last >= MAX_GENERATION:
|
|
531
|
+
return None
|
|
532
|
+
generation = last + 1
|
|
533
|
+
self._last_generation[lease] = generation
|
|
534
|
+
if generation == MAX_GENERATION:
|
|
535
|
+
logger.warning(
|
|
536
|
+
"stream generation exhausted for lease %s; waiting for a new lease",
|
|
537
|
+
lease,
|
|
538
|
+
)
|
|
539
|
+
return StreamSessionIdentity(lease, generation)
|
|
540
|
+
|
|
541
|
+
def _end_session_locked(self, session: _EndpointSession) -> None:
|
|
542
|
+
"""Send END in the session's own format and identity."""
|
|
543
|
+
if session.identity is None:
|
|
544
|
+
self._sink.stream_end_endpoint(session.endpoint)
|
|
545
|
+
self._record_legacy_end_locked(session.endpoint)
|
|
546
|
+
else:
|
|
547
|
+
self._sink.stream_end_v2(session.endpoint, session.identity)
|
|
548
|
+
|
|
549
|
+
def _pump_locked(self) -> None:
|
|
550
|
+
now = time.perf_counter()
|
|
551
|
+
for endpoint, session in list(self._sessions.items()):
|
|
552
|
+
wanted = int((now - session.started_at + self._send_ahead) * SAMPLE_RATE)
|
|
553
|
+
while session.cursors and session.frames_sent < wanted:
|
|
554
|
+
frames = self._chunk_frames_locked(session)
|
|
555
|
+
if frames <= 0:
|
|
556
|
+
break
|
|
557
|
+
data, finished = self._mix_locked(session, frames)
|
|
558
|
+
if session.identity is None:
|
|
559
|
+
self._sink.stream_data_endpoint(endpoint, session.offset, data)
|
|
560
|
+
else:
|
|
561
|
+
self._sink.stream_data_v2(
|
|
562
|
+
endpoint, session.identity, session.offset, data
|
|
563
|
+
)
|
|
564
|
+
session.offset += len(data)
|
|
565
|
+
session.frames_sent += frames
|
|
566
|
+
for source_id in finished:
|
|
567
|
+
source = self._sources.get(source_id)
|
|
568
|
+
if source is not None:
|
|
569
|
+
source.completed_endpoints.add(endpoint)
|
|
570
|
+
source.played = True
|
|
571
|
+
session.cursors.pop(source_id, None)
|
|
572
|
+
self._complete_if_unreferenced_locked(source_id)
|
|
573
|
+
|
|
574
|
+
if not session.cursors:
|
|
575
|
+
if session.empty_since is None:
|
|
576
|
+
session.empty_since = now
|
|
577
|
+
elif now - session.empty_since >= SESSION_END_LINGER:
|
|
578
|
+
self._sessions.pop(endpoint, None)
|
|
579
|
+
self._end_session_locked(session)
|
|
580
|
+
|
|
581
|
+
def _chunk_frames_locked(self, session: _EndpointSession) -> int:
|
|
582
|
+
maximum = 0
|
|
583
|
+
for source_id, cursor in session.cursors.items():
|
|
584
|
+
source = self._sources.get(source_id)
|
|
585
|
+
if source is None:
|
|
586
|
+
continue
|
|
587
|
+
if source.loop and source.frames:
|
|
588
|
+
return CHUNK_FRAMES
|
|
589
|
+
maximum = max(maximum, source.frames - cursor)
|
|
590
|
+
return min(CHUNK_FRAMES, max(0, maximum))
|
|
591
|
+
|
|
592
|
+
def _mix_locked(
|
|
593
|
+
self, session: _EndpointSession, frames: int
|
|
594
|
+
) -> tuple[bytes, set[str]]:
|
|
595
|
+
mixed = [0.0] * (frames * CHANNELS)
|
|
596
|
+
finished: set[str] = set()
|
|
597
|
+
for source_id, initial_cursor in list(session.cursors.items()):
|
|
598
|
+
source = self._sources.get(source_id)
|
|
599
|
+
if source is None or source.frames == 0:
|
|
600
|
+
finished.add(source_id)
|
|
601
|
+
continue
|
|
602
|
+
left_gain, right_gain = _channel_gains(source.gain, source.pan)
|
|
603
|
+
# a new source starts at its own gains (its PCM carries the onset);
|
|
604
|
+
# a changed gain / pan ramps linearly over this chunk (no zipper)
|
|
605
|
+
left0, right0 = session.gains.get(source_id, (left_gain, right_gain))
|
|
606
|
+
session.gains[source_id] = (left_gain, right_gain)
|
|
607
|
+
step_l = (left_gain - left0) / frames
|
|
608
|
+
step_r = (right_gain - right0) / frames
|
|
609
|
+
ramp = step_l != 0.0 or step_r != 0.0
|
|
610
|
+
pcm = source.pcm
|
|
611
|
+
cursor = initial_cursor
|
|
612
|
+
for frame in range(frames):
|
|
613
|
+
if cursor >= source.frames:
|
|
614
|
+
if source.loop:
|
|
615
|
+
cursor = 0
|
|
616
|
+
else:
|
|
617
|
+
finished.add(source_id)
|
|
618
|
+
break
|
|
619
|
+
base = cursor * CHANNELS
|
|
620
|
+
if ramp:
|
|
621
|
+
gl = left0 + step_l * (frame + 1)
|
|
622
|
+
gr = right0 + step_r * (frame + 1)
|
|
623
|
+
else:
|
|
624
|
+
gl, gr = left_gain, right_gain
|
|
625
|
+
mixed[frame * CHANNELS] += pcm[base] * gl
|
|
626
|
+
mixed[frame * CHANNELS + 1] += pcm[base + 1] * gr
|
|
627
|
+
cursor += 1
|
|
628
|
+
session.cursors[source_id] = cursor
|
|
629
|
+
if not source.loop and cursor >= source.frames:
|
|
630
|
+
finished.add(source_id)
|
|
631
|
+
for source_id in tuple(session.gains):
|
|
632
|
+
if source_id in finished or source_id not in session.cursors:
|
|
633
|
+
del session.gains[source_id]
|
|
634
|
+
|
|
635
|
+
_limit(session, mixed, frames)
|
|
636
|
+
samples = (_saturate_pcm16(value) for value in mixed)
|
|
637
|
+
return struct.pack(f"<{frames * CHANNELS}h", *samples), finished
|
|
638
|
+
|
|
639
|
+
def _remove_source_from_sessions_locked(self, source_id: str) -> None:
|
|
640
|
+
now = time.perf_counter()
|
|
641
|
+
for session in self._sessions.values():
|
|
642
|
+
session.cursors.pop(source_id, None)
|
|
643
|
+
if not session.cursors:
|
|
644
|
+
session.empty_since = session.empty_since or now
|
|
645
|
+
|
|
646
|
+
def _complete_if_unreferenced_locked(self, source_id: str) -> None:
|
|
647
|
+
if any(source_id in session.cursors for session in self._sessions.values()):
|
|
648
|
+
return
|
|
649
|
+
source = self._sources.get(source_id)
|
|
650
|
+
if source is not None and source.waiting_endpoints:
|
|
651
|
+
return
|
|
652
|
+
if source_id in self._sources:
|
|
653
|
+
source = self._sources.pop(source_id)
|
|
654
|
+
source.playback._set_state(PlaybackStatus.STOPPED)
|
|
655
|
+
|
|
656
|
+
def _set_source_property(self, source_id: str, name: str, value) -> None:
|
|
657
|
+
with self._lock:
|
|
658
|
+
source = self._sources.get(source_id)
|
|
659
|
+
if source is not None:
|
|
660
|
+
setattr(source, name, value)
|
|
661
|
+
|
|
662
|
+
def _record_legacy_end_locked(self, endpoint: StreamEndpoint) -> None:
|
|
663
|
+
self._last_legacy_end_at[endpoint] = time.perf_counter()
|
|
664
|
+
|
|
665
|
+
def _legacy_guard_locked(self, endpoint: StreamEndpoint) -> bool:
|
|
666
|
+
"""True while a legacy END to this endpoint is too recent for BEGIN."""
|
|
667
|
+
latest = self._last_legacy_end_at.get(endpoint, 0.0)
|
|
668
|
+
return time.perf_counter() - latest < LEGACY_END_TO_BEGIN_GUARD
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _session_current(
|
|
672
|
+
session: _EndpointSession, mode: Optional[EndpointStreamMode]
|
|
673
|
+
) -> bool:
|
|
674
|
+
"""Whether ``session`` may keep streaming to an endpoint now in ``mode``:
|
|
675
|
+
legacy stays legacy; v2 needs the lease it began under to still be the
|
|
676
|
+
endpoint's usable lease."""
|
|
677
|
+
if mode is None:
|
|
678
|
+
return False
|
|
679
|
+
if session.identity is None:
|
|
680
|
+
return mode.lease is None
|
|
681
|
+
return mode.lease == session.identity.lease
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _limit(session: _EndpointSession, mixed: list[float], frames: int) -> None:
|
|
685
|
+
"""Keep the summed sources in range without a hard clamp: past
|
|
686
|
+
LIMIT_THRESHOLD, a gain reduction to LIMIT_TARGET / peak (attack within the chunk, release
|
|
687
|
+
by LIMIT_RELEASE_PER_CHUNK per chunk), ramped per sample, then a soft knee."""
|
|
688
|
+
peak = max((abs(v) for v in mixed), default=0.0)
|
|
689
|
+
want = LIMIT_TARGET / peak if peak > LIMIT_THRESHOLD else 1.0
|
|
690
|
+
start = session.limit
|
|
691
|
+
end = want if want < start else min(want, start + LIMIT_RELEASE_PER_CHUNK)
|
|
692
|
+
session.limit = end
|
|
693
|
+
# an idle limiter passes a block within LIMIT_THRESHOLD bit-identically
|
|
694
|
+
# (no knee; +32768 saturates to 32767 afterwards); during release
|
|
695
|
+
# (coefficient < 1) the knee still shapes the tail
|
|
696
|
+
if start == 1.0 and end == 1.0 and peak <= LIMIT_THRESHOLD:
|
|
697
|
+
return
|
|
698
|
+
step = (end - start) / frames
|
|
699
|
+
span = FULL_SCALE - LIMIT_KNEE
|
|
700
|
+
for frame in range(frames):
|
|
701
|
+
g = start + step * (frame + 1)
|
|
702
|
+
for ch in range(CHANNELS):
|
|
703
|
+
i = frame * CHANNELS + ch
|
|
704
|
+
v = mixed[i] * g
|
|
705
|
+
a = abs(v)
|
|
706
|
+
if a > LIMIT_KNEE:
|
|
707
|
+
v = math.copysign(LIMIT_KNEE + span * math.tanh((a - LIMIT_KNEE) / span), v)
|
|
708
|
+
mixed[i] = v
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _normalize_pcm16(pcm: bytes, *, sample_rate: int, channels: int) -> tuple[int, ...]:
|
|
712
|
+
if sample_rate <= 0:
|
|
713
|
+
raise ValueError("sample_rate must be positive")
|
|
714
|
+
if channels not in (1, 2):
|
|
715
|
+
raise ValueError("channels must be 1 or 2")
|
|
716
|
+
bytes_per_frame = channels * 2
|
|
717
|
+
if len(pcm) % bytes_per_frame:
|
|
718
|
+
raise ValueError("PCM16 data must contain complete frames")
|
|
719
|
+
if not pcm:
|
|
720
|
+
return ()
|
|
721
|
+
if sample_rate == SAMPLE_RATE:
|
|
722
|
+
# no resampling: C-level copies (the per-sample loop below took ~210 ms
|
|
723
|
+
# for 8.5 s of stereo, holding the GIL and starving the mixer thread)
|
|
724
|
+
values = array("h")
|
|
725
|
+
values.frombytes(pcm)
|
|
726
|
+
if sys.byteorder != "little":
|
|
727
|
+
values.byteswap()
|
|
728
|
+
if channels == 2:
|
|
729
|
+
return tuple(values)
|
|
730
|
+
stereo = array("h", bytes(4 * len(values)))
|
|
731
|
+
stereo[0::2] = values
|
|
732
|
+
stereo[1::2] = values
|
|
733
|
+
return tuple(stereo)
|
|
734
|
+
values = [item[0] for item in struct.iter_unpack("<h", pcm)]
|
|
735
|
+
input_frames = len(values) // channels
|
|
736
|
+
output_frames = max(1, round(input_frames * SAMPLE_RATE / sample_rate))
|
|
737
|
+
output: list[int] = []
|
|
738
|
+
for output_index in range(output_frames):
|
|
739
|
+
position = output_index * sample_rate / SAMPLE_RATE
|
|
740
|
+
left_index = min(input_frames - 1, int(math.floor(position)))
|
|
741
|
+
right_index = min(input_frames - 1, left_index + 1)
|
|
742
|
+
fraction = position - left_index
|
|
743
|
+
if channels == 1:
|
|
744
|
+
value = round(values[left_index] * (1.0 - fraction) + values[right_index] * fraction)
|
|
745
|
+
output.extend((value, value))
|
|
746
|
+
else:
|
|
747
|
+
for channel in range(CHANNELS):
|
|
748
|
+
a = values[left_index * CHANNELS + channel]
|
|
749
|
+
b = values[right_index * CHANNELS + channel]
|
|
750
|
+
output.append(round(a * (1.0 - fraction) + b * fraction))
|
|
751
|
+
return tuple(output)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _channel_gains(gain: float, pan: float) -> tuple[float, float]:
|
|
755
|
+
return (
|
|
756
|
+
gain * (1.0 if pan <= 0.0 else 1.0 - pan),
|
|
757
|
+
gain * (1.0 if pan >= 0.0 else 1.0 + pan),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _saturate_pcm16(value: float) -> int:
|
|
762
|
+
return max(-32768, min(32767, round(value)))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _clamp(value: float, minimum: float, maximum: float) -> float:
|
|
766
|
+
return max(minimum, min(maximum, value))
|