Bluetooth Audio

Connect Bluetooth earbuds and headphones using bluetoothctl, route audio through PipeWire, and play music with mpv.

Prerequisites

  • Arch

  • Fedora/RHEL

  • Debian/Ubuntu

sudo pacman -S bluez bluez-utils pipewire-pulse
sudo dnf install bluez bluez-tools pipewire-pulseaudio
sudo apt install bluez pipewire-pulse
sudo systemctl enable --now bluetooth

Daily Workflow

This is what you’ll do every day after initial pairing.

1. Connect

bluetoothctl connect 24:24:B7:B5:1C:CC

Or non-interactive (faster, no hanging prompt):

echo -e "connect 24:24:B7:B5:1C:CC\nquit" | bluetoothctl
Expected output
Attempting to connect to 24:24:B7:B5:1C:CC
[CHG] Device 24:24:B7:B5:1C:CC Connected: yes
Connection successful

2. Set A2DP Profile (Critical)

The audio sink often won’t appear until you set the A2DP profile. This step is not optional.

pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC a2dp-sink
Replace colons with underscores in the MAC address for pactl commands.

3. Find and Set Audio Sink

pactl list sinks short | grep -i buds
Example output
340	bluez_output.24:24:B7:B5:1C:CC	PipeWire	float32le 2ch 48000Hz	SUSPENDED

Set as default using the sink ID:

pactl set-default-sink 340

Verify:

pactl get-default-sink

4. Play Music

mpv --shuffle ~/Music/**/*.mp3

One-Command Solution

Source the audio functions from domus-musica:

# Add to ~/.zshrc
source ~/atelier/_bibliotheca/domus-musica/config/audio-functions.sh

Then use the buds command:

buds

Or if you prefer a standalone function, add this to ~/.zshrc:

BUDS_MAC="24:24:B7:B5:1C:CC"
BUDS_CARD="bluez_card.24_24_B7_B5_1C_CC"

buds() {
    # Connect
    echo -e "connect $BUDS_MAC\nquit" | bluetoothctl 2>&1 | tail -3
    sleep 2

    # Set A2DP profile (required for sink to appear)
    pactl set-card-profile "$BUDS_CARD" a2dp-sink 2>/dev/null
    sleep 1

    # Find and set sink
    local sink_id
    sink_id=$(pactl list sinks short | awk '/buds|Buds|BUDS/{print $1; exit}')

    if [[ -n "$sink_id" ]]; then
        pactl set-default-sink "$sink_id"
        echo "Audio → Buds3 Pro (sink $sink_id)"
    else
        echo "ERROR: Buds sink not found. Check: pactl list sinks short"
        return 1
    fi
}

First-Time Pairing

Only needed once per device.

Interactive Method

bluetoothctl
[bluetooth]# power on
Changing power on succeeded

[bluetooth]# scan on
Discovery started
[NEW] Device 24:24:B7:B5:1C:CC Buds3 Pro

[bluetooth]# pair 24:24:B7:B5:1C:CC
Attempting to pair with 24:24:B7:B5:1C:CC
[CHG] Device 24:24:B7:B5:1C:CC Paired: yes
Pairing successful

[bluetooth]# trust 24:24:B7:B5:1C:CC
[CHG] Device 24:24:B7:B5:1C:CC Trusted: yes
Changing trust succeeded

[bluetooth]# connect 24:24:B7:B5:1C:CC
Connection successful

[bluetooth]# scan off
[bluetooth]# exit

Non-Interactive Method

bluetoothctl power on
bluetoothctl scan on
# Wait for device to appear, then:
bluetoothctl pair 24:24:B7:B5:1C:CC
bluetoothctl trust 24:24:B7:B5:1C:CC
bluetoothctl connect 24:24:B7:B5:1C:CC
bluetoothctl scan off

Shell Functions

For comprehensive audio functions including buds, speakers, hdmi, vol, mute, and more, source from domus-musica:

source ~/atelier/_bibliotheca/domus-musica/config/audio-functions.sh

Run audio-help for full command reference.

Generic Bluetooth Functions

Add to ~/.zshrc or ~/.bashrc:

# List all paired devices
bt-list() {
    bluetoothctl devices
}

# Show connected devices
bt-connected() {
    bluetoothctl devices | while read -r _ mac name; do
        bluetoothctl info "$mac" 2>/dev/null | grep -q "Connected: yes" && echo "$name ($mac)"
    done
}

# Interactive device selector (requires fzf)
bt-select() {
    local device=$(bluetoothctl devices | fzf --prompt="Select device: ")
    [[ -n "$device" ]] && bluetoothctl connect "$(echo "$device" | awk '{print $2}')"
}

# Disconnect all devices
bt-disconnect-all() {
    bluetoothctl devices | while read -r _ mac _; do
        bluetoothctl disconnect "$mac" 2>/dev/null
    done
    echo "All devices disconnected"
}

# Quick disconnect Buds
buds-off() {
    bluetoothctl disconnect 24:24:B7:B5:1C:CC
    echo "Buds disconnected"
}

