BLE LE Audio — Part 2: BAP, ASCS, PAST, LC3 & Full Stack

BLE LE Audio — Part 2: BAP, ASCS, PAST, LC3 & Full Stack
From GATT services to the LC3 codec, radio coexistence, btmon debugging, and full end-to-end examples
Part 2
Advanced Topics
BAP / ASCS
GATT Services
LC3 Codec
How Audio Compresses
btmon
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

BAP ASCS PACS ASE PAC Record Codec_Specific_Configuration LC3 SDU Interval Sampling Frequency Frame Duration Octets per Frame PAST BIGInfo Scan Delegator Broadcast Assistant btmon BASS Presentation Delay Coexistence MWS

16. The Full BLE Audio Stack — Where Everything Fits

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.

BLE Audio Protocol Stack — All Layers
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.

Two Separate Data Paths — Control vs Audio
Control Path (GATT over ACL)

Phone reads PACS → knows what earbud supports
Phone writes ASCS → configures codec, enables stream
Phone writes ASCS → starts/stops streaming

Uses existing Bluetooth Classic-style ACL link
BlueZ: profile code, D-Bus API, GATT client

+ Audio Path (ISO socket)

Phone calls socket() / setsockopt() / connect()
BlueZ triggers LE_Set_CIG_Parameters HCI
BlueZ triggers LE_Create_CIS HCI

New Isochronous Link Layer channel
BlueZ: ISO socket, ISOAL, Link Layer scheduling

17. PACS — Published Audio Capabilities Service

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.

PACS GATT Service — Characteristics
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.

PAC Record Structure — One Supported Codec Config
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));
}

18. LC3 Codec — How BLE Audio Compresses Sound

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.

LC3 Encoder — What Happens to 10ms of Audio
1. Input

10ms of PCM samples
e.g. 48kHz = 480 samples
16-bit per sample
= 960 bytes raw

2. MDCT

Modified Discrete
Cosine Transform
Converts time→frequency
(like JPEG for audio)

3. Quantisation

Reduce precision of
less important frequencies
Human ear is less
sensitive to high freqs

4. Entropy Coding

Arithmetic coding
Pack bits tightly
Target: 100 bytes
= 80Kbps at 48kHz

5. Output

100 bytes
= one SDU
= one ISO packet
Sent every 10ms

LC3 Configuration Options — The Numbers You Set

LC3 Parameter → Codec_Specific_Configuration LTV mapping
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

BAP Mandatory Codec Configurations (every earbud must support these)
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... */
}
💡 Packet Loss Concealment (PLC): When a packet is truly lost (all NSE retransmissions failed, FT expired), the LC3 decoder can generate a synthetic continuation of the previous audio instead of outputting silence. This is why you rarely hear audible glitches on BLE Audio — the codec hides the missing packet.

19. ASCS — Audio Stream Control Service and ASE State Machine

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 State Machine — Lifecycle of One Audio Stream
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}")

20. PAST — Periodic Advertising Sync Transfer

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 vs With PAST — Battery and Time Savings
Without PAST — Earbud Scans Independently

1. Phone starts BIG broadcast
2. Left earbud scans primary channels (37/38/39)
3. Finds ADV_EXT_IND → reads AuxPtr
4. Scans secondary channels for AUX_ADV_IND
5. Reads SyncInfo → syncs to Periodic Advertising
6. Reads BIGInfo from AUX_SYNC_IND
7. Finally syncs to BIG — audio starts

Right earbud does the SAME thing all over again
Total: ~500ms extra latency, significant battery drain

With PAST — Phone Tells Earbuds Where to Tune

1. Phone starts BIG broadcast
2. Phone sends HCI_LE_Periodic_Advertising_Sync_Transfer
to left earbud (via ACL link — no scanning needed!)
3. Left earbud immediately knows the BIG’s timing
4. Left earbud syncs to BIG directly
5. Phone does same for right earbud

No scanning, no multi-hop advertising chain, instant sync
Total: ~50ms, minimal battery overhead

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.

Auracast Setup with BASS and PAST
Broadcaster
(Airport PA, TV, etc.)

Sends BIG broadcast
Extended + Periodic Advertising
BIGInfo + BASE in AUX_SYNC_IND

→BIS→
→BIS→
Phone (Broadcast Assistant)

1. Scans & finds BIG
2. Shows list to user
3. User picks “English Audio”
4. BASS: writes Broadcast_Source to earbuds
5. Sends PAST to earbuds (hands off sync info)

→PAST→
→BASS→
Earbuds (Scan Delegator)

1. Receive PAST from phone
2. Instantly sync to BIG
3. Select BIS[Left] / BIS[Right]
4. Decode LC3, play audio
No scanning required!

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 */

21. Radio Coexistence — Sharing One Antenna Between Wi-Fi and Bluetooth

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?

Time-Division Coexistence — 10ms Audio Slots vs Wi-Fi Data
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
💡 Why NSE matters for coexistence: If NSE=4 and the audio gets through on the first Subevent, the remaining 3 Subevents are freed for Wi-Fi. If NSE=1 and the packet is lost, you can never recover in this interval. NSE=3 is a common sweet spot — enough retries for robustness, still leaves time for Wi-Fi.

22. btmon — Debugging BLE Audio Live

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);
    }
}

23. Complete Flow — Phone Streams Music to Earbuds

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.

Full BLE Audio Setup Flow — 11 Steps
# 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()

24. Common Mistakes Beginners Make (and How to Fix Them)
❌ Mistake 1: Creating a CIG before the ASCS Enable step
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.
⚠️ Mistake 2: Using usleep(10000) as the send timer
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.
❌ Mistake 3: Sending SDUs larger than Max_SDU
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.
⚠️ Mistake 4: Picking phy=0x01 (1M) instead of phy=0x02 (2M) for audio
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.
❌ Mistake 5: Forgetting to subscribe to ASE Notifications
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.

25. Interview Questions on BLE Audio — Questions and Answers

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.

26. Summary — What You Should Now Understand
BLE Audio Learning Checklist
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
🎯 The Big Picture in One Sentence:

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.

EmbeddedPathashala Home More Bluetooth Posts

Leave a Reply

Your email address will not be published. Required fields are marked *