Skip to content

Instantly share code, notes, and snippets.

@qcasey
Forked from mill1000/README.md
Last active April 5, 2024 16:22
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 9 You must be signed in to fork a gist
  • Save qcasey/5bd4943b27320e65a033948fafb86d19 to your computer and use it in GitHub Desktop.
Save qcasey/5bd4943b27320e65a033948fafb86d19 to your computer and use it in GitHub Desktop.
Headless A2DP Audio Streaming on Ubuntu / Debian for non-raspbian SBCs (ODROID, Orange Pi, Armbian, etc)

About

This gist will show how to setup a generic SBC Debian / Ubuntu install as a headless Bluetooth A2DP audio sink. This will allow your phone, laptop or other Bluetooth device to play audio wirelessly through a Rasperry Pi.

Motivation

This is forked from another gist specific to the Raspberry Pi on Stretch. A required package isn't in Ubuntu's repos, so in this gist we build it from scratch.

Tested to be working on Armbian/Ubuntu/Debian images of the Orange Pi Zero, ODROID XU4, ODROID N2, and Atomic Pi.

Prerequisites

  • A linux SBC running Debian or Ubuntu
  • Bluetooth Dongle or integrated Bluetooth.
  • Sound card (internal or external) that has been set up to work with Alsa.

Auto-Install Script

curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/2a3be1bc78e9ac30b744c2fdd332d035ac13d77d/bt-a2dp-autoinstall.sh | sudo bash

cURL-ing to bash can be dangerous, in the event of a hack. Please ensure what you're downloading first.

Manual Setup

First make sure the system is up to date using the following commands.

sudo apt-get update
sudo apt-get upgrade

Then reboot the device to ensure the latest kernel is loaded.

Now install the required packages.

sudo apt-get install alsa git gcc make autoconf libtool blueman bluez bluetooth libbluetooth-dev libfdk-aac-dev libsbc-dev libasound2-dev libdbus-1-dev python-dbus glib-2.0-dev libperl-dev libgtk2.0-dev

Change over to your /tmp directory, and clone then build another requirement.

cd /tmp
git clone https://github.com/Arkq/bluez-alsa
cd bluez-alsa/
autoreconf --install
mkdir build && cd build
../configure --enable-aac --enable-ofono --enable-debug
make && sudo make install

Make Bluetooth Discoverable

Normally a Bluetooth device is only discoverable for a limited amount of time. Since this is a headless setup we want the device to always be discoverable.

  1. Set the DiscoverableTimeout in /etc/bluetooth/main.conf to 0
# How long to stay in discoverable mode before going back to non-discoverable
# The value is in seconds. Default is 180, i.e. 3 minutes.
# 0 = disable timer, i.e. stay discoverable forever
DiscoverableTimeout = 0
  1. Enable discovery on the Bluetooth controller
sudo bluetoothctl
power on
discoverable on
exit

Install The A2DP Bluetooth Agent

A Bluetooth agent is a piece of software that handles pairing and authorization of Bluetooth devices. The following agent allows the Raspberry Pi to automatically pair and accept A2DP connections from Bluetooth devices. All other Bluetooth services are rejected.

Copy the included file a2dp-agent to /usr/local/bin and make the file executable with

sudo chmod +x /usr/local/bin/a2dp-agent

A Python 3 version has been generously provided by @abelmatser in another gist

Testing The Agent

Before continuing, verify that the agent is functional. The Raspberry Pi should be discoverable, pairable and recognized as an audio device.

Note: At this point the device will not output any audio. This step is only to verify the Bluetooth is discoverable and bindable.

  1. Manually run the agent by executing
sudo /usr/local/bin/a2dp-agent
  1. Attempt to pair and connect with the Raspberry Pi using your phone or computer.
  2. The agent should output the accepted and rejected Bluetooth UUIDs
A2DP Agent Registered
AuthorizeService (/org/bluez/hci0/dev_94_01_C2_47_01_AA, 0000111E-0000-1000-8000-00805F9B34FB)
Rejecting non-A2DP Service
AuthorizeService (/org/bluez/hci0/dev_94_01_C2_47_01_AA, 0000110d-0000-1000-8000-00805f9b34fb)
Authorized A2DP Service
AuthorizeService (/org/bluez/hci0/dev_94_01_C2_47_01_AA, 0000111E-0000-1000-8000-00805F9B34FB)
Rejecting non-A2DP Service

Install The A2DP Bluetooth Agent As A Service

