Add VBAN Receiver plugin provider (#2498)
authorsprocket-9 <sprocketnumber9@gmail.com>
Thu, 20 Nov 2025 13:06:19 +0000 (13:06 +0000)
committerGitHub <noreply@github.com>
Thu, 20 Nov 2025 13:06:19 +0000 (14:06 +0100)
music_assistant/providers/vban_receiver/__init__.py [new file with mode: 0644]
music_assistant/providers/vban_receiver/icon.svg [new file with mode: 0644]
music_assistant/providers/vban_receiver/icon_monochrome.svg [new file with mode: 0644]
music_assistant/providers/vban_receiver/manifest.json [new file with mode: 0644]
music_assistant/providers/vban_receiver/vban.py [new file with mode: 0644]
pyproject.toml
requirements_all.txt

diff --git a/music_assistant/providers/vban_receiver/__init__.py b/music_assistant/providers/vban_receiver/__init__.py
new file mode 100644 (file)
index 0000000..d8ccfea
--- /dev/null
@@ -0,0 +1,323 @@
+"""VBAN protocol receiver plugin for Music Assistant."""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from collections.abc import AsyncGenerator
+from contextlib import suppress
+from typing import TYPE_CHECKING, cast
+
+from aiovban.asyncio.util import BackPressureStrategy
+from aiovban.enums import VBANSampleRate
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    ContentType,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant_models.errors import SetupFailedError
+from music_assistant_models.media_items import AudioFormat
+from music_assistant_models.streamdetails import StreamMetadata
+
+from music_assistant.constants import (
+    CONF_BIND_IP,
+    CONF_BIND_PORT,
+    CONF_ENTRY_WARN_PREVIEW,
+)
+from music_assistant.helpers.util import (
+    get_ip_addresses,
+)
+from music_assistant.models.plugin import PluginProvider, PluginSource
+
+from .vban import AsyncVBANClientMod
+
+if TYPE_CHECKING:
+    from aiovban.asyncio.device import VBANDevice
+    from aiovban.asyncio.streams import VBANIncomingStream
+    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+DEFAULT_UDP_PORT = 6980
+DEFAULT_PCM_AUDIO_FORMAT = "S16LE"
+DEFAULT_PCM_SAMPLE_RATE = 44100
+DEFAULT_AUDIO_CHANNELS = 2
+
+CONF_VBAN_STREAM_NAME = "vban_stream_name"
+CONF_SENDER_HOST = "sender_host"
+CONF_PCM_AUDIO_FORMAT = "audio_format"
+CONF_PCM_SAMPLE_RATE = "sample_rate"
+CONF_AUDIO_CHANNELS = "audio_channels"
+CONF_VBAN_QUEUE_STRATEGY = "vban_queue_strategy"
+CONF_VBAN_QUEUE_SIZE = "vban_queue_size"
+
+VBAN_QUEUE_STRATEGIES = {
+    "Clear entire queue": BackPressureStrategy.DROP,
+    "Clear the oldest half of the queue": BackPressureStrategy.DRAIN_OLDEST,
+    "Remove single oldest queue entry": BackPressureStrategy.POP,
+}
+
+SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
+
+
+def _get_supported_pcm_formats() -> dict[str, int]:
+    """Return supported PCM formats."""
+    pcm_formats = {}
+    for content_type in ContentType.__members__:
+        if match := re.match(r"PCM_([S|F](\d{2})LE)", content_type):
+            pcm_formats[match.group(1)] = int(match.group(2))
+    return pcm_formats
+
+
+def _get_vban_sample_rates() -> list[int]:
+    """Return supported VBAN sample rates."""
+    return [int(member.split("_")[1]) for member in VBANSampleRate.__members__]
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return VBANReceiverProvider(mass, manifest, config)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,  # noqa: ARG001
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,  # noqa: ARG001
+    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    ip_addresses = await get_ip_addresses()
+
+    def _validate_stream_name(config_value: str) -> bool:
+        """Validate stream name."""
+        try:
+            config_value.encode("ascii")
+        except UnicodeEncodeError:
+            return False
+        return len(config_value) < 17
+
+    return (
+        CONF_ENTRY_WARN_PREVIEW,
+        ConfigEntry(
+            key=CONF_BIND_PORT,
+            type=ConfigEntryType.INTEGER,
+            default_value=DEFAULT_UDP_PORT,
+            label="Receiver: UDP Port",
+            description="The UDP port the VBAN receiver will listen on for connections. "
+            "Make sure that this server can be reached "
+            "on the given IP and UDP port by remote VBAN senders.",
+        ),
+        ConfigEntry(
+            key=CONF_VBAN_STREAM_NAME,
+            type=ConfigEntryType.STRING,
+            label="Sender: VBAN Stream Name",
+            default_value="Network AUX",
+            description="Max 16 ASCII chars.\n"
+            "The VBAN stream name to expect from the remote VBAN sender.\n"
+            "This MUST match what the remote VBAN sender has set for the session name "
+            "otherwise audio streaming will not work.",
+            required=True,
+            validate=_validate_stream_name,  # type: ignore[arg-type]
+        ),
+        ConfigEntry(
+            key=CONF_SENDER_HOST,
+            type=ConfigEntryType.STRING,
+            default_value="127.0.0.1",
+            label="Sender: VBAN Sender hostname/IP address",
+            description="The hostname/IP Address of the remote VBAN SENDER.",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_PCM_AUDIO_FORMAT,
+            type=ConfigEntryType.STRING,
+            default_value=DEFAULT_PCM_AUDIO_FORMAT,
+            options=[ConfigValueOption(x, x) for x in _get_supported_pcm_formats()],
+            label="PCM audio format",
+            description="The VBAN PCM audio format to expect from the remote VBAN sender. "
+            "This MUST match what the remote VBAN sender has set otherwise audio streaming "
+            "will not work.",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_PCM_SAMPLE_RATE,
+            type=ConfigEntryType.INTEGER,
+            default_value=DEFAULT_PCM_SAMPLE_RATE,
+            options=[ConfigValueOption(str(x), x) for x in _get_vban_sample_rates()],
+            label="PCM sample rate",
+            description="The VBAN PCM sample rate to expect from the remote VBAN sender. "
+            "This MUST match what the remote VBAN sender has set otherwise audio streaming "
+            "will not work.",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_AUDIO_CHANNELS,
+            type=ConfigEntryType.INTEGER,
+            default_value=DEFAULT_AUDIO_CHANNELS,
+            options=[ConfigValueOption(str(x), x) for x in list(range(1, 9))],
+            label="Channels",
+            description="The number of audio channels",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_BIND_IP,
+            type=ConfigEntryType.STRING,
+            default_value="0.0.0.0",
+            options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
+            label="Receiver: Bind to IP/interface",
+            description="Start the VBAN receiver on this specific interface. \n"
+            "Use 0.0.0.0 to bind to all interfaces, which is the default. \n"
+            "This is an advanced setting that should normally "
+            "not be adjusted in regular setups.",
+            category="advanced",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_VBAN_QUEUE_STRATEGY,
+            type=ConfigEntryType.STRING,
+            default_value=next(iter(VBAN_QUEUE_STRATEGIES)),
+            options=[ConfigValueOption(x, x) for x in VBAN_QUEUE_STRATEGIES],
+            label="Receiver: VBAN queue strategy",
+            description="What should happen if the receiving queue fills up?",
+            category="advanced",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_VBAN_QUEUE_SIZE,
+            type=ConfigEntryType.INTEGER,
+            default_value=AsyncVBANClientMod.default_queue_size,
+            label="Receiver: VBAN packets queue size",
+            description="This can be increased if MA is running on a very low power device, "
+            "otherwise this should not need to be changed.",
+            category="advanced",
+            required=True,
+        ),
+    )
+
+
+class VBANReceiverProvider(PluginProvider):
+    """Implementation of a VBAN protocol receiver plugin."""
+
+    def __init__(
+        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+    ) -> None:
+        """Initialize MusicProvider."""
+        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
+        self._bind_port: int = cast("int", self.config.get_value(CONF_BIND_PORT))
+        self._bind_ip: str = cast("str", self.config.get_value(CONF_BIND_IP))
+        self._sender_host: str = cast("str", self.config.get_value(CONF_SENDER_HOST))
+        self._vban_stream_name: str = cast("str", self.config.get_value(CONF_VBAN_STREAM_NAME))
+        self._pcm_audio_format: str = cast("str", self.config.get_value(CONF_PCM_AUDIO_FORMAT))
+        self._pcm_sample_rate: int = cast("int", self.config.get_value(CONF_PCM_SAMPLE_RATE))
+        self._audio_channels: int = cast("int", self.config.get_value(CONF_AUDIO_CHANNELS))
+        self._vban_queue_strategy: BackPressureStrategy = VBAN_QUEUE_STRATEGIES[
+            cast("str", self.config.get_value(CONF_VBAN_QUEUE_STRATEGY))
+        ]
+        self._vban_queue_size: int = cast("int", self.config.get_value(CONF_VBAN_QUEUE_SIZE))
+
+        self._vban_receiver: AsyncVBANClientMod | None = None
+        self._vban_sender: VBANDevice | None = None
+        self._vban_stream: VBANIncomingStream | None = None
+
+        self._source_details = PluginSource(
+            id=self.instance_id,
+            name=f"{self.manifest.name}: {self._vban_stream_name}",
+            passive=False,
+            can_play_pause=False,
+            can_seek=False,
+            can_next_previous=False,
+            audio_format=AudioFormat(
+                content_type=ContentType(self._pcm_audio_format.lower()),
+                codec_type=ContentType(self._pcm_audio_format.lower()),
+                sample_rate=self._pcm_sample_rate,
+                bit_depth=_get_supported_pcm_formats()[self._pcm_audio_format],
+                channels=self._audio_channels,
+            ),
+            metadata=StreamMetadata(
+                title=self._vban_stream_name,
+                artist=self._sender_host,
+            ),
+            stream_type=StreamType.CUSTOM,
+        )
+
+    @property
+    def instance_name_postfix(self) -> str | None:
+        """Return a (default) instance name postfix for this provider instance."""
+        return self._vban_stream_name
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        self._vban_receiver = AsyncVBANClientMod(
+            default_queue_size=self._vban_queue_size, ignore_audio_streams=False
+        )
+        try:
+            self._udp_socket_task = asyncio.create_task(
+                self._vban_receiver.listen(
+                    address=self._bind_ip, port=self._bind_port, controller=self
+                )
+            )
+        except OSError as err:
+            raise SetupFailedError(f"Failed to start VBAN receiver plugin: {err}") from err
+
+        self._vban_sender = self._vban_receiver.register_device(self._sender_host)
+        if self._vban_sender:
+            self._vban_stream = self._vban_sender.receive_stream(
+                self._vban_stream_name, back_pressure_strategy=self._vban_queue_strategy
+            )
+
+    async def unload(self, is_removed: bool = False) -> None:
+        """Handle close/cleanup of the provider."""
+        self.logger.debug("Unloading plugin")
+        if self._vban_receiver:
+            self.logger.debug("Closing UDP transport")
+            self._vban_receiver.close()
+            with suppress(asyncio.CancelledError):
+                await self._udp_socket_task
+
+        self._vban_receiver = None
+        self._vban_sender = None
+        self._vban_stream = None
+        await asyncio.sleep(0.1)
+
+    def get_source(self) -> PluginSource:
+        """Get (audio)source details for this plugin."""
+        return self._source_details
+
+    @property
+    def active_player(self) -> bool:
+        """Report the active player status."""
+        return bool(self._source_details.in_use_by)
+
+    async def get_audio_stream(self, player_id: str) -> AsyncGenerator[bytes, None]:
+        """Yield raw PCM chunks from the VBANIncomingStream queue."""
+        self.logger.debug(
+            "Getting VBAN PCM audio stream for Player: %s//Stream: %s//Config: %s",
+            player_id,
+            self._vban_stream_name,
+            self._source_details.audio_format.output_format_str,
+        )
+        while (
+            self._source_details.in_use_by
+            and self._vban_stream
+            and not self._udp_socket_task.done()
+        ):
+            try:
+                packet = await self._vban_stream.get_packet()
+            except asyncio.QueueShutDown:  # type: ignore[attr-defined]
+                self.logger.error(
+                    "Found VBANIncomingStream queue shut down when attempting to get VBAN packet"
+                )
+                break
+
+            yield packet.body.data
diff --git a/music_assistant/providers/vban_receiver/icon.svg b/music_assistant/providers/vban_receiver/icon.svg
new file mode 100644 (file)
index 0000000..939b98a
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="512"
+   height="512"
+   viewBox="0 0 135.46667 135.46667"
+   version="1.1"
+   id="svg1"
+   xml:space="preserve"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs1" /><g
+     id="layer1"><image
+       width="135.46667"
+       height="135.46667"
+       preserveAspectRatio="none"
+       xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABGdBTUEAALGPC/xhBQAAAAFzUkdC&#10;AdnJLH8AAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dE&#10;AAAAAAAA+UO7fwAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAAAd0SU1FB+kLAgsFKGTRqi4AACAASURB&#10;VHja7d15dFzned/x33tnBoMZ7AABkAQJEoRIkOC+UyQtUaLFWF7quFZb27Ebx3abpcupmzbtyWl7&#10;UqdZ2rQ5dk7PaTYnsWu3TZqm8ZbKskRTIiVx31eQBEkQ+z7YZ7tv/9BiSiIpALx3ZoD5fs7BoS1K&#10;F3Pnvvd9n/d5NwkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;8G7mCxfbrXUt3wQAAHmiaqBTwXD1Ilk3zbcBAECeGK2oUlCSRAIAAIC8Ya3eDADmCTcR13uHM+xD&#10;/+97mPf9Bz5dcy59Vu6f++f+uf/8u/+y0QGNLVgsGTMv2szgfApnWr75dY22tLw3zHnryduf/Gnt&#10;O0uCMebNZ33Pn2/8xf1Dp3f/b2vfvLx95zXvvcbbf96vpNmfFM57rvnGH/beD/rmf36fz/fQz8r9&#10;c//cP/fP/c/6/q1V8y/8YxVVz4/Gf34FAJJSo2OK37lLbgcA4G0f03WVct03ggI7P8bNHR4rAAD5&#10;hwAAAAACAAAAoOlMVCQAAAAABAAAAIAAAAAAEAAAADBHGREAAAAAAgAAAEAAoKyebgAAAPIoADA8&#10;TAAA8jADYMS5xgAAsRFQPs4BIA0AAACTAAEAAAEAAACYlwEAcwAAAMizAMCKOQAAADHPLM8CADr/&#10;AADk6RCAIQMAAACTAAEAAAEAAAAgAAAAQOwECAAAyAAAAID5K8hX4FEkZaSGxQtUGA7rvWkiM6NF&#10;Cj851fi96aYbbd2Kp9ys32846KihrkaBQGAaqbGZ33/aTauta0AT8eQDF3w0LFqgaCT8yN+pX8/q&#10;3de01mpweESpdFrWWo1OTCmZnt8rWEujYS2prZQxji/f6VvXTKXSau3oVSIH3o1764QVddUKFxR4&#10;WqaMMeodGFbv8HhG76e8qFB1tVXvuI/ZPKt3S6dd3enqf+C7DgKAnFcYcPTRn/+SuutXy33zrbAe&#10;bz1h3bRK/uz3dfTwyazeq7VWq5obteqf/EuZYOiBW2Q8yv27Y6Oa/K2v6E5n/33/vqykSB/5519W&#10;14K6e2uhWW/nYX3YIuTd13StVWwyrrS1stZqIplQyhp9cLxXR46fUF9Pj5LtHWrr6pPkzPnAwFqp&#10;7qkntOaTn5Z5hCW603lWxqZV8Pu/p3PHzj3S7/LSwqpSbfjlfyWnYsGsyugDt54x0pKONh37j7+l&#10;oZGpjL3z63Zs0sLP/QPJOJ6+U+FEXNHf+x2dunSThoQAYG6aSKX1wiuHtfjntyjuBnwbrwnt3S/n&#10;yAm51mR1L6zA5u3qK6+S6/pz/aq7t9QzEHvwd+E4SkWL1RUtlbFzqKksuqfie/PnmJYq0LRVTXKV&#10;HI3p6fFhnXv+++q7cEl373TJBoJzcv+x0mhIO/Y9rdaiMv+DmYDRot27dfnEeaVypDgEgkENRYqV&#10;jJY+UgBw3wD4sWY9+fee0/e+/i2lM5X0KIyoJ1oqa7wdOa4MTilUEBI7AYo5AHN3bqjR+NUW2aEB&#10;38qIK6lk6TKVVZVl9V6rSyJau3Wrb0FIkZtW6uxxTT0kJTifXsO36u+YHE2UVOjGwgaV/tw/1pZf&#10;/y19+l/8I61sqJVJz730aHNDnboXLM5IJsOmrQq27VDVgtK8qG9ichTY+4xWb2iaHxPg2cmVAEBz&#10;fIfojo4+uZfP+7oh4Uhpheoblmc1rVtRt1ADC2o979W8HQBMjevokWP5FIi/py5MWaOBaIV6dx/Q&#10;1q/8pp754mdVWRySnSPZjqCkil17lCgqytj7lw6Xac3jO30rl7nWW+wtLNaKn/mCqotDAggAsvzi&#10;p1wrnT6l0mTct99REApp9d492Wv/rKv6zRuVLoj4tJuzUezMUY0MxvIuHXc/cVfqCJcr9eHn9MGv&#10;/DstaVqeQw3cgxWXRTW1cZuSGaxiYtao5ANPyDE2L1LGVlJqWaN2febTCpm5mwKg808AMC/OATDG&#10;6PTZC3L6e3ybiJSQkbNspRaWF2flHksKC1S1cYsmfUr/R9JJDZ47+76zuY1M/hz+aKWEddS1tFnN&#10;v/yvdeDAXsm6ORyHWzWuXa2i2oUZDVaMtYrXNWj12pV5M1w8Yo3Ce/dr86bVtKTI9yGA7L8BYyOT&#10;un30VVkfK+jeimoVL6rJyv1FKks0UF8vud5/19ZIK8b71XrqTB5Ox5lW8kWTFbWKfuZL2r13R87e&#10;f6Fx1bh3t8acgsxnTEJRVW/YkDvfjfH/8j2FxYp+7vMqLYuIQ3bZCTCPAwCTExO6xk4cV2R81L8i&#10;WBhR446dGR8Ptq6rpZs3yIRLfWo4pBuvv6rYWHyaj9rkX6/HWvVHS1X82S+pfs1jOVk9LlxWp6lV&#10;65VWdobhVu07oJKiwnwqEjJLHtOuT/2debB4FAQAc9xQe7cq2m/7Fo4kjFHB2vWqKApn9L5CwYAW&#10;bdnmW/rfuJNqOfSyrOVY5/czUVmtDV/8khZXRHOq/+TIqqp5tWJlFVkJzoykm8UVql6zMq/KQ9Ia&#10;pfYd0IYdG+fMRFEQAMzPAGAirs4zJ1WQTvvWzYrV1KmidkFG69ii0qhG1zT7k/53jIpvtGiwo3cG&#10;7ZnNv3GAe4wvbVTTJ5+TcmiJYLggqPonn5LN4hYjiWBIew4cmBOTJb2UCkZV9bnPqaI0LIA5AMre&#10;ZMA7R1/T0vEh/35HJKrSjRvly048D5jYtXbLBpUU+JP+D9i0rr9+RKMT8Wk/6nzv5yTkqHzf09rQ&#10;vDwnvgtrrZY31ila36C0m91P1Lt8tVYsrMyr8uBaKy1coZ2f/ZTMHHs7yFkQAHiwBDB3uoOd3UNq&#10;v3ReAZ+unzIBNW3foYrCzKwBDjpGtWubNWICvrz8dYkxDZ06K+M4068wqDUUC5eo6WMfVyRgciL9&#10;X7/vKXWGirL+JtrSci3ZuD77SQCb+WWj6Q/8lNZtXcPLwU6AeZYByKFnk7bS+OuvykklfKtZJurq&#10;Fa2uyEwAEDRKb9sp14eeXTBg1HH6pPq6B2ZYsxIBJFxpYsMWlVRlfwe8mupyBdesz4mteMcdR3b3&#10;PgUcm3fd2kSwUEs//0uqX1Asqm2wCiBLLl9tUe1gtz/1gJVsUanWb9kk1+9hAGu1cvN6TRWU+fIN&#10;23RSYyeOK+GSM5wNN1Km5i3rfF16Oh0VjQ1yFy7JjWdjpYqGx/RYw9Isd/9tFoYCpHjNYm369Gc8&#10;XhVAU00AgGmLDY0pcfp1RX2qBGJyFF63QdFQQH7PaShfuVITwZAvGzgV93Wr9dzFGW2eRPv/E6Ny&#10;VLl1lwoDgax9hoCx2n3gGcWVO9vSxgqjat67O+8mA0pSXEbpnU9q1+Mergrws/0nthCnAc678MrR&#10;sUOHtf2Zj2oyUOTLpgMTq5oULY1oamjCx3PNXWnvB32Z/R+Q1cKWS4rFJma4m6P1PTSOd3YoORJ7&#10;cN1k3vpXjUoiERkjVRUXabioTFMmoEAwoLTNTJdveNVKpU1KUnaCgJpldRpuWKtkDrW1rnGU2rRd&#10;lX/1PQ2OTebdzLZYqFAVP/MlNdz8N7rdO0J9DAKATOu726OhS2dlNu3xZffWYFGF6jdt0MDB133b&#10;fnjt2lUqKypVzIdrR5JxvfT887Iz/OzW5+yqcaTOQy+q8y//6n0nJjqOUVG4QI4xqiwr1nBxqWoq&#10;SrVr3z71rd6g0ZIK3wOBtAmrZtFCdbT3ZaUztWXbZg1Ei3NuS+LxuiWqWLFUA+dasrNbeJaDgKma&#10;Ou38whfV8ztf1eQjFkJ/p1kb1iswBDD/JJJpxY8dU9CmfLn+mAloycaN8nMSeNljj2kyXOhL+n+s&#10;vU2xts6cPfjJNc77/qSsUWwqqaHJhG52D2rgxm1dPnFef/ofv6rzv/arWnHpuAp8rjhKQhHVL1mS&#10;lXR3WaRAyc2PK21yrzpxnQI1P/OMAiY/B42mrJTesl3b92595LJBll7MAcDMG7m285f12NiwfNng&#10;zpVGNmxUUdSfsdeAIy17Yr/8WMsQsq6KTxzR0GRifi4WchzdaevVX//2f9LSS68p6GMN6hhHwWB2&#10;EnqLG+pUvnRZTj6HlJWq1q1XTXkkbxetdatQ4c9+QVWLq9glEAQAmdbVM6D2c6d8O7KzoKhK1Zu3&#10;+PJyNzYsUX95tS+fu3RiTHfOnJWZRc/RmLnRHzFGGpyyOvKtP1dDanTeTV40sqrdt1/dHh4P7WWg&#10;bKx0p6hSTR/Ym5eTAd/KlsXLarTr5z6vglBAAAFARkeNHF1+4QXJnfLl+lNOUGuaVyngeN8ohhsa&#10;NBEt8qVnUtt7V1dvtM3+AmbunFTderNNF147rGBgfiVSa6vKVNu8Ua5HAVnISE3tN6RkwsNNs4Iy&#10;m7coEg7m7cpk1xqNbdiu3T/1ZI7uEmhZak4AoHmbh+u/1aFw6005PvRcrSsV7dytqMe7AhojVez6&#10;gKzjfTEJuGn9+LvfkZ31uPHcehFdOapov6uAm/ZpB7ikRkZHMj7JbkVTo3rLF3h2zbQSOv2/v63a&#10;sWHPHrHrWpWvWa+qJbWZzQLkWBFNOwUK/e3PqL5xqQACgAwan0xIZ07IsWlf6pnJaKWWr1nl6XWX&#10;11aoaEm9L6fzlY7FNHLlal7F4X1dPUpZfwKAidSU2ts7Mjo0UhgMqGrXHtmCAs9S/5WxId25ckN9&#10;1y57VjkZSWOBqLY+uUfK6GZJJuc62eniSm38mc8pOptsiCUBQAAglmjMtjvd8vpxlU2N+fIph4IF&#10;qlvd5FmVY61V5WONcisqPf9ejTEqv3RCPf2xR6xbDaXzzS9jUzqh+Ghm17qXVZcpvm6TZ1v/BoxR&#10;z+XzGh4aU1nLJQVS3q2cmZRRZPtelVaU5HXKOClpvGmjnvjEs7PIhtBKEwDQ/s9aW0eP+lqu+jIM&#10;IGsV2v20wh5NNXeMtHL3Hk0Z78dNS9MJnX31demRhhZMRlLcXjYDZeXlcnxYKmcC0oUTJzSVSGXu&#10;VXOtNu3eJVtU5l1lZJPS+fOycnTo9DlpYtzDyYBWPZU1WrN2lfJ9uDgeDMl+8Ke1ZvWyXNm1GQQA&#10;HgxY53hRSrtWqSOHFHH9qagTpeWqWVHvybUWV5ZqdMkK+ZGwDoz06crp849UQxpJZi4lAKyriYZl&#10;so73AVXUTSl1vUXJDGa3S4JWZVu2atKj0yGtpMWJCXVeuSYZKd0/pOSdVnm7J0BYpTt3K+AwZyxR&#10;WqGmz/6cyqIFAhsBcRhQhj5f54XLsv29vnzWiXBEy1d508MJLq7VVG2N55OmjGPV+8LfKJWeA4/a&#10;w1uvrC7Xws3blfJ6O2UjKdarc0dPyHFMxk7eXrZqhSaWr/SsfAQco/HWm+rpHXqjLCfSKm9rUdDD&#10;g67S1mrZhm1aVFuZ981Y0lpNPNasPR/ZLycHlkeyyRABQF7oGBhRyfWLMvKnu5beu//RdwW0rpY/&#10;vktJ433vIBwfV9vZc3I9mVho5kRAHgw42vSxj2qsYpG83pAuJKvJV17R+MhkBisNq8LNmzRWWOxh&#10;it6Ve+m8ppLptzN6548eV1nS26WzrYXFqn98NxWRpIlASKEPP6cNa1ewQRABADLSezJGR196SYGE&#10;P7vf1dTUaGX9wke6RnlJROFVzXK9jsuN0ZKuNg21dXuUvs/9SqusOKKmZw8o+fSHlfJ4NYUxRoG+&#10;Tl1+4UePsJxy5oqiITXte1ppD2PYaGpKZ06cfMe4zvDdLiW62j397AknoCVbtqqsKExlZK1Gi8vV&#10;+LnPq6I0wvdBAMD4TCYMtdxS3WCPL9eORUtU1jj7iN5aq+q6hVqwtN7zXkHEpHXuxRc0mUh796hz&#10;7YhS66ooGtaCmgrt2bdD+778z7T4Uz8rG456/vFCybiCf/2/1N89lNG1/6vXNylWXuPd62aMJtva&#10;FOvse8c/Hp2Iq66rVWGP3+vhuhVauLiW2l9S0pVSjWu09xMfVdAhES9OAxRzAHw2mUjp+qEXFP7U&#10;lzwPWVJOQA27n9CxFw+/MT18FhHh6t071R0olHG9fTTJsUHdOnHas9l7vmYtXalqyzZFly59+E29&#10;61YCNqUVC6q1cGmD7hQWazAQ9CUsLbSuCk+9qh8dOp7RYh+QtOTp/RpyCjw7HtqxVotvX9PLk4l3&#10;lA0r6fmDh9S442kp4N1wlI0WaeXuXbpxo83/45rnQJ+k3wZU+MGPaMn5C7p95mpWZtda2hgCgNxt&#10;DXx46c6eU/MnxjUcLvJ4y0+r8WUrVFdToY7+me8MFy0MKVG/Qkk5nr6WASOFz5/V2NC4RxWG9XvS&#10;vspWr1P56vUz/k2jkkas9a2qiMiq8spJHfyjP1Q8mcpoua2tKddo8walPZzMWO4mdf7Y8fseCR1v&#10;71bpQI+Gapd6Vhwn5ahw6x4V/fUPNBIbz/tWzUhKRsq08/Nf0uCdX9PI0LgghgDm5lLAueHWzbtq&#10;vHXZsz3U7zVSUqbqxuWziokKK0tV1tws1+PZ6uFkQvGTJ5T09Lo+17D2zb0AZvHjV0kskKuqq6d0&#10;8Hd/V32jiYwH2Yu3blQwUuFp8xPr61P/zdv3D5QHYgr03PW4p2KVqKrWtk3rMjD5bW5EAa5r1blo&#10;mR7/xMcVeuA6SX9KNQMPBAB5xzoBHT70skr92Bo2EFLp43ulmV7bWm3as0t9JuLtS2mk6MiATp+5&#10;4N1WtTb/zpOoT48p/dff1g9++z+pbzSZ+Y9g03rqQ89q3HrbQBa13VDXA3ri1hjdevllFRhvMx0T&#10;obCSGzYrFHRIbL/9aQNK7fuwVm5oYlUAAQD81nrytBSPeX7dtJWKH2tSeenMJp4VhBxNNTQqZQKe&#10;Lxu79eoRjY/FPays8qOCMkYqs0ktuX1BZ//zb+vwt/+PRifTWfksjeuadGFBvaftWoGb1o3Xjjzk&#10;SGij2J12VcSGPe0qWknOuo1atLBqzrX/RlKJTxdPFEbU9Pe/qLLK0sx21UkDEADkm9jolHT4kAq9&#10;PiLWWo1WV6t0yaIZVRNOSVTRdZtlPU7/F8UnNXHyuGd7xudDBiAsq4qpCS27eVnd//V39J1/8+u6&#10;dOqyXCeQtXHixi2bNFFQ6O11x0Y1dev2Q/+dO3fadbv9prdthJUCFVUqal4z59oeI6n55lmVjsZ8&#10;mUo1uGiZPvbc31I4GBDYCRDy64xuqfPYMTkeb3YiSUEnrD1PPyVnBqef7d6ySa7HkxJljEJ3b2ug&#10;rcPTKRp2ngcBq+PjCv7NX+jQV/+LTr1yQpPxVEZP+nu30mhYgd37PZ9ru3GoQ+3dA3q/tQfp4yck&#10;423mI6WgNu9/RkUeH6OdifYiNjCgwPf/p4pT3g8FpY2jriee1eqNq94xFGCZXU8GAN5u5NJy+aaW&#10;D3R6PoExYY0SjU0qK4pMM2AwsuvWK+3xXvUFbkqR8yc1MDaV16s+ZupcuFjJT3xOj/32V/XTv/lr&#10;anhqp8rKosrWiRfLm5YrWO7tFrpFRnr14I/fdzWhK2n4RqsWTkx4vmKmb9FSldYvnnNj3kbSKy+8&#10;orqbF+THbv6pcFSNP/fzqqosoYkmAIBfppJpvfI3P1DQ496NsVajS5aqvK56Wg1GSXmxGtZv9TZN&#10;L0mjo/rhy0e8772a+V8dDZuAUtES9a3apPpf+hU9/bWv6UNf/Izq62sz++K6rtZ85OMaDHjbU3am&#10;JjXeenNagXJba5tGO2892gGS9xGPlqhp53YFzNxLRI9OJvTKN7+hynjM89fBtVZDNXX60Kc+oeCb&#10;qwKYFkgAAB+2Bp44f0k1yXHPX7C0E1HD7selaRyosqZppVo93NtdemPTmKq71zXVN+xPwjBPuiTG&#10;WqWtUW+kUslnP6kdX/kNbfrpD6k0bDKSCFm2pFqx+pWeN2BFkzFdvDa9E//SaavEubNv5gM8fQHl&#10;7Nip0pLInMxsX7/ept5v/L4K5P1+EAnrqO/JD2vbjnW+Lm3lUGACgLxlJPV19mnwzGkFPN6KM26l&#10;hWs3qiRa+L6fYXL9Zrkhb5OJgVRSXYcOaiqVZsjQo1ueSkvdkQpVfOYL+qmvfEVLH1sqP3dwtdZq&#10;4ZomxUsrPL1uKGDU/qMfyJjpDTmlXKsb126qMBn3fCSppLZeS9evmVPDSuYnx2vq9UMnVHXmiAIe&#10;nzRlJMWdsFb8w1/UotoKsTJw/lU8BAA5YDztKnH2tILppOc9x576ekUrH96zLywuVM3a9Z6fT1gR&#10;G1Dr9Zs+vYb5OyJpJMVtQF0r1mr7r/5bbdzc5NuLXBQKaMUTTynlce69MBnX5K07cqfZqhhj1H+t&#10;ReNdHZ4PJ/UroJInPjDzfTOy2Vbc870lUq4O/9m3VDk+KMfj78a1Vr3FNTrw6U/6O+pGcEEAkLcP&#10;wRidfu24CoZ6PR/bdp2oandul33IMMDqhnpNlVV5nv6farmkzs5+AnH5t4ykr2SBVv7Tf6V1m5t8&#10;qaDL62o02Lh62g31dOv65fERtVy8NqPGPDmVVHHrdRmvWwtrVL1ynZYuXzInC6kxRh0dA+r4k/+m&#10;gOv9iqIpV2rbdUBLN2zknSMAEOcB+GBsdErlF84o5HHllpSjxg2bFC0MP/A7W7Bps0KF3q7vrk9P&#10;6eaLP/R5sh5RgKxVZ7RcS3/py6prXOrtbHbr6vH9T2mqwNuTDE3A6NIrhxSPzyzjlUi7Gj5zRsVp&#10;77dAHi6pUF3z6jlbRh3H0dlXz6rq+GFfhoRSgZA6Nu/x5dhpOv8EAHk/K9w6jo6+dFBOfNLzBiKy&#10;slmVNfcfwy0rCqtm/UbFvXyxjVF/+211tLb72PSbDM63ns3PGz2zjJRLaxUrr9YzP/9FRcPePcfK&#10;aIHizRuU8vi7DqeT6rtyZcYn8RljdLPlutyhQc+/VytHi576oAoLAjnf9psHNJsJ1+rH//1/KNrX&#10;6cv74RoSxuI4YHZp8uulHmnrVLCjTfEVTZ7dipE0GIqqadtmdbT/8D1/v2hRrbqqFnr6zYWUls4c&#10;09hEfM7GfCYg9Rx+RbEbN+7zi+zb1fC9f2VTCUWCjiSjRVUVWr2iQbcqahUtLdd4NCrXOv6tN3el&#10;tvpmrf7gPp35wcFH/3KstGR1o5z6ZTIef+ZAfETt5y/Naix/MjauyrutGve4zLpWGl68XGXL6zTV&#10;0pb7CYAH3PxA37AGvv0niv7SrygdKmBqHW1MPgUAczsLMDAR1/jJ1xRYtlJpDyddjZmAFjY1K1Lw&#10;kiYTqXfM8C5qbpYtLvH0Psri4zp57JRca/3bvc74/D4aaez2TXV/97sys3gWd43RpXBQU6GQ1jUs&#10;VWDlY1p94EO6U1U3nVWZsxJXQMs//nd1+9XjGnrEY24Dxqr0A09o2CnUjLvqeuiEF0UuX1JycnbL&#10;1sYTKZ04eUqLNu6U9fjMinSoULsOHNB3Wv5oDtclRmePndNHdr+u/u1P0sUFkwDn0s6AXSdPacHE&#10;qOerARKrNypa9s7VAIUhR1WbtyjlYWrPOFLP9fPquNmWga1rM3B9M7uftKSReEqJsUmdOn9Np/7P&#10;9/XCv//3qjry/1QR8O8wn+Hyai3duumRr1O5oEzLtu1Q2uNzIUqM1cTVS3ILAiqIht75Ewm+/080&#10;JOd2q8zYmA8pbiO7cr0WlZfM6XokmXZ1+FvfVk1sgAoe+ZQBmPtu3+lU0+0bctZt9XRZ3mQ4olVb&#10;Nun1Hx56+5/V1FZpaslyTzvRZUqp/fBrshmpeqyytjfuDAM7V0bdXQM6+Ad/oucKghrZ/oynHeuf&#10;TPo02vvsR3Xpx6/O+vrWdbVu6wb1FJbJ46XlmkxL+tAntGnfh6YRfN0ny2MkxzhKR4t8eVaJBTVa&#10;vOoxdR0/O6frkcHuAXV96w9U/Qu/rMFAmIoVzAGYC9LWqO2lF1SxeqMU9O7xTAWCKl61RgUvvaJE&#10;ypW1Vqua16iorEKD1rsOc7qnS10Xr/g/+c3OvRElI2l4ytXBP/+/Wrtqq4ZLK3352LcqatW4pEYt&#10;d3tn9d9HC0Oq2LVb/Sbo+cqalCRV1ip83y/XZv35jwULFHryaTnHz8idw0OKVtKp187q7+w+quTW&#10;JzXqUnND83gIwNp5syys/co1FQ71eX7dqdUbFCp+Y0lXNBRQ2ZatGjZBTwtTwc1rGugfzszcfDs3&#10;Z6ncutOtqkunfCutU0XFmqqtnfV/X76wSiOr18l1rb8P7z0/M1pk4ZvapmYtXLwgt1u2aRSeRCqt&#10;g3/6TUUHuvLh6AzmmeX9HIB58mxiw6Na2HJGAY9rj9KyMj2+frWslSKlUcWWLpfr4e8IpSZ1+sUX&#10;lXTNvOgz+DeB0ej5V448dHOmR+plG0fbm9fMasWBkdX6p/bJBoqUr2JFbk6enAAAD+hJREFUpdq+&#10;a8ejb35k/Sz50yubXT3DuvVnf6DS5ISotjHPJwGaeZLMsDr00iEVebzhyVioQIFVaxRypNpVjYpU&#10;L/SsknKMUVl3u0Za72Smt2HnduJwwg349+mNo5KmdbN6G0qiBWrYsVtTyl/pQFATG7erqCA4L5Lb&#10;545dVPiVFxQxJNrBKoA5EcgMXL+lqu5bnu7q5Rqjqo1bVRgJadmWLYo5IQ8LUlodr7ys2FhcjBlm&#10;+4RJ6YwJzepbatywVrcqFiqf2wprrRKNK1W7bNEjljObE32dtHX18l/8lQp72z0/K4C+GwEA/Ogh&#10;xtO6cfAlBYzr6TSJofIqbVnbpNGmJk/H0MNjo0pcvpLBHRnndgsVtAlf67xgwJlx8BgKOCrbuk1T&#10;wVDev39OOKr6PXtkHmWYxvpZ+s2MWtfBoXG1ff2/KTg1JoAAIOdn8Rp1X7qmkrGYp9HxWLhQ7q69&#10;itQs87SCsm2tunjzdmanDc/VGMC6enLLplltMOSnoooSVW3eJZf0ipLWaNmu3aosjcyLONVKunCu&#10;ReVHXlBBTr44hpVmBAC4d5vbO7fuKnCnxdN9va0cae8zmnK82ya01E3JOfm6kmmb0TTtXFVTElFs&#10;3VbZHEvH7tm+QePREl6+N9Nl7RW1ql+/Zk6XtXu5rtWLf/F/FW5vpeIHAUDun/Zq1HnwkBYpkdOf&#10;Mz08qFNHTzKQN60XzmrrR35K4SXLfF3HONMESTQcVMnu/RoPsDfY21mAQIHKt+9UODB/ynVsaFw3&#10;vvnHsuMMBYAAIOfTAFfPntdw913l7twdK10+q9jQKKc/vs/8i5Bj1PCBnRr56HMa8/HVM5LKrWa0&#10;kc2KZXXqWbyMyZXvGKmximzfoZLyonmVWbx18YaqjzyvoHV5yAQAyGUTo1OaOHZUJkenZRcmEho6&#10;cUKJtJv59t/k9kihtVbWdRVyjBbXV2v/L/ys6n/hyzJhnxsUa7Wi88b0Z/Jbq9LNW2SKSP+/O5Cy&#10;hWVav+fx2WdrcrCMptKufvhX31O0rcXTVUb5MbZu2AoYmZNwrXrPnFXDgY9pvDD3Kujm4R792fkL&#10;8+9FtFLAkSJFQRkn8NDK6idtg337j6qqSi2rX6r+iipt37ZFvSuaNBEpkWsd3+s647p68eTpaWdJ&#10;qooLtPLx3Wp1DOsr32XUOqp+8kmZ7z4vO9Pq0uRu6R8fHlPHN/5YG37l36q1sIzBO3EWAHL0IJne&#10;2+3a1dOu28ubH313Mg+FZXX62OsamUzOuzjcutKSj/9t1X342ff/xfa9gUFBMKzycLEKg0HdcTO7&#10;aqE8FddwbHjaQxOL1q5WZ3XdnNxa2fdyZq3GFq9Q09pVunKpNXdGnjx4WBcu39bi57+jBZ/8jAbS&#10;QRIABADiMKAcNDw+pZYfH1Tx51dpSoHc+WDJKfUefz07DYfxvyg54RIpXDKrz5O20oAkpTP/1VSN&#10;DOqH11qn9e8WBowe2/MBDYTCVMIPKuYFEdVs2qhrl1tn+BXl9jiVa62Ofv+H+uCmrQquWKcU6z/F&#10;HADa/5ycudN29pySw0O5U58Yo8q7N9XW1p3FsTiTY4P+2d+fIChp4NVDiqem9yEqqko0sap5hhvL&#10;5JdU2mrN/g+prDiSQ0GqN4VscGRSl77+Byoe7ZM1VNtkAOby9NZ5XJT6ewfVfOW83Mefzom7DNi0&#10;Lh85pMnJuGSc7CQAaLPeozw+oaunTk97L4WG9WtUULVAEz6kcRzrKhmPv+vYBju9VtPMpOV4438E&#10;QiEZH3YxNJKuRytU1dyk4eNnsx4EGA9bTWOMrl3v0JK/+Y4qnvtZT08H1Tzdoo0AgBmaGRdPubJn&#10;TyiybY8mQuGsvwLL46O6cupcVhr/tx417f+7XmYjLWg5o2tXb0rm/YeKwkGjkqf2a8QUeL8ngZEC&#10;ve26+rWvaXhs8icl5x2ndpv3TJ58K7J7Z4Bn3lsB23vbfytZV3/rZ39G/Tue9CWVnXACeuLAfrUe&#10;PzvvuhlpK73+/17U3uYNCqzbrrSl5iYDgJybDHj63BXtH+zXRG1ddnv/AaObJ1/TYO9g9gM+8odv&#10;K0uM67W//EvZacwTsdZq+coVKl7epHHX+wEAR1K447b6Wm7KOoGM7Ax59fDLqty227dqrWt5s5Yv&#10;qtKtroF5V3ZGxxO689+/ocX/eoXS5VW8TGIOAHLM8NCIbp4+rmxP1w7YhJJnzymV5pnkiiJjFfj+&#10;X+jqtbZpdacca7Vs83oNFkZ96X2FTEo3Xj4km6EMkTFGnddvKjru44ZUxaWq37xx3pahK7e6lPje&#10;nyuSTvFCEQCI8ZkcPCAo9tqrKp2ayOJRs0aR/h6dO3Emu3m7twaWyR0q6EjVXTf00g9emvZBPmWl&#10;UZXv2KW0DfhyHHF0aFCJts6M7tg4OjCiydu3ZHz6nWPGUWjvfoXm6VnJVtLplw7LXDylIO8VAYCY&#10;A5Bz+u52KHrnRtZCnbCxCpw5ofhEUoR7ufEGL4116eTXflex0Ylpp8vLVy7XyOIGXw66CcqouL9d&#10;XZ29Gf0q4lYKnzn+xiYOPilYUq/GVQ3ZL6Q+VXVjkwld+ubXpZH+jL5fljaGACDbG2PMBaOTcfVf&#10;PK2wzU7+vTA9qXMvHpSb5R1R7DtmgSlv96pNx/r14td+T1dv9ky7cgoYadeBA4p7eCLkvdImpYuH&#10;X1Myw1vNO8bo1uUrqkpM+vY7xsIRrX185/QCJ+tnY+nf+9fZPqDkd/5SYZsUyADMsaWA87/WP33o&#10;FWlyJAulxWiypUVDHb05EgfbvJ69WzHYq9bf+6q6LlybUdmvW1Qlt3G1Pw20kYrHxzRy81ZWnk7X&#10;3S4Ferp8qwqscZTcuENVReF5W0atpOPPv6Tw2aMKZ+CwAEYbCAAwA+MDIwqcO6mQyfTkP1fumeMa&#10;mUzQ9mez8beuFre36tbv/oZ6zl+eURVqJC3btEEjZdW+VLyOpFRvh7pab/s2Fv8wo/GUgpfO+JYR&#10;tNYqVrtYxSumc3Li3C2kyaSrS9/8plaOdssammgCAOSMlGvV+fIhyY1ntL1dNDmqzpOnZRyH9j8b&#10;L6sxKo5PaMmh7+qF3/x1XWxpn3H/qbAgILN7n4/HEbsaOXZUqZTN2nLZoydPKZLybya7DYa149ln&#10;FcjmcboZaJTvdg7o8v/4lqrduP/vGzEGAQCm+64YtV+/rdruuxnrZQWM0cjVS2pt7xVzPjI77FLg&#10;SNHkuCKnjujWf/4P+l9/+C31D8xuCKi2oV6LljXKv50IpxS6dTsrm8m8Zex2m5KxId+un5ZVcPVa&#10;LVtcnbUoNRNfr5XR8YOvKXXssApooNkJELkjFhvTxIlXFfp4oxIZCJ8dN6XbB3+kVI6svLPz7F10&#10;ZWQcyXGksJXSqYSUmlJh602p5bLOH3lNfXe7FU/ZWXf+rLXat3eH7hRE5FfvOzwyoEs3WrOS/n/L&#10;wOik1ty+rPNVNT6dECj1lFRo4Ya1utXxsvSwcfI5XkanXOn0n35dO1euVu+CJTLWkgIgAEDWBYI6&#10;cfionvjYc7rrFPv+6yKDAxq4fit3XlP7RiTivtnwaC4sHjJvZG6NkaKuZNNJTboJuUppVyKpjs4O&#10;nbt2RQtTVuPXr+nKzduysVGNJtJy3hx2eZRbraosVf/6XUo4ji/tkpFV7OIFTY5NZf3bPvLaUdVv&#10;fUKDxp9dCJNOUNqxU4UHj2jqQbMpzRt7Irg+pOwzWeQ7huK6840/0tIv/6r6nLAve4swzYAAADM0&#10;1NGr9KEfaduaddNcz21mneaKnTyqrv7htxuirPeY02kVdN3WtsTUDLpZxoc03/SumUyldKujXb1D&#10;g+qODanJKVCqr0dtd+6qq7dfF52gEvGEJhNJXX4zUnDerBW9+s7D0QLVJ0bV0Hndl/tPp5P6wWtH&#10;NJV2sx6U9V25qidaz6shUuTL87eSairLdTsaUlfs/nNx4vGEVnXfUngqNotUgHno38S6uzI63eDi&#10;8Quq/tH3tGX9hnd9skf/Tp1EQrdHRoUshMq/2JO2Np2eF/sAXPzq72j46LE82wXOKBQMTPvde+j6&#10;efP2MSzvebeTqXTOnRdeEHQUeGurWePv/T/8mnrX6TT36fpbq5TrKp125Vp7z3GGmev9OMaoIOC8&#10;zwFLs79/a6XkW/eXA4NE4WBQjmN8e/5W0lQy+dB/8R1ldDrtpZ1emUq5Vqm0m+G6xlEo6Myq/JuH&#10;3L+1UjyV9mVTKk9LlOtqw2/+lspWrZkXc5DcVIoMgObBioBUIj/37k6kXL2ZYJ1Te1Vkq3fsWqup&#10;vDnAwSiekXs186+MPrCucZVKuFK+774lVgEAAAACAAAAQAAg1oYDAEAAIIZmAAAgAPAtAiADAAAQ&#10;OwHm3xwA0gAAADAJEAAAEAAAAIB5GQAwBwAAgDwLAKyYAwAAEPPM8iwAoPMPAECeDgFwpiQAAEwC&#10;BAAABAAAAIAAAAAAsRMgAAAgAwAAAAgAAAAAAQAAAGIjIAIAAABAAAAAAHJbcL7dkHGc913GYe3D&#10;NhI0s1oaYh+0QsS8dUWTA9fk/rl/7p/75/5ne//zba9Z84s9aWvT6XlxMxPdd+UmEvdfr2nv/cM+&#10;8PGaBz5p897r2vcU10e/5jQ/q7n3Ysaba3L/3D/3z/1z/w/6rFaFCxYqGC2eF+2lm0rNrwxAdOFS&#10;cjoAAIg5AAAA4D6CgYiRTQf4JgAAyJfef4rTcwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&#10;AAAAAAAAAAAAAAAAAABgGv4/cSHz3HR4JW4AAAAASUVORK5CYII=&#10;"
+       id="image1"
+       x="4.9499511e-07"
+       y="4.9499511e-07" /></g></svg>
diff --git a/music_assistant/providers/vban_receiver/icon_monochrome.svg b/music_assistant/providers/vban_receiver/icon_monochrome.svg
new file mode 100644 (file)
index 0000000..e7aac70
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="512"
+   height="512"
+   viewBox="0 0 135.46666 135.46666"
+   version="1.1"
+   id="svg1"
+   xml:space="preserve"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs1" /><g
+     id="layer1"><image
+       width="135.46666"
+       height="135.46666"
+       preserveAspectRatio="none"
+       xlink:href="data:image/png;base64,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&#10;RU5ErkJggg==&#10;"
+       id="image1"
+       x="-2.2705078e-07"
+       y="-2.2705078e-07" /></g></svg>
diff --git a/music_assistant/providers/vban_receiver/manifest.json b/music_assistant/providers/vban_receiver/manifest.json
new file mode 100644 (file)
index 0000000..dc42ca2
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "plugin",
+  "domain": "vban_receiver",
+  "stage": "alpha",
+  "name": "VBAN Receiver",
+  "description": "VBAN protocol receiver - receive PCM-over-UDP streams from a VBAN protocol sender",
+  "codeowners": ["@sprocket-9"],
+  "documentation": "https://music-assistant.io/plugins/vban-receiver/",
+  "multi_instance": true
+}
diff --git a/music_assistant/providers/vban_receiver/vban.py b/music_assistant/providers/vban_receiver/vban.py
new file mode 100644 (file)
index 0000000..68d3f74
--- /dev/null
@@ -0,0 +1,112 @@
+"""VBAN subclasses to workaround issues in aiovban 0.6.3."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from aiovban.asyncio import AsyncVBANClient
+from aiovban.packet import VBANPacket
+from aiovban.packet.headers import VBANHeaderException
+
+if TYPE_CHECKING:
+    from . import VBANReceiverProvider
+
+logger = logging.getLogger(__name__)
+_aiovban_log_level = os.environ.get("AIOVBAN_LOG_LEVEL", "info").upper()
+logging.getLogger("aiovban.asyncio.aiovban.asyncio.util").setLevel(_aiovban_log_level)
+
+
+class VBANListenerProtocolMod(asyncio.DatagramProtocol):
+    """VBANListenerProcotol workaround."""
+
+    def __init__(self, client: AsyncVBANClientMod) -> None:
+        """Initialize."""
+        # WORKAROUND: each instance gets it's own Future.
+        self.done: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
+        self._background_tasks: set[asyncio.Task[Any]] = set()
+        self._client = client
+
+    def error_received(self, exc: Exception) -> None:
+        """Handle error."""
+        self.done.set_exception(exc)
+
+    def connection_lost(self, exc: Exception | None) -> None:
+        """Handle lost connection."""
+        if self.done.done():
+            return
+        # WORKAROUND: handle exc properly.
+        if exc:
+            self.done.set_exception(exc)
+        else:
+            self.done.set_result(None)
+
+    def connection_made(self, transport) -> None:  # type: ignore[no-untyped-def]
+        """Handle connection made."""
+        logger.debug(f"Connection made to {transport}")
+
+    def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Handle received datagram."""
+        sender_ip, sender_port = addr
+
+        if self._client.quick_reject(sender_ip) or not self._client.active_player:
+            return
+
+        try:
+            packet = VBANPacket.unpack(data)
+        except VBANHeaderException as exc:
+            logger.error(f"Error unpacking packet: {exc}")
+            return
+        except ValueError as exc:
+            # Handle odd packet sent when Voicemeeter start/stops stream
+            error_msg = "6000 is not a valid VBANSampleRate"
+            if str(exc) == error_msg:
+                return
+            raise
+
+        task = asyncio.create_task(self._client.process_packet(sender_ip, sender_port, packet))
+        self._background_tasks.add(task)
+        task.add_done_callback(self._background_tasks.discard)
+
+
+@dataclass
+class AsyncVBANClientMod(AsyncVBANClient):  # type: ignore[misc]
+    """AsyncVBANClient workaround."""
+
+    _controller: VBANReceiverProvider | None = None
+
+    @property
+    def active_player(self) -> bool:
+        """Report the active player status."""
+        return False if not self._controller else self._controller.active_player
+
+    async def listen(
+        self,
+        address: str = "0.0.0.0",
+        port: int = 6980,
+        loop: asyncio.AbstractEventLoop | None = None,
+        controller: VBANReceiverProvider | None = None,
+    ) -> None:
+        """Create UDP listener."""
+        loop = loop or asyncio.get_running_loop()
+        self._controller = controller
+
+        # Create a socket and set the options
+        self._transport, proto = await loop.create_datagram_endpoint(
+            lambda: VBANListenerProtocolMod(self),
+            local_addr=(address, port),
+            allow_broadcast=not self.ignore_audio_streams,
+        )
+
+        # WORKAROUND: await, not return.
+        await proto.done
+
+    def close(self) -> None:
+        """Close down the connection."""
+        self._controller = None
+        if self._transport:
+            self._transport.close()
+            self._transport = None  # type: ignore[assignment]
index 6239a33611eb6a6ad80425b67a051a528f878eba..67d7cbd0703c212487c79bba02c534f53eadc8d1 100644 (file)
@@ -40,6 +40,7 @@ dependencies = [
   "uv>=0.8.0",
   "librosa==0.11.0",
   "gql[all]==4.0.0",
+  "aiovban>=0.6.3",
 ]
 description = "Music Assistant"
 license = {text = "Apache-2.0"}
index 2a3721a294e0f7e9c02d080b952c3d32fbe7f7cc..22871ada436347f2e67d348986663dd95fd8d94b 100644 (file)
@@ -14,6 +14,7 @@ aiorun==2025.1.1
 aioslimproto==3.1.1
 aiosonos==0.1.9
 aiosqlite==0.21.0
+aiovban>=0.6.3
 alexapy==1.29.8
 async-upnp-client==0.45.0
 audible==0.10.0