Skip to content

Commit a4d789d

Browse files
Update Resonate Provider (#2575)
1 parent db9028c commit a4d789d

File tree

3 files changed

+106
-66
lines changed

3 files changed

+106
-66
lines changed

music_assistant/providers/resonate/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
"name": "Resonate (WIP)",
66
"description": "Resonate (working title) is the next generation streaming protocol built by the Open Home Foundation. Follow the development on Discord to see how you can get involved.",
77
"codeowners": ["@music-assistant"],
8-
"requirements": ["aioresonate==0.9.1"]
8+
"requirements": ["aioresonate==0.11.0"]
99
}

music_assistant/providers/resonate/player.py

Lines changed: 104 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919
GroupStateChangedEvent,
2020
VolumeChangedEvent,
2121
)
22-
from aioresonate.server.client import ClientGroupChangedEvent, DisconnectBehaviour
22+
from aioresonate.server.client import DisconnectBehaviour
23+
from aioresonate.server.events import ClientGroupChangedEvent
2324
from aioresonate.server.group import (
24-
AudioCodec,
2525
GroupDeletedEvent,
2626
GroupMemberAddedEvent,
2727
GroupMemberRemovedEvent,
28-
Metadata,
2928
)
29+
from aioresonate.server.metadata import Metadata
30+
from aioresonate.server.stream import AudioCodec, MediaStream
3031
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
3132
from music_assistant_models.constants import PLAYER_CONTROL_NONE
3233
from music_assistant_models.enums import (
@@ -43,13 +44,14 @@
4344
from PIL import Image
4445

4546
from music_assistant.constants import CONF_ENTRY_OUTPUT_CODEC, CONF_OUTPUT_CODEC
47+
from music_assistant.helpers.audio import get_player_filter_params
4648
from music_assistant.helpers.ffmpeg import get_ffmpeg_stream
4749
from music_assistant.models.player import Player, PlayerMedia
4850
from music_assistant.providers.universal_group.constants import UGP_PREFIX
4951
from music_assistant.providers.universal_group.player import UniversalGroupPlayer
5052

5153
if TYPE_CHECKING:
52-
from aioresonate.server.client import Client
54+
from aioresonate.server.client import ResonateClient
5355
from music_assistant_models.event import MassEvent
5456

5557
from .provider import ResonateProvider
@@ -58,10 +60,11 @@
5860
class ResonatePlayer(Player):
5961
"""A resonate audio player in Music Assistant."""
6062

61-
api: Client
63+
api: ResonateClient
6264
unsub_event_cb: Callable[[], None]
6365
unsub_group_event_cb: Callable[[], None]
6466
last_sent_artwork_url: str | None = None
67+
_playback_task: asyncio.Task[None] | None = None
6568

6669
def __init__(self, provider: ResonateProvider, player_id: str) -> None:
6770
"""Initialize the Player."""
@@ -83,8 +86,9 @@ def __init__(self, provider: ResonateProvider, player_id: str) -> None:
8386
self._attr_can_group_with = {provider.lookup_key}
8487
self._attr_power_control = PLAYER_CONTROL_NONE
8588
self._attr_device_info = DeviceInfo()
86-
self._attr_volume_level = resonate_client.volume
87-
self._attr_volume_muted = resonate_client.muted
89+
if player_client := resonate_client.player:
90+
self._attr_volume_level = player_client.volume
91+
self._attr_volume_muted = player_client.muted
8892
self._attr_available = True
8993
self._on_unload_callbacks.append(
9094
self.mass.subscribe(
@@ -158,20 +162,24 @@ async def group_event_cb(self, event: GroupEvent) -> None:
158162

159163
async def volume_set(self, volume_level: int) -> None:
160164
"""Handle VOLUME_SET command on the player."""
161-
self.api.set_volume(volume_level)
165+
if player_client := self.api.player:
166+
player_client.set_volume(volume_level)
162167

163168
async def volume_mute(self, muted: bool) -> None:
164169
"""Handle VOLUME MUTE command on the player."""
165-
if muted:
166-
self.api.mute()
167-
else:
168-
self.api.unmute()
170+
if player_client := self.api.player:
171+
if muted:
172+
player_client.mute()
173+
else:
174+
player_client.unmute()
169175

170176
async def stop(self) -> None:
171177
"""Stop command."""
172178
self.logger.debug("Received STOP command on player %s", self.display_name)
173179
# We don't care if we stopped the stream or it was already stopped
174-
self.api.group.stop()
180+
await self.api.group.stop()
181+
# Clear the playback task reference (group.stop() handles stopping the stream)
182+
self._playback_task = None
175183
self._attr_active_source = None
176184
self._attr_current_media = None
177185
self.update_state()
@@ -189,61 +197,93 @@ async def play_media(self, media: PlayerMedia) -> None:
189197
self._attr_active_source = media.source_id
190198
# playback_state will be set by the group state change event
191199

192-
pcm_format = AudioFormat(
193-
content_type=ContentType.PCM_S16LE,
194-
sample_rate=48000,
195-
bit_depth=16,
196-
channels=2,
197-
)
200+
# Stop previous stream in case we were already playing something
201+
await self.api.group.stop()
202+
# Run playback in background task to immediately return
203+
self._playback_task = asyncio.create_task(self._run_playback(media))
204+
self.update_state()
198205

199-
# select audio source
200-
if media.media_type == MediaType.PLUGIN_SOURCE:
201-
# special case: plugin source stream
202-
assert media.custom_data is not None # for type checking
203-
audio_source = self.mass.streams.get_plugin_source_stream(
204-
plugin_source_id=media.custom_data["provider"],
205-
output_format=pcm_format,
206-
player_id=self.player_id,
207-
)
208-
elif media.source_id and media.source_id.startswith(UGP_PREFIX):
209-
# special case: UGP stream
210-
ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.source_id))
211-
ugp_stream = ugp_player.stream
212-
assert ugp_stream is not None # for type checker
213-
pcm_format.bit_depth = ugp_stream.base_pcm_format.bit_depth
214-
pcm_format.bit_rate = ugp_stream.base_pcm_format.bit_rate
215-
pcm_format.channels = ugp_stream.base_pcm_format.channels
216-
audio_source = ugp_stream.subscribe_raw()
217-
elif media.source_id and media.queue_item_id:
218-
# regular queue (flow) stream request
219-
queue = self.mass.player_queues.get(media.source_id)
220-
start_queue_item = self.mass.player_queues.get_item(
221-
media.source_id, media.queue_item_id
206+
async def _run_playback(self, media: PlayerMedia) -> None:
207+
"""Run the actual playback in a background task."""
208+
try:
209+
pcm_format = AudioFormat(
210+
content_type=ContentType.PCM_S16LE,
211+
sample_rate=48000,
212+
bit_depth=16,
213+
channels=2,
222214
)
223-
assert queue is not None # for type checking
224-
assert start_queue_item is not None # for type checking
225-
audio_source = self.mass.streams.get_queue_flow_stream(
226-
queue=queue, start_queue_item=start_queue_item, pcm_format=pcm_format
227-
)
228-
else:
229-
# assume url or some other direct path
215+
216+
# select audio source
217+
if media.media_type == MediaType.PLUGIN_SOURCE:
218+
# special case: plugin source stream
219+
assert media.custom_data is not None # for type checking
220+
audio_source = self.mass.streams.get_plugin_source_stream(
221+
plugin_source_id=media.custom_data["provider"],
222+
output_format=pcm_format,
223+
player_id=self.player_id,
224+
)
225+
elif media.source_id and media.source_id.startswith(UGP_PREFIX):
226+
# special case: UGP stream
227+
ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.source_id))
228+
ugp_stream = ugp_player.stream
229+
assert ugp_stream is not None # for type checker
230+
pcm_format.bit_depth = ugp_stream.base_pcm_format.bit_depth
231+
pcm_format.bit_rate = ugp_stream.base_pcm_format.bit_rate
232+
pcm_format.channels = ugp_stream.base_pcm_format.channels
233+
audio_source = ugp_stream.subscribe_raw()
234+
elif media.source_id and media.queue_item_id:
235+
# regular queue (flow) stream request
236+
queue = self.mass.player_queues.get(media.source_id)
237+
start_queue_item = self.mass.player_queues.get_item(
238+
media.source_id, media.queue_item_id
239+
)
240+
assert queue is not None # for type checking
241+
assert start_queue_item is not None # for type checking
242+
audio_source = self.mass.streams.get_queue_flow_stream(
243+
queue=queue, start_queue_item=start_queue_item, pcm_format=pcm_format
244+
)
245+
else:
246+
# assume url or some other direct path
247+
audio_source = get_ffmpeg_stream(
248+
audio_input=media.uri,
249+
input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)),
250+
output_format=pcm_format,
251+
)
252+
253+
output_codec = cast("str", self.config.get_value(CONF_OUTPUT_CODEC, "pcm"))
254+
255+
# Convert string codec to AudioCodec enum
256+
audio_codec = AudioCodec(output_codec)
257+
258+
# Apply DSP and other audio filters
230259
audio_source = get_ffmpeg_stream(
231-
audio_input=media.uri,
232-
input_format=AudioFormat(content_type=ContentType.try_parse(media.uri)),
260+
audio_input=audio_source,
261+
input_format=pcm_format,
233262
output_format=pcm_format,
263+
filter_params=get_player_filter_params(
264+
self.mass, self.player_id, pcm_format, pcm_format
265+
),
234266
)
235267

236-
output_codec = cast("str", self.config.get_value(CONF_OUTPUT_CODEC, "pcm"))
237-
238-
# Convert string codec to AudioCodec enum
239-
audio_codec = AudioCodec(output_codec)
268+
# Create MediaStream wrapping the audio source generator
269+
media_stream = MediaStream(
270+
source=audio_source,
271+
audio_format=ResonateAudioFormat(
272+
sample_rate=pcm_format.sample_rate,
273+
bit_depth=pcm_format.bit_depth,
274+
channels=pcm_format.channels,
275+
codec=audio_codec,
276+
),
277+
)
240278

241-
await self.api.group.play_media(
242-
audio_source,
243-
ResonateAudioFormat(pcm_format.sample_rate, pcm_format.bit_depth, pcm_format.channels),
244-
preferred_stream_codec=audio_codec,
245-
)
246-
self.update_state()
279+
stop_time = await self.api.group.play_media(media_stream)
280+
await self.api.group.stop(stop_time)
281+
except asyncio.CancelledError:
282+
self.logger.debug("Playback cancelled for player %s", self.display_name)
283+
raise
284+
except Exception:
285+
self.logger.exception("Error during playback for player %s", self.display_name)
286+
raise
247287

248288
async def set_members(
249289
self,
@@ -257,14 +297,14 @@ async def set_members(
257297
for player_id in player_ids_to_remove or []:
258298
player = self.mass.players.get(player_id, True)
259299
player = cast("ResonatePlayer", player) # For type checking
260-
self.api.group.remove_client(player.api)
300+
await self.api.group.remove_client(player.api)
261301
player.api.disconnect_behaviour = DisconnectBehaviour.STOP
262302
self._attr_group_members.remove(player_id)
263303
for player_id in player_ids_to_add or []:
264304
player = self.mass.players.get(player_id, True)
265305
player = cast("ResonatePlayer", player) # For type checking
266306
player.api.disconnect_behaviour = DisconnectBehaviour.UNGROUP
267-
self.api.group.add_client(player.api)
307+
await self.api.group.add_client(player.api)
268308
self._attr_group_members.append(player_id)
269309
self.update_state()
270310

@@ -303,7 +343,7 @@ async def _on_queue_update(self, event: MassEvent) -> None:
303343
artist = artist_str
304344
if _album := getattr(media_item, "album", None):
305345
album = _album.name
306-
year = _album.year
346+
year = getattr(_album, "year", None)
307347
album_artist = getattr(_album, "artist_str", None)
308348
if _track_number := getattr(media_item, "track_number", None):
309349
track = _track_number

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ aiohttp_asyncmdnsresolver==0.1.1
99
aiohttp-fast-zlib==0.3.0
1010
aiojellyfin==0.14.1
1111
aiomusiccast==0.14.8
12-
aioresonate==0.9.1
12+
aioresonate==0.11.0
1313
aiorun==2025.1.1
1414
aioslimproto==3.1.1
1515
aiosonos==0.1.9

0 commit comments

Comments
 (0)