To make the A2DP Bluetooth Agent run on boot copy the included file bt-agent-a2dp.service to /etc/systemd/system. Now run the following command to enable the A2DP Agent service

sudo systemctl enable bt-agent-a2dp.service

Thanks to @matthijskooijman for fixing up some issues in the Bluetooth Agent service.

Bluetooth devices should now be able to discover, pair and connect to the Raspberry Pi without any user intervention.

Testing Audio Playback

Now that Bluetooth devices can pair and connect with the Raspberry Pi we can test the audio playback.

The tool bluealsa-aplay is used to forward audio from the Bluetooth device to the ALSA output device (sound card).

Execute the following command to accept A2DP audio from any connected Bluetooth device.

bluealsa-aplay -vv 00:00:00:00:00:00

Play a song on the Bluetooth device and the Raspberry Pi should output audio on either the headphone jack or the HDMI port. See this guide for configuring the audio output device of the Raspberry Pi.

Install The Audio Playback As A Service

To make the audio playback run on boot copy the included file a2dp-playback.service to /etc/systemd/system. Now run the following command to enable A2DP Playback service

sudo systemctl enable a2dp-playback.service

We also need to install bluealsa as a service, copy that to /etc/systemd/system as well and enable it.

sudo systemctl enable bluealsa.service

Reboot and enjoy!

Low Volume Output

If you are experiencing low volume output, run alsamixer and increase the volume of the Pi's soundcard.