Audio Profiles

Bluetooth has two audio profiles:

Profile Quality Use Case

A2DP

High quality stereo (CD quality)

Music, videos, games

HSP/HFP

Low quality mono (phone quality)

Calls with microphone

Check Current Profile

pactl list cards short | grep bluez
pactl list cards | grep -A 30 bluez

Set High Quality (A2DP)

pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC a2dp-sink

A2DP disables the microphone. For calls, the system switches to HSP/HFP automatically.

Set Headset Mode (HSP/HFP)

For calls with microphone:

pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC headset-head-unit

Auto-Connect on Boot

Trust Your Device

Trusted devices reconnect automatically when in range:

bluetoothctl trust 24:24:B7:B5:1C:CC

Enable Bluetooth at Boot

sudo systemctl enable bluetooth

Auto Power On

Edit /etc/bluetooth/main.conf:

[Policy]
AutoEnable=true

Troubleshooting

Sink Not Appearing After Connect

This is the most common issue. The A2DP profile must be set:

# 1. Verify connected
echo -e "info 24:24:B7:B5:1C:CC\nquit" | bluetoothctl | grep "Connected:"
# 2. Set A2DP profile (this creates the sink)
pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC a2dp-sink
# 3. Verify sink appeared
pactl list sinks short | grep -i buds
# 4. Set as default
pactl set-default-sink $(pactl list sinks short | awk '/buds|Buds/{print $1}')

wpctl set-default Fails

wpctl set-default can fail with "not a device node" errors. Use pactl instead:

# wpctl (can fail)
wpctl set-default 67

# pactl (more reliable)
pactl set-default-sink 340

To find the correct sink ID:

pactl list sinks short

Device Not Found in Scan

# Ensure Bluetooth is on
bluetoothctl power on
# Check if blocked by rfkill
rfkill list bluetooth
# Unblock if soft-blocked
rfkill unblock bluetooth

Connection Fails Repeatedly

Remove and re-pair:

bluetoothctl remove 24:24:B7:B5:1C:CC

Then pair again from scratch.

Audio Choppy or Stuttering

# Restart the audio stack
systemctl --user restart pipewire pipewire-pulse wireplumber
# Restart Bluetooth service
sudo systemctl restart bluetooth

Connected But No Sound

# 1. Check if sink exists
pactl list sinks short | grep -i buds
# 2. If missing, set A2DP profile
pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC a2dp-sink
# 3. If still missing, reconnect
bluetoothctl disconnect 24:24:B7:B5:1C:CC
sleep 1
echo -e "connect 24:24:B7:B5:1C:CC\nquit" | bluetoothctl
sleep 2
pactl set-card-profile bluez_card.24_24_B7_B5_1C_CC a2dp-sink
# 4. Nuclear option - restart everything
systemctl --user restart pipewire wireplumber
sudo systemctl restart bluetooth

Check Service Status

systemctl status bluetooth
# Live logs
journalctl -u bluetooth -f

PipeWire/WirePlumber Status

systemctl --user status pipewire wireplumber
# List all audio nodes
pw-cli ls Node | grep -E "id |node.description"

Quick Reference

Task Command

Power on

bluetoothctl power on

List paired devices

bluetoothctl devices

Scan for new devices

bluetoothctl scan on

Pair

bluetoothctl pair <MAC>

Trust (auto-reconnect)

bluetoothctl trust <MAC>

Connect

bluetoothctl connect <MAC>

Connect (non-interactive)

echo -e "connect <MAC>\nquit" | bluetoothctl

Disconnect

bluetoothctl disconnect <MAC>

Remove/unpair

bluetoothctl remove <MAC>

Device info

bluetoothctl info <MAC>

Set A2DP profile

pactl set-card-profile bluez_card.<MAC_underscores> a2dp-sink

List audio sinks

pactl list sinks short

Set default sink

pactl set-default-sink <sink_id>

Get default sink

pactl get-default-sink

Volume

wpctl get-volume @DEFAULT_SINK@

Play music

mpv --shuffle ~/Music/*/.mp3

Advanced CLI Patterns

Production-grade patterns for scripting and automation.

MAC Address Transforms (sed)

# Colons → underscores (for pactl card names)
MAC="24:24:B7:B5:1C:CC"
echo "$MAC" | sed 's/:/_/g'
# Output: 24_24_B7_B5_1C_CC

# Build card name dynamically
CARD="bluez_card.$(echo "$MAC" | sed 's/:/_/g')"
pactl set-card-profile "$CARD" a2dp-sink
# Extract MAC from bluetoothctl output
bluetoothctl devices | sed -n 's/^Device \([^ ]*\) .*/\1/p'

Device Status Parsing (awk)

# Formatted device list with connection status
bluetoothctl devices | while read -r _ mac name; do
    status=$(bluetoothctl info "$mac" 2>/dev/null | awk '/Connected:/{print $2}')
    printf "%-20s %-17s %s\n" "$name" "$mac" "${status:-unknown}"
done
# One-liner: connected devices only
bluetoothctl devices | awk '{print $2}' | while read mac; do
    bluetoothctl info "$mac" 2>/dev/null | awk -v mac="$mac" '
        /Name:/{name=$2}
        /Connected: yes/{print name, mac}
    '
done

Sink Analysis (awk + pactl)

# Formatted sink table
pactl list sinks short | awk -F'\t' '{
    state = ($5 == "RUNNING") ? "▶" : "○"
    printf "%s %3d  %-45s %s\n", state, $1, $2, $5
}'
# Find Bluetooth sink ID with fallback
sink_id=$(pactl list sinks short | awk -F'\t' '
    tolower($2) ~ /bluez|buds|airpods|headphone/ {print $1; exit}
')
[[ -z "$sink_id" ]] && echo "No BT sink found" || pactl set-default-sink "$sink_id"
# Volume as percentage (awk math)
wpctl get-volume @DEFAULT_SINK@ | awk '{printf "%.0f%%\n", $2 * 100}'

Connection State Machine (grep + awk)

# Wait for connection with timeout
MAC="24:24:B7:B5:1C:CC"
timeout 10 bash -c "
    while ! bluetoothctl info $MAC 2>/dev/null | grep -q 'Connected: yes'; do
        sleep 0.5
    done
" && echo "Connected" || echo "Timeout"
# Connection health check
check_bt() {
    local mac="$1"
    bluetoothctl info "$mac" 2>/dev/null | awk '
        /Connected:/{conn=$2}
        /Paired:/{pair=$2}
        /Trusted:/{trust=$2}
        END {
            printf "Paired: %s | Trusted: %s | Connected: %s\n",
                   pair, trust, conn
            exit (conn == "yes" ? 0 : 1)
        }
    '
}
check_bt "24:24:B7:B5:1C:CC"

PipeWire Node Inspection (grep + awk + sed)

# Extract node details with structured output
pw-cli ls Node | awk '
    /^[[:space:]]*id / {id=$2}
    /node.description/ {
        gsub(/"/, "", $0)
        split($0, a, "= ")
        desc = a[2]
    }
    /media.class.*Audio\/Sink/ {
        printf "%4d  %s\n", id, desc
    }
'
# Find specific node by description pattern
pw-cli ls Node | awk -v pattern="[Bb]uds" '
    /^[[:space:]]*id / {id=$2}
    /node.description/ && $0 ~ pattern {print id; exit}
'

Robust buds() Function

buds() {
    local MAC="24:24:B7:B5:1C:CC"
    local CARD="bluez_card.$(echo "$MAC" | sed 's/:/_/g')"

    # Connect with timeout
    echo -e "connect $MAC\nquit" | timeout 5 bluetoothctl 2>&1 |
        grep -E "Connected|successful|Failed" | tail -1

    # Wait for connection
    local attempts=0
    while ! bluetoothctl info "$MAC" 2>/dev/null | grep -q "Connected: yes"; do
        ((attempts++)) && ((attempts > 10)) && { echo "Connection timeout"; return 1; }
        sleep 0.5
    done

    # Set A2DP profile
    pactl set-card-profile "$CARD" a2dp-sink 2>/dev/null
    sleep 1

    # Find and set sink with awk
    local sink_id
    sink_id=$(pactl list sinks short | awk -F'\t' 'tolower($2) ~ /buds/{print $1; exit}')

    if [[ -n "$sink_id" ]]; then
        pactl set-default-sink "$sink_id"
        local vol=$(wpctl get-volume @DEFAULT_SINK@ | awk '{printf "%.0f%%", $2*100}')
        echo "✓ Buds3 Pro (sink $sink_id) @ $vol"
    else
        echo "✗ Sink not found. Debug: pactl list sinks short"
        return 1
    fi
}

Audio Diagnostics One-Liners

# All sinks with volume levels
pactl list sinks | awk '
    /Sink #/{sink=$2}
    /Description:/{desc=$0; gsub(/.*: /,"",desc)}
    /Volume:.*front-left/{
        match($0, /([0-9]+)%/, a)
        printf "%-40s %s%%\n", desc, a[1]
    }
'
# Active streams (what's playing)
pw-cli ls Node | awk '
    /^[[:space:]]*id /{id=$2}
    /node.name.*".*"/{name=$3; gsub(/"/, "", name)}
    /media.class.*Stream\/Output/{printf "%d: %s\n", id, name}
'
# Bluetooth card profiles available
pactl list cards | awk '
    /Name:.*bluez/{card=1}
    card && /Profiles:/{prof=1; next}
    card && prof && /^\t\t[a-z]/{print "\t" $1}
    card && /Active Profile:/{print "Active:" $3; card=0; prof=0}
'
# Quick status dashboard
echo "=== Bluetooth ===" && bt-connected 2>/dev/null || bluetoothctl devices Connected
echo -e "\n=== Default Sink ===" && pactl get-default-sink
echo -e "\n=== Volume ===" && wpctl get-volume @DEFAULT_SINK@ | awk '{printf "%.0f%%\n", $2*100}'

See Also