/
/
/
1"""
2Reusable synchronizer role for external visualization bridges.
3
4Provides a SynchronizerRole that receives audio from Sendspin's PushStream,
5computes visualization features (loudness, peak frequency, spectrum), and
6forwards them to an external consumer via callbacks. This role can be used
7by any bridge implementation (e.g. Hue Entertainment) that needs real-time
8audio analysis data without a WebSocket connection.
9"""
10
11from __future__ import annotations
12
13from collections.abc import Callable
14from typing import TYPE_CHECKING
15
16from aiosendspin.models.visualizer import (
17 ClientHelloVisualizerSupport,
18 StreamStartVisualizer,
19)
20from aiosendspin.server.roles import AudioRequirements, Role
21from aiosendspin.server.roles.registry import register_role
22from aiosendspin.server.roles.visualizer.features import (
23 ExtractedFrame,
24 VisualizerFeatureExtractor,
25)
26
27from music_assistant.mass import LOGGER
28
29if TYPE_CHECKING:
30 from aiosendspin.server import SendspinClient
31 from aiosendspin.server.roles import AudioChunk
32
33SYNC_SAMPLE_RATE = 48_000
34SYNC_BIT_DEPTH = 16
35SYNC_CHANNELS = 2
36SYNC_FRAME_DURATION_US = 25_000
37
38SYNC_ROLE_ID = "visualizer@_sync"
39
40
41class SynchronizerRole(Role):
42 """
43 Custom Sendspin visualizer role for external synchronization bridges.
44
45 Receives audio from PushStream, computes visualization features using
46 VisualizerFeatureExtractor, and delivers results via callbacks instead
47 of binary WebSocket messages.
48
49 Created by the role factory registry. After creation, the bridge must
50 call set_callbacks() to wire up the visualization data delivery.
51 """
52
53 def __init__(self, client: SendspinClient) -> None:
54 """
55 Initialize the synchronizer role.
56
57 :param client: The Sendspin client this role belongs to.
58 """
59 self._client = client
60 self._on_visualization_data_cb: Callable[[ExtractedFrame], None] | None = None
61 self._on_stream_start_cb: Callable[[], None] | None = None
62 self._on_stream_end_cb: Callable[[], None] | None = None
63 self._extractor: VisualizerFeatureExtractor | None = None
64 self._stream_config: StreamStartVisualizer | None = None
65 self._audio_requirements: AudioRequirements | None = None
66
67 # -- public API --
68
69 def set_callbacks(
70 self,
71 *,
72 on_visualization_data: Callable[[ExtractedFrame], None],
73 on_stream_start: Callable[[], None],
74 on_stream_end: Callable[[], None],
75 ) -> None:
76 """Wire up bridge callbacks after role creation."""
77 self._on_visualization_data_cb = on_visualization_data
78 self._on_stream_start_cb = on_stream_start
79 self._on_stream_end_cb = on_stream_end
80
81 @property
82 def role_id(self) -> str:
83 """Return role identifier."""
84 return SYNC_ROLE_ID
85
86 @property
87 def role_family(self) -> str:
88 """Return role family name."""
89 return "visualizer"
90
91 def setup_audio_requirements(self) -> None:
92 """Set up audio requirements for visualization analysis."""
93 self._audio_requirements = AudioRequirements(
94 sample_rate=SYNC_SAMPLE_RATE,
95 bit_depth=SYNC_BIT_DEPTH,
96 channels=SYNC_CHANNELS,
97 frame_duration_us=SYNC_FRAME_DURATION_US,
98 )
99
100 def setup_stream_config(self) -> None:
101 """Initialize stream config from client hello visualizer support."""
102 support_raw = self._client.info.visualizer_support
103 if support_raw is None:
104 LOGGER.warning(
105 "SynchronizerRole: no visualizer support in hello for client %s",
106 self._client.client_id,
107 )
108 return
109 payload: dict[str, object] = support_raw.to_dict()
110 if "types" not in payload:
111 payload["types"] = ["loudness", "f_peak"]
112 if "rate_max" not in payload:
113 payload["rate_max"] = 30
114 support = ClientHelloVisualizerSupport.from_dict(payload)
115 self._stream_config = StreamStartVisualizer.from_support(support)
116
117 def get_audio_requirements(self) -> AudioRequirements | None:
118 """Return audio requirements for PushStream."""
119 return self._audio_requirements
120
121 def has_connection(self) -> bool:
122 """Return True â bridge is always 'connected' for audio purposes."""
123 return True
124
125 def supports_preconnect_audio(self) -> bool:
126 """Return True â bridge can receive audio before the stream starts."""
127 return True
128
129 # -- Role lifecycle hooks --
130
131 def on_connect(self) -> None:
132 """Subscribe to VisualizerGroupRole on attach."""
133 self.setup_stream_config()
134 self._subscribe_to_group_role()
135
136 def on_disconnect(self) -> None:
137 """Unsubscribe from VisualizerGroupRole on detach."""
138 self._unsubscribe_from_group_role()
139 self._extractor = None
140 self._stream_config = None
141
142 def on_stream_start(self) -> None:
143 """Create fresh extractor and invoke callback."""
144 if self._stream_config is None:
145 self.setup_stream_config()
146 if self._stream_config is None:
147 return
148 req = self.get_audio_requirements()
149 if req is None:
150 return
151 self._extractor = VisualizerFeatureExtractor(
152 sample_rate=req.sample_rate,
153 channels=SYNC_CHANNELS,
154 config=self._stream_config,
155 )
156 LOGGER.debug("SynchronizerRole stream started for client %s", self._client.client_id)
157 if self._on_stream_start_cb:
158 self._on_stream_start_cb()
159
160 def on_audio_chunk(self, chunk: AudioChunk) -> None:
161 """Process audio chunk and forward visualization data via callback."""
162 if self._extractor is None or self._stream_config is None:
163 return
164 frames = self._extractor.process_chunk(chunk.data, chunk.timestamp_us)
165 if self._on_visualization_data_cb:
166 for frame in frames:
167 self._on_visualization_data_cb(frame)
168
169 def on_stream_clear(self) -> None:
170 """Reset extractor state at stream boundaries."""
171 if self._extractor is not None:
172 self._extractor.reset()
173
174 def on_stream_end(self) -> None:
175 """Reset extractor and invoke callback."""
176 self._extractor = None
177 LOGGER.debug("SynchronizerRole stream ended for client %s", self._client.client_id)
178 if self._on_stream_end_cb:
179 self._on_stream_end_cb()
180
181
182register_role(SYNC_ROLE_ID, lambda client: SynchronizerRole(client=client))
183