#!/usr/bin/python
from __future__ import absolute_import, print_function, unicode_literals
import sys
import dbus
import dbus.service
import dbus.mainloop.glib
try:
from gi.repository import GObject
except ImportError:
import gobject as GObject
AGENT_INTERFACE = "org.bluez.Agent1"
AGENT_PATH = "/test/agent"
class Rejected(dbus.DBusException):
_dbus_error_name = "org.bluez.Error.Rejected"
class Agent(dbus.service.Object):
exit_on_release = True
def set_exit_on_release(self, exit_on_release):
self.exit_on_release = exit_on_release
@dbus.service.method(AGENT_INTERFACE,
in_signature="", out_signature="")
def Release(self):
print("Release")
if self.exit_on_release:
mainloop.quit()
@dbus.service.method(AGENT_INTERFACE,
in_signature="os", out_signature="")
def AuthorizeService(self, device, uuid):
print("AuthorizeService (%s, %s)" % (device, uuid))
if uuid == "0000110d-0000-1000-8000-00805f9b34fb":
print("Authorized A2DP Service")
return
print("Rejecting non-A2DP Service")
raise Rejected("Connection rejected")
@dbus.service.method(AGENT_INTERFACE,
in_signature="o", out_signature="s")
def RequestPinCode(self, device):
print("RequestPinCode (%s)" % (device))
return "0000"
@dbus.service.method(AGENT_INTERFACE,
in_signature="o", out_signature="u")
def RequestPasskey(self, device):
print("RequestPasskey (%s)" % (device))
return dbus.UInt32("password")
@dbus.service.method(AGENT_INTERFACE,
in_signature="ouq", out_signature="")
def DisplayPasskey(self, device, passkey, entered):
print("DisplayPasskey (%s, %06u entered %u)" %
(device, passkey, entered))
@dbus.service.method(AGENT_INTERFACE,
in_signature="os", out_signature="")
def DisplayPinCode(self, device, pincode):
print("DisplayPinCode (%s, %s)" % (device, pincode))
@dbus.service.method(AGENT_INTERFACE,
in_signature="ou", out_signature="")
def RequestConfirmation(self, device, passkey):
print("RequestConfirmation (%s, %06d)" % (device, passkey))
return
@dbus.service.method(AGENT_INTERFACE,
in_signature="o", out_signature="")
def RequestAuthorization(self, device):
print("RequestAuthorization (%s)" % (device))
raise Rejected("Pairing rejected")
@dbus.service.method(AGENT_INTERFACE,
in_signature="", out_signature="")
def Cancel(self):
print("Cancel")
if __name__ == '__main__':
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
agent = Agent(bus, AGENT_PATH)
obj = bus.get_object("org.bluez", "/org/bluez");
manager = dbus.Interface(obj, "org.bluez.AgentManager1")
manager.RegisterAgent(AGENT_PATH, "NoInputNoOutput")
print("A2DP Agent Registered")
manager.RequestDefaultAgent(AGENT_PATH)
mainloop = GObject.MainLoop()
mainloop.run()
[Unit]
Description=A2DP Playback
After=bluealsa.service syslog.service
Requires=bluealsa.service
[Service]
ExecStartPre=/bin/sleep 3
ExecStart=/usr/bin/bluealsa-aplay --profile-a2dp 00:00:00:00:00:00
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=A2DP-Playback
[Install]
WantedBy=multi-user.target
[Unit]
Description=BlueALSA Agent
After=bluetooth.service
Wants=bluetooth.service
[Service]
ExecStart=/bin/sh -c "bluealsa -p a2dp-source -p a2dp-sink"
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=BlueALSA-Agent
[Install]
WantedBy=bluetooth.service
#!/bin/sh
#
# Update, install prereqs
#
apt-get update
apt-get -y install python alsa git gcc make autoconf libtool blueman bluez bluetooth libbluetooth-dev libfdk-aac-dev libsbc-dev libasound2-dev libdbus-1-dev python-dbus glib-2.0-dev libperl-dev libgtk2.0-dev
#
# Clone and build bluez-alsa
#
cd /tmp
git clone https://github.com/Arkq/bluez-alsa
cd bluez-alsa/
autoreconf --install
mkdir build && cd build
../configure --enable-aac --enable-ofono --enable-debug
make && make install
#
# Backup old service files, if they exist
#
if [ -f /usr/local/bin/a2dp-agent ]; then
mv /usr/local/bin/a2dp-agent /usr/local/bin/a2dp-agent.old
fi
if [ -f a2dp-playback.service ]; then
systemctl disable a2dp-playback.service
mv a2dp-playback.service a2dp-playback.service.old
fi
if [ -f bt-agent-a2dp.service ]; then
systemctl disable bt-agent-a2dp.service
mv bt-agent-a2dp.service bt-agent-a2dp.service.old
fi
if [ -f bluealsa.service ]; then
systemctl disable bluealsa.service
mv bluealsa.service bluealsa.service.old
fi
#
# Backup old bluetooth conf if required, then download new configuration
#
if [ -f /etc/bluetooth/main.conf ]; then
mv /etc/bluetooth/main.conf /etc/bluetooth/main.conf.old
fi
curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/07bea3ac973276723df70dd234f138b4bda31219/main.conf > /etc/bluetooth/main.conf
#
# Download and enable service files
#
curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/07bea3ac973276723df70dd234f138b4bda31219/a2dp-agent > /usr/local/bin/a2dp-agent
curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/07bea3ac973276723df70dd234f138b4bda31219/a2dp-playback.service > /etc/systemd/system/a2dp-playback.service
curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/07bea3ac973276723df70dd234f138b4bda31219/bt-agent-a2dp.service > /etc/systemd/system/bt-agent-a2dp.service
curl https://gist.githubusercontent.com/qcasey/5bd4943b27320e65a033948fafb86d19/raw/07bea3ac973276723df70dd234f138b4bda31219/bluealsa.service > /etc/systemd/system/bluealsa.service
systemctl enable bt-agent-a2dp.service
systemctl enable a2dp-playback.service
systemctl enable bluealsa.service
chmod +x /usr/local/bin/a2dp-agent
printf "\nStarting services...\n"
systemctl restart bluetooth
systemctl restart bt-agent-a2dp.service
systemctl restart a2dp-playback.service
systemctl restart bluealsa.service
#
# Done!
#
printf "\nDone!\nYour default Alsa sound card looks like:\n"
printf "\n/etc/asound.conf\n"
cat /etc/asound.conf
printf "\n\nBluetooth should be running. Please restart to take effect.\nIf your Alsa sound card is properly set (see asound.conf above) audio should output correctly.\n"
printf "\nBy default the device will appear as 'BluetoothSpeaker'. This can be changed in /etc/bluetooth/main.conf.\n"
printf "\nHave a nice rest of your day :)\n\n"
[Unit]
Description=A2DP Bluetooth Agent
After=bluetooth.service
Wants=bluetooth.service
[Service]
ExecStartPre=/bin/sh -c "echo power on | bluetoothctl && echo discoverable on | bluetoothctl"
ExecStart=/usr/bin/python -u /usr/local/bin/a2dp-agent
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=A2DP-Agent
[Install]
WantedBy=bluetooth.service
[General]
# Default adaper name
# Defaults to 'BlueZ X.YZ'
Name = BluetoothSpeaker
# Default device class. Only the major and minor device class bits are
# considered. Defaults to '0x000000'.
Class = 0x200414
# How long to stay in discoverable mode before going back to non-discoverable
# The value is in seconds. Default is 180, i.e. 3 minutes.
# 0 = disable timer, i.e. stay discoverable forever
DiscoverableTimeout = 0
# How long to stay in pairable mode before going back to non-discoverable
# The value is in seconds. Default is 0.
# 0 = disable timer, i.e. stay pairable forever
#PairableTimeout = 0
# Automatic connection for bonded devices driven by platform/user events.
# If a platform plugin uses this mechanism, automatic connections will be
# enabled during the interval defined below. Initially, this feature
# intends to be used to establish connections to ATT channels. Default is 60.
#AutoConnectTimeout = 60
# Use vendor id source (assigner), vendor, product and version information for
# DID profile support. The values are separated by ":" and assigner, VID, PID
# and version.
# Possible vendor id source values: bluetooth, usb (defaults to usb)
#DeviceID = bluetooth:1234:5678:abcd
# Do reverse service discovery for previously unknown devices that connect to
# us. This option is really only needed for qualification since the BITE tester
# doesn't like us doing reverse SDP for some test cases (though there could in
# theory be other useful purposes for this too). Defaults to 'true'.
#ReverseServiceDiscovery = true
# Enable name resolving after inquiry. Set it to 'false' if you don't need
# remote devices name and want shorter discovery cycle. Defaults to 'true'.
#NameResolving = true
# Enable runtime persistency of debug link keys. Default is false which
# makes debug link keys valid only for the duration of the connection
# that they were created for.
#DebugKeys = false
# Restricts all controllers to the specified transport. Default value
# is "dual", i.e. both BR/EDR and LE enabled (when supported by the HW).
# Possible values: "dual", "bredr", "le"
#ControllerMode = dual
# Enables Multi Profile Specification support. This allows to specify if
# system supports only Multiple Profiles Single Device (MPSD) configuration
# or both Multiple Profiles Single Device (MPSD) and Multiple Profiles Multiple
# Devices (MPMD) configurations.
# Possible values: "off", "single", "multiple"
#MultiProfile = off
# Permanently enables the Fast Connectable setting for adapters that
# support it. When enabled other devices can connect faster to us,
# however the tradeoff is increased power consumptions. This feature
# will fully work only on kernel version 4.1 and newer. Defaults to
# 'false'.
#FastConnectable = false
Enable=Source,Sink,Media,Socket
[Policy]
# The ReconnectUUIDs defines the set of remote services that should try
# to be reconnected to in case of a link loss (link supervision
# timeout). The policy plugin should contain a sane set of values by
# default, but this list can be overridden here. By setting the list to
# empty the reconnection feature gets disabled.
#ReconnectUUIDs=00001112-0000-1000-8000-00805f9b34fb, 0000111f-0000-1000-8000-00805f9b34fb, 0000110a-0000-1000-8000-00805f9b34fb
# ReconnectAttempts define the number of attempts to reconnect after a link
# lost. Setting the value to 0 disables reconnecting feature.
#ReconnectAttempts=7
# ReconnectIntervals define the set of intervals in seconds to use in between
# attempts.
# If the number of attempts defined in ReconnectAttempts is bigger than the
# set of intervals the last interval is repeated until the last attempt.
#ReconnectIntervals=1, 2, 4, 8, 16, 32, 64
# AutoEnable defines option to enable all controllers when they are found.
# This includes adapters present on start as well as adapters that are plugged
# in later on. Defaults to 'false'.
AutoEnable=true
@abelmatser
Copy link

