12 · bluetooth

A2DP audio playing

mpg123 → /dev/dsp → virtual_oss → SBC → AVDTP → L2CAP → ACL → speaker. One thousand and two bytes per packet. The breakthrough.

● working

This essay is the end of the Bluetooth arc. Essays 9–11 got the chip attached, woken, paired, and encrypted under AES-CCM. None of that produced audio. To play music to a speaker, the path is:

mpg123 → /dev/dsp → virtual_oss → SBC encode → AVDTP MEDIA frames
       → L2CAP CID 0x40 → ACL handle 0x000c → BCM4345 chip
       → AES-CCM encrypted bytes over the air → speaker → wireplumber
       → PipeWire default sink → cone vibrating

Eight transitions. Each one was its own debugging session, but only three of them required code changes in our tree once the chip was talking. The other five required getting the userland incantation right.

The breakthrough commit message was A2DP AUDIO CONFIRMED PLAYING 🎉, and the user’s contemporaneous Slack messages were YE SSIR and, twenty seconds later, A2DP AUDIO CONFIRMED PLAYING. After 37 sessions of BT debugging across roughly three weeks, that’s what shipped.

The L2CAP empty-ACL quirk

The first thing that broke once the encrypted link came up: ACL data started flowing — but ng_l2cap was logging errors and dropping every packet. The chip was emitting 4-byte ACL packets with length=0 between real PDUs as a kind of keepalive. ng_l2cap’s ng_l2cap_lp_acldata_input treated those as malformed fragments and called goto drop with an unexpected-fragment error log entry — but goto drop was after the error path, which already counted the packet against malformed-input limits. Enough keepalives in a row and the L2CAP connection got torn down.

sys/netgraph/bluetooth/l2cap/ng_l2cap_llpi.c

@@ -156,11 +156,20 @@
156 156 /* Check if we have requested/accepted this connection */
157 157 con = ng_l2cap_con_by_addr(l2cap, &ep->bdaddr, ep->link_type);
158 158 if (con == NULL) {
159 NG_L2CAP_ERR(
160 "%s: %s - unexpected LP_ConnectCfm event. Connection does not exist\n",
161 __func__, NG_NODE_NAME(l2cap->node));
162 error = ENOENT;
163 goto out;
159 + /*
160 + * Raw-HCI-initiated connection (hccontrol create_connection
161 + * or similar): no prior lp_con_req from us, so no descriptor
162 + * exists yet. Allocate one so ACL data forwards correctly.
163 + */
164 + if (ep->status != 0)
165 + goto out;
166 + con = ng_l2cap_new_con(l2cap, &ep->bdaddr, ep->link_type);
167 + if (con == NULL) {
168 + error = ENOMEM;
169 + goto out;
170 + }
171 + con->state = NG_L2CAP_W4_LP_CON_CFM;
172 + goto raw_accepted;
164 173 }
165 174
166 175 /* Check connection state */
@@ -184,6 +193,7 @@
184 193 if ((error = ng_l2cap_lp_untimeout(con)) != 0)
185 194 goto out;
186 195
196 + raw_accepted:
187 197 if (ep->status == 0) {
188 198 con->state = NG_L2CAP_CON_OPEN;
189 199 con->con_handle = ep->con_handle;
@@ -686,6 +696,15 @@
686 696 con_handle = NG_HCI_CON_HANDLE(acl_hdr->con_handle);
687 697 pb = NG_HCI_PB_FLAG(acl_hdr->con_handle);
688 698 length = le16toh(acl_hdr->length);
699 +
700 + /*
701 + * Some controllers (BCM4345 observed) emit empty ACL packets
702 + * (PB=continuation, length=0) as keepalive/filler between real
703 + * PDUs. These have no payload; drop silently rather than treat
704 + * as unexpected-fragment.
705 + */
706 + if (length == 0)
707 + goto drop;
689 708
690 709 NG_L2CAP_INFO(
691 710 "%s: %s - got ACL data packet, con_handle=%d, PB=%#x, length=%d\n",

Two changes in one patch. The empty-ACL drop is the second hunk: if (length == 0) goto drop; before the fragment-tracking logic. Silently swallow them — they aren’t malformed, they’re a chip quirk. 033ecf4 ng_l2cap: drop empty ACL packets (BCM4345 keepalive quirk) first; 313881d l2cap: regenerate patch via diff -u regenerated the diff with diff -u after a rebase scrambled the context lines.

The first hunk is the other half of the L2CAP fix and is the more interesting one.

Raw-HCI-initiated connections

The bringup scripts use hccontrol create_connection to open ACL links. That goes through ng_hci’s raw socket path, not through L2CAP’s normal lp_con_req flow. So when Connection_Complete arrives for that handle and L2CAP’s ng_l2cap_lp_con_cfm runs to confirm the connection, L2CAP looks up its own per-peer connection descriptor and finds nothing. Originally it logged unexpected LP_ConnectCfm event. Connection does not exist and bailed.

The patch’s first hunk fixes this: if no descriptor exists and the status is success, allocate a fresh ng_l2cap_con for the bdaddr and continue down the normal “connection accepted” path via a new raw_accepted label. ACL data inbound on that handle now has a place to land. 912a091 ng_l2cap: accept raw-HCI-initiated connections via lp_con_cfm .

This is structurally important because all the bringup scripts use raw-HCI create_connection — there’s no userland code path in our tree that goes through L2CAP’s connect API directly. The kernel had to learn to accept connections it didn’t initiate.

virtual_oss path buffer

virtual_oss is the FreeBSD audio framework that bridges OSS clients (/dev/dsp writers like mpg123) to alternate sinks. Its A2DP backend takes a -P /dev/bluetooth/<bd_addr> argument that opens the netgraph BT socket for that peer. The peer paths look like /dev/bluetooth/e0:d4:64:c5:70:eb — 27 characters. Plus null terminator, plus formatting allowance — well over VMAX_STRING (27). Trying to open the device with the original voss_dsp_tx_device[VMAX_STRING] truncated the bdaddr and the open returned ENOENT.

The patch is small and entirely typing:

+#define	VMAX_PATH 96
...
-extern char voss_dsp_tx_device[VMAX_STRING];
+extern char voss_dsp_tx_device[VMAX_PATH];

Same change for voss_dsp_rx_device and voss_ctl_device. Plus updating the length check in main.c’s argument parsing. 7b3fcf4 virtual_oss: bump path buffer to 96 chars for /dev/bluetooth/<bd_addr> , with bc3b9fb virtual_oss int.h.patch: fix trailing context regenerating the int.h hunk after a context-line drift. None of this is interesting, but without it virtual_oss exits with Device name too long and audio doesn’t move.

The AVDTP / SBC chain — that wasn’t ours

Worth being explicit: we did not write a Bluetooth audio profile stack. virtual_oss’s voss_bt backend already implements AVDTP capability negotiation, SBC encoding, and the L2CAP PSM 25 (AVDTP signaling) / PSM 27 (AVDTP transport) split. All we had to do was give it a working L2CAP underneath. Once the L2CAP empty-ACL fix was in and the path buffer was wide enough to hold the device path, virtual_oss did the rest.

The first audio packet that left the chip:

HCI trace 1 packets
1776846994.269508 ACL len=1002 02 0c 20 e5 03 e1 03 40 00 80 60 00 19 00 00 57 80 00 00 00 01 07 9c f9 40 fb ba a9 a9 99 ba a9 99 99 84 96 bc 57 43 53 9d ae 9c f8 5b 3b 4a 7c 2a 76 34 b8 7e 59 20 64 3f b1 23 88 d3 e7 18 bc 63 ae ab 53 cb 49 52 a2 22 92 a3 14 eb e6 cf 8a

Decoded:

That single packet encodes ~26 ms of stereo 48 kHz audio at the SBC bitrate virtual_oss negotiated. Not particularly compressed (SBC isn’t), but the speaker takes it and the music plays.

The breakthrough

[WAR STORY]

The day audio finally played

A2DP / end-to-end

▸ symptom

Pairing works (essay 11). Encryption is AES-CCM. ACL handle is open. virtual_oss starts and reports it’s connected to /dev/bluetooth/e0:d4:64:c5:70:eb. mpg123 is reading the file. /dev/dsp is being written to. The speaker is silent.

▸ hypothesis 1

The L2CAP AVDTP signaling channel (PSM 25) isn’t getting open. Watched hcisniff. Saw the AVDTP Connect and Get_Capabilities exchanges complete. Saw Set_Configuration succeed. Saw Open succeed. Saw Start succeed. Then nothing. The transport channel was open and the speaker was waiting for media frames.

▸ hypothesis 2

SBC encoding is failing inside virtual_oss. Watched voss.log. SBC frames are being produced. They’re being handed to the L2CAP transport channel. The send is returning success. But no ACL packet ever leaves the chip.

▸ hypothesis 3

ng_l2cap is dropping the outbound ACL on the floor because of the raw-HCI connection issue — the ACL handle exists in the chip but L2CAP doesn’t have a con descriptor for it, so ng_l2cap_lp_send can’t find a target. Confirmed with kernel printfs. The descriptor was missing. Adding the raw_accepted path in ng_l2cap_llpi.c (the first hunk in the patch above) gave L2CAP a descriptor to write into.

▸ breakthrough

With the raw-accepted path in place, the next playback attempt produced the 1002-byte ACL packet shown above. wireplumber on the speaker side logged:

spa.bluez5: Acquiring transport /org/bluez/hci0/dev_E0_D4_64_C5_70_EB/fd0
spa.bluez5.source.media: using A2DP codec SBC
pw.node: bluez_input.E0_D4_64_C5_70_EB.2-106 suspended -> running

The speaker played the opening notes of One More Time. We heard music.

▸ fix

The L2CAP raw_accepted path ( 912a091 ng_l2cap: accept raw-HCI-initiated connections via lp_con_cfm ) plus the empty-ACL drop ( 033ecf4 ng_l2cap: drop empty ACL packets (BCM4345 keepalive quirk) ) plus the virtual_oss path-buffer expansion ( 7b3fcf4 virtual_oss: bump path buffer to 96 chars for /dev/bluetooth/<bd_addr> ) plus the bt_a2dp.sh orchestration script ( 64b2c55 bt_one_shot.sh: single-ACL pair+encrypt+A2DP sequence for the consolidated bt_one_shot.sh variant) — all of these landed on April 22. The breakthrough commit message: baafaea A2DP AUDIO CONFIRMED PLAYING 🎉 A2DP AUDIO CONFIRMED PLAYING 🎉.

▸ lesson

The last fix is rarely the heroic one. By the time A2DP played, every individual change in the path was small — a goto drop on length==0, a raw_accepted label, a buffer size constant, a few orchestration steps in a shell script. The hard work was knowing which small changes were the ones the system needed, and that took the entire arc of essays 9–11 to discover. There is no shortcut. You do the work and at the end audio plays.

The orchestration

bt_a2dp.sh (and its later one-shot consolidation bt_one_shot.sh, 64b2c55 bt_one_shot.sh: single-ACL pair+encrypt+A2DP sequence ) is the script that drives the whole chain from a cold chip through audio playback. It’s 116 lines of set -e shell, structured as:

  1. kldload ng_h4frame ng_hci ng_l2cap ng_btsocket cuse
  2. Toggle BT_REG_ON via gpioctl to power-cycle the chip.
  3. Run bcm_firmware_load.pl (essay 9).
  4. kldload bcm_hostwake (essay 10).
  5. Spin up bt_attach to wire the netgraph mesh: ng_ttyng_h4frameng_hcing_l2capbtsock_l2c / btsock_hci_raw / btsock_sco.
  6. Start hcisniff for the diagnostic log.
  7. hccontrol initialize, write_class_of_device 24:04:14 (audio sink), write_scan_enable 3, write_page_timeout 16384.
  8. Issue Write_Secure_Connections_Host_Support=1 via the helper hcicmd tool (essay 11).
  9. Start hcsecd with the SSP-aware Just Works config.
  10. create_connection to the speaker bdaddr; poll for Connection_Complete in the sniff log; extract the ACL handle.
  11. auth_request on the handle; poll for Auth_Complete.
  12. set_connection_encryption enable=1; poll for Encryption_Change and check enabled=0x02.
  13. Spawn virtual_oss -C 2 -c 2 -r 48000 -b 16 -s 4ms -R /dev/null -P /dev/bluetooth/<bdaddr> -d dsp.
  14. mpg123 /tmp/music/01\ One\ More\ Time.mp3.

It works. It is also fragile — the polling loops use grep against a hex-formatted sniff log, the timing windows are tuned by sleep-and-check, and any change in the chip’s response timing breaks step 10 silently. Filed.

╞═ Phone-side play (after pair) ═╡
sudo /usr/local/sbin/virtual_oss \
    -C 2 -c 2 -r 48000 -b 16 -s 4ms \
    -R /dev/null \
    -P /dev/bluetooth/<peer-bdaddr> \
    -d dsp &
AUDIODEV=/dev/dsp mpg123 /tmp/music/song.mp3

What’s still open

Three honest gaps:

This is the end of the Bluetooth arc. The chip is attached (essay 9), woken (essay 10), paired and encrypted under AES-CCM (essay 11), and now playing audio to a speaker (essay 12). Nothing in this stack is upstream-quality yet — every component has at least one open follow-up — but every component works end-to-end against real hardware on the bench. The next arc is whatever comes after Bluetooth. Probably modem.