feat(ble_audio): Support using PAST in the cap/acceptor example

This commit is contained in:
Linyan Liu
2026-05-14 20:43:07 +08:00
committed by Liu Linyan
parent 0f96f27d35
commit 3b70c1a856
50 changed files with 1210 additions and 562 deletions
@@ -7,9 +7,9 @@
## Overview
This example implements the **Common Audio Profile (CAP) Acceptor** role on top of the NimBLE host stack with ISO and LE Audio support. It is built in one of two mutually exclusive sub-modes selected at build time: a **CAP Acceptor / BAP Unicast Server**, or a **CAP Acceptor / BAP Broadcast Sink** (with the BAP Scan Delegator role enabled). PACS is registered in both modes with LC3 sink and source capabilities (any sampling frequency, 7.5 ms or 10 ms frame, up to 2 channels, 30..155 octets per frame, up to 2 frames per SDU). Sink and source PAC location are set to `FRONT_LEFT | FRONT_RIGHT` (note in `main.c`: with `MONO_AUDIO`, Samsung S24 declines unicast).
This example implements the **Common Audio Profile (CAP) Acceptor** role on top of the NimBLE host stack with ISO and LE Audio support. It can be built with one or both roles selected at build time: a **CAP Acceptor / BAP Unicast Server**, and / or a **CAP Acceptor / BAP Broadcast Sink** (with the BAP Scan Delegator role enabled). Dual-role builds are supported (BAP spec C.2). PACS is registered in all configurations with LC3 sink and source capabilities (any sampling frequency, 7.5 ms or 10 ms frame, up to 2 channels, 30..155 octets per frame, up to 2 frames per SDU). Sink and source PAC location are set to `FRONT_LEFT | FRONT_RIGHT` (note in `main.c`: with `MONO_AUDIO`, Samsung S24 declines unicast).
In **unicast** mode (`cap_acceptor_unicast.c`) the acceptor registers BAP unicast-server callbacks (config / reconfig / qos / enable / start / metadata / disable / stop / release) and CAP stream ops, and on enable of a sink ASE automatically issues `bap_stream_start` (Receiver Start Ready). When the source ASE starts, an internal TX scheduler begins sending dummy SDUs. In **broadcast** mode (`cap_acceptor_broadcast.c`) the acceptor registers BAP scan-delegator and broadcast-sink callbacks, drives PA sync (without PAST) on request, receives BASE and BIGInfo, then calls `esp_ble_audio_bap_broadcast_sink_sync` on the first BIS index. Optional **self-scan** lets the acceptor scan for a broadcast source named `CAP Broadcast Source` and use a hardcoded broadcast code `1234` instead of waiting for a Broadcast Assistant.
In **unicast** mode (`cap_acceptor_unicast.c`) the acceptor registers BAP unicast-server callbacks (config / reconfig / qos / enable / start / metadata / disable / stop / release) and CAP stream ops, and on enable of a sink ASE automatically issues `bap_stream_start` (Receiver Start Ready). When the source ASE starts, an internal TX scheduler begins sending dummy SDUs. In **broadcast** mode (`cap_acceptor_broadcast.c`) the acceptor registers BAP scan-delegator and broadcast-sink callbacks, drives PA sync (preferring PAST when the Assistant supports it) on request, receives BASE / BIGInfo / broadcast code, then calls `esp_ble_audio_bap_broadcast_sink_sync` on the BIS bitmap selected by the Assistant via BASS Modify Source — up to `CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT` BIS streams. The Broadcast Sink object is created on PA sync and deleted on PA loss; Assistant pause / resume keep the same sink. See **Broadcast Mode Internals** below for sequence, gate flags and sink lifecycle. Optional **self-scan** lets the acceptor scan for a broadcast source named `CAP Broadcast Source` and use a hardcoded broadcast code `1234` instead of waiting for a Broadcast Assistant.
The acceptor advertises connectable extended advertising on handle 0 with flags, the ASCS+CAS UUID list, CAS service data with targeted-announcement byte, ASCS targeted-announcement and contexts (unicast build), BASS service data (broadcast build), and the complete device name `cap_acceptor`. The GAP/GATT device name is set to `CAP Acceptor`.
@@ -26,12 +26,12 @@ Open menuconfig:
idf.py menuconfig
```
Under **Example: CAP Acceptor** -> **CAP Acceptor mode**:
Under **Example: CAP Acceptor**:
* **Unicast** (`EXAMPLE_UNICAST`, default) — act as BAP Unicast Server; advertise CAS + ASCS for a CAP Initiator. Mutually exclusive with Broadcast.
* **Broadcast** (`EXAMPLE_BROADCAST`) — act as BAP Broadcast Sink with Scan Delegator; advertise CAS + BASS. Mutually exclusive with Unicast.
* **Unicast** (`EXAMPLE_UNICAST`, default) — act as BAP Unicast Server; advertise CAS + ASCS for a CAP Initiator.
* **Broadcast** (`EXAMPLE_BROADCAST`) — act as BAP Broadcast Sink with Scan Delegator; advertise CAS + BASS.
When **Broadcast** is selected, an additional option appears:
The two options can be enabled together for a dual-role acceptor (BAP spec C.2). When **Broadcast** is selected and **Unicast** is disabled (dual-role + self-scan coexistence is not yet supported), an additional option appears:
* **Scan for Broadcast Sources without Broadcast Assistant** (`EXAMPLE_SCAN_SELF`) — start scanning at boot for a source advertising the name `CAP Broadcast Source`; on match, create the PA sync directly. The hardcoded broadcast code `1234` is used if the BIG is encrypted. Without this option, a separate Broadcast Assistant must drive PA / BIS sync over BASS.
@@ -57,9 +57,135 @@ idf.py -p PORT flash monitor
5. If `EXAMPLE_SCAN_SELF` is set, `check_start_scan` starts extended scanning for the broadcast source.
6. `ext_adv_start` configures and starts connectable extended advertising on handle 0 with the CAS / ASCS / BASS service data appropriate to the build.
7. **Unicast**: on ACL connect the connection handle is stored. On MTU change the acceptor starts service discovery (acting as GATT client). The unicast server then handles config -> reconfig -> qos -> enable -> start (auto Receiver Start Ready for sink ASEs) -> metadata -> disable -> stop -> release per ASE; when the source ASE starts, the TX scheduler begins sending dummy SDUs.
8. **Broadcast**: on `pa_sync_req` from a Broadcast Assistant (or on a scan match in self-scan mode) the acceptor creates a PA sync without PAST. On PA sync, it creates a broadcast sink for the broadcast ID, receives BASE and BIGInfo, then syncs to the first BIS. Stream `started` enters the synced state; `stopped` and `pa_sync_lost` clear flags and (in self-scan mode) restart scanning.
8. **Broadcast**: on `pa_sync_req` the acceptor establishes PA sync — preferring HCI Periodic Advertising Sync Transfer (PAST) when the Assistant supports it, otherwise scanning for the source by address / SID. On PA sync the sink is created (inlined in `broadcast_pa_synced`) and lives until both PA and BIS are gone. BASE, BIGInfo and (if the BIG is encrypted) the broadcast code accumulate as gate flags. When the Assistant's BASS Modify Source pushes a non-zero `bis_sync` bitmap and all gates are open, `bap_broadcast_sink_sync` is called with a `streams[]` of size = popcount of the bitmap (≤ `CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT`). Assistant pause (`bis_sync = 0`) issues `_stop` but **does not delete** the sink; resume re-`_sync`s the same object so the cached BIGInfo / BASE / QoS stay valid. PA loss does **not** tear down the BIG — per BASS spec the two are independent. See **Broadcast Mode Internals** below for the full sequence and state machine.
9. On ACL disconnect the connection handle is reset and `ext_adv_start` re-arms advertising.
## Broadcast Mode Internals
The broadcast acceptor coordinates three roles — Scan Delegator (BASS server), Broadcast Sink (BAP), and PA / BIG sync at the LE controller — under a small atomic-flag state machine. `check_sync_broadcast()` is the single fan-in that calls `bap_broadcast_sink_sync` once all gates are open; each callback that sets a gate invokes it.
### End-to-end sequence (Assistant-driven)
```
Source (SRC) Assistant (ASS) Acceptor (ACC)
| | |
| |---- ACL connect+pair --->|
| |---- BASS Add Source ---->| pa_sync_req_cb
| | (id, addr, sid) |
| | |
| |
| ----- PAST supported (peer + local) ---- |
| |<--- PA state=INFO_REQ ---| set after subscribe
| |- HCI PA Sync Transfer -->|
| |
| ----------- else (no PAST) ------------- |
|<------- periodic_adv_sync_create (scan) -------|
| |
|============== PA sync established ============>| broadcast_pa_synced
| | -> create_broadcast_sink
|============== PA report (BASE) ===============>| base_recv_cb
| | -> FLAG_BASE_RECEIVED
|============== PA report (BIGInfo) ============>| syncable_cb
| | -> FLAG_BROADCAST_SYNCABLE
| |
| ------------ BIG encrypted ------------- |
| |---- BASS Set BCode ----->| broadcast_code_cb
| | | -> FLAG_..._CODE_RECEIVED
| |
| |---- BASS Modify Source ->| bis_sync_req_cb
| | (bis_sync bitmap) | -> FLAG_..._SYNC_REQUESTED
| | check_sync_broadcast -> _sync
|<========= HCI LE BIG Create Sync ==============|
| |
|========== BIG Sync Established ===============>| stream_started_cb
| | -> FLAG_BROADCAST_SYNCED
|============== ISO data (BIS) =================>|
```
### Sync-gate flags
`check_sync_broadcast()` short-circuits on the first unset gate and logs which one it waited on. All gates use atomic bit ops so callbacks may run on any thread without ordering tricks.
| Flag | Set by | Cleared by |
| --------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `FLAG_PA_SYNCED` | `broadcast_pa_synced` | `broadcast_pa_lost` |
| `FLAG_BROADCAST_SYNCABLE` | `syncable_cb` (BIGInfo) | `broadcast_pa_lost` (BIS idle); `broadcast_sink_reset` |
| `FLAG_BASE_RECEIVED` | `base_recv_cb` (first BASE) | `broadcast_pa_lost` (BIS idle); `broadcast_sink_reset` |
| `FLAG_BROADCAST_CODE_REQUIRED` | `syncable_cb` (`biginfo->encryption=1`) | `syncable_cb` (encryption=0); `broadcast_pa_lost` (BIS idle); `broadcast_sink_reset` |
| `FLAG_BROADCAST_CODE_RECEIVED` | `broadcast_code_cb` (BASS Set Broadcast Code); self-scan local code | `broadcast_sink_reset` |
| `FLAG_BROADCAST_SYNC_REQUESTED` | `bis_sync_req_cb` (bitmap ≠ 0); self-scan PA match | `bis_sync_req_cb` (bitmap = 0); `broadcast_sink_reset` |
| `FLAG_BROADCAST_RESYNC_PENDING` | `bis_sync_req_cb` before `_stop` (bitmap change while streaming) | `stream_stopped_cb` after driving the re-sync; `_stop` failure; `broadcast_sink_reset` |
| `FLAG_BROADCAST_SYNCING` | `check_sync_broadcast` after `_sync` returns OK | `stream_started_cb`; `stream_stopped_cb` |
| `FLAG_BROADCAST_SYNCED` | `stream_started_cb` | `stream_stopped_cb` |
`check_sync_broadcast()` runs `_sync` only when:
```
BASE_RECEIVED && BROADCAST_SYNCABLE
&& (!CODE_REQUIRED || CODE_RECEIVED)
&& BROADCAST_SYNC_REQUESTED
&& PA_SYNCED
&& !(SYNCED || SYNCING)
```
### Sink object lifecycle
```
+---------+
[start] -->| Created |
+----+----+
|
| check_sync_broadcast -> _sync
v
+---------+ Modify Source: new +---------+
| Syncing |<------------------------| Stopped |
+----+----+ bis_sync +----^----+
| |
| stream_started_cb | Assistant bis_sync = 0
v | (source pause) OR
+-----------+ | bitmap change -> _stop
| Streaming |---------------------------+
+-----+-----+ |
| | BIG drops while PA gone
| v
+---------> stream_stopped_cb + !PA_SYNCED
|
| _delete + broadcast_sink_reset
v
[end]
```
Key invariants:
- **PA loss does NOT tear down a running BIS.** Per BASS § 3.2.1.6 / § 3.2.1.9, `PA_Sync_State` and `BIS_Sync_State` are independent. While BIS is streaming/syncing, `broadcast_pa_lost` only notifies the assistant (`PA_Sync_State = 0x00`) and clears PA-only local state (`sync_handle`, `FLAG_PA_SYNCED`); the BIG keeps running and audio continues to flow.
- **PA loss with BIS idle tears down the sink.** The sink is bound to the now-dead sync handle and its cached BASE / BIGInfo are stale. `broadcast_pa_lost` calls `_delete` and clears `FLAG_BASE_RECEIVED` / `FLAG_BROADCAST_SYNCABLE` / `FLAG_BROADCAST_CODE_REQUIRED`. The assistant's subscription (`requested_bis_sync`, `FLAG_BROADCAST_SYNC_REQUESTED`, `FLAG_BROADCAST_CODE_RECEIVED`) is preserved so the next PA sync re-creates a fresh sink and resumes streaming.
- **Sink deletion happens in `stream_stopped_cb` when both PA and BIS are gone.** Triggers: assistant unsubscribes via `Modify Source bis_sync = 0`, or the broadcaster stops the BIG while PA is already gone.
- `bis_sync_req_cb` going `X → 0` (Assistant pause) only issues `_stop`, never `_delete`. Going `X → Y` (BIS bitmap switch) likewise only `_stop`s; the next `check_sync_broadcast` (called from `stream_stopped_cb` when PA still synced) re-`_sync`s the same object.
- `pa_sync_term_req_cb` issues the HCI Periodic Advertising Terminate Sync but does **not** clear `broadcast_sink.sync_handle`. Cleanup runs from `BLE_GAP_EVENT_PERIODIC_SYNC_LOST` → `broadcast_pa_lost`. Resetting the handle early would make that gate miss.
### Event handling
| Event | Action |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| **PA lost while BIS active** (broadcaster moved mid-stream) | Set `PA_Sync_State = 0x00`; clear PA-only state (`sync_handle`, `FLAG_PA_SYNCED`). Sink + BIS untouched; BIG keeps running. |
| **PA lost while BIS idle** (Assistant `PA_Sync = 0`, or PA dropped after Assistant paused BIS) | Set `PA_Sync_State = 0x00` (skipped if BASS already updated it in-place); clear PA-only state; `_delete` the sink and clear `FLAG_BASE_RECEIVED` / `FLAG_BROADCAST_SYNCABLE` / `FLAG_BROADCAST_CODE_REQUIRED`. The next PA sync starts from a clean sink and lets the lib redeliver BASE / BIGInfo. |
| **Modify Source `bis_sync = 0`** (PA still synced) | `bis_sync_req_cb` clears `FLAG_BROADCAST_SYNC_REQUESTED` then `_stop`s the BIG. Sink retained. |
| **Modify Source bitmap change** (PA still synced, streaming) | Update `requested_bis_sync` + `FLAG_BROADCAST_SYNC_REQUESTED` + set `FLAG_BROADCAST_RESYNC_PENDING`, then `_stop`. `stream_stopped_cb` clears the flag and re-`_sync`s with the new bitmap. |
| **BIG drops while PA still synced** (e.g. broadcaster pause) | `stream_stopped_cb` clears SYNCED/SYNCING and exposes the loss via `BIS_Sync_State`. `FLAG_BROADCAST_RESYNC_PENDING` is not set, so no auto-retry — per BASS § 3.2.1.9 the assistant drives recovery via Modify Source. |
| **BIG drops after PA lost** (broadcaster turned off) | `stream_stopped_cb` of the last active stream sees `!PA_SYNCED` → `_delete` + `broadcast_sink_reset`. Multi-BIS: earlier callbacks just decrement `active_streams` so `_delete` is not called while the sink is still in use. |
| **Assistant Remove Source** | Spec allows only when BIS not synced; lib handles, app sees no special event. |
Two recurring patterns that drive the above behavior:
- **Update local state before calling `_stop`/`_delete`.** The lib may fire `stream_stopped_cb` synchronously from within `_stop`, so the callback must see the post-stop state. Applies in `bis_sync_req_cb` (updates `requested_bis_sync` + flag before `_stop`) and `broadcast_pa_lost` (no longer calls `_stop`).
- **Sink lifetime is BIS-driven, not PA-driven.** Sink is created on first PA sync and deleted only when the BIG itself stops and PA is also gone. This matches BASS spec's independent PA/BIS state model.
### Multi-BIS (stereo) configuration
`CONFIG_BT_BAP_BROADCAST_SNK_STREAM_COUNT` selects how many BIS streams the acceptor can sync to simultaneously. The `broadcast_sink.cap_streams[N]` array is sized to this value; `bis_sync_req_cb` rejects bitmaps whose popcount exceeds it (`-ENOMEM` back to BASS), and `check_sync_broadcast` builds the `streams[]` argument from the requested bitmap. `broadcast_sink.active_streams` tracks how many of those BIS are currently streaming so that `_delete` / re-sync only run after the *last* stream's `stopped_cb` — calling them while another BIS is still active hits the lib's `BapBsnkNotIdle` path.
For Auracast stereo against a Samsung Galaxy (typical bitmap `0x3` = BIS 1 + BIS 2), set this to `2` in the sdkconfig.
## Expected Log
TAG: `CAP_ACC`.
@@ -4,32 +4,27 @@
menu "Example: CAP Acceptor"
choice EXAMPLE_CAP_ACCEPTOR_MODE
prompt "CAP Acceptor mode"
default EXAMPLE_UNICAST
config EXAMPLE_UNICAST
bool "Unicast"
default y
help
Select exactly one CAP acceptor mode.
If set, advertise as a Unicast acceptor (ASCS) for unicast audio.
config EXAMPLE_UNICAST
bool "Unicast"
help
If selected, the sample will start advertising connectable
for Broadcast Assistants.
config EXAMPLE_BROADCAST
bool "Broadcast"
default y if !EXAMPLE_UNICAST
select BT_NIMBLE_PERIODIC_ADV_SYNC_TRANSFER
help
If set, advertise as a Broadcast acceptor (BASS Scan Delegator)
for syncable broadcast audio. Can coexist with EXAMPLE_UNICAST
for a dual-role CAP Acceptor (BAP spec C.2 allows both).
config EXAMPLE_BROADCAST
bool "Broadcast"
help
If selected, the sample will start advertising syncable
audio streams.
endchoice
if EXAMPLE_BROADCAST
config EXAMPLE_SCAN_SELF
bool "Scan for Broadcast Sources without Broadcast Assistant"
help
If set to true, the sample will start scanning for Broadcast
Sources without waiting for a Broadcast Assistant to connect.
endif
config EXAMPLE_SCAN_SELF
bool "Scan for Broadcast Sources without Broadcast Assistant"
# !EXAMPLE_UNICAST: dual-role + self-scan coexistence not supported yet.
depends on EXAMPLE_BROADCAST && !EXAMPLE_UNICAST
help
If set, the sample will start scanning for Broadcast Sources
without waiting for a Broadcast Assistant to connect.
endmenu
@@ -56,5 +56,7 @@ void broadcast_scan_recv(esp_ble_audio_gap_app_event_t *event);
void broadcast_pa_synced(esp_ble_audio_gap_app_event_t *event);
void broadcast_pa_sync_failed(esp_ble_audio_gap_app_event_t *event);
void broadcast_pa_lost(esp_ble_audio_gap_app_event_t *event);
#endif /* CONFIG_EXAMPLE_BROADCAST */
File diff suppressed because it is too large Load Diff
@@ -56,11 +56,24 @@ static struct peer_config peer = {
static uint8_t ext_adv_data[] = {
/* Flags */
0x02, EXAMPLE_AD_TYPE_FLAGS, (EXAMPLE_AD_FLAGS_GENERAL | EXAMPLE_AD_FLAGS_NO_BREDR),
/* Incomplete List of 16-bit Service UUIDs */
0x05, EXAMPLE_AD_TYPE_UUID16_SOME, (ESP_BLE_AUDIO_UUID_ASCS_VAL & 0xFF),
((ESP_BLE_AUDIO_UUID_ASCS_VAL >> 8) & 0xFF),
(ESP_BLE_AUDIO_UUID_CAS_VAL & 0xFF),
((ESP_BLE_AUDIO_UUID_CAS_VAL >> 8) & 0xFF),
/* Incomplete List of 16-bit Service UUIDs:
* CAS always; ASCS only when unicast role is built;
* BASS only when broadcast role is built.
*/
#if CONFIG_EXAMPLE_UNICAST && CONFIG_EXAMPLE_BROADCAST
0x07, EXAMPLE_AD_TYPE_UUID16_SOME,
(ESP_BLE_AUDIO_UUID_ASCS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_ASCS_VAL >> 8) & 0xFF),
(ESP_BLE_AUDIO_UUID_CAS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_CAS_VAL >> 8) & 0xFF),
(ESP_BLE_AUDIO_UUID_BASS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_BASS_VAL >> 8) & 0xFF),
#elif CONFIG_EXAMPLE_UNICAST
0x05, EXAMPLE_AD_TYPE_UUID16_SOME,
(ESP_BLE_AUDIO_UUID_ASCS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_ASCS_VAL >> 8) & 0xFF),
(ESP_BLE_AUDIO_UUID_CAS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_CAS_VAL >> 8) & 0xFF),
#elif CONFIG_EXAMPLE_BROADCAST
0x05, EXAMPLE_AD_TYPE_UUID16_SOME,
(ESP_BLE_AUDIO_UUID_CAS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_CAS_VAL >> 8) & 0xFF),
(ESP_BLE_AUDIO_UUID_BASS_VAL & 0xFF), ((ESP_BLE_AUDIO_UUID_BASS_VAL >> 8) & 0xFF),
#endif
/* Service Data - 16-bit UUID */
0x04, EXAMPLE_AD_TYPE_SERVICE_DATA16, (ESP_BLE_AUDIO_UUID_CAS_VAL & 0xFF),
((ESP_BLE_AUDIO_UUID_CAS_VAL >> 8) & 0xFF),
@@ -240,6 +253,7 @@ static void pa_sync(esp_ble_audio_gap_app_event_t *event)
{
if (event->pa_sync.status) {
ESP_LOGE(TAG, "PA sync failed, status %d", event->pa_sync.status);
broadcast_pa_sync_failed(event);
return;
}
@@ -291,6 +305,7 @@ static void iso_gap_app_cb(esp_ble_audio_gap_app_event_t *event)
break;
#endif /* CONFIG_EXAMPLE_SCAN_SELF */
case ESP_BLE_AUDIO_GAP_EVENT_PA_SYNC:
case ESP_BLE_AUDIO_GAP_EVENT_PA_SYNC_PAST:
pa_sync(event);
break;
case ESP_BLE_AUDIO_GAP_EVENT_PA_SYNC_LOST:
@@ -603,7 +603,8 @@ static void security_change(esp_ble_iso_gap_app_event_t *event)
int err;
if (event->security_change.status) {
ESP_LOGE(TAG, "Security change failed, status %d", event->security_change.status);
example_audio_security_failed_recover(TAG, event->security_change.conn_handle,
event->security_change.status);
return;
}