Remote-initiated A2DP connections negotiate SBC XQ 552 and stutter; phone-initiated connections get plain SBC and work

REPRODUCIBILITY: 100%
OS VERSION: 5.2.0.17
HARDWARE: Jolla Phone (2026)
UI LANGUAGE: Polish
REGRESSION: no

TAGS: jolla-2026, 5_2_0

DESCRIPTION:
Which A2DP codec gets used depends on which side initiates the
connection. When the earbuds initiate, they select the SBC XQ 552 kbps
endpoint and audio stutters continuously. When the phone initiates,
PulseAudio selects plain SBC and audio is clean. The user has no way to
influence this from the UI, and the choice is lost on every reconnect.

PRECONDITIONS:

  • PulseAudio 17.0 (as shipped)
  • True wireless earbuds: JLab GO Sport+
  • For comparison: Sony WH-1000XM3

STEPS TO REPRODUCE:

  1. Pair the earbuds.
  2. Take them out of the charging case and let them connect by
    themselves — the headset initiates.
  3. Play music and check:
    pactl send-message /card/bluez_card.XX_XX_XX_XX_XX_XX/bluez get-codec
    → “sbc_xq_552”
    Audio stutters in a repeating cycle: roughly a second clean, then
    several seconds of crackling and drop-outs.
  4. Disconnect the device from Settings > Bluetooth, leaving the earbuds
    out of the case so they stay powered on.
  5. Tap the device in the same list — now the phone initiates.
  6. Play music and check again:
    → “sbc”
    Audio is clean.

EXPECTED RESULT:
A working configuration regardless of which side initiates.

ACTUAL RESULT:
Headset-initiated connections get SBC XQ 552 and are unusable.

ADDITIONAL INFORMATION:

The sample rate is NOT the cause. I initially suspected it, because both
values changed together (48000 Hz with XQ, 44100 Hz with plain SBC). To
isolate the variables I forced default-sample-rate = 48000 in
/etc/pulse/daemon.conf. With that, both cases run the sink at 48000 Hz
and the only difference is the codec — “sbc_xq_552” when the earbuds
initiate (stutters) and “sbc” when the phone initiates (clean). So the
SBC XQ variant is what breaks it.

This matches the source: pa_a2dp_endpoint_configurations in
a2dp-codec-util.c lists plain sbc ahead of the XQ variants, so when
BlueZ asks PulseAudio to choose via SelectConfiguration, plain SBC wins.
When the remote device sends its own SetConfiguration instead,
PulseAudio accepts it, and these earbuds pick the highest-bitrate
endpoint we offered — which they cannot sustain.

This is not broken hardware: the same JLab earbuds play flawlessly on an
iPhone 12 Pro Max (which does not implement SBC XQ at all) and on a
Samsung Galaxy A34 (which does). The Sony WH-1000XM3 play flawlessly on
the Jolla — they negotiate plain “sbc” and never enter XQ.

Workaround:
Disconnect and reconnect over D-Bus, which moves the choice of
configuration to the phone:

DEV=/org/bluez/hci1/dev_XX_XX_XX_XX_XX_XX
dbus-send --system --reply-timeout=15000 --dest=org.bluez
–print-reply $DEV org.bluez.Device1.Disconnect || true
sleep 4
dbus-send --system --reply-timeout=15000 --dest=org.bluez
–print-reply $DEV org.bluez.Device1.Connect || true

Run this after the earbuds have connected by themselves. Note that the
Disconnect call often returns NoReply even though it succeeds, hence the
timeout and the “|| true”.

Side effect: after the reconnect, bluetoothd logs
“AVRCP: failed to create uinput for : No such file or directory”,
so headset media buttons may stop working until the next normal
connection.

Two further notes for anyone debugging this:

  • After a manual “switch-codec”, the stream can end up on sink.null,
    which looks exactly like Bluetooth dying. Check
    “pactl list sink-inputs short” and move it back to the bluez sink.
  • module-card-restore does not persist the selected Bluetooth codec, so
    a manual switch-codec is lost on every reconnect.

Questions:

  1. Should PulseAudio renegotiate, or at least constrain, the
    configuration on remote-initiated connections rather than accepting
    whatever the device proposes? The endpoint priority list already
    encodes a sensible preference — it just does not get consulted in
    this path.
  2. Is there any supported way to restrict which A2DP endpoints are
    offered? module-bluez5-discover accepts only headset,
    autodetect_mtu, enable_msbc, output_rate_refresh_interval_ms,
    enable_native_hsp_hs, enable_native_hfp_hf and
    avrcp_absolute_volume, and endpoint registration looks
    unconditional. PipeWire exposes bluez5.codecs for exactly this.

MODIFICATIONS: none in the final state. The daemon.conf sample rate
change described above was only used to isolate the variable and has
been reverted.


Write-up drafted with AI assistance (Claude). All commands, log output
and test results were run and verified on my own device; happy to re-run
anything or provide fuller logs.

7 Likes

Can confirm this behaviour with my Sony WF-C700N on JP26 with SFOS 5.2.0.17

