I wanted to see if my local AI assistant could control a Bluetooth speaker from the server and play music from YouTube. The idea was simple: instead of using a Discord music bot in a voice channel, the assistant would run directly on my computer, connect to a real Bluetooth speaker, and play audio in the room.
It worked.
This became a practical Linux server Bluetooth speaker setup using BlueALSA, yt-dlp, ffmpeg, and Python.
The final setup looks like this:
AI assistant command
-> Python music controller
-> yt-dlp resolves YouTube audio
-> ffmpeg converts the stream
-> aplay sends 48 kHz stereo audio
-> BlueALSA A2DP
-> BR05 Bluetooth speaker
This post documents the real setup, including the parts that failed first. If you are building a local AI assistant, home server dashboard, or vibe-coded automation project, this is the kind of practical glue code that turns an assistant from a chat box into something that actually affects the room around you.
If you are new to this kind of project, read the VibeCodeSource guide to autonomous AI coding agents for the broader idea of agents taking actions in a controlled environment. This post is the hands-on version: one small local action, wired all the way through. (And if you are brand new, here is what vibe coding is in plain terms.)
The Goal
The goal was not to build a polished Spotify clone. The goal was a tiny MVP:
python3 music.py play "lofi hip hop beats"
python3 music.py stop
python3 music.py status
This is for headless Linux or home server users who want local Bluetooth audio control, not desktop users who already have working PipeWire or PulseAudio Bluetooth output.
The main steps:
- pair the Bluetooth speaker
- fix A2DP audio when normal desktop audio does not expose a sink
- give the user session permission to talk to BlueALSA
- stream YouTube audio through
yt-dlp,ffmpeg, andaplay - document the troubleshooting path so the setup can be repeated
The speaker should be connected to the Linux server, not to a phone. The assistant should be able to start and stop playback using local scripts. YouTube audio should stream through the speaker.
That meant solving three smaller problems:
- Pair the Bluetooth speaker with the server.
- Make Linux audio actually play to the speaker.
- Build a small Python controller that streams YouTube audio into that working path.
Hardware and Software Used
This setup used:
- Ubuntu/Linux server
- built-in Intel Bluetooth adapter
- BR05 Bluetooth speaker
- BlueZ for Bluetooth pairing
- BlueALSA for headless Bluetooth audio
yt-dlpfor YouTube audio resolutionffmpegfor transcodingaplayfor playback- Python for the controller script
The speaker showed up as:
BR05
AA:BB:CC:11:22:33
Tested environment:
Ubuntu/Linux server
Intel Bluetooth adapter
BR05 Bluetooth speaker
BlueZ + BlueALSA
Python 3
yt-dlp
ffmpeg
aplay
Your Bluetooth adapter, distro, audio stack, and speaker may behave differently. Your speaker address will also be different. Do not copy AA:BB:CC:11:22:33 unless you are using the same paired device on the same machine. Replace it everywhere with your own speaker MAC address.
Step 1: Install the Basic Tools
The server needed Bluetooth, audio, and media tools:
sudo apt-get update
sudo apt-get install -y bluez pipewire pipewire-pulse wireplumber libspa-0.2-bluetooth alsa-utils mpv yt-dlp playerctl
sudo systemctl enable --now bluetooth
This installed the normal Linux Bluetooth stack and useful audio tools. At first, I tried to use PipeWire for the Bluetooth speaker, but the server only showed a dummy output. That is common on headless or server-ish Linux setups.
The important early checks were:
bluetoothctl show
bluetoothctl devices
wpctl status
The Bluetooth adapter existed and BlueZ could see devices, but PipeWire did not expose the speaker as a normal audio sink.
Step 2: Scan for the Speaker
With the speaker in pairing mode:
bluetoothctl scan on
The speaker appeared as:
Device AA:BB:CC:11:22:33 BR05
Then I paired, trusted, and connected it:
bluetoothctl pair AA:BB:CC:11:22:33
bluetoothctl trust AA:BB:CC:11:22:33
bluetoothctl connect AA:BB:CC:11:22:33
A successful connection eventually looked like this:
Name: BR05
Paired: yes
Bonded: yes
Trusted: yes
Connected: yes
UUID: Audio Sink
The pairing part took a few tries. The speaker disappeared from scans when it left pairing mode, and at one point pairing failed because the speaker appeared to cancel authentication. The practical fix was boring but effective: unplug/replug the speaker, make sure it was not connected to another device, put it back into pairing mode, and try again quickly.
The First Problem: “Protocol Not Available”
The speaker could pair, but audio still did not work.
The Bluetooth logs showed this:
a2dp-sink profile connect failed: Protocol not available
That meant BlueZ could see the speaker, but the A2DP audio profile was not properly available for playback.
This is the point where a lot of “just connect Bluetooth on Linux” tutorials stop being useful. A desktop Linux environment may handle this automatically. A headless server often does not.
Step 3: Use BlueALSA for Headless Playback
The fix was to install BlueALSA. Package and service names vary by distro; these commands were tested on my Ubuntu-based setup.
sudo apt-get install -y bluez-alsa-utils
sudo systemctl restart bluetooth
BlueALSA registered the A2DP endpoints that BlueZ needed. After that, the speaker could connect successfully:
Connection successful
Connected: yes
BlueALSA could also see the speaker PCM:
bluealsa-cli list-pcms
Output:
/org/bluealsa/hci0/dev_AA_BB_CC_11_22_33/a2dpsrc/sink
That was the first sign that the server had a real playback path to the Bluetooth speaker.
The Second Problem: Permissions
At first, the normal user could not talk to BlueALSA over D-Bus:
Rejected send message
BlueALSA’s default policy allows root and members of the audio group. The fix was to add the user to the audio group and add a local D-Bus policy for immediate access.
sudo usermod -aG audio youruser
Group membership usually requires a new login session. Log out and back in, open a new shell/session, or use a local policy only if you understand the access you are granting.
Then this local policy was added as a practical workaround for the active assistant session. Prefer group-based access if it works for your setup.
Any time an AI assistant can control local hardware or system services, treat it as a real security boundary, not just a fun demo. The VibeCodeSource guide to vibe coding security risks covers that mindset in more detail.
sudo tee /etc/dbus-1/system.d/bluealsa-youruser.conf >/dev/null <<'EOF'
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy user="youruser">
<allow send_destination="org.bluealsa"/>
</policy>
</busconfig>
EOF
sudo busctl call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig || true
sudo systemctl restart bluealsa bluetooth
Replace youruser with your Linux username.
After that, bluealsa-cli list-pcms worked from the user session.
The Third Problem: bluealsa-aplay Conflict
BlueALSA installed a service called bluealsa-aplay.service. For this project, it caused a conflict.
The error looked like this:
Couldn't set A2DP configuration: Resource temporarily unavailable
Couldn't select codec: SBC
The fix was to disable the service for this specific outbound-speaker setup:
sudo systemctl disable --now bluealsa-aplay.service
sudo systemctl restart bluetooth bluealsa
Only disable this service if it is present and you are sure you do not need the machine to receive Bluetooth audio. For this use case, the server is sending audio to a Bluetooth speaker. The bluealsa-aplay service is more useful when the computer is receiving Bluetooth audio from another device. Leaving it enabled can grab the transport we want to use.
Step 4: Find the Audio Format the Speaker Accepts
The first test tone failed because I used the wrong format. The speaker/BlueALSA path wanted:
48000 Hz
Stereo
S16_LE
A working test tone used a 48 kHz stereo WAV file:
aplay -D 'bluealsa:DEV=AA:BB:CC:11:22:33,PROFILE=a2dp' test-tone-48k-stereo.wav
That was the first real success: a short beep came out of the BR05 speaker.
At that point, the hard part was done. YouTube playback was just stream plumbing.
Step 5: Build the Python Controller
The controller script lives in this project structure:
/opt/local-music/
music.py
README.md
state.json
music.log
playback.log
test-tone-48k-stereo.wav
The script supports:
python3 music.py status
python3 music.py reconnect
python3 music.py test
python3 music.py play "lofi hip hop beats"
python3 music.py stop
The important constants are simple:
DEVICE_MAC = "AA:BB:CC:11:22:33"
ALSA_DEVICE = f"bluealsa:DEV={DEVICE_MAC},PROFILE=a2dp"
The script checks whether BR05 is connected. For an MVP, checking the final bluetoothctl info output is enough. For a more durable service, you would also check return codes and surface bluetoothctl errors clearly.
def ensure_connected() -> bool:
info = run(["bluetoothctl", "info", DEVICE_MAC], timeout=15).stdout
if "Connected: yes" in info:
return True
run(["bluetoothctl", "connect", DEVICE_MAC], timeout=25)
time.sleep(2)
info = run(["bluetoothctl", "info", DEVICE_MAC], timeout=15).stdout
return "Connected: yes" in info
It checks whether BlueALSA sees the speaker:
def ensure_pcm() -> bool:
pcms = run(["bluealsa-cli", "list-pcms"], timeout=15).stdout
return DEVICE_MAC.replace(":", "_") in pcms
For YouTube playback, it normalizes a search query into a ytsearch1: target:
def yt_target(query: str) -> str:
q = query.strip()
if q.startswith(("http://", "https://", "ytsearch")):
return q
return "ytsearch1:" + q
Then it uses yt-dlp to resolve the audio stream:
def get_stream_url(target: str) -> str:
cp = run([
"yt-dlp",
"--no-playlist",
"--no-warnings",
"-f",
"bestaudio",
"-g",
target,
], timeout=45)
if cp.returncode != 0 or not cp.stdout.strip():
raise RuntimeError((cp.stderr or cp.stdout or "yt-dlp could not resolve stream URL").strip())
return cp.stdout.strip().splitlines()[-1]
Then ffmpeg converts the stream into the known-good format and pipes it to aplay:
ffmpeg -hide_banner -loglevel warning -nostdin -i "$STREAM_URL" \
-vn -f wav -acodec pcm_s16le -ac 2 -ar 48000 pipe:1 \
| aplay -D 'bluealsa:DEV=AA:BB:CC:11:22:33,PROFILE=a2dp' -
That command is the heart of the whole setup.
Use this only for content you have the right to stream, and remember that YouTube and yt-dlp behavior can change over time. Avoid logging resolved stream URLs because they may contain temporary signed parameters. If your script accepts arbitrary user input, pass arguments safely to subprocess.run or quote carefully before using a shell pipeline.
The Final Working Commands
To play music, replace this path with wherever you save your script:
python3 /opt/local-music/music.py play "lofi hip hop beats"
To stop it:
python3 /opt/local-music/music.py stop
To check status:
python3 /opt/local-music/music.py status
A successful status looks like this:
{
"music_process_running": true,
"br05_connected": true,
"bluealsa_pcm_visible": true,
"alsa_device": "bluealsa:DEV=AA:BB:CC:11:22:33,PROFILE=a2dp"
}
In practice, I could tell the assistant:
Play some lofi
and it would run the local script, resolve a YouTube result, and play it through the Bluetooth speaker.
What This Has to Do With Vibe Coding
This is a small project, but it shows the bigger pattern behind practical vibe coding:
- Start with a real-world goal.
- Break it into small verifiable steps.
- Let the AI assistant help with commands, scripts, and debugging.
- Test each step with real output.
- Stop when the physical thing works, not when the code merely looks plausible.
The important part was not the Python script by itself. It was the loop:
try a small step
read the actual error
adjust the plan
verify with a real sound
then build the next layer
That is also the safest way to build larger AI-assisted apps. The VibeCodeSource checklist on why vibe-coded apps break in production applies here too: you do not trust the happy path until you test the real environment.
Troubleshooting Notes
BR05 Does Not Show Up in Scans
Make sure the speaker is really in pairing mode. If it is already connected to your phone or another computer, disconnect or forget it there first.
Try:
bluetoothctl scan on
Then look for the speaker name:
BR05
Pairing Fails With AuthenticationCanceled
This usually means the speaker left pairing mode or canceled the handshake. Put it back in pairing mode and try again quickly.
Bluetooth Says Protocol Not Available
That usually means the A2DP audio profile is not registered correctly. On a headless server, installing and using BlueALSA may be easier than trying to force the normal desktop audio stack.
BlueALSA Says Resource Temporarily Unavailable
Disable bluealsa-aplay.service if you are trying to send audio from the server to the speaker:
sudo systemctl disable --now bluealsa-aplay.service
sudo systemctl restart bluetooth bluealsa
The Speaker Connects but Playback Fails
Check the format. In this setup, 48 kHz stereo S16_LE worked:
ffmpeg -i input -vn -f wav -acodec pcm_s16le -ac 2 -ar 48000 pipe:1 \
| aplay -D 'bluealsa:DEV=AA:BB:CC:11:22:33,PROFILE=a2dp' -
What I Would Improve Next
The MVP works, but it is intentionally small. The next useful upgrades would be:
- volume control
- pause/resume
- queue support
- better playlist handling
- a small dashboard card
- speaker reconnect on boot
- natural-language commands from the assistant
- fallback if YouTube blocks or changes a stream
For a home assistant setup, the dashboard card would be the most fun. Something like:
Music
Status: Playing
Track: Best of lofi hip hop
Output: BR05
Controls: Stop / Reconnect / Test
That would turn the script from a working hack into a small local media service.
Final Thought
This project is a good reminder that useful AI automation does not have to be huge. Sometimes the best assistant feature is not another chat response. It is the ability to control one real thing in the room.
In this case, that real thing was a Bluetooth speaker.
The assistant did not just suggest commands. It helped pair the device, debug Linux audio, write the controller, test the stream, and stop the music when asked.
That is the kind of vibe coding I like: small, weird, practical, and confirmed by an actual sound coming out of an actual speaker.