ESP-IDF Bluetooth LE Audio Architecture
This document describes the internal architecture of the ESP-IDF Bluetooth LE Audio stack together with the general-purpose BLE transport it is built on, across two Bluetooth components:
ESP-BLE-ISO -- a general-purpose BLE transport layer. It provides the GATT, GAP, ISO, L2CAP and HCI primitives, together with the dedicated processing task and locking model that serialize all host events. It is not specific to Bluetooth LE Audio -- it is a self-contained transport that any upper-layer profile can build on, and that can also run on its own: it has its own public API (
esp_ble_iso_*) and its own initialization, so an application can use it directly without ESP-BLE-AUDIO or any profile layer above it.ESP-BLE-AUDIO -- the upper profile layer. It implements the Bluetooth LE Audio profiles and services (PACS, ASCS, BASS, BAP, CAP, VCP, MICP, CSIP, MCP, CCP, TMAP, and so on) and exposes them through the public
esp_ble_audio_*API.
Note
ESP-BLE-AUDIO is currently the only consumer of ESP-BLE-ISO, but it is not the only intended one, and it is not required at all.
ESP-BLE-ISO is an independent component: it can be built and initialized on its own and driven through its public esp_ble_iso_* API for ISO-only use (CIS or BIS without any profile layer) -- see ESP-BLE-ISO Public API.
The transport is also profile-agnostic: its GATT, GAP, ISO, L2CAP and HCI primitives are general BLE building blocks, and other profiles -- for example a future HID-over-ISO -- are expected to build on the same component. Wherever this document describes ESP-BLE-ISO, the behavior applies to any consumer, not only to Bluetooth LE Audio.
Both components run on either of two BLE host stacks -- Bluedroid or NimBLE -- behind a common interface. Making the behavioral differences between the two hosts explicit is a primary goal of this document, because they determine the task context in which callbacks run, the order of operations during connection setup, and how events are dispatched.
Note
This guide serves two audiences. The Overview sections give a layer-level mental model usable by anyone integrating the public API of either component (ESP-BLE-ISO, ESP-BLE-AUDIO). The later sections descend to the implementation level (tasks, queues, locks, per-host dispatch paths) for maintainers working on the components themselves. Source is referenced by file path and symbol name rather than line number, so the references stay valid as the code evolves.
Architecture Overview
Layered Stack
ESP-IDF Bluetooth LE Audio is organized as a stack of cooperating layers. Each layer talks only to the layer directly below it, and all host-specific knowledge is confined to the adapter sublayer inside each component.
flowchart TB
APP["Application / Examples (app task)"]
AUDIO["ESP-BLE-AUDIO —<br/>Bluetooth LE Audio profiles and services<br/>(esp_ble_audio_* API)"]
ISO["ESP-BLE-ISO — transport primitives:<br/>GATT, GAP, ISO, L2CAP, HCI<br/>all serialized by the global ISO lock —<br/>most events run on the ISO task event loop"]
HOST["BLE Host Stack<br/>(Bluedroid or NimBLE)"]
CTRL["BLE controller"]
APP <--> AUDIO
AUDIO <--> ISO
ISO <--> HOST
HOST <--> CTRL
ISO <-. ISO Data Path .-> CTRL
Note
The solid links are the control path, which follows the layering in both directions -- commands flow down, events flow up. The ISO data path is the exception (the dashed link): it bypasses the host stack in both of the Core Specification's data-path directions. On the Input data path (Host to Controller), ESP-BLE-ISO writes SDUs straight to the controller transport, and the controller reports transmit-complete back through a callback into the ISO task; on the Output data path (Controller to Host), the controller delivers received ISO data through the same kind of callback. Neither path goes through the host stack's HCI event dispatch, which keeps the high-rate audio data path short.
The application calls only the public esp_ble_audio_* API (or, for ISO-only use cases, esp_ble_iso_*). Everything below the public API is internal and may change between releases.
Two Components
Component |
Responsibilities |
Key directories |
|---|---|---|
ESP-BLE-ISO |
|
|
ESP-BLE-AUDIO |
|
|
Dual-Host Design
Each component is split into a host-agnostic common part and a host-specific adapter part. The directory under host/common holds one implementation that does not depend on the host stack, while host/adapter/bluedroid and host/adapter/nimble hold the code that talks to each host directly. Exactly one adapter is compiled, selected by the active host (CONFIG_BT_BLUEDROID_ENABLED or CONFIG_BT_NIMBLE_ENABLED). The common layer defines the internal interface that both adapters implement, so the upper layers never branch on the host stack.
The two hosts are not behaviorally identical. They differ in the task their callbacks run on, in how an inbound event reaches the ISO task, and in a number of procedure-level details (MTU exchange, service discovery, ISO setup) that are covered in the per-layer sections. The structural differences are summarized below; the most important one for application developers -- the task a callback runs in -- is detailed in Callback Execution Context.
Aspect |
Bluedroid |
NimBLE |
|---|---|---|
Task that raises inbound events |
The BTU task (BTA GATT and GAP callbacks run here). |
The NimBLE host task. |
How the adapter forwards an event |
A BTA callback builds an event and posts it to the ISO task queue. |
A |
Synchronous exception |
GATT-server attribute access is completed asynchronously: the request is posted to the ISO task, and the response is sent later. |
GATT-server attribute access is completed inline on the host task, because NimBLE requires the value to be returned synchronously. |
ISO task CPU core |
Pinned to the configured Bluedroid core. |
Pinned to the configured NimBLE core. |
ISO task priority |
Matched to the BTU task priority. |
Matched to the NimBLE host task priority. |
Note
The ISO task is deliberately given the same CPU core and the same priority as the host stack's main task (see host/common/include/common/task.h). Running the transport task at the host's priority keeps the two cooperatively scheduled instead of preempting each other.
Component Internals
The implementation details of each component are listed in the table below, covering tasks, queues, locks, and the per-host dispatch paths, for maintainers working inside the component:
Page |
What it covers |
|---|---|
|
|
|
Cross-Cutting Concerns
This section collects the invariants and practical concerns that span both components and recur throughout the implementation.
Host Parity
The most important maintenance invariant is that the two host adapters stay behaviorally equivalent. The common layers above them assume identical behavior, so a change to one adapter's connection, cleanup, discovery or event path must be mirrored in the other -- otherwise the upper layers behave differently depending on the host. Where the two genuinely cannot match, the asymmetry is deliberate, and the complete set is small:
Area |
Bluedroid |
NimBLE |
|---|---|---|
Task that raises inbound events |
BTU task |
NimBLE host task |
GATT-server attribute access |
Posted to the ISO task; response sent asynchronously through BTA |
Inline on the host task; value returned synchronously |
GATT-client read/write completion |
Posted to the ISO task |
Inline on the host task |
HCI ISO commands |
Private direct-HCI path with its own completion semaphore |
Typed |
GATT service registration |
Two-phase and asynchronous ( |
Single-phase |
L2CAP connection-oriented channels |
Not implemented (TODO) |
Implemented (Draft) |
Object Transfer Service (OTS) |
Not implemented (builds on L2CAP) |
Implemented (Draft) |
Connection-initiation event routing |
Share the engine's BTA GATTC interface ( |
Forward GAP events into the engine ( |
Any divergence beyond these needs the same kind of justification, recorded at the point where it is introduced.
Layer Boundaries
The architecture depends on a few boundaries that callers and maintainers should respect:
The public API is the only stable surface. Everything below the
esp_ble_iso_*andesp_ble_audio_*API is internal and may change between releases.The two BLE host stacks are adapted, not modified. The adapter layers exist precisely so the components never change Bluedroid or NimBLE themselves; host behavior is taken as given.
The profile and controller logic is a prebuilt library with a fixed ABI. The open code interacts with it only through the shared function and configuration tables that are validated at initialization; its internals are not part of this contract.
Common Pitfalls
The concurrency model in Concurrency and Thread Safety has practical consequences that are easy to get wrong:
Do not block in a callback. Most callbacks run on the ISO task; blocking there stalls every other event in the stack. Its stack is only 4 KB, so deep call chains and large stack-allocated buffers -- including arrays sized from configuration values -- must be avoided.
Posted events do not race each other. Because a single task drains the ISO task queues, two events that were both posted cannot run concurrently; reasoning that assumes they can is almost always wrong.
Mind the callback's task on NimBLE. A GATT read or write completion, or an inbound write to a local service, runs on the NimBLE host task rather than the ISO task; code shared between paths must be correct on both.
The ISO lock orders access, not completion. Holding it guarantees mutual exclusion, not that an operation you started has finished -- many transport operations complete on a later event.
Memory Footprint
The open ESP-BLE-ISO and ESP-BLE-AUDIO code has small, deterministic fixed costs:
The ISO task uses a 4 KB stack and three priority event queues (32 + 64 + 32 entries) drained by one consumer.
The ISO engine's channel, group and broadcast pools are sized by
CONFIG_BT_ISO_MAX_CHAN,CONFIG_BT_ISO_MAX_CIGandCONFIG_BT_ISO_MAX_BIG; the HCI command pool holds a single buffer.Per connection, NimBLE caches the discovered attribute database and holds deep-copied parameters for GATT procedures in flight; subscriptions are tracked in per-connection lists on both hosts.
GATT service attribute tables are allocated at initialization, sized by the services that are enabled.
Because these pools and tables are sized from Kconfig, the open code's footprint is fixed at build time, with no per-stream dynamic growth beyond the SDU buffers in flight. The prebuilt profile-and-controller library is the exception: it allocates its own profile and controller state dynamically -- at initialization and as connections and streams are set up -- so that part of the footprint is not fixed at build time.
Appendix -- File and Directory Map
ESP-BLE-ISO
Directory |
Contents |
|---|---|
|
The public |
|
Host-agnostic transport: connection, advertising, scanning, GATT, HCI, ISO glue, L2CAP, the ISO task and the ISO lock; |
|
The ISO engine (CIS and BIG state machines and channel API). |
|
Per-host adapters for GAP, GATT, ISO, HCI and (NimBLE) L2CAP. |
|
Address, UUID, CRC, crypto, timer and buffer helpers. |
ESP-BLE-AUDIO
Directory |
Contents |
|---|---|
|
Public |
|
Initialization orchestration ( |
|
The GATT service adapters (PACS, ASCS, BASS, CAS, CSIS, HAS, MCS, MICS, TBS, TMAS, VCS). |
|
The Object Transfer Service. |
|
The prebuilt per-target profile and controller library. |