Core Concepts
Both Stream Types
Real Code Examples
Step by Step
Why Do We Need Isochronous Streams?
When you stream music from your phone to wireless earbuds, the audio must arrive at exactly the right time. A small delay in regular data transfer (like downloading a file) doesn’t matter. But for audio, even a 20ms inconsistency causes clicks, pops, or the left and right earbuds going out of sync.
Traditional BLE uses ACL links — these are great for data but not designed for strict timing. Bluetooth LE Audio solves this with Isochronous Streams: time-scheduled channels where audio packets go out at fixed, predictable intervals — like a train that runs exactly on time, every single time.
This post covers everything from the basics of CIS and BIS, through robustness parameters, all the way to BlueZ ISO socket code you can actually run.
Key Terms in This Post
There are two flavours of Isochronous Streams in BLE Audio. Choosing which one to use depends on your use case:
| Feature | CIS — Connected Isochronous Stream | BIS — Broadcast Isochronous Stream |
|---|---|---|
| Connection needed? | Yes — needs an ACL link first | No — fully connectionless |
| Acknowledgements? | Yes — receiver confirms each packet | No — fire and forget |
| Direction | Bidirectional possible (mic + speaker) | One-way only (source → sinks) |
| Who can receive? | Only the paired device | Any device in range |
| Typical use case | Your earbuds, phone calls, gaming headsets | Public PA, hearing loops, TV audio sharing |
| Group container | CIG — Connected Isochronous Group | BIG — Broadcast Isochronous Group |
| Range | Limited by the weak return ACK path | Much greater — no ACK power penalty |
| BlueZ socket call | connect() to earbud address | connect() to BDADDR_ANY |
Everyday analogy: CIS is like a phone call — both sides confirm the conversation. BIS is like an FM radio station — it just transmits and anyone with a radio can listen.
BLE Audio has a strict hierarchy. One device is always in charge of timing and scheduling — everything else follows its lead.
|
Initiator (Central)
Creates and schedules every audio packet’s timing Sends Usually: phone, tablet, smart TV In Core spec = Central |
⇄ |
Acceptor (Peripheral)
Follows the Initiator’s schedule Receives Usually: earbuds, hearing aids, speakers In Core spec = Peripheral |
Important thing to remember: Either device can be the audio source or sink. Your earbud’s microphone sends voice data back to the phone — the earbud is still the Acceptor, but it generates audio data. The Initiator tells it exactly when to send that data.
As we go up the protocol stack, the names change:
| Layer | Initiator Side | Acceptor Side |
|---|---|---|
| Core (Link Layer) | Central | Peripheral |
| BAPS / GATT | Client | Server |
| CAP | Initiator | Acceptor |
| Top-level profiles | Sender / Broadcaster | Receiver / Broadcast Sink |
The LC3 codec (mandatory for BLE Audio) works in 10ms frames — it grabs 10ms of audio, compresses it, and hands it off to Bluetooth. Bluetooth transmits it in a fixed window called the Isochronous Interval.
| ← ISO Interval 10ms → | ← ISO Interval 10ms → | ← ISO Interval 10ms → | ||||||||||||
| D — Audio Packet | Ack ✓ | D — Audio Packet | Ack ✓ | D — Audio Packet | ✗ Lost | |||||||||
| ▲ Anchor Point 1 |
▲ Anchor Point 2 |
|||||||||||||
| Initiator → | ← Acceptor | No Ack received | ||||||||||||
The 3rd packet gets no Ack — Initiator knows it was lost and can decide to retransmit
| Term | What it means |
|---|---|
| ISO Interval | Fixed time gap between each audio transmission. Usually 10ms. Like the tick of a metronome. |
| Anchor Point | The exact moment each ISO Interval begins — the starting “tick”. Determined by the Initiator. |
| D (Data) | The audio packet — contains one encoded LC3 frame (10ms of audio compressed to ~100 bytes) |
| Ack | Acknowledgement from the Acceptor: “I got the packet, it was valid” |
Wireless environments have interference. What if one transmission gets blocked by Wi-Fi? BLE Audio’s answer: retry the same packet multiple times within one ISO Interval, each time on a different radio channel. Each retry slot is called a Subevent.
| ← CIS Event (entire ISO Interval) → | |||||||
| Sub_Interval 1 | Sub_Interval 2 | Sub_Interval 3 | Sub_Interval 4 | ||||
| Tx | Rx | Tx | Rx | Tx | Rx | Tx | Rx |
| Subevent 1 Channel f₁ |
Subevent 2 Channel f₂ |
Subevent 3 Channel f₃ |
Subevent 4 Channel f₄ |
||||
If Wi-Fi blocks f₁, the same audio packet retries on f₂, f₃, f₄ — at least one channel should be clear
The inter-frame gap between Subevents is always 150 microseconds — just enough time for the radio to switch channels. The BLE Core 5.2 spec introduced an improved channel selection algorithm that decides which frequency to use for each Subevent.
The CIE Bit — Saving Battery
Once the Acceptor successfully receives the packet and sends an Ack, the Initiator sets the CIE (Close Isochronous Event) bit in its next transmission. This tells the Acceptor: “you already got the packet — go to sleep until the next ISO Interval.” No need to stay awake for Subevents 3 and 4.
| Tx (Data) | Rx (Ack ✓) | Tx (CIE=1, NPI=1) | Subevent 3 FREE | Subevent 4 FREE |
| Packet delivered | Close early | Freed for Wi-Fi or other BT | ||
The freed Subevents can be used by the chip for Wi-Fi transmissions or other Bluetooth connections — very important since phones share one antenna for both Wi-Fi and Bluetooth.
Every packet transmitted over the air has this overall structure:
| Preamble 1-2 bytes |
Access Address 4 bytes |
ISO PDU (Header + Payload + Optional MIC) 2 – 257 bytes |
CRC 3 bytes |
The ISO PDU’s header contains control bits that manage the flow of data. For a CIS, these are:
| LLID 2 bits |
NESN 1 bit |
SN 1 bit |
CIE 1 bit |
RFU 1 bit |
NPI 1 bit |
RFU 1 bit |
Length 8 bits |
| Field | What it stands for | Simple explanation |
|---|---|---|
| LLID | Link Layer ID | Is this packet framed or unframed? (framing = how audio frames map to PDUs) |
| NESN | Next Expected Sequence Number | The receiver tells the sender which packet it’s expecting next — this is how ACKs work |
| SN | Sequence Number | The sequence number of this current packet being sent |
| CIE | Close Isochronous Event | Set to 1 means: “packet received, closing this event early, Acceptor can sleep now” |
| NPI | Null Payload Indicator | Set to 1 means: “this packet has no audio data” — just used for timing or ACK purposes |
These three parameters control how hard the system tries to get each audio packet through. You do not set these directly — you give the Controller higher-level hints (latency, SDU size) and it calculates the best NSE/FT/BN values automatically.
| Name | Full Name | What it does | Effect on latency |
|---|---|---|---|
| NSE | Number of Subevents | Maximum retransmission attempts per ISO Interval (like number of tries per round) | No direct effect |
| FT | Flush Timeout | How many consecutive ISO Intervals a packet can try to get through before it’s abandoned (flushed) | ↑ FT = ↑ Latency |
| BN | Burst Number | How many audio packets are bundled into one CIS event (requires a bigger ISO Interval) | ↑ BN = ↑ ISO Interval |
NSE=4, FT=1 Example — Four Tries Per Interval
| ISO Interval 1 — Flush Point FP0 | ISO Interval 2 — Flush Point FP1 | ISO Interval 3 | |||||||
| P0 ✗ | P0 ✗ | P0 ✗ | P0 ✗ FLUSHED |
P1 ✓ | P1 ACK→CIE |
— | — | P2 ✓ | P2 ACK→CIE |
P0 was lost (bad interference). The Acceptor reconstructs it using Packet Loss Correction. P1 and P2 get through fine.
FT=3 — More Intervals = More Chances (But More Latency)
With FT=3, the packet P0 can retry across 3 ISO Intervals (30ms total) before being flushed. This helps in noisy environments but adds up to 30ms of latency. The risk with FT alone is that a struggling packet (P0) can hog all Subevents, blocking P1 and P2 from getting airtime.
BN=2 — Fairness Fix (Share Subevents Between Two Packets)
The solution to the “hogging” problem is Burst Number. With BN=2, the ISO Interval is doubled to 20ms, so two packets (P0 and P1) are loaded into each interval. The 4 Subevents are shared between them — P0 can’t consume all 4. This is fairer but doubles the interval length.
Maximum Transport Latency, Max SDU size, and SDU interval via HCI. The Bluetooth Controller chip takes these inputs and decides NSE, FT, and BN internally. You can’t override them directly — and that’s intentional, so apps don’t break each other’s radio scheduling.For a phone call you need audio flowing both ways at the same time. BLE Audio is clever about this: instead of creating a second CIS, the Acceptor’s acknowledgement packet carries the microphone audio back to the Initiator. One CIS, two audio streams.
| Initiator (Phone) |
L (audio→) | Ack (CIE) | R (audio→) | Ack (CIE) | L (audio→) | Ack (CIE) |
| Left Earbud (has mic) |
←Mic Data inside Ack |
←Mic Data inside Ack |
||||
| Right Earbud (no mic) |
Ack only NPI=1 |
|||||
The microphone data from the left earbud travels inside its acknowledgement packet back to the phone. The right earbud has no microphone, so it sends a plain acknowledgement with NPI=1 (null payload, no audio data).
Rules for bidirectional CIS (important for implementation):
- Only the Initiator sets the CIE bit — and only after both directions are acknowledged
- FT and BN can differ for each direction (uplink voice vs downlink music may have different needs)
- ISO Interval and NSE must be the same for both directions
- If the Initiator has had its packet ACKed but hasn’t received the Acceptor’s data yet, it must keep sending null PDUs to give the Acceptor chances to retransmit
When your phone streams audio to both earbuds simultaneously, it creates two CIS streams inside one CIG. These can be scheduled in two ways:
|
Sequential — CIS 0 completes first
Gap between CIS 0 end and CIS 1 start wastes airtime if connection is reliable |
vs |
Interleaved — CIS 0 and CIS 1 alternate
If both earbuds ACK their first Subevent, both close early → large gap freed for Wi-Fi |
||||||||||||||||||
How Left and Right Earbuds Stay in Sync
Left and right earbuds don’t talk to each other — but they must play audio at precisely the same moment. The solution is the CIG synchronisation point and CIS Sync Delays:
| CIG Reference Point |
CIS 0 Event (Left Earbud) |
CIS 1 Event (Right Earbud) |
CIG Sync Point |
| ← CIS_Sync_Delay[0] → | ← CIS_Sync_Delay[1] → |
Both earbuds calculate the same CIG Sync Point → add Presentation Delay → render audio at the same microsecond
The Presentation Delay (set by the Basic Audio Profile, BAP) is an extra buffer added after the Sync Point. Both earbuds wait exactly this long before playing — even if one received its packet earlier than the other. This is why BLE Audio earbuds achieve microsecond-level synchronisation without knowing each other exists.
The Bluetooth Host (your app code, via BlueZ) sends HCI commands to the Controller chip to set up streams. The CIG goes through these states:
| No CIG Nothing configured |
→ LE_Set_CIG_Parameters → | Configurable CIG CISes defined, not active 0-31 CIS configs |
→ LE_Create_CIS → | Active CIG Audio flowing! 1+ CIS active |
From Active, disconnecting all CISes → Inactive CIG. Use LE_Remove_CIG to return to No CIG.
| HCI Command | What it tells the Controller |
|---|---|
| LE_Set_CIG_Parameters | Define the CIG and all CISes: SDU size, max latency, PHY, retransmission count hint |
| LE_Create_CIS | Activate one or more of the configured CISes → actual audio streaming begins |
| LE_Remove_CIG | Tear down the entire CIG completely |
BlueZ 5.64+ exposes BLE Audio via ISO sockets. They look and feel like regular BSD sockets, but under the hood they trigger all the HCI commands we just discussed. Here’s a step-by-step guide:
Step 1 — Create the ISO Socket
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <bluetooth/bluetooth.h>
#include <bluetooth/iso.h> /* ISO socket support — BlueZ 5.64+ */
int main(void)
{
int sock_fd;
/*
* PF_BLUETOOTH = Bluetooth address family
* SOCK_SEQPACKET = message-oriented, ordered, each send() = one SDU
* BTPROTO_ISO = ISO transport protocol (new in BlueZ 5.64)
*/
sock_fd = socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_ISO);
if (sock_fd < 0) {
perror("socket() failed - check BlueZ version >= 5.64");
return -1;
}
printf("ISO socket created, fd=%d\n", sock_fd);
return 0;
}
Step 2 — Bind to Local Adapter
/* sockaddr_iso is the BLE Audio equivalent of sockaddr_in */
struct sockaddr_iso local_addr;
memset(&local_addr, 0, sizeof(local_addr));
local_addr.iso_family = AF_BLUETOOTH;
local_addr.iso_bdaddr = *BDADDR_ANY; /* Use default HCI adapter */
local_addr.iso_bdaddr_type = BDADDR_LE_PUBLIC;
if (bind(sock_fd, (struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) {
perror("bind() failed");
close(sock_fd);
return -1;
}
printf("Bound to local BLE adapter\n");
Step 3 — Set QoS Parameters
This is where you specify latency and SDU size. BlueZ passes these to the Controller via LE_Set_CIG_Parameters — the Controller then calculates NSE, FT, and BN internally.
struct bt_iso_qos qos;
memset(&qos, 0, sizeof(qos));
/*
* BT_ISO_QOS_CIG_UNSET and BT_ISO_QOS_CIS_UNSET mean:
* "let the Controller auto-assign IDs"
*/
qos.ucast.cig = BT_ISO_QOS_CIG_UNSET;
qos.ucast.cis = BT_ISO_QOS_CIS_UNSET;
/* ----- OUTGOING: phone -> earbud (music/audio) ----- */
qos.ucast.out.interval = 10000; /* SDU interval in microseconds (= 10ms) */
qos.ucast.out.latency = 10; /* Maximum transport latency in ms */
qos.ucast.out.sdu = 100; /* Maximum SDU size in bytes (LC3 frame) */
qos.ucast.out.phy = 0x02; /* PHY: 0x01=1M, 0x02=2M (preferred for audio) */
qos.ucast.out.rtn = 2; /* Retransmission number hint (Controller decides FT/NSE) */
/* ----- INCOMING: earbud mic -> phone (voice) ----- */
/* Set sdu=0 for unidirectional (music-only, no mic) */
qos.ucast.in.interval = 10000;
qos.ucast.in.latency = 10;
qos.ucast.in.sdu = 0; /* 0 = no uplink audio (unidirectional) */
qos.ucast.in.phy = 0x02;
qos.ucast.in.rtn = 2;
if (setsockopt(sock_fd, SOL_BLUETOOTH, BT_ISO_QOS, &qos, sizeof(qos)) < 0) {
perror("setsockopt(BT_ISO_QOS) failed");
return -1;
}
printf("QoS set: 10ms interval, 10ms latency, 100-byte SDU, 2M PHY\n");
Step 4 — Connect to the Earbud (Triggers LE_Create_CIS)
bdaddr_t earbud_bdaddr;
/* Replace with your earbud's Bluetooth address */
str2ba("AA:BB:CC:DD:EE:FF", &earbud_bdaddr);
struct sockaddr_iso remote_addr;
memset(&remote_addr, 0, sizeof(remote_addr));
remote_addr.iso_family = AF_BLUETOOTH;
remote_addr.iso_bdaddr = earbud_bdaddr;
remote_addr.iso_bdaddr_type = BDADDR_LE_PUBLIC;
/*
* connect() triggers:
* 1. LE_Set_CIG_Parameters HCI command (Controller calculates NSE/FT/BN)
* 2. LE_Create_CIS HCI command (opens the audio stream)
* 3. Earbud receives HCI LE CIS Request and accepts it
* 4. Both sides get HCI LE CIS Established event
*/
if (connect(sock_fd, (struct sockaddr *)&remote_addr, sizeof(remote_addr)) < 0) {
perror("connect() failed - is device paired and in range?");
return -1;
}
printf("CIS established! Audio stream is live.\n");
Step 5 — Send Audio SDUs (10ms loop)
uint8_t audio_sdu[100]; /* One 10ms LC3-encoded audio frame */
/*
* In production: replace with LC3 encoder output.
* Each send() delivers exactly one SDU (one ISO Interval of audio).
* BlueZ handles fragmenting it into PDUs, NSE retransmissions, etc.
*/
while (1) {
/* TODO: fill audio_sdu with actual LC3-encoded audio */
memset(audio_sdu, 0x55, sizeof(audio_sdu)); /* silence placeholder */
ssize_t written = send(sock_fd, audio_sdu, sizeof(audio_sdu), 0);
if (written < 0) {
perror("send() failed");
break;
}
/* Sleep for one ISO Interval (10ms = 10000 microseconds) */
usleep(10000);
}
close(sock_fd);
return 0;
send() every 10ms with your audio data.BIS is the broadcast version. The transmitter just sends continuously — no pairing, no acknowledgements, no per-device overhead. Any device within range can tune in.
| LLID 2 bits |
CSSN 3 bits |
CSTF 1 bit |
RFU 2 bits |
Length 8 bits |
No NESN, SN, CIE, or NPI bits — because there are no acknowledgements in broadcast
| Field | Full Name | What it does |
|---|---|---|
| CSSN | Control Subevent Sequence Number | Version number for control information — receiver uses this to detect if the control info has changed |
| CSTF | Control Subevent Transmission Flag | Set to 1 means “a Control Subevent follows after the data Subevents” (used for channel map updates) |
BIS Robustness — Group Count, IRC, and PTO
Since there are no ACKs, BIS uses different tricks to ensure delivery:
| BIS Event — NSE=6 Subevents | |||||
| P0 | P1 | P2 | P0 | P1 | P2 |
| Group 0 (g=0) | Group 1 (g=1) | ||||
| BN=3, NSE=6 → GC=2 → each of 3 packets sent twice on different channels | |||||
With no connection, how does an earbud know where to tune in? The broadcaster publishes a multi-hop chain of advertising packets:
| 1. ADV_EXT_IND Primary ch 37/38/39 Broadcaster’s address (AdvA) |
→ | 2. AUX_ADV_IND Secondary ch 0-36 Broadcast Audio UUID |
→ | 3. AUX_SYNC_IND Periodic Advertising ACAD: BIGInfo (timing) |
→ | 4. BIS Audio Data 37 data channels Actual encoded audio packets |
What’s in BIGInfo?
BIGInfo (inside the ACAD field) gives the receiver everything it needs to decode the timing and structure of the BIG. Key fields include ISO_Interval, Sub_Interval, BIS_Spacing, NSE, BN, PTO, IRC, Max_PDU, Channel Map, and PHY. For encrypted broadcasts, it also contains GIV and GSKD (encryption keys derivation info).
What’s in BASE? The 3-Level Structure
| Level 1 (BIG-wide) — Presentation Delay + Number of Subgroups | |||
| Level 2: Subgroup 0 LC3 Codec Config + Metadata “Programme Info: English News” |
Level 2: Subgroup 1 LC3 Codec Config + Metadata “Programme Info: Spanish News” |
||
| Level 3: BIS[0x00] Channel: Left |
Level 3: BIS[0x01] Channel: Right |
Level 3: BIS[0x02] Channel: Left |
Level 3: BIS[0x03] Channel: Right |
A left earbud reads BIS[0x00], a right earbud reads BIS[0x01]. Headphones read both. A hearing loop in Spanish reads BIS[0x02] and [0x03].
Setting up a BIG in BlueZ is simpler than CIS because there’s no per-device pairing. You bind to broadcast addresses and start transmitting. BlueZ handles the Extended Advertising chain automatically.
Broadcaster — Create a BIG and Start Sending Audio
#include <bluetooth/bluetooth.h>
#include <bluetooth/iso.h>
#include <unistd.h>
int main(void)
{
int bc_fd;
bc_fd = socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_ISO);
/* Bind to local adapter */
struct sockaddr_iso local = {
.iso_family = AF_BLUETOOTH,
.iso_bdaddr = *BDADDR_ANY,
.iso_bdaddr_type = BDADDR_LE_PUBLIC,
};
bind(bc_fd, (struct sockaddr *)&local, sizeof(local));
/* Configure BIG QoS */
struct bt_iso_qos qos;
memset(&qos, 0, sizeof(qos));
qos.bcast.big = BT_ISO_QOS_BIG_UNSET; /* Auto-assign BIG ID */
qos.bcast.bis = BT_ISO_QOS_BIS_UNSET; /* Auto-assign BIS ID */
qos.bcast.packing = 0x00; /* Sequential packing of BISes */
qos.bcast.framing = 0x00; /* Unframed (10ms codec = 10ms ISO Interval) */
qos.bcast.encryption = 0x00; /* No encryption — public broadcast */
/* Audio stream parameters */
qos.bcast.out.interval = 10000; /* 10ms */
qos.bcast.out.latency = 10;
qos.bcast.out.sdu = 100;
qos.bcast.out.phy = 0x02; /* 2M PHY */
qos.bcast.out.rtn = 4; /* More retransmissions — no ACKs in BIS */
setsockopt(bc_fd, SOL_BLUETOOTH, BT_ISO_QOS, &qos, sizeof(qos));
/*
* connect() to BDADDR_ANY = start broadcasting
* BlueZ automatically sets up:
* - ADV_EXT_IND on primary channels 37/38/39
* - AUX_ADV_IND on secondary channels with Broadcast_ID
* - Periodic Advertising train with BIGInfo and BASE
* - Then starts the BIG itself
*/
struct sockaddr_iso dst = {
.iso_family = AF_BLUETOOTH,
.iso_bdaddr = *BDADDR_ANY,
.iso_bdaddr_type = BDADDR_LE_PUBLIC,
};
if (connect(bc_fd, (struct sockaddr *)&dst, sizeof(dst)) < 0) {
perror("broadcast connect() failed");
return -1;
}
printf("BIG started! Broadcasting audio on 10ms intervals...\n");
/* Send audio every 10ms */
uint8_t audio_buf[100];
while (1) {
/* In real code: fill with LC3-encoded audio */
memset(audio_buf, 0xAB, sizeof(audio_buf));
send(bc_fd, audio_buf, sizeof(audio_buf), 0);
usleep(10000); /* 10ms = 10000 microseconds */
}
close(bc_fd);
return 0;
}
Broadcast Sink — Sync to a BIG and Receive Audio
int sink_fd;
sink_fd = socket(PF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_ISO);
/*
* To sync to a specific broadcaster:
* 1. Scan to find the ADV_EXT_IND and get the broadcaster's address
* 2. Read AUX_ADV_IND to get the Broadcast_ID
* 3. Sync to the Periodic Advertising train to get BIGInfo and BASE
* 4. Use the BIS_Index from BASE to choose which BIS to receive
* BlueZ tools like btmon and btvirt can help with testing
*/
/* Broadcaster's BT address (from scanning AUX_ADV_IND) */
bdaddr_t bc_addr;
str2ba("11:22:33:44:55:66", &bc_addr); /* broadcaster's address */
struct sockaddr_iso sink_addr = {
.iso_family = AF_BLUETOOTH,
.iso_bdaddr = bc_addr,
.iso_bdaddr_type = BDADDR_LE_PUBLIC,
.iso_bc_sid = 0x00, /* SID from ADV_EXT_IND */
};
/* Configure sync timeout */
struct bt_iso_qos sink_qos;
memset(&sink_qos, 0, sizeof(sink_qos));
sink_qos.bcast.sync_timeout = 0x4000; /* sync timeout in 10ms units */
sink_qos.bcast.mse = 0x00; /* max subevents to scan */
setsockopt(sink_fd, SOL_BLUETOOTH, BT_ISO_QOS, &sink_qos, sizeof(sink_qos));
/*
* connect() = start syncing to the BIG
* BlueZ reads the Periodic Advertising, parses BIGInfo,
* and starts receiving audio packets
*/
if (connect(sink_fd, (struct sockaddr *)&sink_addr, sizeof(sink_addr)) < 0) {
perror("sync to BIG failed - is broadcaster advertising?");
return -1;
}
printf("Synced to BIG! Receiving audio...\n");
/* Receive audio SDUs */
uint8_t recv_buf[256];
while (1) {
ssize_t len = recv(sink_fd, recv_buf, sizeof(recv_buf), 0);
if (len > 0) {
/* Each recv() gives one complete SDU = one 10ms audio frame */
printf("Received %zd bytes of audio\n", len);
/* In real code: pass to LC3 decoder, then to audio output */
}
}
close(sink_fd);
btmon to see all HCI commands and events. BlueZ’s tools/isotest.c in the BlueZ source tree is an excellent reference — it shows both CIS and BIS examples with full error handling.ISOAL solves a very specific problem: what if the codec frame length and the ISO Interval don’t match? This is handled entirely inside the Bluetooth chip — you never write ISOAL code — but understanding it helps when debugging audio glitches.
| Unframed (no ISOAL work)
Codec frame = 10ms One complete audio frame fits LLID bits indicate: unframed This is the normal BLE Audio case |
⚡ | Framed (ISOAL handles it)
Codec frame = 7.5ms Frames don’t divide evenly — LLID bits indicate: framed Needed for dual Classic+LE devices |
The 7.5ms mismatch occurs because classic Bluetooth devices (old mice, keyboards) use a 7.5ms radio interval. A dual-mode device (phone) that supports both Classic and LE Audio must align its timing — ISOAL handles the conversion transparently.
| Your Application / LC3 Codec (SDUs) | ||||
| ↕ | ||||
| ISO Adaptation Manager (ISOAL) | ||||
| Fragmentation & Recombination (Unframed PDUs) |
Segmentation & Reassembly (Framed PDUs with Time_Offset) |
|||
| ↕ | ||||
| Link Layer (PDUs, Subevents, Frequency Hopping) | ||||
In the framed case, each SDU segment gets a Time_Offset field which tells the receiver exactly when the original SDU’s audio should be played relative to the Anchor Point — so even though the audio data is split across multiple PDUs, the receiver reassembles it and plays it at exactly the right time.
| Abbreviation | Full Name | What it means in plain English |
|---|---|---|
| CIS | Connected Isochronous Stream | Private audio stream with acknowledgements — your earbuds |
| BIS | Broadcast Isochronous Stream | Public one-way audio stream — like FM radio over Bluetooth |
| CIG | Connected Isochronous Group | Container holding all CISes for one session (e.g., left + right earbud) |
| BIG | Broadcast Isochronous Group | Container holding all BISes for one broadcast (e.g., English left/right + Spanish left/right) |
| ISO Interval | Isochronous Interval | Fixed timing gap between audio transmissions — typically 10ms |
| Anchor Point | — | The precise start moment of each ISO Interval — like the tick of a metronome |
| NSE | Number of Subevents | Maximum retry attempts per ISO Interval (each on a different frequency channel) |
| FT | Flush Timeout | Max ISO Intervals before giving up on a packet and discarding it |
| BN | Burst Number | Packets per CIS/BIS event — larger BN = bigger ISO Interval |
| CIE | Close Isochronous Event | Header bit that says “stop retransmitting, packet delivered, Acceptor can sleep” |
| NPI | Null Payload Indicator | Header bit that says “this packet carries no audio data — just used for timing” |
| IRC | Immediate Repetition Count | Number of groups in a BIS event that carry current (not future) data |
| PTO | Pre-Transmission Offset | BIS only: send future packets early in current event for extra robustness |
| ISOAL | Isochronous Adaptation Layer | Chip-internal layer that handles frame-size mismatch between codec and Link Layer |
| BASE | Broadcast Audio Source Endpoint | Describes what each BIS contains: codec, language, left/right channel allocation |
| BIGInfo | BIG Information | Timing structure of a BIG broadcast in Periodic Advertising (ISO_Interval, BIS_Spacing, channel map etc.) |
| PAST | Periodic Advertising Sync Transfer | Phone tells your earbuds where to find the BIG — earbuds skip scanning and save battery |
| bt_iso_qos | BlueZ ISO QoS struct | The C struct you pass to setsockopt() to configure audio stream parameters in BlueZ |
Keep Building on BLE Audio
You now understand the foundation of BLE Audio’s Isochronous Streams. Next steps: BAP (Basic Audio Profile), ASCS (Audio Stream Control Service), the LC3 codec in depth, and hands-on BlueZ pairing + GATT setup.