abelmatser commented May 17, 2021

Thanks a lot for this fork. It allowed me to start streaming music to my Orange Pi Zero (running Armbian 21 Buster with an external USB Bluetooth dongle), which I could not get the hang of with the original script. I used this to configure a Bluetooth sink option for Snapcast.

Configure Bluetooth
Follow the auto-install script on this page.
To test this I could attach my headphones to my OPZ and stream music from my phone to the device. I could hear this in my headphones - which are configured as the default sound output for me - so the script worked for me.
Sometimes my phone would not connect to the Bluetooth device anymore due to erronous changes made by me. To enter the bluetooth adapter use bluetoothctl for manual commands or remove and unpair your phone in one command with
echo untrust ${MAC} | bluetoothctl && echo remove ${MAC} | bluetoothctl

Configure Alsa loopback & Snapcast
Create a temporary loop sudo modprobe snd-aloop
Find out which device is the loopback device aplay -l
For me the loopback card is card 1. The loop in is subdevice 0 hw:1,0,x and the loop out is subdevice 1 hw:1,1,x. You can choose any value between 0 and 7 for x, I chose 0.
In /etc/snapserver.conf add
source = alsa://?name=Bluetooth&device=hw:1,1,0&send_silence=true&idle_threshold=100
Restart the snapserver service: sudo service snapserver restart
Open a playback window for snapcast at http://${IP}:1780/ and switch to the 'Bluetooth' channel
Play the speaker test to the loop in: speaker-test -D hw:1,0,0 -c 2 this should now playback in your browser, showing the loopback works successfully. If it does not, maybe your devices are different from mine.
Create a lasting loopback device by adding snd-aloop to /etc/modules.

Tie it all together
Finally, change the default sound output device. This redirects the output from the bluetooth sink to the loop in, since the bluetooth sink just uses the default audio device.
Create /etc/asound.conf with inside

defaults.pcm.card 1
defaults.pcm.device 0
defaults.pcm.subdevice 0
defaults.ctl.card 1  # Not sure if this is necessary

Now the loopback will transfer all sound coming into the default sound card (hw:1,0,0) to the loop out (hw:1,1,0), which in turn is read and played by Snapcast.

Reboot to get all elements to play nicely together. Now you can enjoy!

@abelmatser
Copy link

abelmatser commented May 18, 2021

After some fiddling, I got it to work in a VM as well. I am running Ubuntu server 20.04.02 on Proxmox, with the USB Bluetooth attached to the VM. In an Ubuntu 21.04 VM I got loads of errors with Python 2.7 since it is deprecated. Would someone be willing to port the Python script above to Python 3? I am afraid I do not have the necessary knowledge.

Additional steps VM
Use the auto-install script.
Since Python 2 is deprecated, make sure it is installed correctly by trying python or python2.7. In the Python 2.7 shell, you should be able to import dbus without errors. If you encounter errors, find a way to install python2 and python-dbus.
Use which python or which python2.7 to find out where python is installed. We will add these manually to some files:
In /usr/local/bin/a2dp-agent change the first line to reflect the result of which python, for me this became #!/usr/bin/python2.7
In /etc/systemd/system/bt-agent-a2dp.service change ExecStart=/usr/bin/python -u /usr/local/bin/a2dp-agent to ExecStart=/usr/bin/${result which python} -u /usr/local/bin/a2dp-agent.
Use the rest of my previous post.

Also note that when testing for audio and bluetooth devices with the default user instead of root, they should of course be part of that group. So aplay -l will not work if your default user is not part of the audio group. Add the groups to your user with:

sudo usermod -a -G audio $USER
sudo usermod -a -G bluetooth $USER

@abelmatser
Copy link

abelmatser commented May 18, 2021

I do have an error with python-dbus, but this could be because I was messing with the Pythons symlinks

$ sudo apt install python-dbus
Reading package lists... Done
Building dependency tree
Reading state information... Done
python-dbus is already the newest version (1.2.16-1build1).
0 upgraded, 0 newly installed, 0 to remove and 60 not upgraded.
1 not fully installed or removed.
After this operation, 0 B of additional disk space will be used.
Do you want to continue? [Y/n] y
Setting up python-dbus (1.2.16-1build1) ...
/var/lib/dpkg/info/python-dbus.postinst: 6: pycompile: not found
dpkg: error processing package python-dbus (--configure):
 installed python-dbus package post-installation script subprocess returned error exit status 127
Errors were encountered while processing:
 python-dbus
E: Sub-process /usr/bin/dpkg returned an error code (1)

@qcasey
Copy link
Author

qcasey commented May 18, 2021

Glad you found it somewhat useful. And thank you for the added context! Unfortunately I don't have the time to port the py script to python3

@abelmatser
Copy link

I took a small jab at it with the code translation tool 2to3 and tried running it. Then I had to replace GLib.MainLoop instead of deprecated GObject.MainLoop. Now it is running and working for me! Let me know if you have time to test it one day or to update your gist. I have saved it here.

@qcasey
Copy link
Author

qcasey commented May 24, 2021

Thanks! I'll definitely try that out. For now I've added a link in the gist's install notes.

Appreciate it

@feel2death
Copy link

feel2death commented Aug 31, 2021

hi i have a problem with this setup
i need restart the a2dp-agent service multiple time to make it work with my phone or execute/run a2dp- agent script instead service one to make my phone connected
idk what should i do even reenable the service doesnt fix it cuz if i rebooting the server my phone cant connect to the server anymore so i need manually restart the service or sometimes remove the device first from bluetoothctl to make it connect

@Dax0s
Copy link

Dax0s commented Jul 30, 2023

This worked for me, thank you so much!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment