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
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. |
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
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 |
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
Quick Reference
| Task | Command |
|---|---|
Power on |
|
List paired devices |
|
Scan for new devices |
|
Pair |
|
Trust (auto-reconnect) |
|
Connect |
|
Connect (non-interactive) |
|
Disconnect |
|
Remove/unpair |
|
Device info |
|
Set A2DP profile |
|
List audio sinks |
|
Set default sink |
|
Get default sink |
|
Volume |
|
Play music |
|
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
-
Audio Output Management - PipeWire sink routing
-
AWK Mastery - Field processing patterns
-
sed Deep Dive - Stream transformations