Extend Local Audio Out provider with PulseAudio support#3724
Extend Local Audio Out provider with PulseAudio support#3724iVolt1 wants to merge 115 commits intomusic-assistant:devfrom
Conversation
Re-edit: with only these config settings, my MA takes that value by default for all (!) devices. For hdmi this then presents buffer underruns on anything above 48k |
|
A related issue I ran into: when playing via the Local Audio / PipeWire output, multiple corked sink-inputs remain active long after playback stops and only restarting the MA container clears them. As a side effect, any USB output devices stay powered on unnecessarily. Do you see the same behaviour? I traced it to sendspin_bridge.py: the PASimpleStream is not reliably closed on stop because the CancelledError handling interrupts the finally block before stream.close() is called. A fix involving asyncio.shield() around the close call resolves it on my end. If this is a known issue and/or you can reproduce it, I will/can open a separate PR for sendspin_bridge once yours lands. EDIT: context: I have a DAC at the other end of the usb out of my laptop that remains 'on' needlessly |
The fix wraps stream.close() in asyncio.shield() so cancellation of the parent task can't interrupt the executor call. The suppress(Exception) wrapper is still there as a safety net.
In HA's hassio_audio environment module-suspend-on-idle isn't loaded so DACs stay powered once played to regardless of stream state. Maybe your pipewire-pulse loads the module. The asyncio.shield fix is included in my PR so feel free to test it. |
|
I will verify tomorrow. With me it was partially the local host config of the usb .... but when corked processes stayed open it never shut down too, only with a restart of pipewire (after playing ended) or a shutdown of MA it went down after x seconds. Thanks for the effort so far, as said back tomorrow :) |
The MA hassio_audio pulse audio plugin does not include suspend_on_idle so that is likely why devices are not going to Idle or Suspend. It may depend on your linux distribution whether that is included or not. |
|
@marcelveldt, @MarvinSchenkel Is there anything else besides the Security Check holding back this PR from being merged to dev? I would really like to see it move forward since it is how I play audio here every day and it seems really solid. Also local audio is a much requested feature for whole house audio using line outputs with multiple DACS or 5.1/7.1 stereo pairs so I think many users will be happy to see this implemented. Thanks, iVolt1. |
Nope...it is the processes that stay open, see my screenshot above, as soon as I reset pw (i.e. processes gone) or close MA container (ditto), USB goes down in seconds and after that also my dac suspends. Pipe/pulse is not proprely closing processes so some code needed to push to do so . Alsa devices no problem |
Ditto, I would like to see this go live too so next steps can be planned. |
There was a problem hiding this comment.
Pull request overview
Extends the built-in Local Audio Out player provider to add Linux PulseAudio sink discovery/output, while updating Sendspin bridge format handling so bridge players can advertise per-device PCM formats.
Changes:
- Add Linux PulseAudio sink enumeration/playback plumbing in
local_audio, including a newpa_simple.pyhelper and bridge/player changes. - Update Sendspin bridge format selection so bridge players can expose sink-specific sample rate/bit depth.
- Add Linux-only
pulsectldependency and refresh Local Audio docs/metadata.
Note: the supplied diff did not include the Dockerfile.base or bundled binary/library changes mentioned in the PR description, so those parts could not be reviewed here.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| requirements_all.txt | Adds Linux-only pulsectl to aggregate requirements. |
| music_assistant/providers/sendspin/playback.py | Uses bridge-declared output format for member format selection. |
| music_assistant/providers/sendspin/bridge_role.py | Parameterizes bridge audio requirements and exposes the configured format. |
| music_assistant/providers/local_audio/sendspin_bridge.py | Reworks the bridge for Linux PulseAudio output and per-device format advertising. |
| music_assistant/providers/local_audio/README.md | Updates provider documentation for Linux PulseAudio and macOS behavior. |
| music_assistant/providers/local_audio/provider.py | Adds a Linux libpulse-simple availability check at init. |
| music_assistant/providers/local_audio/player.py | Changes player volume/mute persistence and PulseAudio sink volume handling. |
| music_assistant/providers/local_audio/pa_simple.py | Adds ctypes-based PulseAudio streaming and sink enumeration helpers. |
| music_assistant/providers/local_audio/manifest.json | Adds Linux-only pulsectl requirement and updates codeowners. |
| music_assistant/providers/local_audio/constants.py | Updates local audio defaults, cache constants, and UUID namespace. |
| music_assistant/providers/local_audio/init.py | Removes provider config entries and keeps basic provider setup exports. |
|
|
||
| # 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. |
| 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() |
| 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() |
| await self.mass.players.register_or_update(player) | ||
| # Restore cached volume/mute state from previous session | ||
| await player.restore_state() |
| 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) |
| name: str = sink.get("name", "") | ||
| desc: str = sink.get("description", name) | ||
| spec_str: str = sink.get("sample_specification", "") |
| 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): |
| 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 () |
| # 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} |

Extend Local Audio Out provider with PulseAudio support
Summary
Extends the existing
local_audioplayer provider to support PulseAudio on Linux, while preserving the existing PortAudio/CoreAudio path on macOS. Each PulseAudio sink is registered as an external Sendspin bridge client, enabling synchronized multi-room playback alongside existing Sendspin players. Audio is written directly to PulseAudio via a minimallibpulse-simplectypes wrapper. The separatepulse_audioprovider is superseded by this change and removed.Motivation
The existing
local_audioprovider uses PortAudio/sounddevice which cannot enumerate PulseAudio virtual sinks such asmodule-remap-sinkstereo pairs. On Linux, PulseAudio sits on top of ALSA and owns the hardware devices, so PortAudio can only see physical ALSA devices — not virtual sinks. This change targets PulseAudio directly on Linux viapactlandlibpulse-simple, correctly discovering and playing to all sinks including remap sinks, combined sinks, S/PDIF, and HDMI outputs, while preserving full macOS functionality.Changes
Modified —
music_assistant/providers/local_audio/Dependencies
pulseaudio-utilsinDockerfile.base, with bundled binary as fallbackdepends_on: sendspin)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-sinkcan 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.
# Extend Local Audio Out provider with PulseAudio support