Extend Local Audio Out provider with PulseAudio support#3724
Conversation
🔒 Dependency Security Report✅ No dependency changes detected in this PR. |
|
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. |
|
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. |
|
I did some tests with following feedback
After that I installed pulseaudio-utils in the container and restarted. 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? |
|
Yeah true, I use beta, should it be dev, there is just a few days diff between them and this inside the container The error in MA (whenin 96k): |
|
Update, claude suggested this; 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. EDIT: it is not perfect as I still have ticks at various moments, seemingly random and not a lot but still .... |
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? |
|
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. |
|
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. |
|
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.
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. |
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
…et(f"{CONF_PLAYERS}/{device_uuid}")
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.
@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 :) |
|
@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" |
There was a problem hiding this comment.
So, why got the hardware volume control got removed ?
hardware volume control should at all time be preferred over software control
… via a cube root, so the same slider position produces the same loudness as the old software scaling.
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
|
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 |
…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.

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