Advanced Topics
GATT Services
How Audio Compresses
Debugging Live
What We Cover in Part 2
In Part 1 we learned what Isochronous Streams are — the timing, the PDUs, the BlueZ ISO socket API. In Part 2 we go up the stack (BAP profiles, ASCS, PACS, GATT services) and down to the hardware (LC3 codec, ISOAL timing, radio coexistence with Wi-Fi). We also show how to debug everything live with btmon and walk a complete phone-to-earbud scenario from pairing to audio rendering.
All code examples are taken from BlueZ source: tools/isotest.c, profiles/audio/bap.c, lib/bluetooth/iso.h, and the BlueZ unit test suite.
New Terms in Part 2
Before diving into individual pieces, it helps to see the full picture. BLE Audio is not one protocol — it is a stack of profiles and services built on top of the Isochronous transport we studied in Part 1.
| CAP — Common Audio Profile (top-level coordination) | |||
| BAP Basic Audio Profile |
VCP / MICP Volume / Mic Control |
CSIP Coordinated Set |
MCP / CCP Media / Call Control |
| ASCS ASE Control |
PACS Published Audio Caps |
BASS Broadcast Audio Scan |
CSIS Coordinated Set ID |
| GATT (Generic Attribute Profile) — all above services run on top of this | |||
| ISO Sockets / CIS (Connected Isochronous Stream) | ISO Sockets / BIS (Broadcast Isochronous Stream) | ||
| Link Layer — Isochronous Physical Channel (Part 1 topics: ISO Interval, Subevents, NSE, FT, BN) | |||
The key insight is this: GATT controls the setup (negotiating codec parameters, enabling ASEs), and ISO sockets carry the audio. They are separate paths — GATT uses the regular ACL link, while audio flows on the dedicated ISO link.
| Control Path (GATT over ACL)
Phone reads PACS → knows what earbud supports Uses existing Bluetooth Classic-style ACL link |
+ | Audio Path (ISO socket)
Phone calls socket() / setsockopt() / connect() New Isochronous Link Layer channel |
Before streaming any audio, the phone must know what the earbud is capable of. This discovery happens via the PACS GATT service running on the earbud. Think of PACS as the earbud’s “menu” — it lists every codec configuration it can handle.
| GATT Characteristic | UUID | What it tells the phone |
|---|---|---|
| Sink PAC | 0x2BC9 | Codecs earbud can receive (e.g. LC3 at 48kHz, 16kHz) |
| Source PAC | 0x2BCB | Codecs earbud can transmit — only non-zero if it has a microphone |
| Sink Audio Locations | 0x2BCA | Channel allocation: Front Left (0x01), Front Right (0x02), or both |
| Source Audio Locations | 0x2BCC | Which direction the mic audio comes from |
| Available Audio Contexts | 0x2BCD | What types of audio it can handle right now: Media, Conversational, Ringtone etc. |
| Supported Audio Contexts | 0x2BCE | All context types the earbud firmware supports (regardless of current state) |
What is a PAC Record?
Each PAC characteristic contains one or more PAC Records. Each record describes one supported codec configuration. A modern earbud might have 3-5 PAC records covering different sample rates and bitrates.
| Field | Size | Example Value | Meaning |
|---|---|---|---|
| Coding_Format | 1 byte | 0x06 | LC3 codec (0x06 is the assigned number for LC3) |
| Company_ID | 2 bytes | 0x0000 | 0x0000 = standard SIG codec (not vendor-specific) |
| Vendor_Specific_Codec_ID | 2 bytes | 0x0000 | 0 for standard codecs |
| Codec_Specific_Capabilities | variable | LTV-encoded | Supported sample rates, frame durations, channel counts, octets range |
| Metadata | variable | LTV-encoded | Preferred contexts: “this PAC record is best for Media streaming” |
LTV Format — Length, Type, Value
The Codec_Specific_Capabilities field uses a compact LTV (Length-Type-Value) encoding. Each capability is listed as: 1 byte length, 1 byte type, N bytes of value. This is similar to TLV but the Length includes the Type byte.
/* Example: Decoding a PAC record's Codec_Specific_Capabilities in C */
/* Raw bytes from a Sink PAC characteristic */
uint8_t caps[] = {
0x03, 0x01, 0x05, 0x00, /* Supported Sampling Frequencies: 16kHz + 48kHz */
0x02, 0x02, 0x03, /* Supported Frame Durations: 7.5ms + 10ms */
0x02, 0x03, 0x01, /* Supported Audio Channel Counts: 1 channel */
0x05, 0x04, 0x1E, 0x00, 0x78, 0x00, /* Min octets=30, Max octets=120 */
0x02, 0x05, 0x01 /* Max Codec Frames per SDU: 1 */
};
/* How to read it:
* Byte 0: Length = 0x03 (meaning: type + 2 value bytes follow)
* Byte 1: Type = 0x01 (Supported_Sampling_Frequencies)
* Byte 2-3: Value = 0x0005
* Bit 0 = 8kHz (not set)
* Bit 1 = 11.025kHz (not set)
* Bit 2 = 16kHz (set!) ← 0x0004 bit is bit 2
* ...
* Bit 8 = 48kHz (set!) ← 0x0100 not in 0x0005, wait...
*
* Actually 0x0005 = bits 0 and 2 set:
* bit 0 = 8000 Hz
* bit 2 = 16000 Hz
* Typical earbud: 0x00FD = 8/16/24/32/44.1/48 kHz all supported
*/
/* Type values for Codec_Specific_Capabilities */
#define LC3_SUPPORTED_SAMPLING_FREQ 0x01
#define LC3_SUPPORTED_FRAME_DURATION 0x02
#define LC3_SUPPORTED_CHAN_COUNT 0x03
#define LC3_SUPPORTED_OCTETS_PER_FRAME 0x04
#define LC3_SUPPORTED_FRAMES_PER_SDU 0x05
/* Sampling frequency bit masks (used in type 0x01) */
#define LC3_FREQ_8KHZ (1 << 0)
#define LC3_FREQ_11KHZ (1 << 1)
#define LC3_FREQ_16KHZ (1 << 2)
#define LC3_FREQ_22KHZ (1 << 3)
#define LC3_FREQ_24KHZ (1 << 4)
#define LC3_FREQ_32KHZ (1 << 5)
#define LC3_FREQ_441KHZ (1 << 6) /* 44.1 kHz */
#define LC3_FREQ_48KHZ (1 << 7)
BlueZ: Reading PACS with D-Bus
/* In BlueZ, you access PACS through the MediaEndpoint D-Bus interface.
* When BlueZ discovers a PACS service, it creates a MediaEndpoint object
* and calls your registered SelectConfiguration() D-Bus method.
*
* The flow in Python (bluetoothctl uses this internally):
*
* 1. Phone pairs with earbud
* 2. BlueZ reads Sink PAC characteristic over GATT
* 3. BlueZ calls your app's SelectConfiguration(capabilities) D-Bus method
* 4. Your app picks the best codec config from capabilities
* 5. Your app returns the chosen Codec_Specific_Configuration back to BlueZ
* 6. BlueZ writes this config to the earbud's ASCS ASE characteristic
*/
/* Example D-Bus callback skeleton in C using GLib */
#include <gio/gio.h>
static void select_configuration(GDBusMethodInvocation *invocation,
GVariant *capabilities)
{
/* capabilities contains the PAC record data from the earbud */
/* Parse it and pick the best supported config */
GVariantBuilder builder;
g_variant_builder_init(&builder, G_VARIANT_TYPE("ay"));
/* Build Codec_Specific_Configuration for 48kHz, 10ms, 100 octets/frame */
/* LTV format: Sampling Frequency */
uint8_t config[] = {
0x02, 0x01, 0x08, /* Sampling Freq: 0x08 = 48kHz */
0x02, 0x02, 0x02, /* Frame Duration: 0x02 = 10ms */
0x05, 0x03, 0x01, 0x00, 0x00, 0x00, /* Audio Channel: Front Left */
0x03, 0x04, 0x64, 0x00, /* Octets per Codec Frame: 100 bytes */
0x02, 0x05, 0x01 /* Codec Frames per SDU: 1 */
};
for (size_t i = 0; i < sizeof(config); i++)
g_variant_builder_add(&builder, "y", config[i]);
g_dbus_method_invocation_return_value(invocation,
g_variant_new("(ay)", &builder));
}
LC3 (Low Complexity Communication Codec) is the mandatory codec for BLE Audio. Every BLE Audio device must support it. It was designed to achieve better audio quality than SBC at lower bitrates, while using less CPU than AAC or aptX.
| 1. Input
10ms of PCM samples |
→ | 2. MDCT
Modified Discrete |
→ | 3. Quantisation
Reduce precision of |
→ | 4. Entropy Coding
Arithmetic coding |
→ | 5. Output
100 bytes |
LC3 Configuration Options — The Numbers You Set
| Type | Parameter Name | Common Values | What changing it does |
|---|---|---|---|
| 0x01 | Sampling_Frequency | 0x03=16kHz 0x05=24kHz 0x08=48kHz |
Higher = more frequencies captured, better treble, bigger SDU size |
| 0x02 | Frame_Duration | 0x00=7.5ms 0x01=10ms |
10ms gives better quality. 7.5ms gives lower latency (for gaming) |
| 0x03 | Audio_Channel_Allocation | 0x01=Front Left 0x02=Front Right 0x03=Both |
Tells which earbud plays this BIS channel (left or right or mono) |
| 0x04 | Octets_per_Codec_Frame | 30-240 bytes | More bytes = higher bitrate = better quality. 100 bytes at 48kHz = 80Kbps (HQ) |
| 0x05 | Codec_Frame_Blocks_per_SDU | 1 (almost always) | How many codec frames fit in one SDU. Usually 1. |
Predefined Quality Settings — Mandatory Support Table
| Config Name | Sample Rate | Frame Duration | Octets/Frame | Bitrate | Best for |
|---|---|---|---|---|---|
| 8_1 | 8 kHz | 7.5 ms | 26 | 27.7 Kbps | Narrow-band voice |
| 8_2 | 8 kHz | 10 ms | 30 | 24 Kbps | Narrow-band voice |
| 16_1 | 16 kHz | 7.5 ms | 30 | 32 Kbps | Low-latency voice calls |
| 16_2 ⭐ | 16 kHz | 10 ms | 40 | 32 Kbps | Wideband voice (mandatory baseline) |
| 48_1 | 48 kHz | 7.5 ms | 75 | 80 Kbps | Low-latency music |
| 48_4 ⭐ | 48 kHz | 10 ms | 120 | 96 Kbps | High-quality music streaming |
⭐ marks the most common configs. 16_2 is for calls, 48_4 is for music.
LC3 in Practice — Using liblc3 with ISO Sockets
/*
* liblc3 is the open-source reference LC3 implementation.
* Install: git clone https://github.com/google/liblc3
*
* This snippet shows the glue between LC3 encoder and ISO send()
*/
#include "lc3.h" /* liblc3 header */
#define SAMPLE_RATE 48000
#define FRAME_US 10000 /* 10ms frame in microseconds */
#define OCTETS_PER_FRAME 100 /* 100 bytes/frame = 80Kbps */
#define NUM_CHANNELS 1
/* Calculate frame size in samples: 48000 * 10ms = 480 samples */
int frame_samples = lc3_frame_samples(FRAME_US, SAMPLE_RATE);
/* Set up LC3 encoder (allocated once, reused each frame) */
lc3_encoder_t encoder = lc3_setup_encoder(FRAME_US, SAMPLE_RATE,
0, /* 0 = no pitch detection */
alloca(lc3_encoder_size(FRAME_US,
SAMPLE_RATE)));
int16_t pcm_buf[480]; /* 10ms of raw PCM audio (480 samples at 48kHz) */
uint8_t lc3_buf[100]; /* Compressed LC3 output = one ISO SDU */
/* Main loop: encode and send every 10ms */
while (1) {
/* Step 1: Get 10ms of PCM from audio capture (e.g. ALSA) */
get_audio_capture(pcm_buf, frame_samples);
/* Step 2: LC3-encode into 100 bytes */
int ret = lc3_encode(encoder,
LC3_PCM_FORMAT_S16, /* input format: signed 16-bit */
pcm_buf, /* input PCM samples */
1, /* stride (channels) */
OCTETS_PER_FRAME, /* output size: 100 bytes */
lc3_buf); /* output buffer */
if (ret != 0) {
fprintf(stderr, "LC3 encode error: %d\n", ret);
continue;
}
/* Step 3: Send one SDU over ISO socket */
ssize_t sent = send(iso_sock_fd, lc3_buf, OCTETS_PER_FRAME, 0);
if (sent != OCTETS_PER_FRAME) {
perror("ISO send() incomplete");
}
/* ISO socket timestamps ensure we don't need usleep() manually —
* the kernel blocks until the next ISO Interval if we send too fast */
}
/* On the RECEIVER side: */
uint8_t rx_buf[100];
int16_t pcm_out[480];
lc3_decoder_t decoder = lc3_setup_decoder(FRAME_US, SAMPLE_RATE,
0,
alloca(lc3_decoder_size(FRAME_US,
SAMPLE_RATE)));
while (1) {
ssize_t n = recv(iso_sock_fd, rx_buf, sizeof(rx_buf), 0);
if (n <= 0) {
/* Packet loss: pass NULL to decoder, it uses PLC (Packet Loss Concealment) */
lc3_decode(decoder, NULL, 0, LC3_PCM_FORMAT_S16, pcm_out, 1);
} else {
lc3_decode(decoder, rx_buf, n, LC3_PCM_FORMAT_S16, pcm_out, 1);
}
/* Send pcm_out to ALSA playback... */
}
ASCS is the GATT service that controls the lifecycle of an audio stream. It lives on the earbud (Acceptor/Server side) and the phone (Initiator/Client) writes to it to move a stream from idle to active.
The core concept is an ASE (Audio Stream Endpoint). Think of an ASE as one audio pipe — it can be a Sink ASE (earbud receives audio) or a Source ASE (earbud sends mic audio). Each ASE has its own GATT characteristic and its own state machine.
| ASE States (each transition triggered by a write to ASE Control Point) | ||||||||
| Idle No stream configured |
Config Codec→ | Codec Configured LC3 params set |
Config QoS→ | QoS Configured CIS params set |
Enable→ | Enabling CIS being set up |
→Rx/Tx Start Ready→ | Streaming Audio flowing! |
From Streaming: Disable → Disabling → QoS Configured → Releasing → Idle (full teardown)
ASE Control Point — Operations Written by the Phone
| Opcode | Operation | State Transition | What’s written in the payload |
|---|---|---|---|
| 0x01 | Config Codec | Idle → Codec Configured | ASE_ID, Coding Format (0x06=LC3), Codec_Specific_Configuration (LTV: freq, duration, octets) |
| 0x02 | Config QoS | Codec Configured → QoS Configured | CIG_ID, CIS_ID, SDU_Interval, Framing, PHY, Max_SDU, Retransmission_Number, Max_Transport_Latency, Presentation_Delay |
| 0x03 | Enable | QoS Configured → Enabling | ASE_ID, Metadata (Context Type: Media=0x0004, Conversational=0x0002) |
| 0x04/0x05 | Receiver Start Ready / Tx Start Ready | Enabling → Streaming | ASE_ID only — earbud confirms CIS is established and it’s ready to receive audio |
| 0x06 | Disable | Streaming → Disabling | Pause audio — keep CIS alive (quick resume possible) |
| 0x08 | Release | Any → Releasing → Idle | Full teardown — CIS disconnected, CIG removed |
BlueZ BAP Plugin — How It Drives ASCS
/* BlueZ profiles/audio/bap.c handles the ASCS state machine automatically.
* When your app calls the BlueZ MediaTransport.Acquire() D-Bus method,
* BlueZ does the following GATT writes in sequence:
*
* 1. Write Config Codec (opcode 0x01) to ASE Control Point
* → Earbud sends Notification: ASE state = Codec Configured
*
* 2. Write Config QoS (opcode 0x02)
* → Earbud sends Notification: ASE state = QoS Configured
*
* 3. Write Enable (opcode 0x03) with context = Media
* → Earbud sends Notification: ASE state = Enabling
*
* 4. BlueZ calls LE_Set_CIG_Parameters + LE_Create_CIS (ISO socket connect())
* → HCI event: LE CIS Established
*
* 5. BlueZ writes Receiver Start Ready (opcode 0x04)
* → Earbud sends Notification: ASE state = Streaming
*
* 6. BlueZ returns the ISO file descriptor to your app via
* MediaTransport.Acquire() reply
*
* 7. Your app writes LC3 audio to that file descriptor (send())
*/
/* Simplified D-Bus sequence to start audio in Python */
import dbus
bus = dbus.SystemBus()
# Get the transport object (BlueZ discovered it via ASCS)
transport = bus.get_object("org.bluez", "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/fd0")
transport_iface = dbus.Interface(transport, "org.bluez.MediaTransport1")
# Acquire returns the ISO socket file descriptor + transport parameters
fd, mtu_r, mtu_w = transport_iface.Acquire()
# fd is the ISO socket — write LC3 audio to it every 10ms
print(f"Got ISO fd={fd}, read_mtu={mtu_r}, write_mtu={mtu_w}")
We learned in Part 1 that a BIS receiver must scan to find the broadcaster’s Periodic Advertising train, then read BIGInfo to sync to the BIG. This scanning takes time and wastes battery. PAST eliminates this scanning step.
| Without PAST — Earbud Scans Independently
1. Phone starts BIG broadcast Right earbud does the SAME thing all over again |
⚡ | With PAST — Phone Tells Earbuds Where to Tune
1. Phone starts BIG broadcast No scanning, no multi-hop advertising chain, instant sync |
PAST in the Context of the Auracast Use Case
Auracast is the official Bluetooth brand for public audio broadcasting (hearing loops, airport PA, gym TV audio). The BASS (Broadcast Audio Scan Service) profile provides a GATT-level way for the phone to act as a Broadcast Assistant: the user’s phone scans for broadcasts, the user picks one from the phone’s UI, and the phone uses PAST to tell the earbuds where to sync — no need for the earbuds to scan at all.
| Broadcaster (Airport PA, TV, etc.) Sends BIG broadcast |
→BIS→ →BIS→ |
Phone (Broadcast Assistant)
1. Scans & finds BIG |
→PAST→ →BASS→ |
Earbuds (Scan Delegator)
1. Receive PAST from phone |
BASS GATT Characteristics
| Characteristic | UUID | What the phone writes / reads |
|---|---|---|
| Broadcast Audio Scan Control Point | 0x2BC7 | Phone writes “Add Source” (opcode 0x02) with Broadcast_ID, Advertiser address, PA_Sync, BIS_Sync bitmask |
| Broadcast Receive State | 0x2BC8 | Earbud notifies phone of current sync state: PA_Sync_State (syncing/synced/failed), BIS_Sync (active BIS bitmask), Encrypt_State |
BlueZ: PAST HCI Command
/*
* The HCI command that sends the sync info to the earbud is:
* LE_Periodic_Advertising_Sync_Transfer (OGF=0x08, OCF=0x005A)
*
* Parameters:
* Connection_Handle: the ACL handle of the earbud connection
* Service_Data: opaque 16-bit value passed to earbud (can be BIS index)
* Sync_Handle: handle of the Periodic Advertising train we're synced to
*
* BlueZ does this via the MediaAssistant D-Bus interface.
* The following is a simplified view of what BlueZ sends internally:
*/
struct hci_le_pa_sync_transfer {
uint16_t connection_handle; /* earbud's ACL connection handle */
uint16_t service_data; /* passed through to earbud */
uint16_t sync_handle; /* our PA sync handle (from LE_PA_Create_Sync) */
} __attribute__((packed));
/*
* On the earbud side, the Controller generates:
* HCI_LE_Periodic_Advertising_Sync_Transfer_Received event
* which gives the earbud everything needed to sync to the BIG:
* - Sync_Handle (new local handle)
* - Advertiser address + SID
* - PA_Interval
* - Clock_Accuracy
* The earbud then reads BIGInfo from the synced PA train.
*/
/* BlueZ exposes this through org.bluez.MediaAssistant1 D-Bus API */
/* When you call PushSource() on a broadcast receiver device, BlueZ
* internally triggers the PAST HCI command */
Modern phones have one physical antenna shared between Wi-Fi (2.4GHz) and Bluetooth. They use a tiny hardware switch to alternate between them. The challenge: audio needs Bluetooth every 10ms, but Wi-Fi also needs the antenna for data. Who wins?
| 10ms Timeline | |||||||
| BT Tx/Rx (CIS se1) |
Wi-Fi Data |
Wi-Fi Data |
BT CIE→Close |
Wi-Fi Data |
Wi-Fi Data |
Gap before next anchor |
|
| Anchor+Subevent 1 | Large Wi-Fi window freed by early CIE close | CIE=1 sent | More Wi-Fi time | Dead time | |||
When CIE closes the audio event early after 1 Subevent, Wi-Fi gets 8ms+ of the 10ms interval — enough for 5-6 TCP packets
MWS — Modem and Wireless Stack Coexistence
For more complex coexistence (e.g. when both Wi-Fi and LTE also compete for the antenna), Bluetooth 5.0+ includes the MWS transport layer — a standardised way for the Bluetooth Controller to tell other radio subsystems “I need the antenna for the next 3ms”. The Controller asserts a hardware signal, and the Wi-Fi chip waits politely.
| Mechanism | How it works | Who controls it |
|---|---|---|
| CIE Early Close | Audio event ends as soon as ACK received — frees remaining Subevents | BT Controller automatically |
| MWS Hardware Signal | BT Controller pulls a GPIO high → Wi-Fi chip sees signal → defers its Tx | BT Controller, chip-to-chip |
| Adaptive Frequency Hopping | BT avoids Wi-Fi channels — already in the BLE channel selection algorithm | BT Controller, auto-updated channel map |
| 2M PHY (LE 2M) | Doubles transmission speed → BT occupies antenna for half the time | Set in QoS: phy = 0x02 |
btmon is BlueZ’s HCI sniffer. It captures every HCI command and event between your Linux host and the Bluetooth Controller chip, and prints them in human-readable form. It is the most important tool for debugging BLE Audio setup problems.
Starting btmon
# Run in a separate terminal — it captures all HCI traffic live
sudo btmon
# Save to a file for later analysis
sudo btmon -w audio_session.btsnoop
# Open in Wireshark later
wireshark audio_session.btsnoop
What You See When a CIS is Created
/* Example btmon output when phone sets up CIS to earbud */
@ HCI Command: LE Set CIG Parameters (0x08|0x0062) plen 25
CIG ID: 0x00
C to P Interval: 10000 us <-- 10ms ISO interval
P to C Interval: 10000 us
Worst Case SCA: 251-500 ppm
Packing: Sequential (0x00)
Framing: Unframed (0x00)
CIS Count: 1
CIS ID: 0x00
Max SDU C to P: 100 <-- 100 bytes per frame
Max SDU P to C: 0 <-- unidirectional (no mic)
PHY C to P: LE 2M (0x02)
PHY P to C: LE 2M (0x02)
RTN C to P: 2 <-- retransmission hint
@ HCI Event: Command Complete (0x0e) plen 12
LE Set CIG Parameters (0x08|0x0062) ncmd 1
Status: Success (0x00)
CIG ID: 0x00
CIS Count: 1
Connection Handle: 0x0100 <-- CIS handle assigned
@ HCI Command: LE Create CIS (0x08|0x0064) plen 5
CIS Count: 1
CIS Handle: 0x0100
ACL Handle: 0x0040 <-- existing ACL connection
@ HCI Event: LE CIS Established (0x13) subevent 0x19 plen 28
Status: Success (0x00)
CIS Handle: 0x0100
C to P PHY: LE 2M (0x02)
P to C PHY: LE 2M (0x02)
NSE: 2 <-- Controller chose NSE=2
C to P Flush Timeout: 1 <-- FT=1
P to C Flush Timeout: 1
Max PDU C to P: 100
Max PDU P to C: 0
ISO Interval: 8 <-- 8 * 1.25ms = 10ms
Diagnosing Common Issues with btmon
| Symptom you see in btmon | Likely cause | Fix |
|---|---|---|
| LE Set CIG Parameters Status: Invalid HCI Command Parameters (0x12) |
SDU size or latency contradicts what earbud advertised in PACS | Check that sdu and latency in bt_iso_qos match what earbud’s PACS reports |
| LE Create CIS Status: Connection Failed to be Established (0x3e) |
Earbud not in range or rejected the CIS request | Check ACL connection is active first. Check earbud’s ASCS state is QoS Configured (not Idle) |
| ISO SDU packets showing “Packet Sequence Number” gaps |
Application sending SDUs too slowly or unevenly | Ensure you send exactly one SDU every ISO Interval (10ms). Use a timer, not usleep(). |
| ISO Timestamped data packets with TIMESTAMP_STATUS = valid |
— (this is good!) | The kernel is stamping each SDU with the exact anchor point time — use SO_TIMESTAMPING for sub-millisecond A/V sync |
Checking ISO Socket Timestamps in Code
/*
* ISO sockets support SO_TIMESTAMPING to get the exact anchor point
* of each packet — crucial for A/V sync with video playback.
*/
int enable = SOF_TIMESTAMPING_RX_HARDWARE |
SOF_TIMESTAMPING_RAW_HARDWARE;
setsockopt(iso_fd, SOL_SOCKET, SO_TIMESTAMPING, &enable, sizeof(enable));
/* Then use recvmsg() to receive data + timestamp together */
struct msghdr msg;
struct iovec iov;
uint8_t data_buf[100];
uint8_t ctrl_buf[CMSG_SPACE(sizeof(struct scm_timestamping))];
iov.iov_base = data_buf;
iov.iov_len = sizeof(data_buf);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl_buf;
msg.msg_controllen = sizeof(ctrl_buf);
ssize_t n = recvmsg(iso_fd, &msg, 0);
/* Extract hardware timestamp from ancillary data */
struct cmsghdr *cmsg;
for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_type == SCM_TIMESTAMPING) {
struct scm_timestamping *ts = (void *)CMSG_DATA(cmsg);
/* ts->ts[2] = hardware timestamp = exact anchor point */
printf("Anchor point: %ld.%09ld sec\n",
ts->ts[2].tv_sec, ts->ts[2].tv_nsec);
}
}
Let’s walk every single step from “earbuds power on” to “music playing” — mapping each step to the protocol, HCI command, and BlueZ API call involved.
| # | What happens | Protocol / Layer | HCI Command / Event | BlueZ / App API |
|---|---|---|---|---|
| 1 | Earbuds start advertising | BLE GAP — connectable advertising | LE_Set_Extended_Adv_Params + LE_Set_Extended_Adv_Enable | btmgmt power on (earbud firmware) |
| 2 | Phone scans and finds earbuds | BLE GAP — scanning | LE_Set_Scan_Params + LE_Set_Scan_Enable | bluetoothctl scan on |
| 3 | ACL connection established | BLE — LE Create Connection | LE_Create_Connection → LE Connection Complete event | bluetoothctl connect AA:BB:CC:DD:EE:FF |
| 4 | Phone reads PACS — discovers earbud capabilities | GATT — Read Characteristic | ATT Read Request (handle of Sink PAC) | BlueZ auto-reads during GATT discovery |
| 5 | Phone picks codec config (48kHz/10ms/100 bytes) | BAP — Initiator logic | — | BlueZ calls SelectConfiguration() D-Bus method on app |
| 6 | Phone writes Config Codec to earbud’s ASCS | ASCS — ASE Control Point | ATT Write Request (ASE Control Point, opcode 0x01) | BlueZ bap.c writes ASCS automatically |
| 7 | Phone writes Config QoS + Enable to ASCS | ASCS — QoS Config (0x02) + Enable (0x03) | ATT Write Request (opcode 0x02 then 0x03) | BlueZ bap.c on app calling MediaTransport.Acquire() |
| 8 | CIG created + CIS opened | Link Layer — Isochronous | LE_Set_CIG_Parameters → LE_Create_CIS → LE CIS Established | ISO socket connect() in BlueZ |
| 9 | Receiver Start Ready exchanged | ASCS — opcode 0x04 | ATT Write → ASE state = Streaming | BlueZ bap.c confirms CIS established |
| 10 | App encodes PCM → LC3 → ISO send() | LC3 codec + ISO socket | LL: CIS PDU with audio SDU every 10ms | lc3_encode() + send(iso_fd, buf, 100, 0) |
| 11 | Earbud receives, decodes LC3, renders audio | Earbud firmware — LC3 decoder + I2S DAC | SDU timestamped → add Presentation Delay → play at correct time | lc3_decode() + audio HAL write() |
The sequence must be: ASCS Config Codec → Config QoS → Enable → then CIS. If you try to open a CIS before the earbud’s ASE is in the Enabling state, the earbud will reject it with “LE CIS Request rejected”. Always let BlueZ’s BAP plugin drive the ASCS state machine before creating the CIS socket.
usleep() can drift by hundreds of microseconds per call. After 100 frames (1 second) you could be 50ms late. The correct approach: use clock_gettime(CLOCK_MONOTONIC), calculate the next deadline, and sleep with clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next_deadline, NULL). Or better: use a POSIX timer with timerfd_create() that fires exactly every 10ms.If you negotiated
sdu = 100 bytes and try to send() 120 bytes, the Controller drops the packet silently. The earbud sees a lost packet and activates PLC (Packet Loss Concealment). The result is a subtle, intermittent audio glitch that is very hard to trace. Always encode your LC3 frame to exactly Octets_per_Codec_Frame bytes and ensure it matches your QoS Max_SDU.LE 1M PHY uses half the transmission speed of LE 2M PHY. For the same 100-byte SDU, 1M takes twice as long on the air — this eats into the CIE early-close opportunity and reduces Wi-Fi coexistence headroom significantly. Unless you have a specific compatibility reason, always use 2M PHY for BLE Audio.
ASCS ASE characteristics send GATT Notifications when state changes. If you don’t write the CCCD (Client Characteristic Configuration Descriptor) to enable notifications, you’ll never know when the earbud moves to the Streaming state or rejects your config. In BlueZ, this is handled automatically when you use the BAP plugin — but in raw GATT code, you must explicitly enable notifications on every ASE characteristic and the ASE Control Point.
These questions come up in BLE Audio / embedded Bluetooth engineering interviews.
| Question | What a good answer covers |
|---|---|
| What is the difference between CIS and BIS? | CIS requires an ACL connection, has ACKs, is bidirectional, used for earbuds. BIS is connectionless, no ACKs, unidirectional, used for public broadcast like Auracast. Both use the same ISO Interval / Subevent / NSE mechanism at the Link Layer. |
| Why do we need NSE, FT, and BN? Can’t we just retransmit once? | NSE allows multiple retransmissions within one ISO Interval on different frequency channels (combats burst interference). FT allows retransmission across multiple intervals (trades latency for reliability). BN solves the “hogging” problem when FT > 1. A single retransmission would fail in high-interference environments like a crowded 2.4GHz band. |
| What does the CIE bit do and why does it matter? | The CIE (Close Isochronous Event) bit in the CIS PDU header signals early termination of the isochronous event. Once the packet is ACKed, the Initiator sets CIE=1, the Acceptor sleeps, and unused Subevents are freed for Wi-Fi or other Bluetooth connections. Critical for coexistence on shared antenna phones. |
| How does PAST help BIS receivers? | PAST (Periodic Advertising Sync Transfer) lets the phone transfer its PA sync state to earbuds via the existing ACL link, eliminating the need for each earbud to independently scan for the broadcaster’s advertising chain. This saves ~500ms of setup time and significant battery on the earbuds. |
| What is the role of PACS and ASCS in BLE Audio? | PACS (Published Audio Capabilities Service) is a GATT service on the earbud that advertises what codecs and configurations it supports. ASCS (Audio Stream Control Service) is the GATT service that controls the ASE state machine — the phone writes to ASCS to configure, enable, and start/stop audio streams. PACS is read-only; ASCS is write-driven by the Initiator. |
| What is ISOAL and when is it needed? | ISOAL (Isochronous Adaptation Layer) handles the case where the codec frame duration doesn’t align with the ISO Interval. For example, LC3 at 7.5ms frames vs a 10ms ISO Interval. ISOAL adds Time_Offset fields to framed PDUs so the receiver knows exactly when to play each segment. It lives inside the Bluetooth chip — application code doesn’t interact with it directly. |
| How does the ISO socket API in BlueZ map to HCI commands? | socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_ISO) creates the socket. setsockopt(BT_ISO_QOS) stores QoS parameters. connect() triggers LE_Set_CIG_Parameters + LE_Create_CIS internally. send() sends one audio SDU per call. The ISO socket API abstracts all HCI command sequencing away from the application. |
| What command would you check in btmon to verify NSE and FT values chosen by the controller? | The LE CIS Established HCI event (subevent 0x19). It contains the NSE, C_to_P_Flush_Timeout, P_to_C_Flush_Timeout, Max_PDU sizes, and ISO_Interval that the Controller negotiated based on the hints given in LE_Set_CIG_Parameters. The application’s RTN (retransmission number) parameter is only a hint — the Controller makes the final NSE/FT decision. |
| ✓ | Concept | You should be able to… |
|---|---|---|
| ✓ | CIS vs BIS | Explain which to use for earbuds vs public broadcasts, and why ACK behaviour differs |
| ✓ | ISO Interval / Anchor Point / Subevent | Draw a timeline showing where packets are sent, where ACKs come back, and what CIE early close does |
| ✓ | NSE, FT, BN | Explain the trade-offs: NSE adds retries per interval, FT adds intervals (latency), BN adds fairness |
| ✓ | CIG / BIG state machine | List the HCI commands and their order: Set_CIG_Parameters → Create_CIS → CIS_Established |
| ✓ | PACS + ASCS | Explain what each GATT service does, which device hosts them, and the 6-step ASE state machine |
| ✓ | LC3 Codec + Config Numbers | Read a PAC record LTV blob, identify sampling frequency and octets/frame, pick correct config for voice vs music |
| ✓ | BlueZ ISO Sockets | Write a complete CIS sender: socket() → bind() → setsockopt(BT_ISO_QOS) → connect() → send() loop |
| ✓ | PAST + BASS + Auracast | Explain how the phone transfers sync info to earbuds to avoid scanning, and what BASS’s Broadcast Receive State characteristic contains |
| ✓ | ISOAL | Explain why framed vs unframed PDUs exist and what Time_Offset is used for |
| ✓ | btmon debugging | Run btmon, identify the LE CIS Established event, read NSE/FT values the Controller chose, spot status errors |
BLE Audio = LC3 codec (compress 10ms of audio to 100 bytes) + PACS/ASCS GATT services (negotiate and control streams) + ISO sockets (send 100 bytes every 10ms with Subevent retransmissions for robustness) + Presentation Delay synchronisation (make left and right earbuds play at exactly the same microsecond).
What’s Next in the Series?
Part 3 will cover the complete BlueZ BAP implementation walkthrough — building a full LE Audio application from scratch on a Raspberry Pi with a USB Bluetooth 5.2 dongle, using real earbuds for testing.
