/
/
/
1"""
2Reusable bridge roles for in-process Sendspin integrations.
3
4Provides a BridgePlayerRole that receives audio from Sendspin's PushStream
5and forwards it to an external player via callbacks. This role can be used
6by any bridge implementation (AirPlay, etc.) to integrate external players
7with Sendspin's synchronization and timing.
8
9Also provides BridgeVisualizerRole and BridgeColorRole for in-process
10consumers of the visualization pipeline (e.g. Hue Entertainment): feature
11extraction runs against the group's audio and results are delivered via
12callbacks instead of a WebSocket connection.
13"""
14
15from __future__ import annotations
16
17from collections.abc import Callable
18from typing import TYPE_CHECKING
19
20from aiosendspin.models.core import ServerStateMessage
21from aiosendspin.models.visualizer import BeatAvailability, StreamStartVisualizer
22from aiosendspin.server import VolumeChangedEvent
23from aiosendspin.server.roles import AudioRequirements, Role
24from aiosendspin.server.roles.registry import register_role
25from aiosendspin.server.roles.visualizer.features import VisualizerFeatureExtractor
26
27from music_assistant.mass import LOGGER
28
29if TYPE_CHECKING:
30 from aiosendspin.models.core import ServerStatePayload
31 from aiosendspin.models.types import ServerMessage
32 from aiosendspin.models.visualizer import BeatTiming, ClientHelloVisualizerSupport
33 from aiosendspin.server import SendspinClient
34 from aiosendspin.server.roles import AudioChunk
35 from aiosendspin.server.roles.visualizer.features import ExtractedFrame
36
37# Audio format constants for bridge players.
38BRIDGE_SAMPLE_RATE = 44100
39BRIDGE_BIT_DEPTH = 16
40BRIDGE_CHANNELS = 2
41BRIDGE_BYTES_PER_SAMPLE = BRIDGE_BIT_DEPTH // 8
42
43BRIDGE_ROLE_ID = "player@_bridge"
44VISUALIZER_BRIDGE_ROLE_ID = "visualizer@_bridge"
45COLOR_BRIDGE_ROLE_ID = "color@_bridge"
46
47
48class BridgePlayerRole(Role):
49 """
50 Custom Sendspin player role for external player bridges.
51
52 This role receives audio from Sendspin's PushStream and forwards it
53 to an external player via callbacks. It bypasses the normal WebSocket
54 audio delivery since external players don't have a WebSocket connection.
55
56 Created by the role factory registry. After creation, the bridge must
57 call set_callbacks() to wire up audio/volume/stream callbacks.
58 """
59
60 def __init__(self, client: SendspinClient) -> None:
61 """
62 Initialize the bridge player role.
63
64 :param client: The Sendspin client this role belongs to.
65 """
66 self._client = client
67 self._on_audio_chunk_cb: Callable[[AudioChunk], None] | None = None
68 self._on_volume_change_cb: Callable[[int], None] | None = None
69 self._on_mute_change_cb: Callable[[bool], None] | None = None
70 self._on_stream_start_cb: Callable[[], None] | None = None
71 self._on_stream_end_cb: Callable[[], None] | None = None
72 self._on_explicit_stop_cb: Callable[[], None] | None = None
73 self._audio_requirements: AudioRequirements | None = None
74 self._volume: int = 100
75 self._muted: bool = False
76 # Bridge-specific timing reported to PushStream so server scheduling
77 # accounts for the downstream player's startup and jitter requirements.
78 self._required_lead_time_ms: int = 0
79 self._min_buffer_ms: int = 0
80
81 def set_callbacks(
82 self,
83 *,
84 on_audio_chunk: Callable[[AudioChunk], None],
85 on_volume_change: Callable[[int], None],
86 on_mute_change: Callable[[bool], None],
87 on_stream_start: Callable[[], None],
88 on_stream_end: Callable[[], None],
89 on_explicit_stop: Callable[[], None] | None = None,
90 initial_volume: int = 100,
91 initial_muted: bool = False,
92 ) -> None:
93 """
94 Wire up bridge callbacks after role creation.
95
96 :param on_audio_chunk: Callback to receive audio chunks.
97 :param on_volume_change: Callback when volume level changes.
98 :param on_mute_change: Callback when mute state changes.
99 :param on_stream_start: Callback when the stream starts.
100 :param on_stream_end: Callback when the stream ends.
101 :param on_explicit_stop: Callback when playback was ended by an explicit
102 user command (stop, or removal from the group), so the bridge can
103 silence its player at once instead of keeping its transport warm.
104 :param initial_volume: Initial volume level (0-100).
105 :param initial_muted: Initial mute state.
106 """
107 self._on_audio_chunk_cb = on_audio_chunk
108 self._on_volume_change_cb = on_volume_change
109 self._on_mute_change_cb = on_mute_change
110 self._on_stream_start_cb = on_stream_start
111 self._on_stream_end_cb = on_stream_end
112 self._on_explicit_stop_cb = on_explicit_stop
113 self.update_player_state(volume=initial_volume, muted=initial_muted)
114
115 @property
116 def role_id(self) -> str:
117 """Return role identifier."""
118 return BRIDGE_ROLE_ID
119
120 @property
121 def role_family(self) -> str:
122 """Return role family name."""
123 return "player"
124
125 def setup_audio_requirements(
126 self,
127 sample_rate: int = BRIDGE_SAMPLE_RATE,
128 bit_depth: int = BRIDGE_BIT_DEPTH,
129 channels: int = BRIDGE_CHANNELS,
130 ) -> None:
131 """
132 Set up audio requirements for bridge PCM format.
133
134 Call with the sink's native rate/depth so MA transcodes to the
135 correct format before delivering chunks. Defaults to the bridge
136 constants for callers that don't need format negotiation.
137 """
138 self._audio_requirements = AudioRequirements(
139 sample_rate=sample_rate,
140 bit_depth=bit_depth,
141 channels=channels,
142 transformer=None,
143 )
144
145 @property
146 def preferred_format(self) -> AudioRequirements | None:
147 """Return the audio format declared via setup_audio_requirements."""
148 return self._audio_requirements
149
150 def get_audio_requirements(self) -> AudioRequirements | None:
151 """Return audio requirements for PushStream."""
152 return self._audio_requirements
153
154 def set_timing(self, *, required_lead_time_ms: int, min_buffer_ms: int) -> None:
155 """
156 Configure timing values reported to PushStream for scheduling.
157
158 Call before or during a stream session to adjust the lead time and
159 ongoing buffer floor reported to the Sendspin send-ahead calculation.
160 Bridge implementations should reflect their downstream player's real
161 startup latency (CLI spawn, protocol handshake, device buffer fill)
162 and jitter requirements.
163 """
164 self._required_lead_time_ms = max(0, required_lead_time_ms)
165 self._min_buffer_ms = max(0, min_buffer_ms)
166
167 def get_required_lead_time_us(self) -> int:
168 """Return bridge startup lead time in microseconds."""
169 return self._required_lead_time_ms * 1_000
170
171 def get_min_buffer_us(self) -> int:
172 """Return bridge minimum ongoing buffer duration in microseconds."""
173 return self._min_buffer_ms * 1_000
174
175 def get_player_volume(self) -> int | None:
176 """Return current volume level."""
177 return self._volume
178
179 def get_player_muted(self) -> bool | None:
180 """Return current mute state."""
181 return self._muted
182
183 def set_player_volume(self, volume: int) -> None:
184 """
185 Set volume and notify bridge.
186
187 The level is on the scale the role reports, and is stored, announced to
188 the client and handed to the bridge unchanged.
189 """
190 self._volume = volume
191 self._emit_volume_changed()
192 if self._on_volume_change_cb:
193 self._on_volume_change_cb(volume)
194
195 def set_player_mute(self, muted: bool) -> None:
196 """Set mute state and notify bridge."""
197 self._muted = muted
198 self._emit_volume_changed()
199 if self._on_mute_change_cb:
200 self._on_mute_change_cb(muted)
201
202 def update_player_state(self, *, volume: int | None = None, muted: bool | None = None) -> None:
203 """
204 Adopt volume/mute state the bridged player is already in.
205
206 Use this instead of set_player_volume/set_player_mute for state that came
207 from the player's own side (device feedback, a mute applied elsewhere): the
208 bridge callbacks are not invoked, so the value is not pushed back at the
209 player it was just read from.
210
211 :param volume: Volume level (0-100) the player is at, or None to leave it.
212 :param muted: Mute state the player is in, or None to leave it.
213 """
214 changed = False
215 if volume is not None and volume != self._volume:
216 self._volume = volume
217 changed = True
218 if muted is not None and muted != self._muted:
219 self._muted = muted
220 changed = True
221 if changed:
222 self._emit_volume_changed()
223
224 def on_audio_chunk(self, chunk: AudioChunk) -> None:
225 """Receive audio chunk from PushStream and forward to callback."""
226 if self._on_audio_chunk_cb:
227 self._on_audio_chunk_cb(chunk)
228
229 def on_connect(self) -> None:
230 """Subscribe to PlayerGroupRole on attach."""
231 self._subscribe_to_group_role()
232
233 def on_disconnect(self) -> None:
234 """Unsubscribe from PlayerGroupRole on detach."""
235 self._unsubscribe_from_group_role()
236
237 def has_connection(self) -> bool:
238 """Return True to indicate bridge is "connected" for audio purposes."""
239 return True
240
241 def supports_preconnect_audio(self) -> bool:
242 """Return True -- bridge can receive audio before the stream starts."""
243 return True
244
245 def on_stream_start(self) -> None:
246 """Log stream start and invoke callback."""
247 LOGGER.debug("BridgePlayerRole stream started for client %s", self._client.client_id)
248 if self._on_stream_start_cb:
249 self._on_stream_start_cb()
250
251 def on_stream_end(self) -> None:
252 """Log stream end and invoke the stream-end callback."""
253 LOGGER.debug("BridgePlayerRole stream ended for client %s", self._client.client_id)
254 if self._on_stream_end_cb:
255 self._on_stream_end_cb()
256
257 def notify_explicit_stop(self) -> None:
258 """
259 Notify the bridge that playback ended on an explicit user command.
260
261 Call this after the stop (or group removal) has ended the stream, so
262 the bridge can tear its downstream transport down at once instead of
263 keeping it warm for a next track that will not come.
264 """
265 LOGGER.debug("BridgePlayerRole explicit stop for client %s", self._client.client_id)
266 if self._on_explicit_stop_cb:
267 self._on_explicit_stop_cb()
268
269 def _emit_volume_changed(self) -> None:
270 """Emit VolumeChangedEvent so the SendspinPlayer stays in sync."""
271 self.emit_client_event(VolumeChangedEvent(volume=self._volume, muted=self._muted))
272
273
274class BridgeVisualizerRole(Role):
275 """
276 Custom Sendspin visualizer role for in-process bridges.
277
278 Runs the server's visualizer feature extraction on the group's audio and
279 forwards extracted frames, beat schedules, and stream lifecycle events to
280 callbacks instead of sending binary frames over a WebSocket.
281
282 Created by the role factory registry. After creation, the bridge must call
283 set_callbacks() and setup_visualizer(), then attach the roles via the
284 client's attach_preinitialized_roles(): external clients never get a
285 transport attach, so the group-role subscription (which beat schedules
286 and lifecycle broadcasts flow through) has to be made explicitly.
287 """
288
289 def __init__(self, client: SendspinClient) -> None:
290 """
291 Initialize the bridge visualizer role.
292
293 :param client: The Sendspin client this role belongs to.
294 """
295 self._client = client
296 self._support: ClientHelloVisualizerSupport | None = None
297 self._extractor: VisualizerFeatureExtractor | None = None
298 self._beat_availability = BeatAvailability.PENDING
299 self._on_frame_cb: Callable[[ExtractedFrame], None] | None = None
300 self._on_beats_cb: Callable[[list[BeatTiming]], None] | None = None
301 self._on_beats_clear_cb: Callable[[], None] | None = None
302 self._on_stream_start_cb: Callable[[], None] | None = None
303 self._on_stream_clear_cb: Callable[[], None] | None = None
304 self._on_stream_end_cb: Callable[[], None] | None = None
305
306 def set_callbacks(
307 self,
308 *,
309 on_frame: Callable[[ExtractedFrame], None],
310 on_beats: Callable[[list[BeatTiming]], None],
311 on_beats_clear: Callable[[], None],
312 on_stream_start: Callable[[], None],
313 on_stream_clear: Callable[[], None],
314 on_stream_end: Callable[[], None],
315 ) -> None:
316 """
317 Wire up bridge callbacks after role creation.
318
319 :param on_frame: Callback receiving extracted visualizer frames.
320 :param on_beats: Callback receiving beat schedule segments.
321 :param on_beats_clear: Callback when the beat schedule is dropped.
322 :param on_stream_start: Callback when the stream starts.
323 :param on_stream_clear: Callback when buffered stream state is cleared (seek).
324 :param on_stream_end: Callback when the stream ends.
325 """
326 self._on_frame_cb = on_frame
327 self._on_beats_cb = on_beats
328 self._on_beats_clear_cb = on_beats_clear
329 self._on_stream_start_cb = on_stream_start
330 self._on_stream_clear_cb = on_stream_clear
331 self._on_stream_end_cb = on_stream_end
332
333 def setup_visualizer(self, support: ClientHelloVisualizerSupport) -> None:
334 """
335 Configure feature extraction from the registered hello's support object.
336
337 :param support: The visualizer support object from the client hello.
338 """
339 self._support = support
340
341 @property
342 def role_id(self) -> str:
343 """Return role identifier."""
344 return VISUALIZER_BRIDGE_ROLE_ID
345
346 @property
347 def role_family(self) -> str:
348 """Return role family name."""
349 return "visualizer"
350
351 def get_audio_requirements(self) -> AudioRequirements:
352 """Return audio requirements for visualizer analysis."""
353 return AudioRequirements(
354 sample_rate=48_000,
355 bit_depth=16,
356 channels=2,
357 frame_duration_us=25_000,
358 )
359
360 def has_connection(self) -> bool:
361 """Return True to indicate bridge is "connected" for audio purposes."""
362 return True
363
364 def supports_preconnect_audio(self) -> bool:
365 """Return True -- bridge receives audio without a transport attach."""
366 return True
367
368 def replay_from_pcm_cache(self) -> bool:
369 """Replay buffered PCM on late join (visualizer is analysis-only)."""
370 return True
371
372 @property
373 def wants_beats(self) -> bool:
374 """True if the bridge requested beats and beats are not unavailable."""
375 return (
376 self._support is not None
377 and "beat" in self._support.types
378 and self._beat_availability is not BeatAvailability.UNAVAILABLE
379 )
380
381 def append_beats(self, beats: list[BeatTiming]) -> None:
382 """Forward a beat schedule segment to the bridge."""
383 if self._on_beats_cb and beats:
384 self._on_beats_cb(list(beats))
385
386 def clear_beats(self) -> None:
387 """Forward a beat-schedule clear to the bridge."""
388 if self._on_beats_clear_cb:
389 self._on_beats_clear_cb()
390
391 def set_beat_availability(self, availability: BeatAvailability) -> None:
392 """Track beat availability; UNAVAILABLE drops the delivered schedule."""
393 self._beat_availability = availability
394 if availability is BeatAvailability.UNAVAILABLE:
395 self.clear_beats()
396
397 def on_connect(self) -> None:
398 """Subscribe to VisualizerGroupRole on attach."""
399 self._subscribe_to_group_role()
400
401 def on_disconnect(self) -> None:
402 """Unsubscribe from VisualizerGroupRole on detach."""
403 self._unsubscribe_from_group_role()
404
405 def on_stream_start(self) -> None:
406 """Build a fresh extractor for the new stream and notify the bridge."""
407 if self._support is not None:
408 req = self.get_audio_requirements()
409 self._extractor = VisualizerFeatureExtractor(
410 sample_rate=req.sample_rate,
411 channels=req.channels,
412 config=StreamStartVisualizer.from_support(self._support),
413 )
414 LOGGER.debug("BridgeVisualizerRole stream started for client %s", self._client.client_id)
415 if self._on_stream_start_cb:
416 self._on_stream_start_cb()
417
418 def on_audio_chunk(self, chunk: AudioChunk) -> None:
419 """Extract features from group audio and forward each frame."""
420 if self._extractor is None or self._on_frame_cb is None:
421 return
422 for frame in self._extractor.process_chunk(chunk.data, chunk.timestamp_us):
423 self._on_frame_cb(frame)
424
425 def on_stream_clear(self) -> None:
426 """Reset extractor state on seek/clear and notify the bridge."""
427 if self._extractor is not None:
428 self._extractor.reset()
429 if self._on_stream_clear_cb:
430 self._on_stream_clear_cb()
431
432 def on_stream_end(self) -> None:
433 """Tear down the extractor and notify the bridge."""
434 self._extractor = None
435 LOGGER.debug("BridgeVisualizerRole stream ended for client %s", self._client.client_id)
436 if self._on_stream_end_cb:
437 self._on_stream_end_cb()
438
439
440class BridgeColorRole(Role):
441 """
442 Custom Sendspin color role for in-process bridges.
443
444 Receives color palette state updates from ColorGroupRole and forwards
445 them to a callback instead of sending server/state over a WebSocket.
446 """
447
448 def __init__(self, client: SendspinClient) -> None:
449 """
450 Initialize the bridge color role.
451
452 :param client: The Sendspin client this role belongs to.
453 """
454 self._client = client
455 self._on_color_cb: Callable[[ServerStatePayload], None] | None = None
456
457 def set_callbacks(self, *, on_color: Callable[[ServerStatePayload], None]) -> None:
458 """
459 Wire up the color update callback after role creation.
460
461 :param on_color: Callback receiving server/state payloads with color updates.
462 """
463 self._on_color_cb = on_color
464
465 @property
466 def role_id(self) -> str:
467 """Return role identifier."""
468 return COLOR_BRIDGE_ROLE_ID
469
470 @property
471 def role_family(self) -> str:
472 """Return role family name."""
473 return "color"
474
475 def has_connection(self) -> bool:
476 """Return True to indicate bridge is "connected"."""
477 return True
478
479 def send_message(self, message: ServerMessage) -> None:
480 """Intercept outbound server/state color updates and forward to the bridge."""
481 if isinstance(message, ServerStateMessage) and self._on_color_cb:
482 self._on_color_cb(message.payload)
483
484 def on_connect(self) -> None:
485 """Subscribe to ColorGroupRole on attach."""
486 self._subscribe_to_group_role()
487
488 def on_disconnect(self) -> None:
489 """Unsubscribe from ColorGroupRole on detach."""
490 self._unsubscribe_from_group_role()
491
492
493register_role(BRIDGE_ROLE_ID, lambda client: BridgePlayerRole(client=client))
494register_role(VISUALIZER_BRIDGE_ROLE_ID, lambda client: BridgeVisualizerRole(client=client))
495register_role(COLOR_BRIDGE_ROLE_ID, lambda client: BridgeColorRole(client=client))
496