Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
2856d7c
Allow per player sample_rate, bit depth, and channels.
iVolt1 Apr 14, 2026
ce8c173
Allow per player sample_rate, bit depth, and channels.
iVolt1 Apr 14, 2026
4706fde
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 15, 2026
5b1fc3a
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 15, 2026
66e2bd3
Add pulsectl to requirement.
iVolt1 Apr 15, 2026
c72c0f5
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 15, 2026
9bd9915
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 16, 2026
ba6590b
Update requirements add "pulsectl; sys_platform == 'linux'"
iVolt1 Apr 16, 2026
6a86b97
Add pulse audio output
iVolt1 Apr 16, 2026
7309c7b
Add pulse audio output
iVolt1 Apr 16, 2026
a5b74ba
pa operations
iVolt1 Apr 16, 2026
a2ebfd5
Update sendspin_bridge.py for pulse audio
iVolt1 Apr 16, 2026
e21f5f6
Update player.py for pulse audio
iVolt1 Apr 16, 2026
b561b65
Changes fro pulse audio
iVolt1 Apr 16, 2026
9706612
Changes for pulse audio
iVolt1 Apr 16, 2026
e1c6ba7
Changes for pulse audio
iVolt1 Apr 16, 2026
00b900f
Update for adding pulse audio
iVolt1 Apr 16, 2026
09fcf26
Add def _get_pulse_server() -> str:
iVolt1 Apr 16, 2026
ead112b
Replace PULSE_SERVER references.
iVolt1 Apr 16, 2026
99051ea
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 16, 2026
bf782bd
Fix enumerate_pa_sinks()
iVolt1 Apr 16, 2026
dd95b27
Fix enumerate_pa_sinks()
iVolt1 Apr 16, 2026
4ec8de4
Update for hardware volume cap
iVolt1 Apr 16, 2026
62ccd4d
Changes for hardware volume cap.
iVolt1 Apr 16, 2026
8e1a7a9
Fix volume cap
iVolt1 Apr 16, 2026
0167e36
debug code sample rate and bit dpeth.
iVolt1 Apr 16, 2026
7013b19
Remove ctypes from sample rate bit dpeth determination.
iVolt1 Apr 16, 2026
8844368
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 17, 2026
538b390
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 17, 2026
b8a3d93
Add pulseaudio-utils in order to get pactl
iVolt1 Apr 17, 2026
966b511
Create bin folder
iVolt1 Apr 17, 2026
38f5a21
Update bin
iVolt1 Apr 17, 2026
c8e5408
Delete music_assistant/providers/local_audio/bin
iVolt1 Apr 17, 2026
9901d1a
Create
iVolt1 Apr 17, 2026
ab8a014
Delete music_assistant/providers/local_audio/bin
iVolt1 Apr 17, 2026
a6b2678
Create pa
iVolt1 Apr 17, 2026
4c363fb
Delete music_assistant/providers/local_audio/bin/pa
iVolt1 Apr 17, 2026
4e2ae15
Create pa
iVolt1 Apr 17, 2026
ed46c4b
Add pactl binary
iVolt1 Apr 17, 2026
49b13c8
Create pa
iVolt1 Apr 17, 2026
4df0f3b
Add pactl lib
iVolt1 Apr 17, 2026
01cafea
Use pactl for sink enumeration
iVolt1 Apr 17, 2026
88522d8
Delete music_assistant/providers/local_audio/bin/pa
iVolt1 Apr 17, 2026
6eb0416
Delete music_assistant/providers/local_audio/bin/lib/pa
iVolt1 Apr 17, 2026
7242966
lib_dir now correctly points to bin/lib/
iVolt1 Apr 17, 2026
bcaf769
Update README.md for local_audio specifics
iVolt1 Apr 17, 2026
3d9c61a
Remove Future Work section
iVolt1 Apr 17, 2026
fe99113
Apply hardware volume cap to parent audio devices, not stereo pairs
iVolt1 Apr 17, 2026
ebff30f
Apply volume cap to hardware devices, not stereo pairs
iVolt1 Apr 17, 2026
8ddb73d
Update sendspin_bridge.py
iVolt1 Apr 17, 2026
a697151
Resest stereo pairs to 100% volume on restart
iVolt1 Apr 17, 2026
e20468b
Explain Expanding Outputs with Stereo Pair Remap Sinks
iVolt1 Apr 17, 2026
7d1d327
Update requirements_all.txt for local_audio pulsectl dependency
Apr 17, 2026
c79d63a
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 18, 2026
63294b7
Delete music_assistant/providers/local_audio/bin directory
iVolt1 Apr 18, 2026
1bfa26c
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 18, 2026
a494ce9
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 19, 2026
1944e22
Add a code owner.
iVolt1 Apr 19, 2026
df28efb
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 19, 2026
b975315
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 19, 2026
34fc444
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 20, 2026
fb298c0
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 20, 2026
17cdad1
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 20, 2026
abc6595
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 20, 2026
51f6a79
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 20, 2026
e989891
Lint fixes
iVolt1 Apr 20, 2026
8373f01
Lint - Moved import ctypes to top-level imports
iVolt1 Apr 20, 2026
0037e11
Lint fixes
iVolt1 Apr 20, 2026
432ba98
Remove import CACHE_CATEGORY_PREV_STATE
iVolt1 Apr 20, 2026
da27967
Lint fixes
iVolt1 Apr 20, 2026
70aa2cb
added restore_state() and _save_state() methods following the squeeze…
iVolt1 Apr 20, 2026
b8d04d5
Add state constants
iVolt1 Apr 20, 2026
56ef965
State fixes
iVolt1 Apr 20, 2026
df178ea
3 ruff fixes
iVolt1 Apr 21, 2026
b6d84d3
Three lint fixes
iVolt1 Apr 21, 2026
ebf17fe
One mypy fix
iVolt1 Apr 21, 2026
1923f99
int() fix
iVolt1 Apr 21, 2026
d58a634
Two ruff change
iVolt1 Apr 21, 2026
16d7cc6
ruff formatting
iVolt1 Apr 21, 2026
e8689c4
ruff formatter applied
iVolt1 Apr 21, 2026
531f6c8
Ruff formatting
iVolt1 Apr 21, 2026
a7bd1b7
Ruff formatting
iVolt1 Apr 21, 2026
e8dd533
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 21, 2026
f08af66
Removed the duplicate TYPE_CHECKING linux import block
iVolt1 Apr 21, 2026
5a24b83
lint fixes
iVolt1 Apr 21, 2026
522d122
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 21, 2026
88b3c85
fix: ruff lint and format
iVolt1 Apr 21, 2026
78e1f25
fix: ruff lint and format
iVolt1 Apr 21, 2026
b490fec
Delete music_assistant/providers/pulse_audio/sendspin_bridge.py
iVolt1 Apr 21, 2026
324fb37
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 21, 2026
e72d47f
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 21, 2026
200d4a4
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 21, 2026
b90fde1
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 22, 2026
1714852
Revert to MA settings
iVolt1 Apr 23, 2026
ac1c047
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 23, 2026
3732b78
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 26, 2026
ebf07fb
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 27, 2026
dd23336
Remove hardware volume control and cap
iVolt1 Apr 28, 2026
d8189d4
Update constants.py
iVolt1 Apr 28, 2026
9844406
Remove hardware volume control and cap
iVolt1 Apr 28, 2026
975d614
Remove hardware volume control
iVolt1 Apr 28, 2026
af6d8e2
Set hardware volume to 100%
iVolt1 Apr 28, 2026
29cda3b
Set hardware volume to 100%
iVolt1 Apr 28, 2026
8c1b399
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 28, 2026
b98a1c5
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 29, 2026
17dfa3f
Fix usb device staying powered on
iVolt1 Apr 29, 2026
c157a65
Lint/ruff
iVolt1 Apr 29, 2026
a996836
Update sendspin_bridge.py
iVolt1 Apr 29, 2026
9198338
lint/ruff
iVolt1 Apr 29, 2026
82dd700
Lint/ruff
iVolt1 Apr 29, 2026
f4893ab
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 30, 2026
1999f82
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 Apr 30, 2026
c4ada94
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 May 2, 2026
c79fa69
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 May 5, 2026
9b67b98
Merge branch 'music-assistant:dev' into MA_pulse_audio
iVolt1 May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 74 additions & 14 deletions music_assistant/providers/local_audio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.

ah ok - this is indeed what I expected

- **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

Expand All @@ -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 │
└─────────────┬──────────────┘
Expand All @@ -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)
37 changes: 7 additions & 30 deletions music_assistant/providers/local_audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ()
Comment on lines 24 to +31


async def setup(
Expand Down
17 changes: 10 additions & 7 deletions music_assistant/providers/local_audio/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions music_assistant/providers/local_audio/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading