diff --git a/music_assistant/providers/local_audio/README.md b/music_assistant/providers/local_audio/README.md index 7556d24414..4f2b9a5945 100644 --- a/music_assistant/providers/local_audio/README.md +++ b/music_assistant/providers/local_audio/README.md @@ -2,14 +2,17 @@ ## Overview -The Local Audio Out provider exposes locally attached soundcards (USB DACs, built-in speakers, HDMI audio, etc.) as players in Music Assistant. It leverages the Sendspin provider for synchronization and timing, registering each soundcard as an external Sendspin bridge client. +The Local Audio Out provider exposes locally attached soundcards as players in Music Assistant. On Linux it enumerates PulseAudio sinks (USB DACs, built-in audio, HDMI, remap sinks, virtual sinks, etc.); on macOS it enumerates PortAudio/CoreAudio devices. It leverages the Sendspin provider for synchronization and timing, registering each device as an external Sendspin bridge client. ### Key Features -- **Automatic Device Discovery**: Enumerates all audio output devices via PortAudio/sounddevice +- **Automatic Device Discovery**: On Linux, enumerates all PulseAudio output sinks via `pactl --format=json` — returns native sample rate and format regardless of active stream state. On macOS, enumerates via PortAudio/sounddevice +- **Native Format Negotiation** *(Linux)*: Each PA sink advertises its native sample rate and bit depth (16, 24, or 32-bit) so Music Assistant transcodes to the correct format — no unnecessary resampling - **Sendspin Integration**: Each device is registered as a Sendspin bridge client, enabling synchronized multi-room playback -- **Software Volume Control**: Per-device volume and mute via PCM sample scaling -- **Stable Player IDs**: Uses UUIDv5 from device name + host API index so players persist across restarts +- **Hardware Volume Control**: PulseAudio sink volume on Linux, CoreAudio on macOS, with automatic fallback to software scaling +- **Software Volume Control**: Per-device volume and mute via PCM sample scaling when hardware control is unavailable or disabled +- **Stable Player IDs**: Uses UUIDv5 derived from device name + host API index so players persist across restarts +- **Hardware Volume Ceiling** *(Linux)*: Configurable per-provider PA sink volume ceiling applied at startup ## Architecture @@ -19,11 +22,12 @@ The Local Audio Out provider exposes locally attached soundcards (USB DACs, buil ┌──────────────────────────────────────────────────────────────┐ │ LocalAudioProvider │ │ - Thin provider shell, delegates to bridge manager │ +│ - Verifies libpulse-simple present on Linux at init │ └──────────────────────────────────────────────────────────────┘ │ ┌─────────────▼──────────────┐ │ LocalAudioBridgeManager │ - │ - Enumerates soundcards │ + │ - Enumerates devices │ │ - Creates/stops bridges │ └─────────────┬──────────────┘ │ @@ -35,44 +39,100 @@ The Local Audio Out provider exposes locally attached soundcards (USB DACs, buil │ │ │ │ │ Sendspin Client ──► │ │ Sendspin Client ──► │ │ BridgePlayerRole │ │ BridgePlayerRole │ -│ sounddevice Output │ │ sounddevice Output │ +│ PA/sounddevice out │ │ PA/sounddevice out │ └─────────────────────┘ └────────────────────────┘ ``` ### Audio Flow +#### Linux (PulseAudio) ``` Sendspin PushStream │ ▼ BridgePlayerRole.on_audio_chunk │ - ▼ (volume/mute applied) + ▼ (software volume/mute applied, format conversion for 24-bit) +asyncio.Queue + │ + ▼ +PASimpleStream (libpulse-simple via ctypes) + │ + ▼ +PulseAudio Sink + │ + ▼ +Physical Audio Device +``` + +#### macOS (CoreAudio) +``` +Sendspin PushStream + │ + ▼ +BridgePlayerRole.on_audio_chunk + │ + ▼ (software volume/mute applied) asyncio.Queue │ ▼ sounddevice.RawOutputStream (PortAudio) │ ▼ -Physical Soundcard +CoreAudio Device ``` +### Bit Depth Handling (Linux) + +| Sink Format | MA Delivery | PA Stream Format | Conversion | +|-------------|-----------------------------------|-------------------|------------------------------------| +| `s16le` | 16-bit PCM | `PA_SAMPLE_S16LE` | None | +| `s24le` | 32-bit container (left-justified) | `PA_SAMPLE_S24LE` | Unpack int32, repack to 3-byte LE | +| `s32le` | 32-bit PCM | `PA_SAMPLE_S32LE` | None | + ### File Structure -| File | Description | -|------|-------------| -| `__init__.py` | Provider entry point, setup, and config | +| File/Folder | Description | +|-------------|-------------| +| `__init__.py` | Provider entry point, setup, and config entries | | `provider.py` | `LocalAudioProvider` class | -| `sendspin_bridge.py` | Bridge manager and per-device bridge implementation | -| `constants.py` | Shared constants (UUID namespace, buffer size) | +| `sendspin_bridge.py` | Bridge manager and per-device bridge (PA on Linux, sounddevice on macOS) | +| `player.py` | `LocalAudioPlayer` — MA player model for each device | +| `pa_simple.py` | ctypes wrapper around `libpulse-simple` for direct PCM output; sink enumeration via `pactl` *(Linux only)* | +| `constants.py` | Shared constants (UUID namespace, buffer size, config keys) | | `manifest.json` | Provider metadata and dependencies | +| `bin/pactl` | Bundled `pactl` binary for sink enumeration (fallback if `pulseaudio-utils` not installed) | +| `bin/lib/` | Bundled `libpulsecommon` shared library required by the bundled `pactl` binary | ## Dependencies - **Sendspin provider** (`depends_on: sendspin`): Required for audio synchronization and player management -- **sounddevice**: Python bindings for PortAudio, used for audio output +- **libpulse-simple** *(Linux)*: PulseAudio simple client library accessed via ctypes for direct PCM streaming to sinks +- **pactl** *(Linux)*: Used for sink enumeration via `--format=json`. Provided by `pulseaudio-utils` in the MA base image, with a bundled binary as fallback +- **pulsectl** *(Linux)*: Python PulseAudio bindings used for hardware volume and mute control +- **sounddevice** *(macOS)*: Python bindings for PortAudio, used for audio output and device enumeration - **numpy**: Used for PCM volume scaling +## Configuration + +| Setting | Platform | Description | +|---------|----------|-------------| +| Volume control mode | All | `hardware` (OS-level), `software` (PCM scaling), or `disabled` | +| Hardware volume ceiling | Linux only | Sets PA sink volume on startup to cap maximum output level (0–100, default 50) | + +## Expanding Outputs with Stereo Pair Remap Sinks + +Multi-channel sound cards (5.1, 7.1 surround) expose a single multi-channel PulseAudio sink by default. To use each channel pair as an independent MA player, PulseAudio module-remap-sink can split a multi-channel sink into individual stereo sinks — one per channel pair (front, rear, side, center/LFE). The Local Audio Out provider discovers and registers all remap sinks automatically alongside physical sinks, so no additional configuration is needed in MA once the remap sinks exist. +For Home Assistant OS users, the companion addon Pulse Audio Stereo Pairs automates this setup. It runs as a lightweight HA addon that creates the remap sinks on startup and reacts to audio device hot-plug and unplug events, removing the need to configure remap sinks manually via pactl. Once both the addon and this provider are running, each channel pair of every multi-channel card appears as a separate player in Music Assistant. + +## Notes + +- On Linux, multi-channel sinks (5.1, 7.1) are supported — the bridge opens a stereo stream and PulseAudio handles channel remapping automatically. +- Virtual sinks created by `module-remap-sink` (stereo pairs split from multi-channel cards) are fully supported and are the recommended way to expose individual speaker pairs as independent MA players. +- On Linux, `pactl --format=json` is used for enumeration because it always reports the sink's native sample rate and format, unlike `pulsectl`/libpulse which reports the currently negotiated stream format when streams are active. +- On Linux, hardware volume control uses `pulsectl` to set the PA sink volume directly. If `pulsectl` is unavailable the provider falls back to software volume automatically. +- On macOS, hardware volume control uses CoreAudio. If that fails the provider falls back to software volume automatically. + ## Related Documentation - [Sendspin Provider](../sendspin/README.md) diff --git a/music_assistant/providers/local_audio/__init__.py b/music_assistant/providers/local_audio/__init__.py index e1645b5054..f4635f0b43 100644 --- a/music_assistant/providers/local_audio/__init__.py +++ b/music_assistant/providers/local_audio/__init__.py @@ -4,21 +4,14 @@ from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption -from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from music_assistant_models.enums import ProviderFeature from music_assistant.mass import MusicAssistant -from .constants import ( - CONF_VOLUME_CONTROL, - VOLUME_CONTROL_DISABLED, - VOLUME_CONTROL_HARDWARE, - VOLUME_CONTROL_SOFTWARE, -) from .provider import LocalAudioProvider if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.provider import ProviderManifest from music_assistant.models import ProviderInstanceType @@ -29,29 +22,13 @@ async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, + 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.""" - # ruff: noqa: ARG001 - return ( - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label="Volume control mode", - options=[ - ConfigValueOption(title="Hardware (preferred)", value=VOLUME_CONTROL_HARDWARE), - ConfigValueOption(title="Software", value=VOLUME_CONTROL_SOFTWARE), - ConfigValueOption(title="Disabled", value=VOLUME_CONTROL_DISABLED), - ], - default_value=VOLUME_CONTROL_HARDWARE, - description="Hardware uses OS/ALSA-level volume control. " - "Software applies volume scaling to PCM audio data. " - "Disabled passes audio at full volume.", - ), - ) + return () async def setup( diff --git a/music_assistant/providers/local_audio/constants.py b/music_assistant/providers/local_audio/constants.py index 2cb6d9608c..7b80480910 100644 --- a/music_assistant/providers/local_audio/constants.py +++ b/music_assistant/providers/local_audio/constants.py @@ -4,13 +4,16 @@ import uuid -# UUID namespace for generating stable player IDs from device name + host API -DEVICE_UUID_NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890") +# UUID namespace for generating stable player IDs from device name + host API. +DEVICE_UUID_NAMESPACE = uuid.UUID("a7d68578-af81-4e3e-a8b8-df8f9d6d1f05") -# Default buffer size in frames for the sounddevice output stream -DEFAULT_BUFFER_FRAMES = 2048 +# Category for caching previous player state (volume/mute). +# Bump the integer to invalidate old cached values when the format changes. +CACHE_CATEGORY_PREV_STATE = 1 -CONF_VOLUME_CONTROL = "volume_control" +# Volume control — software only VOLUME_CONTROL_SOFTWARE = "software" -VOLUME_CONTROL_HARDWARE = "hardware" -VOLUME_CONTROL_DISABLED = "disabled" + +# Defaults +DEFAULT_PLAYER_VOLUME = 25 # initial volume for new players (percent) +DEFAULT_BUFFER_FRAMES = 1024 # sounddevice blocksize (frames) diff --git a/music_assistant/providers/local_audio/manifest.json b/music_assistant/providers/local_audio/manifest.json index ab2d7cbaee..5d4c0f5064 100644 --- a/music_assistant/providers/local_audio/manifest.json +++ b/music_assistant/providers/local_audio/manifest.json @@ -3,9 +3,9 @@ "domain": "local_audio", "name": "Local Audio Out", "description": "Play audio through locally attached soundcards (USB DACs, built-in speakers, etc.).", - "codeowners": ["@music-assistant"], + "codeowners": ["@music-assistant", "@iVolt1"], "depends_on": "sendspin", - "requirements": ["sounddevice==0.5.5"], + "requirements": ["sounddevice==0.5.5", "pulsectl; sys_platform == 'linux'"], "documentation": "https://music-assistant.io/player-support/local-audio/", "builtin": true, "allow_disable": true, diff --git a/music_assistant/providers/local_audio/pa_simple.py b/music_assistant/providers/local_audio/pa_simple.py new file mode 100644 index 0000000000..0b991e5922 --- /dev/null +++ b/music_assistant/providers/local_audio/pa_simple.py @@ -0,0 +1,269 @@ +"""Minimal ctypes wrapper around libpulse-simple for direct PA sink PCM streaming.""" + +from __future__ import annotations + +import contextlib +import ctypes +import os +import threading +from pathlib import Path +from typing import Any, ClassVar, Final + +PA_STREAM_PLAYBACK: Final = 1 + +PA_SAMPLE_S16LE: Final = 3 +PA_SAMPLE_S32LE: Final = 7 # verified via pa_sample_format_to_string +PA_SAMPLE_S24LE: Final = 9 # packed 3-byte LE — native format of s24le PA sinks + +# Map PA sample format constant -> bit depth +_PA_FORMAT_TO_BIT_DEPTH: Final[dict[int, int]] = { + PA_SAMPLE_S16LE: 16, + PA_SAMPLE_S24LE: 24, + PA_SAMPLE_S32LE: 32, +} + + +def _pa_sample_format(bit_depth: int) -> int: + """Return PA sample format constant for given bit depth.""" + if bit_depth == 32: + return PA_SAMPLE_S32LE + if bit_depth == 24: + # MA delivers in 32-bit containers; _apply_software_volume repacks to + # packed 3-byte before writing, so PA sees s24le here. + return PA_SAMPLE_S24LE + return PA_SAMPLE_S16LE + + +class _PASampleSpec(ctypes.Structure): + _fields_: ClassVar = [ + ("format", ctypes.c_int), + ("rate", ctypes.c_uint32), + ("channels", ctypes.c_uint8), + ] + + +def _find_pulse_server() -> str: + """Detect the PulseAudio server socket path.""" + if server := os.environ.get("PULSE_SERVER"): + return server + for path in ( + "/run/audio/pulse.sock", + "/run/pulse/native", + "/var/run/pulse/native", + ): + if os.path.exists(path): + return f"unix:{path}" + return "" + + +def _get_pulse_server() -> str: + """Return the PulseAudio server path, checked fresh on each call. + + Intentionally not cached — the socket may not exist at import time + but appear later once the audio addon has fully started. + """ + return _find_pulse_server() + + +def _load_lib() -> ctypes.CDLL: + lib = ctypes.CDLL("libpulse-simple.so.0") + lib.pa_simple_new.restype = ctypes.c_void_p + lib.pa_simple_new.argtypes = [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ] + lib.pa_simple_write.restype = ctypes.c_int + lib.pa_simple_write.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_void_p, + ] + lib.pa_simple_drain.restype = ctypes.c_int + lib.pa_simple_drain.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + lib.pa_simple_free.restype = None + lib.pa_simple_free.argtypes = [ctypes.c_void_p] + return lib + + +_lib: ctypes.CDLL | None = None + + +def _get_lib() -> ctypes.CDLL: + global _lib # noqa: PLW0603 + if _lib is None: + _lib = _load_lib() + return _lib + + +class PASimpleStream: + """Synchronous PCM playback stream to a named PulseAudio sink. + + All libpulse calls are serialized behind a threading.Lock so that + concurrent executor threads cannot simultaneously write/free the + same pa_simple connection, which causes assertion failures in libpulse. + """ + + def __init__( + self, + sink_name: str, + app_name: str, + rate: int, + channels: int, + bit_depth: int = 16, + ) -> None: + """Open a synchronous PCM playback stream to the named PulseAudio sink.""" + lib = _get_lib() + spec = _PASampleSpec( + format=_pa_sample_format(bit_depth), + rate=rate, + channels=channels, + ) + error = ctypes.c_int(0) + self._lib = lib + self._lock = threading.Lock() + pulse_server = _get_pulse_server() + self._conn: int | None = lib.pa_simple_new( + pulse_server.encode() if pulse_server else None, + app_name.encode(), + PA_STREAM_PLAYBACK, + sink_name.encode(), + b"playback", + ctypes.byref(spec), + None, + None, + ctypes.byref(error), + ) + if not self._conn: + raise OSError( + f"pa_simple_new failed for sink '{sink_name}' " + f"(pa_error={error.value}, server={pulse_server!r})" + ) + + def write(self, data: bytes) -> None: + """Write a PCM chunk. Blocks until PA has buffered it.""" + with self._lock: + if not self._conn: + return + error = ctypes.c_int(0) + ret = self._lib.pa_simple_write(self._conn, data, len(data), ctypes.byref(error)) + if ret < 0: + raise OSError(f"pa_simple_write failed (pa_error={error.value})") + + def drain(self) -> None: + """Block until all buffered audio has played out.""" + with self._lock: + if not self._conn: + return + error = ctypes.c_int(0) + self._lib.pa_simple_drain(self._conn, ctypes.byref(error)) + + def close(self) -> None: + """Free the PA stream. + + Acquires the lock before zeroing _conn and calling pa_simple_free, + ensuring no concurrent write() or drain() can touch the pointer + between the None assignment and the free call. + """ + with self._lock: + conn, self._conn = self._conn, None + if conn: + self._lib.pa_simple_free(conn) + + def __enter__(self) -> PASimpleStream: + """Enter context manager.""" + return self + + def __exit__(self, *_: object) -> None: + """Exit context manager and close the stream.""" + self.close() + + +def enumerate_pa_sinks() -> list[dict[str, Any]]: + """Enumerate stereo-capable PulseAudio sinks via pactl JSON output. + + Uses pactl --format=json list sinks which always returns the sink's + native sample rate and format regardless of active stream state — + unlike pulsectl/libpulse which reports the currently negotiated format + when streams are active (which can differ from native hardware format). + + Returns list of dicts with keys: + - name: display name (PA sink description) + - pa_sink_name: internal PA sink name + - max_output_channels: number of channels + - sample_rate: sink native sample rate in Hz + - bit_depth: sink native bit depth (16, 24, or 32) + """ + import json # noqa: PLC0415 + import shutil # noqa: PLC0415 + import subprocess # noqa: PLC0415 + + # Locate pactl — prefer bundled binary, fall back to system + bundled = os.path.join(os.path.dirname(__file__), "bin", "pactl") + if os.path.isfile(bundled): + if not os.access(bundled, os.X_OK): + with contextlib.suppress(OSError): + Path(bundled).chmod(0o755) + if os.path.isfile(bundled) and os.access(bundled, os.X_OK): + pactl_bin = bundled + elif path := shutil.which("pactl"): + pactl_bin = path + else: + raise FileNotFoundError( + "pactl not found — bundled binary missing and pulseaudio-utils not installed" + ) + + # Build environment with bundled lib dir and PULSE_SERVER + lib_dir = os.path.join(os.path.dirname(__file__), "bin", "lib") + existing_ld = os.environ.get("LD_LIBRARY_PATH", "") + ld_path = f"{lib_dir}:{existing_ld}" if existing_ld else lib_dir + env = {**os.environ, "LD_LIBRARY_PATH": ld_path} + pulse_server = _get_pulse_server() + if pulse_server: + env["PULSE_SERVER"] = pulse_server + + result = subprocess.run( # noqa: S603 + [pactl_bin, "--format=json", "list", "sinks"], + capture_output=True, + text=True, + timeout=5, + env=env, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"pactl exited {result.returncode}: {result.stderr.strip()}") + + sinks = [] + for sink in json.loads(result.stdout): + name: str = sink.get("name", "") + desc: str = sink.get("description", name) + spec_str: str = sink.get("sample_specification", "") + driver: str = sink.get("driver", "") + try: + parts = spec_str.split() + fmt = parts[0] # e.g. 's32le' + channels = int(parts[1].replace("ch", "")) + sample_rate = int(parts[2].replace("Hz", "")) + bit_depth = int("".join(filter(str.isdigit, fmt.split("le")[0].split("be")[0]))) + except (IndexError, ValueError): + continue + if channels < 2: + continue + sinks.append( + { + "name": desc, + "pa_sink_name": name, + "max_output_channels": channels, + "sample_rate": sample_rate, + "bit_depth": bit_depth, + "is_remap": driver == "module-remap-sink.c", + } + ) + return sinks diff --git a/music_assistant/providers/local_audio/player.py b/music_assistant/providers/local_audio/player.py index 73565f1bf6..900fca5770 100644 --- a/music_assistant/providers/local_audio/player.py +++ b/music_assistant/providers/local_audio/player.py @@ -10,18 +10,21 @@ from music_assistant_models.enums import IdentifierType, PlayerFeature, PlayerType from music_assistant_models.player import DeviceInfo -from music_assistant.helpers.process import check_output from music_assistant.models.player import Player from .constants import ( - CONF_VOLUME_CONTROL, + CACHE_CATEGORY_PREV_STATE, + DEFAULT_PLAYER_VOLUME, DEVICE_UUID_NAMESPACE, - VOLUME_CONTROL_HARDWARE, - VOLUME_CONTROL_SOFTWARE, ) -if sys.platform == "darwin": - from .coreaudio_volume import set_device_mute, set_device_volume +if sys.platform == "linux": + try: + import pulsectl + + _PULSECTL_AVAILABLE = True + except ImportError: + _PULSECTL_AVAILABLE = False if TYPE_CHECKING: from .provider import LocalAudioProvider @@ -46,15 +49,18 @@ def __init__( device_name: str, hostapi_index: int, device_index: int, + pa_sink_name: str | None = None, + is_remap: bool = False, ) -> None: - """ - Initialize the Local Audio player. + """Initialize the Local Audio player. :param provider: The Local Audio provider instance. :param player_id: Stable player ID derived from device UUID. :param device_name: The device name reported by PortAudio. :param hostapi_index: The host API index. :param device_index: The PortAudio device index (maps to ALSA card on Linux). + :param pa_sink_name: The PulseAudio sink name for this device (Linux only). + :param is_remap: True if this is a remap/filter sink (not a physical ALSA sink). """ super().__init__(provider, player_id) self._attr_type = PlayerType.PLAYER @@ -71,86 +77,79 @@ def __init__( device_uuid = get_device_uuid(device_name, hostapi_index) self._attr_device_info.add_identifier(IdentifierType.UUID, device_uuid) self._attr_can_group_with = set() - self._attr_volume_level = 100 + self._attr_volume_level = DEFAULT_PLAYER_VOLUME self._device_index = device_index - # Set when hardware volume fails, causes automatic fallback to software - self._hardware_volume_fallback = False - - @property - def volume_control_mode(self) -> str: - """Return the effective volume control mode for this player.""" - if self._hardware_volume_fallback: - return VOLUME_CONTROL_SOFTWARE - # Volume control mode is configured at provider level - return str(self._provider.config.get_value(CONF_VOLUME_CONTROL) or VOLUME_CONTROL_HARDWARE) + self._pa_sink_name = pa_sink_name + self._is_remap = is_remap + + async def restore_state(self) -> None: + """Restore cached volume/mute state from a previous session.""" + if last_state := await self.mass.cache.get( + key=self.player_id, + provider=self._provider.instance_id, + category=CACHE_CATEGORY_PREV_STATE, + ): + self._attr_volume_muted = last_state[0] + self._attr_volume_level = last_state[1] + else: + self._attr_volume_muted = False + self._attr_volume_level = DEFAULT_PLAYER_VOLUME + + async def _save_state(self) -> None: + """Persist current volume/mute state to cache.""" + await self.mass.cache.set( + key=self.player_id, + data=[self._attr_volume_muted, self._attr_volume_level], + provider=self._provider.instance_id, + category=CACHE_CATEGORY_PREV_STATE, + ) async def volume_set(self, volume_level: int) -> None: """Handle VOLUME_SET command.""" self._attr_volume_level = volume_level - if self.volume_control_mode == VOLUME_CONTROL_HARDWARE: - await self._set_hardware_volume(volume_level) + await self._save_state() self.update_state() async def volume_mute(self, muted: bool) -> None: """Handle VOLUME_MUTE command.""" self._attr_volume_muted = muted - mode = self.volume_control_mode - if mode == VOLUME_CONTROL_HARDWARE: - await self._set_hardware_mute(muted) + await self._save_state() self.update_state() - async def _set_hardware_volume(self, volume: int) -> None: - """Set the OS-level volume for this device. + async def apply_hardware_ceiling(self) -> None: + """Set PA sink hardware volume to 100% via pulsectl (Linux only). - :param volume: Volume level 0-100. + Ensures the PA sink is at full hardware volume so that software + volume scaling in the bridge has full dynamic range to work with. + No-op on non-Linux or if no PA sink. """ - try: - if sys.platform == "darwin": - loop = asyncio.get_running_loop() - ok = await loop.run_in_executor(None, set_device_volume, self.name, volume) - if not ok: - self.logger.warning("CoreAudio volume control failed for %s", self.name) - self._hardware_volume_fallback = True - elif sys.platform == "linux": - # Use -c to target the specific ALSA card by index - rc, _ = await check_output( - "amixer", "-c", str(self._device_index), "sset", "Master", f"{volume}%" - ) - if rc != 0: - self.logger.warning("amixer volume failed for card %d", self._device_index) - self._hardware_volume_fallback = True - else: - self.logger.warning( - "Hardware volume not supported on %s, falling back to software", - sys.platform, - ) - self._hardware_volume_fallback = True - except FileNotFoundError: - self.logger.warning("Volume control command not found, falling back to software") - self._hardware_volume_fallback = True - - async def _set_hardware_mute(self, muted: bool) -> None: - """Set the OS-level mute state for this device. - - :param muted: Whether to mute or unmute. + if sys.platform != "linux" or not self._pa_sink_name: + return + loop = asyncio.get_running_loop() + ok = await loop.run_in_executor(None, self._set_pulse_volume, self._pa_sink_name, 100) + if ok: + self.logger.debug("PA sink %s set to 100%% hardware volume", self._pa_sink_name) + else: + self.logger.warning("Failed to set hardware volume for sink %s", self._pa_sink_name) + + def _set_pulse_volume(self, pa_sink_name: str, volume: int) -> bool: + """Set PulseAudio sink volume. Returns True on success. + + Intended to be called via run_in_executor. + + :param pa_sink_name: The PulseAudio sink name. + :param volume: Volume level 0-100. """ + if not _PULSECTL_AVAILABLE: + return False try: - if sys.platform == "darwin": - loop = asyncio.get_running_loop() - ok = await loop.run_in_executor(None, set_device_mute, self.name, muted) - if not ok: - self.logger.warning("CoreAudio mute control failed for %s", self.name) - self._hardware_volume_fallback = True - elif sys.platform == "linux": - toggle = "mute" if muted else "unmute" - rc, _ = await check_output( - "amixer", "-c", str(self._device_index), "sset", "Master", toggle - ) - if rc != 0: - self.logger.warning("amixer mute failed for card %d", self._device_index) - self._hardware_volume_fallback = True - else: - self._hardware_volume_fallback = True - except FileNotFoundError: - self.logger.warning("Mute control command not found, falling back to software") - self._hardware_volume_fallback = True + with pulsectl.Pulse("ma-local-audio") as pulse: + for sink in pulse.sink_list(): + if sink.name == pa_sink_name: + pulse.volume_set_all_chans(sink, volume / 100.0) + return True + self.logger.warning("PA sink %s not found for volume control", pa_sink_name) + return False + except Exception as err: + self.logger.warning("pulsectl volume error for %s: %s", pa_sink_name, err) + return False diff --git a/music_assistant/providers/local_audio/provider.py b/music_assistant/providers/local_audio/provider.py index 4c3b9d7dbc..637ef96bea 100644 --- a/music_assistant/providers/local_audio/provider.py +++ b/music_assistant/providers/local_audio/provider.py @@ -2,6 +2,9 @@ from __future__ import annotations +import ctypes +import sys + from music_assistant.models.player_provider import PlayerProvider from .sendspin_bridge import LocalAudioBridgeManager @@ -14,6 +17,15 @@ class LocalAudioProvider(PlayerProvider): async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" + if sys.platform == "linux": + # Verify libpulse-simple is present before we try to do anything + try: + ctypes.CDLL("libpulse-simple.so.0") + except OSError as err: + raise RuntimeError( + "libpulse-simple.so.0 not found — is PulseAudio installed?" + ) from err + self._bridge_manager = LocalAudioBridgeManager(self) async def loaded_in_mass(self) -> None: @@ -22,8 +34,7 @@ async def loaded_in_mass(self) -> None: async def unload(self, is_removed: bool = False) -> None: """Handle unload/removal of the provider.""" - bridge_manager = getattr(self, "_bridge_manager", None) - if bridge_manager: + if bridge_manager := getattr(self, "_bridge_manager", None): await bridge_manager.stop_all() async def discover_players(self) -> None: diff --git a/music_assistant/providers/local_audio/sendspin_bridge.py b/music_assistant/providers/local_audio/sendspin_bridge.py index 1f698a6112..c1656f00f1 100644 --- a/music_assistant/providers/local_audio/sendspin_bridge.py +++ b/music_assistant/providers/local_audio/sendspin_bridge.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio +import sys from contextlib import suppress from typing import TYPE_CHECKING, Any, cast import numpy as np -import sounddevice as sd from aiosendspin.models.core import ClientHelloPayload from aiosendspin.models.core import DeviceInfo as SendspinDeviceInfo from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat @@ -23,11 +23,19 @@ ) from music_assistant.providers.sendspin.helpers import bridge_client_id_from_uuid -from .constants import DEFAULT_BUFFER_FRAMES, VOLUME_CONTROL_SOFTWARE +from .constants import DEFAULT_BUFFER_FRAMES from .player import LocalAudioPlayer, get_device_uuid +if sys.platform == "linux": + from .pa_simple import PASimpleStream, enumerate_pa_sinks + if TYPE_CHECKING: - from aiosendspin.server import ExternalStreamStartRequest, SendspinClient, SendspinServer + import sounddevice as sd # noqa: F401 + from aiosendspin.server import ( + ExternalStreamStartRequest, + SendspinClient, + SendspinServer, + ) from aiosendspin.server.roles import AudioChunk from music_assistant.providers.sendspin.provider import SendspinProvider @@ -42,7 +50,6 @@ def __init__( self, provider: LocalAudioProvider, player: LocalAudioPlayer, - device_index: int, device_info: dict[str, Any], sendspin_server: SendspinServer, ) -> None: @@ -51,26 +58,33 @@ def __init__( :param provider: The Local Audio provider instance. :param player: The LocalAudioPlayer that owns this bridge. - :param device_index: The PortAudio device index. - :param device_info: The device info dict from sounddevice.query_devices(). + :param device_info: Device info dict — on Linux: from enumerate_pa_sinks(); + on Darwin: from sounddevice.query_devices() with 'index' added. :param sendspin_server: The Sendspin server to register with. """ self.provider = provider self.mass = provider.mass self.player = player self.sendspin_server = sendspin_server - self.device_index = device_index - self.device_name: str = device_info["name"] self.device_info = device_info + self.device_name: str = device_info["name"] + # Linux: PA sink name; Darwin: not used for streaming + self.pa_sink_name: str | None = device_info.get("pa_sink_name") + # Linux: from PA sample_spec; Darwin: fixed bridge defaults + self.sample_rate: int = device_info.get("sample_rate", BRIDGE_SAMPLE_RATE) + self.bit_depth: int = device_info.get("bit_depth", BRIDGE_BIT_DEPTH) + # Darwin only + self.device_index: int | None = device_info.get("index") self.logger = provider.logger.getChild(f"bridge.{self.device_name}") self._sendspin_client: SendspinClient | None = None self._bridge_client_id: str | None = None self._bridge_role: BridgePlayerRole | None = None self._is_streaming = False + self._logged_chunk_fmt: bool = False self._write_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._writer_task: asyncio.Task[None] | None = None - self._output_stream: sd.RawOutputStream | None = None + self._output_stream: Any | None = None # sd.RawOutputStream on Darwin self._lock = asyncio.Lock() @property @@ -84,13 +98,25 @@ async def start(self) -> None: device_uuid = get_device_uuid(self.device_name, hostapi_index) self._bridge_client_id = bridge_client_id_from_uuid(device_uuid) - # Pre-register identifier so protocol linking matches our LocalAudioPlayer if sendspin_prov := self._get_sendspin_provider(): sendspin_prov.register_bridge_identifiers( self._bridge_client_id, {IdentifierType.UUID: device_uuid}, ) + # On Linux advertise the sink's native format so MA transcodes correctly. + # On Darwin use fixed bridge defaults. + _depths = sorted({self.bit_depth, BRIDGE_BIT_DEPTH}, reverse=True) + supported_formats = [ + SupportedAudioFormat( + codec=AudioCodec.PCM, + channels=BRIDGE_CHANNELS, + sample_rate=self.sample_rate, + bit_depth=d, + ) + for d in _depths + ] + hello = ClientHelloPayload( client_id=self._bridge_client_id, name=self.device_name, @@ -101,21 +127,14 @@ async def start(self) -> None: manufacturer="Local Audio", ), player_support=ClientHelloPlayerSupport( - supported_formats=[ - SupportedAudioFormat( - codec=AudioCodec.PCM, - channels=BRIDGE_CHANNELS, - sample_rate=BRIDGE_SAMPLE_RATE, - bit_depth=BRIDGE_BIT_DEPTH, - ) - ], + supported_formats=supported_formats, buffer_capacity=1_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), ) self.logger.debug( - "Registering Sendspin bridge for %s with client_id=%s", + "Registering Sendspin bridge for %s (client_id=%s)", self.device_name, self._bridge_client_id, ) @@ -124,17 +143,32 @@ async def start(self) -> None: hello, on_stream_start=self._on_stream_start ) - roles = self._sendspin_client.roles_by_family("player") - if roles: - self._bridge_role = cast("BridgePlayerRole", roles[0]) - self._bridge_role.set_callbacks( - on_audio_chunk=self._on_audio_chunk, - on_volume_change=self._on_volume_change, - on_mute_change=self._on_mute_change, - on_stream_start=self._on_bridge_stream_start, - on_stream_end=self._on_bridge_stream_end, - ) - self._bridge_role.setup_audio_requirements() + for role in self._sendspin_client.roles_by_family("player"): + self.logger.debug("Found player role: %s type=%s", role.role_id, type(role).__name__) + if isinstance(role, BridgePlayerRole): + self._bridge_role = role + break + + if self._bridge_role is None: + self.logger.error("No BridgePlayerRole found for %s", self.device_name) + return + + # Restore last volume from cache, fall back to default + init_volume = self.player._attr_volume_level + + self._bridge_role.set_callbacks( + on_audio_chunk=self._on_audio_chunk, + on_volume_change=self._on_volume_change, + on_mute_change=self._on_mute_change, + on_stream_start=self._on_bridge_stream_start, + on_stream_end=self._on_bridge_stream_end, + initial_volume=int(init_volume) if init_volume is not None else 25, + ) + self._bridge_role.setup_audio_requirements( + sample_rate=self.sample_rate, + bit_depth=self.bit_depth, + channels=BRIDGE_CHANNELS, + ) self.logger.info( "Sendspin bridge registered for %s (client_id=%s)", @@ -144,10 +178,7 @@ async def start(self) -> None: def _get_sendspin_provider(self) -> SendspinProvider | None: """Get the Sendspin provider if available.""" - return cast( - "SendspinProvider | None", - self.mass.get_provider("sendspin"), - ) + return cast("SendspinProvider | None", self.mass.get_provider("sendspin")) async def stop(self) -> None: """Stop and unregister the Sendspin bridge.""" @@ -157,7 +188,6 @@ async def stop(self) -> None: await self.sendspin_server.remove_client(self._bridge_client_id) self._sendspin_client = None self._bridge_role = None - self.logger.debug("Sendspin bridge stopped for %s", self.device_name) def _on_stream_start(self, request: ExternalStreamStartRequest) -> None: @@ -173,13 +203,9 @@ def _on_bridge_stream_start(self) -> None: """Start the audio writer task for a new stream.""" if self._writer_task is not None and not self._writer_task.done(): self._writer_task.cancel() - self._is_streaming = True - - # Drain stale audio data from the previous stream while not self._write_queue.empty(): self._write_queue.get_nowait() - self._writer_task = self.mass.create_task(self._audio_writer()) self.logger.info("Bridge writer started for %s", self.device_name) @@ -200,53 +226,135 @@ def _on_audio_chunk(self, chunk: AudioChunk) -> None: """Handle an incoming audio chunk.""" if not self._is_streaming: return + if not self._logged_chunk_fmt: + self.logger.debug( + "First chunk: len=%d sample_rate=%d bit_depth=%d", + len(chunk.data), + self.sample_rate, + self.bit_depth, + ) + self._logged_chunk_fmt = True self._write_queue.put_nowait(chunk.data) def _apply_software_volume(self, pcm_data: bytes) -> bytes: - """Apply software volume scaling if configured, reading current player state.""" - if self.player.volume_control_mode != VOLUME_CONTROL_SOFTWARE: - return pcm_data + """Apply software volume scaling and format conversion.""" if self.player.volume_muted: + if self.bit_depth == 24: + # PA expects packed s24le: 3 bytes/sample, not 4 + return b"\x00" * (len(pcm_data) * 3 // 4) return b"\x00" * len(pcm_data) volume = self.player.volume_level - if volume is None or volume >= 100: + scale = volume / 100.0 if (volume is not None and volume < 100) else None + + if self.bit_depth == 32: + if scale is None: + return pcm_data + samples = np.frombuffer(pcm_data, dtype=np.int32).copy() + scaled = np.clip(samples.astype(np.float64) * scale, -2147483648, 2147483647) + return scaled.astype(np.int32).tobytes() + + if self.bit_depth == 24: + # MA delivers 24-bit audio left-justified in 32-bit containers. + # Always repack to packed s24le (bytes 1-3 of each int32). + samples = np.frombuffer(pcm_data, dtype=np.int32).copy() + if scale is not None: + samples = np.clip( + samples.astype(np.float64) * scale, -2147483648, 2147483647 + ).astype(np.int32) + return samples.view(np.uint8).reshape(-1, 4)[:, 1:].tobytes() + + # 16-bit + if scale is None: return pcm_data - samples = np.frombuffer(pcm_data, dtype=np.int16).copy() - scale = volume / 100.0 - samples = np.clip(samples * scale, -32768, 32767).astype(np.int16) - return samples.tobytes() + samples_16 = np.frombuffer(pcm_data, dtype=np.int16).copy() + scaled = np.clip(samples_16.astype(np.float64) * scale, -32768, 32767) + return scaled.astype(np.int16).tobytes() async def _audio_writer(self) -> None: - """Write queued audio data to the soundcard.""" - loop = asyncio.get_running_loop() + """Write queued audio to the output device.""" + if sys.platform == "linux": + await self._audio_writer_pulse() + else: + await self._audio_writer_sounddevice() + + async def _audio_writer_pulse(self) -> None: + """Write queued audio to a PA sink via PASimpleStream (Linux).""" + stream: PASimpleStream | None = None + write_future: asyncio.Future[None] | None = None + try: + self.logger.debug( + "Opening PA stream: sink=%s rate=%d channels=%d bit_depth=%d", + self.pa_sink_name, + self.sample_rate, + BRIDGE_CHANNELS, + self.bit_depth, + ) + assert self.pa_sink_name is not None # guarded by Linux-only call path + pa_sink_name = self.pa_sink_name # capture for lambda — assert doesn't narrow closures + stream = await self.mass.loop.run_in_executor( + None, + lambda: PASimpleStream( + sink_name=pa_sink_name, + app_name="music-assistant", + rate=self.sample_rate, + channels=BRIDGE_CHANNELS, + bit_depth=self.bit_depth, + ), + ) + self.logger.debug("PA stream opened for %s", self.pa_sink_name) + assert stream is not None # assigned above; satisfies mypy + + while True: + data = await self._write_queue.get() + if data is None or not self._is_streaming: + break + data = self._apply_software_volume(data) + write_future = self.mass.loop.run_in_executor(None, stream.write, data) + await write_future + write_future = None + + except asyncio.CancelledError: + pass + except OSError as err: + self.logger.error("PA stream error for %s: %s", self.pa_sink_name, err) + finally: + self._is_streaming = False + if write_future is not None: + with suppress(Exception): + await asyncio.shield(write_future) + if stream is not None: + with suppress(Exception): + await asyncio.shield(self.mass.loop.run_in_executor(None, stream.close)) + if self._writer_task is asyncio.current_task(): + self._writer_task = None + + async def _audio_writer_sounddevice(self) -> None: + """Write queued audio to a sounddevice output stream (Darwin).""" + import sounddevice as _sd # noqa: PLC0415 + try: - self._output_stream = sd.RawOutputStream( + self._output_stream = _sd.RawOutputStream( device=self.device_index, - samplerate=BRIDGE_SAMPLE_RATE, + samplerate=self.sample_rate, channels=BRIDGE_CHANNELS, dtype="int16", blocksize=DEFAULT_BUFFER_FRAMES, ) self._output_stream.start() - self.logger.debug("Audio output stream opened for %s", self.device_name) + self.logger.debug("sounddevice stream opened for %s", self.device_name) while True: data = await self._write_queue.get() - if data is None: - break - if not self._is_streaming: + if data is None or not self._is_streaming: break - # Apply software volume right before writing to minimize latency data = self._apply_software_volume(data) try: - await loop.run_in_executor(None, self._output_stream.write, data) - except sd.PortAudioError as err: - self.logger.error("PortAudio error writing to %s: %s", self.device_name, err) + await self.mass.loop.run_in_executor(None, self._output_stream.write, data) + except _sd.PortAudioError as err: + self.logger.error("PortAudio error for %s: %s", self.device_name, err) break - except sd.PortAudioError as err: - self.logger.error( - "Failed to open audio output stream for %s: %s", self.device_name, err - ) + except _sd.PortAudioError as err: + self.logger.error("Failed to open sounddevice stream for %s: %s", self.device_name, err) finally: self._is_streaming = False if self._output_stream is not None: @@ -267,28 +375,28 @@ async def _stop_streaming(self) -> None: """Stop streaming (internal, called with lock held).""" self._is_streaming = False if self._writer_task and not self._writer_task.done(): - # Signal the writer to stop gracefully by sending None. - # Don't cancel the task directly as it may be blocked in - # run_in_executor writing to the audio device - cancelling - # while a write is in progress can cause a segfault. - while not self._write_queue.empty(): - self._write_queue.get_nowait() - self._write_queue.put_nowait(None) - try: - await asyncio.wait_for(self._writer_task, timeout=2.0) - except TimeoutError: - # Writer didn't stop in time - it may be stuck in a blocking write. - # Cancel it as a last resort but don't close the stream until it's done. + if sys.platform == "linux": + # PA writer handles CancelledError cleanly self._writer_task.cancel() - with suppress(asyncio.CancelledError): + with suppress(asyncio.CancelledError, Exception): await self._writer_task - except asyncio.CancelledError: - pass + else: + # sounddevice: signal gracefully via None to avoid segfault + # on a cancelled blocking write + while not self._write_queue.empty(): + self._write_queue.get_nowait() + self._write_queue.put_nowait(None) + try: + await asyncio.wait_for(self._writer_task, timeout=2.0) + except TimeoutError: + self._writer_task.cancel() + with suppress(asyncio.CancelledError): + await self._writer_task + except asyncio.CancelledError: + pass self._writer_task = None - # Drain any remaining items while not self._write_queue.empty(): self._write_queue.get_nowait() - # Stream cleanup is handled in _audio_writer's finally block class LocalAudioBridgeManager: @@ -314,15 +422,14 @@ def sendspin_server(self) -> SendspinServer | None: return None async def discover_and_register(self) -> None: - """Enumerate local audio devices, register players and Sendspin bridges.""" + """Enumerate output devices, register players and Sendspin bridges.""" sendspin_server = self.sendspin_server if not sendspin_server: self.logger.debug("Sendspin provider not available, skipping device enumeration") return - loop = asyncio.get_running_loop() try: - devices: list[dict[str, Any]] = await loop.run_in_executor( + devices: list[dict[str, Any]] = await self.mass.loop.run_in_executor( None, self._enumerate_output_devices ) except Exception as err: @@ -337,9 +444,9 @@ async def discover_and_register(self) -> None: async with self._lock: for device in devices: - device_index: int = device["index"] device_name: str = device["name"] hostapi_index: int = device.get("hostapi", 0) + pa_sink_name: str | None = device.get("pa_sink_name") device_uuid = get_device_uuid(device_name, hostapi_index) client_id = bridge_client_id_from_uuid(device_uuid) @@ -347,16 +454,21 @@ async def discover_and_register(self) -> None: self.logger.debug("Bridge already exists for %s", device_name) continue - # Register (or update) our player with MA player = LocalAudioPlayer( - self.provider, device_uuid, device_name, hostapi_index, device_index + self.provider, + player_id=device_uuid, + device_name=device_name, + hostapi_index=hostapi_index, + device_index=device.get("index", 0), + pa_sink_name=pa_sink_name, ) await self.mass.players.register_or_update(player) + # Restore cached volume/mute state from previous session + await player.restore_state() + # Set PA sink hardware volume to 100% on init + await player.apply_hardware_ceiling() - # Then set up the Sendspin bridge with identifier for protocol linking - bridge = SendspinLocalAudioBridge( - self.provider, player, device_index, device, sendspin_server - ) + bridge = SendspinLocalAudioBridge(self.provider, player, device, sendspin_server) try: await bridge.start() except Exception: @@ -373,43 +485,44 @@ async def discover_and_register(self) -> None: continue self._bridges[client_id] = bridge - self.logger.info("Bridge created for %s", device_name) + self.logger.info( + "Bridge created for %s (pa_sink=%s)", + device_name, + pa_sink_name or "n/a", + ) @staticmethod def _enumerate_output_devices() -> list[dict[str, Any]]: - """ - Enumerate available audio output devices via sounddevice. + """Enumerate available audio output devices. - Only devices that can actually be opened are returned. This filters out - ALSA virtual devices (dmix, surround*, etc.) that may be unavailable - when another audio system (like PipeWire) controls the hardware. + On Linux: uses enumerate_pa_sinks() from pa_simple — returns PA sinks + directly with native sample_rate and bit_depth populated. + On Darwin: uses sounddevice, testing each device can be opened, with + fixed bridge sample rate/bit depth defaults. """ - devices: list[dict[str, Any]] = [] - # sd.query_devices() returns a DeviceList (not a plain list), - # where each element is a dict with device properties. - all_devices = sd.query_devices() - for idx, dev in enumerate(all_devices): - max_output_channels: int = dev.get("max_output_channels", 0) - if max_output_channels < 2: - continue - # Test if the device can actually be opened - many ALSA virtual - # devices will fail if PipeWire or another audio server is active - # TODO: query supported sample rates per device and default to 48kHz - try: - test_stream = sd.RawOutputStream( - device=idx, - samplerate=BRIDGE_SAMPLE_RATE, - channels=BRIDGE_CHANNELS, - dtype="int16", - ) - test_stream.close() - except sd.PortAudioError: - # Device unavailable, skip it - continue - dev_with_index = dict(dev) - dev_with_index["index"] = idx - devices.append(dev_with_index) - return devices + if sys.platform != "linux": + # Darwin / other: sounddevice path + import sounddevice as _sd # noqa: PLC0415 + + devices: list[dict[str, Any]] = [] + for idx, dev in enumerate(_sd.query_devices()): + if dev.get("max_output_channels", 0) < 2: + continue + try: + test_stream = _sd.RawOutputStream( + device=idx, + samplerate=BRIDGE_SAMPLE_RATE, + channels=BRIDGE_CHANNELS, + dtype="int16", + ) + test_stream.close() + except _sd.PortAudioError: + continue + dev_with_index = dict(dev) + dev_with_index["index"] = idx + devices.append(dev_with_index) + return devices + return enumerate_pa_sinks() async def stop_all(self) -> None: """Stop all Sendspin bridges.""" @@ -418,5 +531,4 @@ async def stop_all(self) -> None: with suppress(Exception): await bridge.stop() self._bridges.clear() - self.logger.debug("All local audio bridges stopped") diff --git a/music_assistant/providers/sendspin/bridge_role.py b/music_assistant/providers/sendspin/bridge_role.py index f3931a8b42..da1bf59a43 100644 --- a/music_assistant/providers/sendspin/bridge_role.py +++ b/music_assistant/providers/sendspin/bridge_role.py @@ -95,15 +95,30 @@ def role_family(self) -> str: """Return role family name.""" return "player" - def setup_audio_requirements(self) -> None: - """Set up audio requirements for bridge PCM format.""" + def setup_audio_requirements( + self, + sample_rate: int = BRIDGE_SAMPLE_RATE, + bit_depth: int = BRIDGE_BIT_DEPTH, + channels: int = BRIDGE_CHANNELS, + ) -> None: + """Set up audio requirements for bridge PCM format. + + Call with the sink's native rate/depth so MA transcodes to the + correct format before delivering chunks. Defaults to the bridge + constants for callers that don't need format negotiation. + """ self._audio_requirements = AudioRequirements( - sample_rate=BRIDGE_SAMPLE_RATE, - bit_depth=BRIDGE_BIT_DEPTH, - channels=BRIDGE_CHANNELS, - transformer=None, # Raw PCM, no encoding + sample_rate=sample_rate, + bit_depth=bit_depth, + channels=channels, + transformer=None, ) + @property + def preferred_format(self) -> AudioRequirements | None: + """Return the audio format declared via setup_audio_requirements.""" + return self._audio_requirements + def get_audio_requirements(self) -> AudioRequirements | None: """Return audio requirements for PushStream.""" return self._audio_requirements diff --git a/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index 6d0a579992..ba882b139a 100644 --- a/music_assistant/providers/sendspin/playback.py +++ b/music_assistant/providers/sendspin/playback.py @@ -1171,6 +1171,14 @@ def _get_member_output_format(self, player_id: str) -> AudioFormat: channels=preferred_fmt.channels, ) elif isinstance(role, BridgePlayerRole): + fmt = role.preferred_format + if fmt is not None: + return AudioFormat( + content_type=ContentType.from_bit_depth(fmt.bit_depth), + sample_rate=fmt.sample_rate, + bit_depth=fmt.bit_depth, + channels=fmt.channels, + ) return AudioFormat( content_type=ContentType.from_bit_depth(BRIDGE_BIT_DEPTH), sample_rate=BRIDGE_SAMPLE_RATE, diff --git a/requirements_all.txt b/requirements_all.txt index 40aca02d81..029b6f4b09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -56,6 +56,7 @@ pkce==1.0.3 plexapi==4.17.2 podcastparser==0.6.11 propcache>=0.2.1 +pulsectl; sys_platform == 'linux' py-opensonic==9.0.1 pyblu==2.0.6 pycares==4.11.0