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/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))