2 Likes

Thanks for the pointer — the trigger was the piece I was missing.

I went with a systemd rather than Situations. It listens on pactl subscribe, so it reacts to the card event instead of polling, reads the codec, and only acts when it actually sees sbc_xq_552 — then does the D-Bus disconnect/reconnect. Happy to post the script if anyone wants it.

A deviation from your version that turned out to matter. I dropped the switch-codec line: the reconnect alone is enough, since it hands SelectConfiguration back to the phone, and in my testing switch-codec is what occasionally left the stream on sink.null. I also had to ignore events for 20 s after a fix, since the forced reconnect generates its own card events and triggered a second round.

Would you post the script here?
The sound I get from my caraudio is awful now.

Hi

The script:

#!/bin/sh
CARD=bluez_card.2B_C7_31_4C_E6_CE
DEV=/org/bluez/hci1/dev_2B_C7_31_4C_E6_CE

wait_codec() {
  i=0
  while [ $i -lt 30 ]; do
    c=$(pactl send-message /card/$CARD/bluez get-codec 2>/dev/null | tr -d '"')
    if [ -n "$c" ]; then
      echo "$c"
      return 0
    fi
    sleep 0.5
    i=$((i+1))
  done
  return 1
}

fix() {
  pactl list cards short | grep -q "$CARD" || return 0

  codec=$(wait_codec) || { echo "a2dp-fix: no codec after 15s, giving up"; return 0; }
  [ "$codec" = "sbc_xq_552" ] || return 0

  echo "a2dp-fix: $codec detected, reconnecting"
  dbus-send --system --reply-timeout=15000 --dest=org.bluez --print-reply \
    "$DEV" org.bluez.Device1.Disconnect >/dev/null 2>&1 || true
  sleep 4
  dbus-send --system --reply-timeout=15000 --dest=org.bluez --print-reply \
    "$DEV" org.bluez.Device1.Connect >/dev/null 2>&1 || true
  sleep 1
  echo "a2dp-fix: now $(wait_codec)"
}

busy=0
last=0
LC_ALL=C pactl subscribe | while read -r line; do
  case "$line" in
    *"on card"*)
      [ "$busy" = 1 ] && continue
      now=$(date +%s)
      [ $((now - last)) -lt 20 ] && continue
      busy=1
      fix
      last=$(date +%s)
      busy=0
      ;;
  esac
done

Values CARD and DEV you can take from:

[defaultuser@JollaPhoneRS bin]$ pactl list cards short | grep bluez
1	bluez_card.2B_C7_31_4C_E6_CE	module-bluez5-device.c
[defaultuser@JollaPhoneRS bin]$ busctl --system tree org.bluez | grep dev_
    │ ├─/org/bluez/hci1/dev_2B_C7_31_4C_E6_CE
    │ │ ├─/org/bluez/hci1/dev_2B_C7_31_4C_E6_CE/sep1
    │ │ │ └─/org/bluez/hci1/dev_2B_C7_31_4C_E6_CE/sep1/fd0
    │ │ └─/org/bluez/hci1/dev_2B_C7_31_4C_E6_CE/sep2
    │ ├─/org/bluez/hci1/dev_62_A6_E5_B5_A1_27
    │ ├─/org/bluez/hci1/dev_6C_97_D5_EB_9C_2B
    │ ├─/org/bluez/hci1/dev_A8_6B_AD_70_BA_9A
    │ └─/org/bluez/hci1/dev_CC_98_8B_64_8C_24

And automation by systemctl:

mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/a2dp-fix.service <<'EOF'
[Unit]
Description=Force plain SBC on remote-initiated A2DP
After=pulseaudio.service
[Service]
ExecStart=%h/bin/a2dp-fix.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now a2dp-fix
1 Like

Awesome. This works great.
Thanks!

Thanks for the great script. A couple of steps were missed though, which might confuse Linux/SfOS newbies:

  • You need to install the Bluez tools:
    devel-su pkcon install bluez5-tools
  • The systemd service assumes the script is executable. Rather than doing that, I think using the followng ExecStart line is better:
    ExecStart=/bin/sh %h/bin/a2dp-fix.sh

Also, I personally put the script in “~/.local/bin/” so there isn’t a “bin” folder blatantly visible.

And the script’s long delays didn’t seem to be necessary on my Jolla Phone 2, so that part of the script looks like:

	echo "a2dp-fix: $codec detected, reconnecting"
	dbus-send --system --reply-timeout=15000 --dest=org.bluez --print-reply "$Device" org.bluez.Device1.Disconnect >/dev/null 2>&1 || true
	sleep 1
	dbus-send --system --reply-timeout=15000 --dest=org.bluez --print-reply "$Device" org.bluez.Device1.Connect >/dev/null 2>&1 || true
	#sleep 1
	echo "a2dp-fix: now $(wait_codec)"

The 5.2.0.18-Release fixed it for me.

2 Likes

I confirm. Now it works without the script.

Same here. Should be marked as solved?

Yes, the problem is already solved