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.

creative bt-w3 plugged into laptop on desk

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.

Questions or comments?
Please feel free to contact me.