Skip to content

Commit 692a5d2

Browse files
committed
Add VBAN Receiver plugin provider
1 parent 14e1fb7 commit 692a5d2

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""
2+
VBAN protocol receiver plugin for Music Assistant.
3+
4+
We tie a single player to a single VBAN session name.
5+
The provider has multi instance support,
6+
so multiple players can be linked to multiple VBAN streams.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import re
13+
from collections.abc import AsyncGenerator
14+
from contextlib import suppress
15+
from typing import TYPE_CHECKING, cast
16+
17+
from aiovban.asyncio import AsyncVBANClient
18+
from aiovban.asyncio.util import BackPressureStrategy
19+
from aiovban.enums import VBANSampleRate
20+
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
21+
from music_assistant_models.enums import (
22+
ConfigEntryType,
23+
ContentType,
24+
MediaType,
25+
ProviderFeature,
26+
StreamType,
27+
)
28+
from music_assistant_models.errors import SetupFailedError
29+
from music_assistant_models.media_items import AudioFormat
30+
31+
from music_assistant.constants import (
32+
CONF_BIND_IP,
33+
CONF_BIND_PORT,
34+
CONF_ENTRY_WARN_PREVIEW,
35+
)
36+
from music_assistant.helpers.util import (
37+
get_ip_addresses,
38+
)
39+
from music_assistant.models.player import PlayerMedia
40+
from music_assistant.models.plugin import PluginProvider, PluginSource
41+
42+
if TYPE_CHECKING:
43+
from aiovban.asyncio.device import VBANDevice
44+
from aiovban.asyncio.streams import VBANIncomingStream
45+
from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
46+
from music_assistant_models.provider import ProviderManifest
47+
48+
from music_assistant.mass import MusicAssistant
49+
from music_assistant.models import ProviderInstanceType
50+
51+
DEFAULT_UDP_PORT = 6980
52+
DEFAULT_PCM_AUDIO_FORMAT = "S16LE"
53+
DEFAULT_PCM_SAMPLE_RATE = 44100
54+
55+
CONF_VBAN_STREAM_NAME = "vban_stream_name"
56+
CONF_SENDER_HOST = "sender_host"
57+
CONF_PCM_AUDIO_FORMAT = "audio_format"
58+
CONF_PCM_SAMPLE_RATE = "sample_rate"
59+
60+
SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
61+
62+
63+
def _get_supported_pcm_formats() -> dict[str, int]:
64+
"""Return supported PCM formats."""
65+
pcm_formats = {}
66+
for content_type in ContentType.__members__:
67+
if match := re.match(r"PCM_([S|F](\d{2})LE)", content_type):
68+
pcm_formats[match.group(1)] = int(match.group(2))
69+
return pcm_formats
70+
71+
72+
def _get_vban_sample_rates() -> list[str]:
73+
"""Return supported VBAN sample rates."""
74+
return [member.split("_")[1] for member in VBANSampleRate.__members__]
75+
76+
77+
async def setup(
78+
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
79+
) -> ProviderInstanceType:
80+
"""Initialize provider(instance) with given configuration."""
81+
return VBANReceiverProvider(mass, manifest, config)
82+
83+
84+
async def get_config_entries(
85+
mass: MusicAssistant, # noqa: ARG001
86+
instance_id: str | None = None, # noqa: ARG001
87+
action: str | None = None, # noqa: ARG001
88+
values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
89+
) -> tuple[ConfigEntry, ...]:
90+
"""
91+
Return Config entries to setup this provider.
92+
93+
instance_id: id of an existing provider instance (None if new instance setup).
94+
action: [optional] action key called from config entries UI.
95+
values: the (intermediate) raw values for config entries sent with the action.
96+
"""
97+
ip_addresses = await get_ip_addresses()
98+
99+
def _validate_stream_name(config_value: str) -> bool:
100+
"""Validate stream name."""
101+
try:
102+
config_value.encode("ascii")
103+
except UnicodeEncodeError:
104+
return False
105+
return len(config_value) < 17
106+
107+
return (
108+
CONF_ENTRY_WARN_PREVIEW,
109+
ConfigEntry(
110+
key=CONF_BIND_PORT,
111+
type=ConfigEntryType.INTEGER,
112+
default_value=DEFAULT_UDP_PORT,
113+
label="Receiver: UDP Port",
114+
description="The UDP port the VBAN receiver will listen on for connections. "
115+
"Make sure that this server can be reached "
116+
"on the given IP and UDP port by remote VBAN senders.",
117+
),
118+
ConfigEntry(
119+
key=CONF_VBAN_STREAM_NAME,
120+
type=ConfigEntryType.STRING,
121+
label="Sender: VBAN Stream Name",
122+
default_value="Network AUX",
123+
description="Max 16 ASCII chars.\n"
124+
"The VBAN stream name to expect from the remote VBAN sender.\n"
125+
"This MUST match what the remote VBAN sender has set for the session name "
126+
"otherwise audio streaming will not work.",
127+
required=True,
128+
validate=_validate_stream_name, # type: ignore[arg-type]
129+
),
130+
ConfigEntry(
131+
key=CONF_SENDER_HOST,
132+
type=ConfigEntryType.STRING,
133+
default_value="127.0.0.1",
134+
label="Sender: VBAN Sender hostname/IP address",
135+
description="The hostname/IP Address of the remote VBAN SENDER.",
136+
required=True,
137+
),
138+
ConfigEntry(
139+
key=CONF_PCM_AUDIO_FORMAT,
140+
type=ConfigEntryType.STRING,
141+
default_value=DEFAULT_PCM_AUDIO_FORMAT,
142+
options=[ConfigValueOption(x, x) for x in _get_supported_pcm_formats()],
143+
label="PCM audio format",
144+
description="The VBAN PCM audio format to expect from the remote VBAN sender. "
145+
"This MUST match what the remote VBAN sender has set otherwise audio streaming "
146+
"will not work.",
147+
required=True,
148+
),
149+
ConfigEntry(
150+
key=CONF_PCM_SAMPLE_RATE,
151+
type=ConfigEntryType.STRING,
152+
default_value=DEFAULT_PCM_SAMPLE_RATE,
153+
options=[ConfigValueOption(x, x) for x in _get_vban_sample_rates()],
154+
label="PCM sample rate",
155+
description="The VBAN PCM sample rate to expect from the remote VBAN sender. "
156+
"This MUST match what the remote VBAN sender has set otherwise audio streaming "
157+
"will not work.",
158+
required=True,
159+
),
160+
ConfigEntry(
161+
key=CONF_BIND_IP,
162+
type=ConfigEntryType.STRING,
163+
default_value="0.0.0.0",
164+
options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
165+
label="Receiver: Bind to IP/interface",
166+
description="Start the VBAN receiver on this specific interface. \n"
167+
"Use 0.0.0.0 to bind to all interfaces, which is the default. \n"
168+
"This is an advanced setting that should normally "
169+
"not be adjusted in regular setups.",
170+
category="advanced",
171+
required=True,
172+
),
173+
)
174+
175+
176+
class VBANReceiverProvider(PluginProvider):
177+
"""Implementation of a VBAN protocol receiver plugin."""
178+
179+
def __init__(
180+
self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
181+
) -> None:
182+
"""Initialize MusicProvider."""
183+
super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
184+
self.logger = self.logger.getChild(self.instance_id.split("--")[1])
185+
self._bind_port: int = cast("int", self.config.get_value(CONF_BIND_PORT))
186+
self._bind_ip: str = cast("str", self.config.get_value(CONF_BIND_IP))
187+
self._sender_host: str = cast("str", self.config.get_value(CONF_SENDER_HOST))
188+
self._vban_stream_name: str = cast("str", self.config.get_value(CONF_VBAN_STREAM_NAME))
189+
self._pcm_audio_format: str = cast("str", self.config.get_value(CONF_PCM_AUDIO_FORMAT))
190+
self._pcm_sample_rate: int = cast("int", self.config.get_value(CONF_PCM_SAMPLE_RATE))
191+
192+
self._vban_receiver: AsyncVBANClient | None = None
193+
self._vban_device: VBANDevice | None = None
194+
self._vban_stream: VBANIncomingStream | None = None
195+
196+
self._source_details = PluginSource(
197+
id=self.instance_id,
198+
name=f"{self.manifest.name}: {self._vban_stream_name}",
199+
passive=False,
200+
can_play_pause=False,
201+
can_seek=False,
202+
can_next_previous=False,
203+
audio_format=AudioFormat(
204+
content_type=ContentType(self._pcm_audio_format.lower()),
205+
codec_type=ContentType(self._pcm_audio_format.lower()),
206+
sample_rate=self._pcm_sample_rate,
207+
bit_depth=_get_supported_pcm_formats()[self._pcm_audio_format],
208+
channels=2,
209+
),
210+
metadata=PlayerMedia(
211+
"VBAN Receiver",
212+
artist=self._sender_host,
213+
title=self._vban_stream_name,
214+
media_type=MediaType.PLUGIN_SOURCE,
215+
),
216+
stream_type=StreamType.CUSTOM,
217+
)
218+
219+
@property
220+
def supported_features(self) -> set[ProviderFeature]:
221+
"""Return the features supported by this Provider."""
222+
return {ProviderFeature.AUDIO_SOURCE}
223+
224+
@property
225+
def instance_name_postfix(self) -> str | None:
226+
"""Return a (default) instance name postfix for this provider instance."""
227+
return self._vban_stream_name
228+
229+
async def handle_async_init(self) -> None:
230+
"""Handle async initialization of the provider."""
231+
self._vban_receiver = AsyncVBANClient(ignore_audio_streams=False)
232+
try:
233+
result = await self._vban_receiver.listen(self._bind_ip, self._bind_port)
234+
except OSError as err:
235+
raise SetupFailedError(f"Failed to start VBAN receiver plugin: {err}") from err
236+
else:
237+
self._udp_socket_fut = result
238+
239+
self._vban_device = self._vban_receiver.register_device(self._sender_host, self._bind_port)
240+
if self._vban_device:
241+
self._vban_stream = self._vban_device.receive_stream(
242+
self._vban_stream_name, back_pressure_strategy=BackPressureStrategy.DRAIN_OLDEST
243+
)
244+
245+
async def unload(self, is_removed: bool = False) -> None:
246+
"""Handle close/cleanup of the provider."""
247+
self.logger.debug("Unloading plugin")
248+
if self._vban_receiver:
249+
self.logger.info("Closing UDP transport")
250+
# Can raise an uncatchable exception due to bug in aiovban library.
251+
self._vban_receiver.close()
252+
253+
if self._udp_socket_fut and not self._udp_socket_fut.done():
254+
self._udp_socket_fut.cancel()
255+
with suppress(asyncio.CancelledError):
256+
await self._udp_socket_fut
257+
258+
self._udp_socket_fut = None
259+
self._vban_receiver = None
260+
self._vban_device = None
261+
self._vban_stream = None
262+
await asyncio.sleep(0.1)
263+
264+
def get_source(self) -> PluginSource:
265+
"""Get (audio)source details for this plugin."""
266+
return self._source_details
267+
268+
async def get_audio_stream(self, player_id: str) -> AsyncGenerator[bytes, None]:
269+
"""Yield raw PCM chunks from the VBANIncomingStream queue."""
270+
self.logger.debug(
271+
"Sending VBAN PCM audio stream for Player: %s//Stream: %s//Config: %s",
272+
player_id,
273+
self._vban_stream_name,
274+
self._source_details.audio_format.output_format_str, # type: ignore[union-attr]
275+
)
276+
while (
277+
self._source_details.in_use_by and self._vban_stream and not self._udp_socket_fut.done()
278+
):
279+
try:
280+
packet = await self._vban_stream.get_packet()
281+
except asyncio.QueueShutDown: # type: ignore[attr-defined]
282+
self.logger.error(
283+
"Found VBANIncomingStream queue shut down when attempting to get VBAN packet"
284+
)
285+
raise
286+
287+
# Skip processing full null packets.
288+
# pipewire vban-send module constantly sends full null VBAN packets when a "Stream"
289+
# is established e.g when squeezelite starts up with the vban-send sink as its
290+
# output device.
291+
if packet.body.data == bytes(len(packet.body.data)):
292+
continue
293+
294+
yield packet.body.data
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"type": "plugin",
3+
"domain": "vban_receiver",
4+
"stage": "alpha",
5+
"name": "VBAN Receiver",
6+
"description": "VBAN protocol receiver - receive PCM-over-UDP streams from a VBAN protocol sender",
7+
"codeowners": ["@sprocket-9"],
8+
"documentation": "https://music-assistant.io/plugins/vban-receiver/",
9+
"multi_instance": true
10+
}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies = [
4141
"llvmlite==0.44.0",
4242
"numpy==2.2.6",
4343
"gql[all]==4.0.0",
44+
"aiovban>=0.6.3",
4445
]
4546
description = "Music Assistant"
4647
license = {text = "Apache-2.0"}

requirements_all.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ aiorun==2025.1.1
1414
aioslimproto==3.1.1
1515
aiosonos==0.1.9
1616
aiosqlite==0.21.0
17+
aiovban>=0.6.3
1718
alexapy==1.29.8
1819
async-upnp-client==0.45.0
1920
audible==0.10.0

0 commit comments

Comments
 (0)