Skip to content

fix(linux): stop fighting the A2DP profile, and make the codec choosable - #766

Open
injkgz wants to merge 4 commits into
librepods-org:linux/rustfrom
injkgz:fix/linux-audio-profile-handling
Open

fix(linux): stop fighting the A2DP profile, and make the codec choosable#766
injkgz wants to merge 4 commits into
librepods-org:linux/rustfrom
injkgz:fix/linux-audio-profile-handling

Conversation

@injkgz

@injkgz injkgz commented Sep 4, 2026

Copy link
Copy Markdown

Problem

On Linux, pressing play on the AirPods stem did not resume playback reliably. Watching the MPRIS traffic showed the play command arriving and being cancelled right after:

kded6       -> Play      (stem press)
wireplumber -> Pause     (~0.5s later)
kded6       -> Play
wireplumber -> Pause

The cause is in MediaController: it re-activates its cached A2DP profile on every playback start. Switching the card profile recreates the PipeWire sink, which stops the stream that triggered the activation, and WirePlumber pauses the MPRIS player.

While tracing that, two more problems in the same path showed up, both matching existing issues: the cached card index goes stale across reconnects, and a not-yet-enumerated card triggers an immediate WirePlumber restart that silences audio for the better part of a minute.

Changes

fix(linux): stop fighting the A2DP profile on playback startcloses #680, #681, #682

  • An already-active a2dp-sink* profile is left untouched; the preferred profile is selected only when the card is off or on a non-A2DP profile. The negotiated codec is preserved.
  • The card is resolved by Bluetooth MAC before every activation and the cached index refreshed, so a changed index after a reconnect is picked up without restarting the app.
  • Profile enumeration gets a bounded grace period (5 × 1s) before a WirePlumber restart is considered. The restart stays as a last resort, retries after it are bounded, and no sleep happens after the final failed attempt. A persistent failure is logged and returns without activating a profile.

feat(linux): let the user pick the preferred A2DP codec

The preference order was hardcoded as ["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"], so AAC was never selected — even though it is what AirPods use natively with Apple devices and what PipeWire ranks highest for them. A Preferred audio codec setting (AAC / SBC-XQ / SBC) now lives in the settings tab, is stored as preferred_codec in app_settings.json, and defaults to AAC. The remaining codecs are kept as fallbacks.

Testing

Tested on Fedora 44 (KDE Plasma, Wayland), PipeWire 1.6.8, WirePlumber 0.5.14, BlueZ 5.87, with AirPods Pro 3 (model A3064):

  • Stem press pause/resume, repeated: only kded6 sends MPRIS commands now, no cancelling Pause follows, and the card stays on a2dp-sink throughout.
  • Playback start with A2DP already active logs A2DP profile a2dp-sink already active, leaving it unchanged and performs no profile switch.
  • Disconnect/reconnect cycles (case closed/opened): no WirePlumber restarts were triggered, and audio came back on its own.
  • HFP switching for calls still works: the profile moves to headset-head-unit while a client captures from the AirPods microphone and returns to a2dp-sink afterwards.
  • Codec setting: switching between AAC, SBC-XQ and SBC in the settings tab takes effect on the next activation.

cargo build --release is clean; no new warnings.

injkgz and others added 4 commits September 4, 2026 11:12
Three related problems made playback controls unreliable on Linux.

MediaController re-activated its cached A2DP profile on every playback
start, even when the card was already using another A2DP variant.
Switching profiles recreates the PipeWire sink and stops the stream that
triggered the activation, so a play command from the AirPods stem was
cancelled about half a second later by the resulting pause. Any active
a2dp-sink variant is now left untouched, which also keeps the negotiated
codec unchanged. Fixes librepods-org#680.

The cached PulseAudio/PipeWire card index went stale across reconnects,
since PipeWire may assign a different index. Availability checks then
queried a card that no longer existed and concluded A2DP was unavailable.
The card is now resolved by Bluetooth MAC before each activation and the
cached index refreshed. Fixes librepods-org#681.

A freshly connected card can appear before its profiles are enumerated.
The first missing-profile result triggered an immediate WirePlumber
restart, which interrupts audio for several seconds and races
pipewire-pulse rebuilding the card. Enumeration now gets a bounded grace
period, the restart remains as a last resort, and post-restart retries are
bounded too, without sleeping after the final attempt. Fixes librepods-org#682.
The profile preference order was hardcoded as SBC-XQ, SBC, AAC, so AAC was
never selected even though it is the codec AirPods use natively with Apple
devices, and it is what PipeWire ranks highest for them.

Add a "Preferred audio codec" setting (AAC, SBC-XQ, SBC) to the settings
tab, stored as `preferred_codec` in app_settings.json and defaulting to
AAC. The chosen codec is tried first and the remaining ones act as
fallbacks, so a card that cannot negotiate the preferred codec still gets
a working A2DP profile.
The LE monitor decodes left, right and case levels from the proximity
advertisement but only pushed them into the tray. The window kept showing
whatever AACP last reported, and over AACP the case reports itself as
disconnected whenever the buds are outside it, so the case level was
missing from the device view.

Forward the decoded levels to the UI as an AACPEvent::BatteryInfo, the
same event the AACP path already sends, skipping components the
advertisement marks as unavailable (0xff).

While here, move the "Auto-connect is disabled" log into the else branch.
It sat inside the auto-connect branch, so it was printed right after a
successful connection:

    Successfully connected to AirPods 34:0E:22:CE:A2:80
    Auto-connect is disabled for 34:0E:22:CE:A2:80, not attempting to connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cAE8Gb2vCuv9WLMh91R9g
async_main's result was unwrapped, so any Bluetooth error at startup
panicked. With panic = "abort" in the release profile, a panic on the
backend thread takes the whole process down, and the user sees a core
dump with no explanation. Powering on a soft-blocked adapter is the
common way to hit this:

    thread '<unnamed>' panicked at src/main.rs:98:18:
    called `Result::unwrap()` on an `Err` value: Error { kind: Failed, message: "Failed" }

Log what actually went wrong for each startup step - D-Bus connection,
adapter lookup, powering on - and exit with status 1 instead of aborting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7MEAowWUpkKaDm8W2ws3C
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant