Bluetooth Audio on OpenBSD with the Creative BT-W3
Fifteen years ago, NetBSD's Bluetooth audio stack was
imported
into OpenBSD.
From what I remember using it back then, it worked sufficiently well but its
configuration was cumbersome.
It supported Bluetooth HID keyboards and mice, audio, and serial devices.
Six years ago, however, it was
tedu'd
due to conflicts with how it integrated into our kernel.
While we still have no Bluetooth support today, it is possible to play audio on Bluetooth headphones using a small hardware dongle.
Table of Contents
Creative BT-W2
Last year I
came across
the
Creative BT-W2
USB device, which presents a standard
uaudio(4)
device on OpenBSD and handles all of the Bluetooth pairing and audio
communication itself with just one shortcoming: it did not expose any volume
control mechanism.
OpenBSD's sound server,
sndiod
,
did have software volume control so it was possible to limit the volume through
aucatctl
(now
sndioctl
).
I've been using the BT-W2 frequently since then to send audio from my OpenBSD laptop to my Apple AirPods Pro, but unfortunately Apple released a firmware at some point that limited the volume output when paired with such devices, including Android phones. Presumably this was a safety measure because unless the sending side was doing software volume control (which the AirPods wouldn't know about), the AirPods would play at maximum volume.
Unfortunately, even at the loudest volume from sndiod
, the volume to the
AirPods was still quite low, sometimes even too low to understand YouTube videos
with poor audio like conference talks.
Otherwise though, the BT-W2 worked well and I didn't notice any latency or
video sync issues on OpenBSD.
Creative BT-W3
The other day I became aware of the updated
Creative BT-W3,
which now has a USB-C interface instead of USB-A and finally exposes hardware
mixer control (note the 2 ctls
):
uaudio0 at uhub0 port 3 configuration 1 interface 1 "Creative Technology Ltd Creative BT-W3" rev 2.00/1.00 addr 2
uaudio0: class v1, full-speed, sync, channels: 2 play, 1 rec, 2 ctls
audio1 at uaudio0
Since Tweeting about the BT-W2 last year, OpenBSD's audio system has changed
quite a bit and now sndiod
controls output volume itself with sndioctl
being
the preferred utility, rather than directly changing hardware mixer settings
with mixerctl
as in years past.
The new hardware volume control (outputs.dac
) can still be seen or modified
directly with mixerctl
and passing it the control device for audio1
(as
the default /dev/audioctl0
is for the built-in audio device of my laptop):
# mixerctl -f /dev/audioctl1
outputs.dac=161
outputs.dac_mute=off
record.enable=sysctl
Whatever mechanism the BT-W3 uses to handle this hardware volume control (whether just doing software volume limiting itself, or passing it through to the AirPods through some fancy audio protocol), the benefit is that now the AirPods can be used at full volume from OpenBSD.
Automatically Switching to Bluetooth
My laptop's Dolby Atmos speaker setup is pretty good, so normally I just listen to music or play YouTube videos through the speakers. When my son is napping and I need to use my AirPods, I want to just plug in the BT-W3 dongle and have it automatically start sending audio to my AirPods, and have the volume controls on my keyboard control the AirPods.
To accomplish this, set an alternate device name with sndiod
:
# rcctl set sndiod flags -f rsnd/0 -F rsnd/1
# rcctl restart sndiod
In this mode, sndiod
will play through rsnd/1
if it exists, which maps to
the second audio device (audio1
).
If the device is not present, such as when the BT-W3 is not plugged in, it will
play through rsnd/0
which maps to audio0
, the laptop's built-in speakers.
This works fine if the device is present when sndiod
starts, but otherwise it
will need a SIGHUP
to re-scan the audio devices once the BT-W3 is plugged in,
and start sending audio through it.
This can be done automatically with hotplugd
:
# cat > /etc/hotplug/attach
case $2 in
uaudio*)
pkill -HUP sndiod
;;
esac
^D
# chmod +x /etc/hotplug/attach
# rcctl enable hotplugd
# rcctl start hotplugd
Now when a new uaudio
device is plugged in and detected by the kernel,
hotplugd
will send a SIGHUP
to sndiod
which will see that rsnd/1
is
available and start sending audio to it.
When the BT-W3 is unplugged, sndiod
will automatically detect that the device
is no longer usable and send audio to its fallback, rsnd/0
.
Hardware device switching will be seamless and any applications playing audio
won't have to stop or be restarted.
My
window manager
is configured to respond to the hardware volume keys on my laptop (F4 for mute,
F5 for volume down, and F6 for volume up) by executing sndioctl
, so the
commands will work the same regardless of which device sndiod
is talking to.
definekey top F4 exec sndioctl -q output.mute=!; pkill -USR1 i3status; true
definekey top F5 exec sndioctl -q output.mute=0; sndioctl -q output.level=-0.05; pkill -USR1 i3status; true
definekey top F6 exec sndioctl -q output.mute=0; sndioctl -q output.level=+0.05; pkill -USR1 i3status; true
Responding to Headphone Buttons
If your Bluetooth headphones have buttons on them, these can pass through the BT-W3 as USB HID reports. My AirPods Pro have one hardware button (a squeeze on the stem) which can be single, double, or triple pressed to perform a play/pause, next track, and previous track.
The possible actions that the BT-W3 supports can be seen with
usbhidctl
on the first HID report of the device, which must be located in dmesg
:
uaudio1 at uhub0 port 1 configuration 1 interface 1 "Creative Technology Ltd Creative BT-W3" rev 2.00/1.00 addr 10
uaudio1: class v1, full-speed, sync, channels: 2 play, 1 rec, 2 ctls
audio2 at uaudio1
uhidev4 at uhub0 port 1 configuration 1 interface 3 "Creative Technology Ltd Creative BT-W3" rev 2.00/1.00 addr 10
uhidev4: iclass 3/0, 3 report ids
uhid11 at uhidev4 reportid 1: input=2, output=0, feature=0
[...]
In my case, the first HID report on the BT-W3 is uhid11
, so running
usbhidctl
on /dev/uhid11
can retrieve the full report descriptor:
# usbhidctl -f /dev/uhid11 -r
Report descriptor:
Collection page=Consumer usage=Consumer_Control
Input size=1 count=1 page=Consumer usage=Play/Pause, logical range 0..1
Input size=1 count=1 page=Consumer usage=Scan_Next_Track, logical range 0..1
Input size=1 count=1 page=Consumer usage=Scan_Previous_Track, logical range 0..1
Input size=1 count=1 page=Consumer usage=Stop, logical range 0..1
Input size=1 count=1 page=Consumer usage=Play, logical range 0..1
Input size=1 count=1 page=Consumer usage=Pause, logical range 0..1
Input size=1 count=1 page=Consumer usage=Fast_Forward, logical range 0..1
Input size=1 count=1 page=Consumer usage=Rewind, logical range 0..1
Input size=1 count=1 page=Consumer usage=Volume_Increment, logical range 0..1
Input size=1 count=1 page=Consumer usage=Volume_Decrement, logical range 0..1
Input size=1 count=1 page=Consumer usage=Mute, logical range 0..1
End collection
Total input size 2 bytes
Total output size 0 bytes
Total feature size 0 bytes
By using the -l
option, input reports can be seen when the button on the
AirPod is pressed:
# usbhidctl -f /dev/uhid11 -l
Consumer_Control.Play/Pause=1
Consumer_Control.Scan_Next_Track=0
Consumer_Control.Scan_Previous_Track=0
Consumer_Control.Stop=0
Consumer_Control.Play=0
Consumer_Control.Pause=0
Consumer_Control.Fast_Forward=0
Consumer_Control.Rewind=0
Consumer_Control.Volume_Increment=0
Consumer_Control.Volume_Decrement=0
Consumer_Control.Mute=0
Consumer_Control.Play/Pause=0
[...]
One event is generated to report Consumer_Control.Play/Pause=1
, then another
right after it to report Consumer_Control.Play/Pause=0
.
To automate responding to these events,
usbhidaction
can be used.
By default, the /dev/uhid*
devices are owned by root:wheel
and are mode
0600
, so to make things easier, I'll chmod
them 0660
so I can access them
without doas
.
This needed because the program has to run as my own user to access my X11
session and environment variables.
With a simple configuration file, I can make usbhidaction
run my
music
script to play/pause, skip to the next track, or play the previous track.
$ cat .usbhidaction.conf
Consumer:Play/Pause 1
~/bin/music playpause
Consumer:Scan_Next_Track 1
~/bin/music next
Consumer:Scan_Previous_Track 1
~/bin/music prev
$ usbhidaction -dv -c .usbhidaction.conf -f /dev/uhid11
PARSE:1 Consumer:Play/Pause, 1, '~/bin/music playpause'
PARSE:2 Consumer:Scan_Next_Track, 1, '~/bin/music next'
PARSE:3 Consumer:Scan_Previous_Track, 1, '~/bin/music prev'
report size 2
executing '~/bin/music playpause'
Unfortunately usbhidaction
is not a very user-friendly program so it must be
started after the BT-W3 is plugged in and you must lookup which uhid
device is
the correct one to operate on each time.
I have
some
hacks
to work around these issues but it would be nice to have something more generic
that listens for input reports from all uhid
devices automatically and outputs
them on some device stream that any program can listen to.
But that is a project for another time.