Skip to content

Extend Local Audio Out provider with PulseAudio support#3724

Open
iVolt1 wants to merge 115 commits intomusic-assistant:devfrom
iVolt1:MA_pulse_audio
Open

Extend Local Audio Out provider with PulseAudio support#3724
iVolt1 wants to merge 115 commits intomusic-assistant:devfrom
iVolt1:MA_pulse_audio

Conversation

@iVolt1
Copy link
Copy Markdown
Contributor

@iVolt1 iVolt1 commented Apr 17, 2026

Extend Local Audio Out provider with PulseAudio support

Note to reviewers: This PR supersedes #3683 which proposed a separate pulse_audio provider. Based on feedback from that review, this PR instead extends the existing local_audio provider to support PulseAudio on Linux. Key changes addressing the review comments:

  • pulseaudio-utils added to Dockerfile.base: Added as a standard apt dependency alongside existing runtime packages — a single line addition rather than a separate build stage
  • Bundled pactl binary: Still included in the provider bin/ directory to facilitate testing with the MA DEV SERVER app, which does not build from the modified Dockerfile.base. This will be removed in a follow-up PR once validated against MA Nightly
  • PulseAudio output via libpulse-simple: As suggested, pa_simple.py uses libpulse-simple directly via ctypes rather than PortAudio, avoiding the PortAudio/PulseAudio build-from-source issue entirely
  • AI assistance disclosure: Claude was substantially involved in the development of this provider

Summary

Extends the existing local_audio player 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 minimal libpulse-simple ctypes wrapper. The separate pulse_audio provider is superseded by this change and removed.

Motivation

The existing local_audio provider uses PortAudio/sounddevice which cannot enumerate PulseAudio virtual sinks such as module-remap-sink stereo 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 via pactl and libpulse-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/

File Description
init.py Added hardware volume ceiling config entry (Linux only); updated volume control mode description
provider.py Added libpulse-simple presence check on Linux at init
player.py Added pa_sink_name and is_remap params; Linux uses software volume mode with hardware ceiling set once at startup; apply_hardware_ceiling sets physical ALSA sinks to ceiling value and remap sinks to 100%; pulsectl helpers for volume and mute
sendspin_bridge.py Unified Linux (PASimpleStream) and Darwin (sounddevice) audio output paths; native format negotiation per sink; initial_volume=25 on bridge registration; apply_hardware_ceiling call per player
pa_simple.py New file — ctypes wrapper around libpulse-simple for direct PCM playback; enumerate_pa_sinks via pactl --format=json
constants.py Added CONF_HARDWARE_VOLUME_CEILING, DEFAULT_HARDWARE_VOLUME_CEILING
manifest.json Added pulsectl; sys_platform == 'linux' to requirements
README.md Updated for unified Linux/macOS provider
bin/pactl Bundled pactl binary (fallback if pulseaudio-utils not installed)
bin/lib/libpulsecommon-16.1.so Bundled library required by bundled pactl binary

Dependencies

  • libpulse-simple.so.0 (Linux): Must be present on the host — standard PulseAudio installation
  • pactl (Linux): Required for sink enumeration — provided by pulseaudio-utils in Dockerfile.base, with bundled binary as fallback
  • pulsectl (Linux): Python PulseAudio bindings for volume and mute control — installed via provider manifest requirements
  • numpy: Used for PCM volume scaling (already a MA dependency)
  • Sendspin provider (depends_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-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.

# Extend Local Audio Out provider with PulseAudio support

@vingerha
Copy link
Copy Markdown

vingerha commented Apr 27, 2026

I have no problem outputting to multiple pulse audio sinks with higher sample rates per device capabilities on pipewire-pulse using the conf below.

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
I also wonder how you get to multiple output bitrates, have you tweaked wireplumber?

@vingerha
Copy link
Copy Markdown

vingerha commented Apr 29, 2026

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

iVolt1 added 2 commits April 29, 2026 11:00
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.
@iVolt1
Copy link
Copy Markdown
Contributor Author

iVolt1 commented Apr 29, 2026

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

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.

@vingerha
Copy link
Copy Markdown

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 :)

@vingerha
Copy link
Copy Markdown

vingerha commented Apr 30, 2026

update: the fix is not working for me, the processes remain active even after they stopped playing minutes ago and (with me) this prevents to suspend the usb, which then continues to provide 5v to the dac and that stays on.
The sendspin_bridge 'fix' (call it a draft version) that I worked on terminates the corked/no processes after a few seconds.

image
 pactl list sink-inputs | grep -E "Sink Input #|Corked:"
Sink Input #68
        Corked: no
Sink Input #82
        Corked: no
Sink Input #88
        Corked: no

@iVolt1
Copy link
Copy Markdown
Contributor Author

iVolt1 commented May 1, 2026

update: the fix is not working for me, the processes remain active even after they stopped playing minutes ago and (with me) this prevents to suspend the usb, which then continues to provide 5v to the dac and that stays on. The sendspin_bridge 'fix' (call it a draft version) that I worked on terminates the corked/no processes after a few seconds.

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.

@iVolt1
Copy link
Copy Markdown
Contributor Author

iVolt1 commented May 1, 2026

@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.

@vingerha
Copy link
Copy Markdown

vingerha commented May 1, 2026

update: the fix is not working for me, the processes remain active even after they stopped playing minutes ago and (with me) this prevents to suspend the usb, which then continues to provide 5v to the dac and that stays on. The sendspin_bridge 'fix' (call it a draft version) that I worked on terminates the corked/no processes after a few seconds.

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.

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

@vingerha
Copy link
Copy Markdown

vingerha commented May 1, 2026

@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.

Ditto, I would like to see this go live too so next steps can be planned.
Wrt to hdmi....on intel there remains an issue playing above 48k, this seems to be a intel<>pw issue (not related to this PR)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 new pa_simple.py helper and bridge/player changes.
  • Update Sendspin bridge format selection so bridge players can expose sink-specific sample rate/bit depth.
  • Add Linux-only pulsectl dependency 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.
Comment on lines 107 to +110
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()
Comment on lines 113 to +116
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()
Comment on lines 465 to +467
await self.mass.players.register_or_update(player)
# Restore cached volume/mute state from previous session
await player.restore_state()
Comment on lines +128 to +133
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)
Comment on lines +245 to +247
name: str = sink.get("name", "")
desc: str = sink.get("description", name)
spec_str: str = sink.get("sample_specification", "")
Comment on lines +250 to +255
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):
Comment on lines 24 to +31
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 ()
Comment on lines +208 to +227
# 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}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants