Skip to content

Extend Local Audio Out provider with PulseAudio support#3724

Open
iVolt1 wants to merge 149 commits into
music-assistant:devfrom
iVolt1:MA_pulse_audio
Open

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

Conversation

@iVolt1

@iVolt1 iVolt1 commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

Comment thread music_assistant/providers/local_audio/__init__.py Outdated
Comment thread music_assistant/providers/local_audio/bin/lib/libpulsecommon-16.1.so Outdated
Comment thread music_assistant/providers/local_audio/manifest.json Outdated
Comment thread music_assistant/providers/local_audio/pa_simple.py
Comment thread music_assistant/providers/local_audio/player.py Outdated
Comment thread music_assistant/providers/local_audio/player.py Outdated
Comment thread music_assistant/providers/local_audio/README.md Outdated
Comment thread music_assistant/providers/local_audio/sendspin_bridge.py Outdated
Comment thread music_assistant/providers/local_audio/sendspin_bridge.py Outdated
Comment thread music_assistant/providers/local_audio/sendspin_bridge.py Outdated
Comment thread music_assistant/providers/local_audio/sendspin_bridge.py
@OzGav

OzGav commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Marking as draft. There are number of mypy/lint problems as well as the review comments above. Select ready to review again when you want another review.

@OzGav OzGav marked this pull request as draft April 18, 2026 11:28
@iVolt1 iVolt1 marked this pull request as ready for review April 21, 2026 12:45
@iVolt1

iVolt1 commented Apr 21, 2026

Copy link
Copy Markdown
Contributor Author

The Dependency Security Check / security-check (pull_request_target)](https://github.com/music-assistant/server/actions/runs/24722720402/job/72315484752?pr=3724) is from the added pulsectl as a Linux-only dependency for PulseAudio volume control. It's a pure Python wrapper around libpulse with no native code of its own. Requesting dependencies-reviewed label.

@vingerha

vingerha commented Apr 22, 2026

Copy link
Copy Markdown

I did some tests with following feedback

  • ubuntu 24.04 machine, pipewire, this machine is purely used to play music
  • MA as a container
sudo docker run -d \
  --name music_assistant_beta \
  --network host \
  --group-add audio \
  --privileged \
  -v /home/vingerha/docker/music_assistant_beta:/data \
  -v /mnt/qnapmedia/music:/media \
  -v /home/vingerha/docker/music_assistant_beta/local_audio:/app/venv/lib/python3.14/site-packages/music_assistant/providers/local_audio \
  -v /home/vingerha/docker/music_assistant_beta/sendspin:/app/venv/lib/python3.14/site-packages/music_assistant/providers/sendspin \
  -v /run/user/1000/pulse:/run/user/1000/pulse \
  -e PULSE_SERVER=unix:/run/user/1000/pulse/native \
  --cap-add=DAC_READ_SEARCH \
  --cap-add=SYS_ADMIN \
  --security-opt apparmor:unconfined \
  ghcr.io/music-assistant/server:beta

After that I installed pulseaudio-utils in the container and restarted.
MA shows to play at 48k/32b, I tested with source files from 32b to 332(DSD) and it
In order to go 'higher' I modified pipewire.conf to use 96k default, I restarted pipewire service
I restarted MA container so it identifies the new setting. When playing MA does show 96k/32b but after 1-2s play no sound is heared, regardless of the bitrate of the sourcefile. Used a bit oa AI (as this goes beyond my basic knowledge) and this does not really come any further

EDIT: I just noticed you updated the list of files, I was still on yesterday's state

@iVolt1

iVolt1 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author

I did some tests with following feedback

* ubuntu 24.04 machine, pipewire, this machine is purely used to play music

* MA as a container
sudo docker run -d \
  --name music_assistant_beta \
  --network host \
  --group-add audio \
  --privileged \
  -v /home/vingerha/docker/music_assistant_beta:/data \
  -v /mnt/qnapmedia/music:/media \
  -v /home/vingerha/docker/music_assistant_beta/local_audio:/app/venv/lib/python3.14/site-packages/music_assistant/providers/local_audio \
  -v /home/vingerha/docker/music_assistant_beta/sendspin:/app/venv/lib/python3.14/site-packages/music_assistant/providers/sendspin \
  -v /run/user/1000/pulse:/run/user/1000/pulse \
  -e PULSE_SERVER=unix:/run/user/1000/pulse/native \
  --cap-add=DAC_READ_SEARCH \
  --cap-add=SYS_ADMIN \
  --security-opt apparmor:unconfined \
  ghcr.io/music-assistant/server:beta

After that I installed pulseaudio-utils in the container and restarted. MA shows to play at 48k/32b, I tested with source files from 32b to 332(DSD) and it In order to go 'higher' I modified pipewire.conf to use 96k default, I restarted pipewire service I restarted MA container so it identifies the new setting. When playing MA does show 96k/32b but after 1-2s play no sound is heared, regardless of the bitrate of the sourcefile. Used a bit oa AI (as this goes beyond my basic knowledge) and this does not really come any further

EDIT: I just noticed you updated the list of files, I was still on yesterday's state

It looks like you are working off of beta branch. Possibly the dev branch is required. What does 'pactl list sinks short' say?

@vingerha

vingerha commented Apr 22, 2026

Copy link
Copy Markdown

Yeah true, I use beta, should it be dev, there is just a few days diff between them
This is on the host:

 pactl list sinks short
50      alsa_output.pci-0000_00_1f.3.hdmi-surround      PipeWire        s32le 6ch 96000Hz       IDLE

and this inside the container

root@hplaptop-ubuntu:/app/venv#  pactl list sinks short
50      alsa_output.pci-0000_00_1f.3.hdmi-surround      PipeWire        s32le 6ch 96000Hz       IDLE
50      alsa_output.pci-0000_00_1f.3.hdmi-surround      PipeWire        s32le 6ch 96000Hz       IDLE
bash: 50: command not found

The error in MA (whenin 96k):

[music_assistant.Local Audio Out.bridge.Built-in Audio Digital Surround 5.1 (HDMI)] PA stream error for alsa_output.pci-0000_00_1f.3.hdmi-surround: pa_simple_write failed (pa_error=3)

@vingerha

vingerha commented Apr 22, 2026

Copy link
Copy Markdown

Update, claude suggested this;
In this case the fix would be replacing PASimpleStream in pa_simple.py with a pa_mainloop-based stream that sets prebuf=0 (start playing immediately without waiting to fill a prebuffer) and a large tlength matching MA's chunk size of ~59000 bytes. That alone would likely fix the 3s drop.

It then generated a whole new pa_main.py which now indeed allows me to play at 92kHz....but the changes are substantial, beyond my knowledge and I just never trust AI to be correct and concise, so without the proper knowledge at my end I cannot judge this.

pa_simple.py

EDIT: it is not perfect as I still have ticks at various moments, seemingly random and not a lot but still ....

@iVolt1

iVolt1 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author

[music_assistant.Local Audio Out.bridge.Built-in Audio Digital Surround 5.1 (HDMI)] PA stream error for alsa_output.pci-0000_00_1f.3.hdmi-surround: pa_simple_write failed (pa_error=3)

Thanks for testing.

I assume /app/venv/lib/python3.14/site-packages/music_assistant/providers/local_audio and /app/venv/lib/python3.14/site-packages/music_assistant/providers/sendspin came from the latest in MA_pulse_audio in my repo.

Can you run the local_audio provider with debug logging on in the provider settings and share some more log output?

@vingerha

vingerha commented Apr 22, 2026

Copy link
Copy Markdown

Just to be sure I overwrote the whole local_audio and sendspin files... it still needs the 'fix' as suggested by Claude, else sound dies in 2-3s with the same error in MA log. I will play a while today/tomorrow and add updates if/where needed

Update: pw top showed a mismatch in frames between MA and PW host....aligned now. There is quite a bit of stuff required on the host to get this local audio stuff (also for my PR) to work.... I wonder how it would work on windows container :)

