| Supported Targets | ESP32-H4 | ESP32-S31 |
|---|
TMAP Central Example
(See the README.md file in the upper level examples directory for more information about examples.)
Overview
This example takes the TMAP Call Gateway (CG) and Unicast Media Sender (UMS) roles, registered together via esp_ble_audio_tmap_register(ESP_BLE_AUDIO_TMAP_ROLE_CG | ESP_BLE_AUDIO_TMAP_ROLE_UMS). It scans for connectable extended advertising that carries TMAS service data with the UMR role bit set, connects, pairs, exchanges MTU, and then drives TMAP, CSIP and VCP discovery before bringing up unicast audio.
The example uses the ESP-BLE-AUDIO library pieces for: CAP initiator with the BAP Unicast Client (LC3 preset 48_2_1, MEDIA context), CSIP Set Coordinator, VCP Volume Controller, MCP server backed by the media proxy player, and CCP server registering a single GTBS bearer (Generic TBS, UCI un000, tel,wechat URI schemes, 5G technology). Each transmitting stream runs its own TX scheduler in the ISO task, feeding dummy ISO SDUs filled with that stream's sequence number. Device name is set to TMAP Central.
Topology
The number of peripherals and the number of CIS is a build-time choice. The first CIS is always bidirectional — one sink and one source ASE of the same peripheral share it — and a second CIS, when present, is transmit-only:
| Topology | Peripherals | CIS #0 | CIS #1 |
|---|---|---|---|
1P_1CIS |
1 | TX+RX — sink #0 + source #0 | — |
1P_2CIS (default) |
1 | TX+RX — sink #0 + source #0 | TX — sink #1 of the same peer |
2P_2CIS |
2 (CSIP coordinated set) | TX+RX — rank 1 member | TX — rank 2 member |
All CIS live in a single CIG. Which endpoint goes on which CIS is expressed by one table (bindings[] in main/cap_initiator.c); everything else in the state machine is shared between the three topologies.
The topology picked here must match how tmap_peripheral is built. The central does not negotiate the topology — it looks for a fixed number of peers and a fixed number of sink ASEs on each. A mismatch does not fall back gracefully: it stalls in discovery or refuses to build the group.
| Central Unicast topology | Peripheral boards | Earbuds type | Earbud Location |
|---|---|---|---|
1 peripheral, 1 CIS (TX+RX) |
1 | Single headset |
Mono |
1 peripheral, 2 CISes (TX+RX, TX) (default) |
1 | Single headset (default) |
Stereo (default) |
2 peripherals (coordinated set), 2 CISes (TX+RX, TX) |
2 | Duo headset on both |
Left on one, Right on the other |
What a mismatch looks like:
- central built for 2 peripherals, only one flashed → stops after
Peer 1/2 connected, scanning for the restand never starts audio - central built for 2 CISes, peripheral left on
Mono→[SNK #1] Peer 0 has no sink endpoint 1andFailed to create unicast group - peripheral built with
CONFIG_BT_ASCS_MAX_ACTIVE_ASESbelow 3 → the third codec configuration is rejected with[SNK #1] Config response, rsp_code 13(Insufficient Resources) andUnicast start completed, err -77 - two set members flashed with the same ear → same rank on both,
both report rank 1, streams come up unlocked
The peripheral's Kconfig only offers the location options that suit its earbuds type — mono/stereo for a single headset, left/right for a duo — so the two peripheral settings on their own cannot express an unsupported combination. What still has to be kept in step is picking the right one of the three rows above.
Channel allocation
The rendered channel comes from the peer's PACS Sink Audio Locations, not from its CSIP rank — CSIS only requires a rank to be unique and non-zero, it says nothing about which ear a device is. sink_preset() in main/cap_initiator.c resolves it as:
- peer carries one sink stream → its own advertised location (
FRONT_LEFT/FRONT_RIGHT), or mono when it advertises an empty bitmap or both channels - peer carries two sink streams → front left for the first, front right for the second
An acceptor that publishes no location therefore gets a mono stream, which is what the peripheral example's Mono location produces.
Rank still decides which member gets the bidirectional CIS in 2P_2CIS, which is a separate question from which ear it is.
Coordinated set (2P_2CIS)
The central resolves each advertised RSI against the SIRK hardcoded in main/csip_set_coordinator.c, connects only members of that set, orders them by CSIS rank, and runs the CSIP ordered access procedure — locking the set ascending by rank before stream setup and releasing it once unicast_start_complete fires.
CAP names the members by their CSIS instance (ESP_BLE_AUDIO_CAP_SET_TYPE_CSIP) as soon as every member exposes one, and falls back to naming them by connection (..._AD_HOC) otherwise — that fallback is what lets the same code path serve a peripheral without CSIS. Both forms resolve to the same connections; the set lock itself is driven by the CSIP layer, not by the CAP set type.
Volume is treated as a property of the whole set: every member gets its own VCP volume controller, and volume / mute go through the CAP commander (esp_ble_audio_cap_commander_change_volume*) so both earbuds change together. The commander refuses to run until each member has a discovered volume controller, so the example reads each member's VCS state first — that also syncs the VCS change counter.
To make that visible, the example keeps driving the set once the streams are up: every 5 minutes it issues one commander procedure, walking the volume through 32 / 96 / 160 / 224 and then muting and unmuting. Each step is a single procedure covering every member, so both earbuds report the same value in the same tick rather than being written one at a time. It runs on its own application task — not the audio task and not the esp_timer one, which the stack's own timers share — and the audio APIs take the host lock themselves, so calling them from there is safe. A tick is skipped rather than queued if the previous procedure has not reported back, and the cycle pauses as soon as the set is no longer whole.
Requirements
- A board with BLE 5.2, ISO, and LE Audio support (e.g. ESP32-H4, ESP32-S31)
- One or two boards running tmap_peripheral, depending on the topology
Configuration
idf.py menuconfig
Under Example: TMAP Central (CG & UMS) → Unicast topology, pick one — and build the peripheral to match, per the table in Topology:
- 1 peripheral, 1 CIS (TX+RX) (
EXAMPLE_TMAP_CEN_TOPO_1P_1CIS) — peripheral:Single headset+Mono. - 1 peripheral, 2 CISes (TX+RX, TX) (
EXAMPLE_TMAP_CEN_TOPO_1P_2CIS, default) — peripheral:Single headset+Stereo, which is also its default. It must expose two sink ASEs (CONFIG_BT_ASCS_MAX_ASE_SNK_COUNT, 2 in itssdkconfig.defaults). - 2 peripherals (coordinated set), 2 CISes (TX+RX, TX) (
EXAMPLE_TMAP_CEN_TOPO_2P_2CIS) — selectsBT_CSIP_SET_COORDINATOR. Peripheral:Duo headseton both boards, oneLeftand oneRight; their ranks default from that and must stay distinct. Rank picks which member gets the bidirectional CIS, the location picks the channel.
Security & Pairing
Just-Works pairing (LE Secure Connections, no MITM, no I/O capability) with bonding enabled, inherited from ../../common_components/example_init/ble_audio_example_init.c.
Build & Flash
The base sdkconfig.defaults defaults to the Bluedroid host; idf.py automatically merges the per-target overlay (sdkconfig.defaults.$IDF_TARGET). To build with NimBLE host instead, layer sdkconfig.defaults.nimble on top via -DSDKCONFIG_DEFAULTS.
Bluedroid host (default)
idf.py set-target esp32h4
idf.py -p PORT flash monitor
NimBLE host
idf.py set-target esp32h4
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32h4;sdkconfig.defaults.nimble" -p PORT flash monitor
For esp32s31, replace the chip overlay accordingly.
(Exit serial monitor with Ctrl-].)
Example Flow
app_maininitializes NVS, Bluetooth, the audio common layer, and registers TMAP CG+UMS.- Sub-modules initialize in order: CAP initiator (one TX scheduler per sink stream), CSIP set coordinator, VCP volume controller plus CAP commander callbacks, MCP server (media proxy), CCP server (GTBS bearer).
- The audio stack starts, the device name is set, and extended scanning begins.
- Each connectable scan result is parsed for TMAS service data and, in the two-peripheral topology, for the CSIS RSI. A peer is taken when its TMAP role contains UMR and — for a coordinated set build — its RSI resolves against our SIRK. Peers already connected are skipped by address.
- On ACL connect, security is initiated; on security change, an MTU exchange is requested.
- After the MTU exchange and GATT discovery both complete, TMAP, VCP and CSIP discovery start for that peer, and scanning resumes if more peers are still wanted. VCP discovery runs on every member, not just the first.
- Once every peer has been through set discovery, the members are ordered by rank, the set is locked (two-peripheral topology only) and
cap_initiator_setupruns CAS discover → sink and source ASE discovery on every member. The CAS discovery also records each peer's CSIS instance, and PACS reports its Sink Audio Locations. - When the last member reports back, the unicast group is created from
bindings[]— each sink's channel resolved from that peer's advertised location — and unicast audio starts. The set lock is released when the start procedure completes. - Each sink stream that reaches "started" arms its own TX scheduler at the QoS interval and sends ISO SDUs filled with its own sequence number; the source stream's incoming SDUs are counted as RX metrics.
- Once the streams are up and every member's VCS state has been read, a periodic demo starts driving the set through the CAP commander — one procedure every 5 minutes, cycling volume and then mute, each applying to the whole set at once.
- Losing one member costs the others nothing: the CIG stays up, their CIS keep streaming, and the departed member's slot in the group is held for whatever comes back to fill it. Only that member is scanned for and rediscovered, and starting it again reuses its slot — releasing a stream leaves it in the group, and configuring it again rebinds the new endpoint to the CIS the slot already owns. The group is deleted only when the last member goes.
Expected Log
Initialization phase:
TMAP_CEN: CAP initiator initialized: 2 peer(s), 2 CIS
TMAP_CEN: CSIP set coordinator initialized
TMAP_CEN: VCP volume controller initialized
TMAP_CEN: MCP server initialized
TMAP_CEN: Registered gtbs bearer <n>
Scan and connect (the 2/2 count and the set member note only appear in a coordinated-set build):
TMAP_CEN: Found peer 1/2 (TMAS UMR, set member)
TMAP_CEN: Connected: handle <h> role <r> peer <addr>
TMAP_CEN: Security: handle <h> level <l> bonded <b>
TMAP_CEN: MTU updated: handle <h> mtu <m>
TMAP_CEN: Service discovery started: handle <h>
TMAP_CEN: Service discovery complete: handle <h> status 0
TMAP_CEN: TMAP discovery done: handle <h>
TMAP_CEN: Peer 1/2 connected, scanning for the rest
TMAP_CEN: CSIP discovered: handle <h> set_size 2 rank 1 lockable 1
Set lock and CAP setup:
TMAP_CEN: All 2 peer(s) ready
TMAP_CEN: Set locked
TMAP_CEN: CAP initiator setup for 2 member(s)
TMAP_CEN: Found CAS with CSIS: handle <h>
TMAP_CEN: [SNK #0] Endpoint discovered on handle <h>
TMAP_CEN: [SRC #0] Endpoint discovered on handle <h>
TMAP_CEN: Sink discover complete: handle <h>
TMAP_CEN: Source discover complete: handle <h>
TMAP_CEN: [SNK #0] Peer 0 (loc 0x00000001) renders front left
TMAP_CEN: [SNK #1] Peer 1 (loc 0x00000002) renders front right
TMAP_CEN: Created unicast group: 2 CIS
TMAP_CEN: Started 3 unicast stream(s) as a coordinated set
TMAP_CEN: Unicast start completed
TMAP_CEN: Set released
Volume, applied to the whole set through the CAP commander:
TMAP_CEN: VCP discovery done, vocs <n> aics <n>
TMAP_CEN: VCP state cb done, volume <v> mute <m>
TMAP_CEN: Driving the volume of 2 member(s) every 300 s
TMAP_CEN: Changing volume of 2 member(s) to 32
TMAP_CEN: Set volume changed
TMAP_CEN: Changing volume of 2 member(s) to 96
TMAP_CEN: Set volume changed
TMAP_CEN: Changing volume mute state of 2 member(s) to 1
TMAP_CEN: Set volume mute changed
Streaming phase. Each stream reports its own count, so the two sinks advance independently and the source counts what the rank 1 peripheral sends back:
TMAP_CEN: [SNK #0] Stream started
TMAP_CEN: [SNK #0] Streaming, interval <i>, length <l>
TMAP_CEN: [SNK #1] Stream started
TMAP_CEN: [SNK #1] Streaming, interval <i>, length <l>
TMAP_CEN: [SNK #0] TX: <count> packets
TMAP_CEN: [SNK #1] TX: <count> packets
TMAP_CEN: [SRC #0] RX: <count> packets
Call control / disconnect:
TMAP_CEN: CCP: Placing call to remote with id <i> to <caller>
TMAP_CEN: CCP: Call terminated for id <i> with reason <r>
TMAP_CEN: [SNK #0] Stream stopped, reason 0x<rr>
TMAP_CEN: [SNK #0] ISO disconnected, reason 0x<rr>
TMAP_CEN: Disconnected: handle <h> reason 0x<rr>
TMAP_CEN: Deleted unicast group
Tag is TMAP_CEN.
Peer Pairing
Run tmap_peripheral on one or two boards, matching the topology.
1 peripheral (1P_1CIS / 1P_2CIS)
- Flash the peripheral and let it start extended advertising.
1P_2CISworks with its defaults (Single headset+Stereo); for1P_1CISswitch its location to Mono. - Flash and start this central; it scans for TMAS+UMR.
- The central connects, pairs, and exchanges MTU with the peripheral.
- TMAP discovery completes on both sides; the central runs CAP unicast setup against the peripheral's ASCS.
- The central starts the sink stream(s) and feeds ISO SDUs at the QoS interval; the peripheral transmits back on the bidirectional CIS.
- The central can drive the peripheral's volume via VCP and accept TBS originate/terminate from the peripheral.
2 peripherals (2P_2CIS)
- Build the peripheral twice as Duo headset, once with Earbud Location = Left and once with Right. The rank default follows the location (left → 1, right → 2), so the two boards get distinct ranks without touching Device rank in set. Flash one image per board.
- Flash and start this central. It connects the first member it finds, resumes scanning, then connects the second.
- After both members finish CSIP discovery the central locks the set by rank, sets up one CIG with two CIS, and releases the lock when the procedure completes.
- The rank 1 board gets the bidirectional CIS and transmits back; the rank 2 board gets a transmit-only CIS. Which channel each renders comes from its Earbud Location, so a rank/ear mismatch shows up in the
renders front left/renders front rightlog rather than silently sending the wrong channel. - Once both streams are up and both members' VCS state is read, the volume demo starts: every 5 minutes one CAP commander procedure moves both boards to the same volume, and the mute steps at the end of the cycle mute and unmute them together. Both boards log the same value within one tick — that is the point of routing it through the commander instead of writing each VCS separately.
- Losing either board leaves the other one streaming: its CIS is untouched, and the central just rescans for the missing one and re-enters it into the slot it left. The surviving earbud logs nothing at all — no
Disable request, noISO disconnected— while the central logsLost a member; 1 still streamingand later[SNK #n] Already streaming, left alone.