@iVolt1

iVolt1 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author

Just to be sure I overwrote the whole local_audio and sendspin files... it still needs the 'fix' as suggested by Claude, else sound dies in 2-3s with the same error in MA log. I will play a while today/tomorrow and add updates if/where needed

Update: pw top showed a mismatch in frames between MA and PW host....aligned now. There is quite a bit of stuff required on the host to get this local audio stuff (also for my PR) to work.... I wonder how it would work on windows container :)

It sounds like you got 96k working in the container with the alignment? How was that accomplished? I have been working in the MA dev server app environment so the pulse audio stuff is already configured.

@vingerha

vingerha commented Apr 22, 2026

Copy link
Copy Markdown

With pipewire correctly configured, when starting MA container it will find the 'best' rate, since I configured my device default on 96k ...it took that. the pa_simple adaptation is still needed to be able to play more than 2-3s though. I will setup dev as well now as else this testing makes less sense...likely back tomorrow.

@vingerha

Copy link
Copy Markdown

I have been working in the MA dev server app environment so the pulse audio stuff is already configured.

Hmm... whatever I use, nightly or beta-latest, both dockerbase state to have pulseaudio but both fail on pactl, only when I add pulseaudio-utils (again??) then the container is OK and discovers the pulse device.
The downside is that I need to change quant with increasing rate and for now I can only play pw-top error-less at 48k. with 96 and 192 there are infrequent dropouts

image

Comment thread .vscode/settings.json Outdated
@vingerha

vingerha commented Apr 24, 2026

Copy link
Copy Markdown

With quite a bit of help from AI, I now have this code modified so that it takes the sourcetrack bit/depth and use that to send at the closest (identical or next highest) bitrate. i.e. it switches bitrate when the next track has another bitrate. It works now on my hdmi but this pulseaudio PR never showed my local speaker or line out so I cannot test it.
Questions, assuming you are interested to take this one level further

  • since AI often goes rabbit hunting and I am still not the expert.... are you able to review proposed changes on usability/quality?
  • even if the quality looks (more or less) OK, I am not even sure if this approach is correct as changes to local_audio or sendpsin should not just be targetting pulse, would you know how to verify this on other OS/devices?

EDIT: the key reason for changing bits originally was that your ootb code would not play 96k on my setup, so pa_simple.py was modified. Later on I then wanted to be dynamic on the rates..... All in all it may be too much as well for MA to accept, small(er) steps needed. My suggestion would then be to first be able to play on 'any' device target rate. Dynamic changing I may add later as a separate PR, after your PR gets accepted. If you want to stick woith 48k playing only , fine too but I guess there will be bug/issue reports afterwards.

iVolt1 added 14 commits June 10, 2026 15:10
End of start() — fires once when the bridge registers, undoing any deferred_volume effect from pa_simple_new opening the initial stream
_on_bridge_stream_end() — fires after each playback session ends, resetting before the next session starts
initial_volume=100 in set_callbacks — prevents the spurious VolumeChangedEvent(25) that fires on bridge init when the restored volume differs from BridgePlayerRole's default of 100
app_name=f"music-assistant-{pa_sink_name}" — gives each sink a unique stream restore ID so module-stream-restore tracks them independently. Previously all bridges shared "music-assistant" so the last-used volume for any sink polluted all others
_reset_sink_volume() — belt-and-suspenders pactl set-sink-volume 100% called at bridge startup and after each stream ends, undoing any residual deferred_volume effect

The unique app_name is probably the most important fix — it means module-stream-restore will save and restore per-sink rather than globally across all local audio players.
The three fixes are now:

initial_volume=self._volume_level — correct slider position from cached volume
app_name=f"music-assistant-{pa_sink_name}" — unique per sink, prevents module-stream-restore conflicts
_reset_sink_volume() via pactl — resets PA sink hardware to 100% at startup and after each stream ends
This is to resolve multiple sound cards on the same box from playing out of sync.

Each audio chunk now carries its server-domain playback timestamp.
The bridge waits until the scheduled time before writing to PA/ALSA,
enabling synchronized playback across multiple local soundcards on
the same host (e.g. X-Fi remap sinks and HD Audio Generic remap sinks).

Since the bridge runs in-process with the Sendspin server, both share
CLOCK_MONOTONIC_RAW, so chunk.timestamp_us is already in the local
clock domain — no NTP/Kalman conversion is needed. static_delay_us
from the existing per-player config is subtracted before scheduling,
allowing fine-grained offset adjustment per device.

Chunks arriving more than 500ms late are dropped with a warning rather
than played out-of-order.
@iVolt1

iVolt1 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

FYI, not sure if interested but ... once every 2 days I update too and see if it still works, I try to play as much as possible through the nigtly/dev. Till now all still fine

Issues I have and very (!) likely due to my own device,..so as a FYI only Switching on alsa, either album or device, sometimes this kills the container. Quite hard to reproduce as if I do the same after container restart then all works fine (of course...). My usb dac takes time to start and it needs 3 clicks for the first track to play (after being off)

@vingerha Thanks for the testing. I have not seen a container self kill before. The only recent material change I have made is today where the synchronization between players better conforms to the Sendspin standard which made a noticeable improvement in syncing between various local audio devices on the same box. So please give that a chance if you can. Thanks again.

@vingerha

Copy link
Copy Markdown

@vingerha Thanks for the testing. I have not seen a container self kill before. The only recent material change I have made is today where the synchronization between players better conforms to the Sendspin standard which made a noticeable improvement in syncing between various local audio devices on the same box. So please give that a chance if you can. Thanks again.

Will try later today, was pre-occupied to make it work with hdmi multi-channel and got pretty close ...no cigar yet :)

@iVolt1

iVolt1 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@marcelveldt and @MarvinSchenkel I would like to request another review or update on anything else you think I need to do in order to move this PR forward. Thanks - iVolt1.

# Volume control — software only
VOLUME_CONTROL_SOFTWARE = "software"
VOLUME_CONTROL_HARDWARE = "hardware"
VOLUME_CONTROL_DISABLED = "disabled"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So, why got the hardware volume control got removed ?
hardware volume control should at all time be preferred over software control

iVolt1 added 2 commits June 13, 2026 08:25
set_sink_volume() now sets cvolume.channels=2 (matching BRIDGE_CHANNELS, the actual channel count of every one of these remap sinks) with both channel values set to the same pa_vol, instead of channels=1 relying on PA's remap path.
…ume slider is set to, while each remap-sink zone gets independent hardware-attenuated volume control via the cube-root mapping
@vingerha

Copy link
Copy Markdown

I did some tests yesterday, all still fine but as you are updating again (I have been there :) ) ...let me know if / when I should retest

iVolt1 added 2 commits June 13, 2026 09:18
…ter/base sinks with no remap children — i.e., a standalone card with the remap addon off gets full hardware volume control, exactly like a remap-sink zone. Only a master sink that does have remap-sink siblings stays on software control (the cross-talk case).
a new self.pa_channels attribute (from max_output_channels — for example  8 for the X-Fi 7.1 master, 6 for HD Audio 5.1, 2 for remap sinks) is now passed to every set_sink_volume() call, so pa_cvolume.channels always matches the target sink's actual channel map. This should make volume control responsive on the multi-channel masters too.
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