What is ASCS and Why Does It Exist?
When you connect a BLE wireless earbud to your phone, several things happen before you hear any audio. The phone first needs to find out what audio capabilities the earbud supports. Then it must agree on a codec, set up a wireless “pipe” with specific quality settings, and finally tell the earbud to start listening. All of this negotiation goes through ASCS โ the Audio Stream Control Service.
ASCS is a GATT-based Bluetooth service introduced as part of the LE Audio stack in Bluetooth Core Spec 5.2. It runs on devices like earbuds, hearing aids, headsets, and wireless microphones โ basically anything that needs to send or receive audio over a BLE unicast connection.
The service exposes a set of characteristics that let a client (your phone or laptop) discover, configure, and control Audio Stream Endpoints (ASEs) on the server (the audio device). An ASE is simply an audio endpoint โ a Sink ASE receives audio (speakers), a Source ASE transmits audio (microphone).
Important distinction: ASCS only sets up and controls the audio stream. The actual audio data travels over a CIS (Connected Isochronous Stream), not over GATT. Think of ASCS as the “control plane” and CIS as the “data plane.”
Before diving deep, here is a simple view of where ASCS sits in the overall LE Audio stack. Understanding this context will help you avoid confusion about what ASCS does vs what other layers handle.
| Application Layer (Music Player, Call App, Gaming) |
| โ |
| BAP โ Basic Audio Profile (orchestrates everything) |
| โ |
| ASCS (stream control) ย +ย PACS (codec capabilities) |
| โ |
| GATT / ATT (reads and writes characteristics) |
| โ |
| L2CAP / LE-ACL (BLE connection for control messages) |
| โ |
| CIS / CIG โ Audio Data (isochronous channel for actual audio) |
Key Insight: PACS tells the client what audio codecs the device supports. ASCS is what the client uses to say “OK, I want to use LC3 at 16 kHz โ set it up!” The actual audio then flows over a CIS, not over GATT.
These terms come up throughout the entire spec. Get these clear in your head before reading further.
| Term | What It Means in Plain Language |
|---|---|
| ASE (Audio Stream Endpoint) | A logical audio channel on the server side. Like a socket โ audio data flows in or out of it. The server (earbud) owns the ASE; the client (phone) configures it. |
| Sink ASE | An ASE where audio data flows into the server. The server is the Audio Sink โ like a speaker receiving music. Audio comes from the phone and goes into the earbud. |
| Source ASE | An ASE where audio data flows out of the server. The server is the Audio Source โ like a microphone in the earbud sending your voice to the phone. |
| ASE_ID | A unique number the server assigns to each ASE for a given client. Think of it as the ASE’s name or handle โ you refer to it in all control operations. |
| CIS (Connected Isochronous Stream) | A BLE isochronous channel dedicated to carrying audio data. Once an ASE is in Streaming state, it is “coupled” to a CIS. The actual audio SDUs travel here, not over GATT. |
| CIG (Connected Isochronous Group) | A group of CIS streams that are synchronized together. Useful for stereo earbuds where both left and right channels must be in sync. Identified by CIG_ID. |
| QoS (Quality of Service) | Configuration parameters that define the reliability and latency characteristics of the audio stream. Includes SDU Interval, PHY type, Max SDU size, Retransmission Number, Max Transport Latency, and Presentation Delay. |
| Presentation Delay | How much time (in microseconds) the audio device needs from when it receives audio data to when it actually plays it. This allows for buffering and synchronization between earbuds. |
| ASE Control Point | A special GATT characteristic that acts as the command channel. The client writes operations (like Config Codec, Enable, Release) to this characteristic. The server responds with notifications. |
| Client vs Server in ASCS | In ASCS, the server is the audio device (earbud, hearing aid). The client is the controlling device (phone, laptop). The server hosts the GATT service; the client reads and writes it. |
ASCS exposes exactly three types of GATT characteristics. There can be multiple instances of Sink ASE and Source ASE characteristics, but only one instance of the ASE Control Point.
| Characteristic | UUID | Properties | Security | Count on Server |
|---|---|---|---|---|
| Sink ASE | ยซSink ASEยป | Read, Notify | Encryption Required | One or more (one per concurrent sink stream) |
| Source ASE | ยซSource ASEยป | Read, Notify | Encryption Required | One or more (one per concurrent source stream) |
| ASE Control Point | ยซASE Control Pointยป | Write, WriteWithoutResponse, Notify | Encryption Required | Exactly one (controls all ASEs) |
Important Rules: At least one of Sink ASE or Source ASE must be supported (you cannot have an ASCS with neither). There can be only one ASCS instance on a server. The service UUID must be set to ยซAudio Stream Controlยป as defined in Bluetooth Assigned Numbers.
Multi-Client Isolation
One clever design in ASCS is that the server exposes separate ASE characteristic values for each connected client. Even though there is only one GATT attribute handle for the Sink ASE characteristic, Client A and Client B each get their own independent copy of the value. Client A’s configuration does not affect Client B’s view of the ASE, and vice versa.
Example: An earbud that supports 3 concurrent unicast streams for a single client would expose 3 ASE characteristics โ maybe two Sink ASEs (music + voice) and one Source ASE (microphone). Each characteristic can serve one stream per client.
Every ASE has its own state machine running on the server. When you understand the state machine, you understand ASCS. All the control operations are just ways to move between these states.
The 7 States
| State Name | Value | What It Means | ASE Type |
|---|---|---|---|
| Idle | 0x00 | No codec config, no QoS config. ASE is doing nothing. | All |
| Codec Configured | 0x01 | A codec has been agreed upon. Server also exposes its preferred QoS params here. No QoS config yet. | All |
| QoS Configured | 0x02 | Codec and QoS both configured. CIG/CIS IDs assigned. A CIS may exist but ASE is not coupled to it yet. | All |
| Enabling | 0x03 | Codec + QoS + Metadata applied. CIS may exist but ASE is not yet coupled. Getting ready to stream. Risk of lost packets if streaming starts now. | All |
| Streaming | 0x04 | ASE is coupled to a CIS. Audio Sink has signaled Receiver Start Ready. Audio data is actively flowing. | All |
| Disabling | 0x05 | ASE is being decoupled from CIS. Source ASE keeps transmitting until Audio Sink sends Receiver Stop Ready. Metadata still associated. | Source ASE only |
| Releasing | 0x06 | CIS is being torn down or already disconnected. QoS config is gone. Codec config may or may not be cached. Metadata gone. | All |
Sink ASE Flow (Happy Path)
For a Sink ASE (like a speaker in an earbud), the normal audio setup and teardown path looks like this:
|
IDLE
|
โ | Config Codec (client writes opcode 0x01) |
| โ | ||
|
CODEC CONFIGURED
|
โ | Config QoS (client writes opcode 0x02) |
| โ | ||
|
QoS CONFIGURED
|
โ | Enable (client writes opcode 0x03) |
| โ | ||
|
ENABLING
|
โ | Receiver Start Ready (server/client writes opcode 0x04) |
| โ | ||
|
STREAMING โถ Audio Flowing
|
โ | Disable (opcode 0x05) โ goes directly to QoS Configured (Sink ASE only) |
| โ Release (opcode 0x08) | ||
|
RELEASING
|
โ | Released โ server autonomously goes to IDLE (no cache) or CODEC CONFIGURED (cache) |
Source ASE Difference โ The Disabling State
A Source ASE (microphone) behaves slightly differently when disabled. Instead of jumping directly to QoS Configured, it goes through a Disabling intermediate state. This is because the audio source keeps transmitting until the receiver (phone) explicitly signals it is ready to stop receiving. This gives the receiver time to finish processing buffered audio.
|
STREAMING (Source ASE)
|
โ |
DISABLING
|
โ |
QoS CONFIGURED
|
| Disable (0x05) | Receiver Stop Ready (0x06) | Back to configured state |
Sink ASE Disable: Sink ASE skips the Disabling state entirely. Disable operation takes a Sink ASE directly from Enabling or Streaming to QoS Configured.
Link Loss Behavior
What happens if the BLE connection drops unexpectedly? ASCS has specific rules:
- CIS link loss while in Streaming or Disabling: Server immediately transitions that ASE to QoS Configured state.
- LE-ACL link loss (the BLE connection itself) in any state: Server immediately transitions all affected ASEs to Releasing state, then runs the Released operation.
All control operations are written to the ASE Control Point characteristic. Every write starts with a 1-byte opcode, followed by the operation parameters. The server responds with a notification on the same characteristic.
| Opcode | Operation | Valid In State | Who Initiates |
|---|---|---|---|
| 0x01 | Config Codec | Idle, Codec Configured, QoS Configured | Client or Server |
| 0x02 | Config QoS | Codec Configured, QoS Configured | Client only |
| 0x03 | Enable | QoS Configured | Client only |
| 0x04 | Receiver Start Ready | Enabling | Client or Server (Audio Sink side) |
| 0x05 | Disable | Enabling, Streaming | Client or Server |
| 0x06 | Receiver Stop Ready | Disabling (Source ASE only) | Client only (Audio Sink side) |
| 0x07 | Update Metadata | Enabling, Streaming | Client or Server |
| 0x08 | Release | Codec Configured through Disabling | Client or Server |
| โ | Released | Releasing only | Server only (autonomous) |
Purpose: Agree on which audio codec to use, and at what configuration (sample rate, frame duration, etc.). This is the very first step in setting up any audio stream. Once this succeeds, the server moves to Codec Configured state and also tells the client its preferred QoS parameters (like what PHY it wants).
Both the client and server can initiate Config Codec. A server might autonomously configure itself when it powers on.
Packet Format
| Field | Size (Bytes) | Description |
|---|---|---|
| Opcode | 1 | 0x01 = Config Codec |
| Number_of_ASEs | 1 | How many ASEs to configure. Must be โฅ 1. You can configure multiple ASEs in one write. |
| ASE_ID[i] | 1 | ID of the ASE to configure (from ASE characteristic) |
| Target_Latency[i] | 1 | 0x01 = Low Latency, 0x02 = Balanced, 0x03 = High Reliability. Hints to server for QoS preferences. |
| Target_PHY[i] | 1 | 0x01=LE 1M, 0x02=LE 2M, 0x03=LE Coded. Target PHY to achieve latency goal. |
| Codec_ID[i] | 5 | Byte 0: Coding Format (e.g. 0x06 = LC3). Bytes 1โ2: Company_ID (0x0000 for standard codecs). Bytes 3โ4: Vendor-specific ID (0x0000 for standard). |
| Codec_Specific_Config_Length[i] | 1 | Length of the codec config that follows. Can be 0. |
| Codec_Specific_Config[i] | Variable | LTV-formatted codec parameters (sample rate, frame duration, octets per frame, etc.). Defined by BAP spec. |
Example: Configuring LC3 at 16 kHz on ASE #1
/*
* Config Codec operation โ write to ASE Control Point characteristic
* Configuring ASE_ID=1 with LC3, 16kHz, balanced latency, LE 2M PHY
*
* Byte layout of the write:
*/
uint8_t config_codec_op[] = {
0x01, /* Opcode: Config Codec */
0x01, /* Number_of_ASEs: 1 */
/* ASE[0] parameters */
0x01, /* ASE_ID: 1 */
0x02, /* Target_Latency: 0x02 = Balanced latency and reliability */
0x02, /* Target_PHY: 0x02 = LE 2M PHY */
/* Codec_ID (5 bytes) */
0x06, /* Coding_Format: LC3 */
0x00, 0x00, /* Company_ID: 0x0000 (standard codec) */
0x00, 0x00, /* Vendor_Codec_ID: 0x0000 (standard codec) */
/* Codec_Specific_Configuration (LTV format, defined by BAP) */
0x10, /* Codec_Specific_Config_Length: 16 bytes */
/* LTV: Sampling_Frequency = 16000 Hz (0x03) */
0x02, /* Length */
0x01, /* Type: Sampling_Frequency */
0x03, /* Value: 0x03 = 16000 Hz */
/* LTV: Frame_Duration = 10ms (0x01) */
0x02, /* Length */
0x02, /* Type: Frame_Duration */
0x01, /* Value: 0x01 = 10ms */
/* LTV: Octets_per_Codec_Frame = 40 */
0x03, /* Length */
0x04, /* Type: Octets_per_Codec_Frame */
0x28, 0x00 /* Value: 40 bytes (little-endian) */
};
/*
* Write this buffer to the ASE Control Point characteristic
* using GATT Write Characteristic Value or Write Without Response
*/
bt_gatt_write(conn, ase_ctrl_point_handle, config_codec_op, sizeof(config_codec_op));
/*
* Server will respond with a notification on ASE Control Point:
* [Opcode=0x01][Num_ASEs=1][ASE_ID=1][Response_Code=0x00][Reason=0x00]
*
* Then server sends notification on Sink ASE characteristic with:
* [ASE_ID=1][ASE_State=0x01 (Codec Configured)][Framing][Preferred_PHY]...
*/
After Success: The server responds on the ASE characteristic with state = 0x01 (Codec Configured) and also fills in its preferred QoS parameters โ Preferred_PHY, Preferred_Retransmission_Number, Max_Transport_Latency, Presentation_Delay_Min, Presentation_Delay_Max. These are hints for the next step (Config QoS).
Purpose: Set the Quality of Service parameters for the CIS that will carry this ASE’s audio. You are telling the server: “I will create a CIS with CIG_ID X and CIS_ID Y, with these exact timing and reliability parameters.” Only the client can initiate this operation.
The parameters you send here map directly to the HCI LE Set CIG Parameters command (HCI_LE_Set_CIG_Parameters). So the client’s host layer uses these values when it sets up the CIG/CIS at the controller level.
Packet Format
| Field | Size (Bytes) | Description |
|---|---|---|
| Opcode | 1 | 0x02 = Config QoS |
| Number_of_ASEs | 1 | Number of ASEs. โฅ 1. |
| ASE_ID[i] | 1 | ASE identifier |
| CIG_ID[i] | 1 | Connected Isochronous Group ID. Groups related CIS streams (e.g. left+right earbud). |
| CIS_ID[i] | 1 | Connected Isochronous Stream ID within the CIG. Must be unique per direction per client. |
| SDU_Interval[i] | 3 | Time interval between audio frames in microseconds. Range: 0xFF to 0xFFFFF. E.g. 10000 = 10ms frame interval. |
| Framing[i] | 1 | 0x00 = Unframed ISOAL PDUs, 0x01 = Framed. Unframed is simpler and preferred when SDU size is fixed. |
| PHY[i] | 1 | Bit 0=LE 1M, Bit 1=LE 2M, Bit 2=LE Coded. Use the server’s Preferred_PHY as a guide. |
| Max_SDU[i] | 2 | Maximum size of a single audio SDU (frame) in bytes. Range: 0โ0xFFF. |
| Retransmission_Number[i] | 1 | How many times to retransmit each audio packet. Higher = more reliable but more latency/power. |
| Max_Transport_Latency[i] | 2 | Maximum end-to-end transport latency in milliseconds. Range: 5โ4000ms. |
| Presentation_Delay[i] | 3 | How long the server needs to buffer and render audio after receiving it, in microseconds. Must be within the server’s stated Min/Max range. |
Example: Config QoS for LC3 16kHz
/*
* Config QoS operation for ASE_ID=1
* Setting up CIG_ID=0x01, CIS_ID=0x01 with 10ms frame interval
*/
uint8_t config_qos_op[] = {
0x02, /* Opcode: Config QoS */
0x01, /* Number_of_ASEs: 1 */
0x01, /* ASE_ID: 1 */
0x01, /* CIG_ID: 1 */
0x01, /* CIS_ID: 1 */
/* SDU_Interval: 10000 us = 10ms (3 bytes, little-endian) */
0x10, 0x27, 0x00,
0x00, /* Framing: 0x00 = Unframed */
0x02, /* PHY: LE 2M */
/* Max_SDU: 40 bytes (2 bytes little-endian) */
0x28, 0x00,
0x02, /* Retransmission_Number: 2 */
/* Max_Transport_Latency: 10ms = 10 (2 bytes little-endian) */
0x0A, 0x00,
/* Presentation_Delay: 40000 us (3 bytes little-endian) */
0x40, 0x9C, 0x00
};
bt_gatt_write(conn, ase_ctrl_point_handle, config_qos_op, sizeof(config_qos_op));
/*
* On success, server notifies ASE characteristic with:
* ASE_State = 0x02 (QoS Configured)
* Additional params include: CIG_ID, CIS_ID, SDU_Interval, Framing,
* PHY, Max_SDU, Retransmission_Number, Max_Transport_Latency, Presentation_Delay
*/
Rule to remember: Two Sink ASEs for the same client cannot share the same CIG_ID + CIS_ID combination. Similarly for Source ASEs. If you try this, the server rejects with Response_Code 0x09, Reason 0x0A (Invalid_ASE_CIS_Mapping).
Purpose: Tell the server to enable the ASE and apply any Metadata. This moves the ASE to Enabling state. The CIS can now be established (though the ASE is not yet coupled to it). Only the client initiates Enable.
Metadata in this context means audio context type information โ things like “this audio is voice” or “this audio is media” or “this audio is for notifications.” This helps devices make decisions about routing, processing, and priority.
Packet Format
| Field | Size (Bytes) | Description |
|---|---|---|
| Opcode | 1 | 0x03 = Enable |
| Number_of_ASEs | 1 | Number of ASEs to enable |
| ASE_ID[i] | 1 | ASE identifier |
| Metadata_Length[i] | 1 | Length of Metadata. Can be 0 if no metadata. |
| Metadata[i] | Variable | LTV-formatted metadata. Only present if Metadata_Length > 0. Streaming_Audio_Contexts is the most common type. |
/*
* Enable operation โ ASE_ID=1, with Media audio context metadata
*
* Streaming_Audio_Contexts LTV:
* Type 0x02 = Streaming_Audio_Contexts
* Value: bitmask โ 0x0004 = Media
*/
uint8_t enable_op[] = {
0x03, /* Opcode: Enable */
0x01, /* Number_of_ASEs: 1 */
0x01, /* ASE_ID: 1 */
0x04, /* Metadata_Length: 4 bytes of metadata follow */
/* LTV: Streaming_Audio_Contexts = Media */
0x03, /* Length: 3 bytes of LTV content */
0x02, /* Type: Streaming_Audio_Contexts */
0x04, 0x00 /* Value: 0x0004 = Media (little-endian) */
};
bt_gatt_write(conn, ase_ctrl_point_handle, enable_op, sizeof(enable_op));
/*
* Server moves ASE to Enabling state (0x03)
*
* Special case for Sink ASE:
* If a CIS is already established AND server is acting as Audio Sink
* AND server is ready to receive audio, the server MAY autonomously
* call Receiver Start Ready and skip directly to Streaming state
* without sending a notification for the Enabling state first.
*/
Purpose: This operation is the “green light” signal that says the Audio Sink is ready to start receiving audio. It completes the coupling of the ASE to the CIS and moves the ASE to Streaming state.
Who sends it depends on which side is the Audio Sink:
- If the server is the Sink (earbud receiving music): The server sends Receiver Start Ready autonomously.
- If the client is the Sink (phone receiving voice from earbud): The client writes Receiver Start Ready to the ASE Control Point.
Direction Rule: If the client tries to send Receiver Start Ready for a Sink ASE (where the server is already the sink), the server rejects it with Response_Code 0x05 (Invalid ASE direction). This operation is only valid for Source ASEs when initiated by the client.
Packet Format
| Field | Size (Bytes) | Description |
|---|---|---|
| Opcode | 1 | 0x04 = Receiver Start Ready |
| Number_of_ASEs | 1 | Number of ASEs |
| ASE_ID[i] | 1 | ASE identifier |
/*
* Receiver Start Ready โ client signals it is ready to receive
* audio from the Source ASE (Source ASE = server sending voice audio)
* This is the client acting as Audio Sink for a Source ASE.
*/
uint8_t recv_start_ready[] = {
0x04, /* Opcode: Receiver Start Ready */
0x01, /* Number_of_ASEs: 1 */
0x02 /* ASE_ID: 2 (the Source ASE, e.g. microphone) */
};
bt_gatt_write(conn, ase_ctrl_point_handle, recv_start_ready, sizeof(recv_start_ready));
/*
* Server transitions Source ASE to Streaming state (0x04)
* Audio data now flows over the CIS
*/
Purpose: Stop the audio stream for an ASE. Behavior differs between Sink and Source ASEs.
- Sink ASE: Disable moves directly to QoS Configured. No Disabling intermediate state.
- Source ASE: Disable moves to Disabling state first. The source keeps transmitting until Receiver Stop Ready is sent.
Packet Format
| Field | Size (Bytes) | Description |
|---|---|---|
| Opcode | 1 | 0x05 = Disable |
| Number_of_ASEs | 1 | Number of ASEs to disable |
| ASE_ID[i] | 1 | ASE identifier |
/* Disable Sink ASE #1 โ goes directly to QoS Configured */
uint8_t disable_sink[] = { 0x05, 0x01, 0x01 };
bt_gatt_write(conn, ase_ctrl_point_handle, disable_sink, sizeof(disable_sink));
/* ASE #1 moves: Streaming โ QoS Configured */
/* Disable Source ASE #2 โ goes to Disabling first */
uint8_t disable_source[] = { 0x05, 0x01, 0x02 };
bt_gatt_write(conn, ase_ctrl_point_handle, disable_source, sizeof(disable_source));
/* ASE #2 moves: Streaming โ Disabling */
/* Source ASE stays in Disabling and keeps transmitting */
/* Client must follow with Receiver Stop Ready (0x06) to complete teardown */
Purpose: The second part of stopping a Source ASE. After Disable puts a Source ASE into Disabling state, the Audio Sink sends Receiver Stop Ready to confirm it has finished consuming audio. This moves the Source ASE back to QoS Configured state.
Only valid for Source ASEs. Only the client (acting as Audio Sink) sends this. The server rejects it with error 0x05 (Invalid ASE direction) if called for a Sink ASE.
/* Receiver Stop Ready for Source ASE #2 */
uint8_t recv_stop_ready[] = { 0x06, 0x01, 0x02 };
bt_gatt_write(conn, ase_ctrl_point_handle, recv_stop_ready, sizeof(recv_stop_ready));
/* Source ASE #2 moves: Disabling โ QoS Configured */
Purpose: Change the Metadata for an active ASE without tearing down and re-establishing the stream. Valid in Enabling and Streaming states. Both client and server can initiate this.
Use case: A call comes in while music is playing. The phone changes the Streaming_Audio_Contexts metadata from “Media” to “Conversational” โ without stopping the audio stream and restarting it from scratch.
/*
* Update Metadata โ change audio context from Media to Conversational
* while ASE is in Streaming state
*/
uint8_t update_metadata_op[] = {
0x07, /* Opcode: Update Metadata */
0x01, /* Number_of_ASEs: 1 */
0x01, /* ASE_ID: 1 */
0x04, /* Metadata_Length: 4 bytes */
/* LTV: Streaming_Audio_Contexts = Conversational (0x0002) */
0x03, /* Length */
0x02, /* Type: Streaming_Audio_Contexts */
0x02, 0x00 /* Value: 0x0002 = Conversational */
};
bt_gatt_write(conn, ase_ctrl_point_handle, update_metadata_op, sizeof(update_metadata_op));
/* ASE stays in Streaming state, only metadata changes */
Purpose: Completely tear down all resources associated with an ASE. Immediately decouples the ASE from its CIS, tears down the CIS, and frees all configuration. This is the “nuclear option” โ it takes you all the way back to Idle or Codec Configured (if server caches the codec config).
Valid from any state that has a configuration โ Codec Configured, QoS Configured, Enabling, Streaming, Disabling. Can be initiated by either client or server.
/* Release ASE #1 โ from any configured state */
uint8_t release_op[] = { 0x08, 0x01, 0x01 };
bt_gatt_write(conn, ase_ctrl_point_handle, release_op, sizeof(release_op));
/*
* Server transitions ASE to Releasing state (0x06)
* Server tears down the CIS
* Server then autonomously runs Released operation:
* - If caching codec: ASE goes to Codec Configured (0x01)
* - If not caching: ASE goes to Idle (0x00)
* Server sends notification on ASE characteristic with final state
* Server does NOT send notification on ASE Control Point for Released
*/
Purpose: This is the final cleanup step after Release or after an LE-ACL link loss. The server runs this autonomously โ the client does not write anything. The server decides whether to cache the codec configuration or start fresh from Idle.
| Scenario | Next State | What Server Writes to Characteristic |
|---|---|---|
| Server wants to cache codec config | Codec Configured (0x01) | Saves Codec_ID, Codec_Specific_Config, preferred QoS. Client can skip Config Codec next time. |
| Server does not cache config | Idle (0x00) | Deletes all Additional_ASE_Parameters. ASE is completely clean. |
No Control Point Notification: Unlike all other operations, the Released operation does NOT generate a notification on the ASE Control Point characteristic. The server only notifies the ASE characteristic itself with the new state.
Here is the full sequence showing how a phone (Client/Audio Source) sets up a music stream to earbuds (Server/Audio Sink). This is the most common LE Audio unicast scenario.
| Phone (Client) | GATT / BLE | Earbud (Server) |
|---|---|---|
| 1. Discover ASCS service Find ASE characteristics and ASE Control Point handle |
โ GATT Discover Services โ
โ Service/Characteristic Handles โ |
Returns ASCS UUID, Sink ASE handle, ASE Control Point handle |
| 2. Check supported codecs Read PACS to know what codecs server supports |
โ GATT Read PACS โ
โ PAC Records (LC3 supported) โ |
Returns PAC records showing LC3, supported sample rates, frame durations |
| 3. Subscribe to notifications Enable CCC descriptor on Sink ASE and ASE Control Point |
โ Write CCC = 0x0001 โ
โ OK โ |
Stores CCCD subscription for this client |
| 4. Config Codec (0x01) Request LC3, 16kHz, 10ms frames on Sink ASE #1 |
โ Write ASE Control Point โ
โ Notify: CP response (Success) โ |
ASE moves to Codec Configured (0x01). Server exposes preferred QoS: PHY, retransmission count, presentation delay range |
| 5. Config QoS (0x02) CIG_ID=1, CIS_ID=1, SDU_Interval=10ms, PHY=LE2M, Max_SDU=40, Presentation_Delay=40ms |
โ Write ASE Control Point โ
โ Notify: CP response (Success) โ |
ASE moves to QoS Configured (0x02). CIG/CIS IDs stored. QoS params stored. |
| 6. Create CIG at HCI level Client host calls HCI_LE_Set_CIG_Parameters to create CIG_ID=1 in controller |
Internal HCI โ (not on BLE link) |
No action from server at this point |
| 7. Enable (0x03) Enable Sink ASE #1 with Media audio context metadata |
โ Write ASE Control Point โ
โ Notify: CP response (Success) โ |
ASE moves to Enabling (0x03). Server is now aware audio is about to start. |
| 8. Create CIS connection Client calls HCI_LE_Create_CIS. BLE controller establishes the isochronous channel. |
โ BLE CIS Setup โ (HCI level) |
CIS is now established. Both sides have isochronous channel ready. |
| Server is Sink โ it sends Receiver Start Ready autonomously | โ Notify: ASE State=Streaming โ | 9. Server autonomously sends Receiver Start Ready ASE coupled to CIS. Earbud is ready to receive audio. |
| ๐ต Sending Audio SDUs over CIS | โ LC3 Audio Frames (CIS) โ ASE State = Streaming (0x04) |
๐ต Receiving and Playing Audio |
| 10. User pauses โ Disable (0x05) Disable Sink ASE #1 |
โ Write ASE Control Point โ
โ Notify: ASE State=QoS Configured โ |
Sink ASE goes directly to QoS Configured (0x02). CIS may stay up. |
| 11. App closes โ Release (0x08) Release all resources for ASE #1 |
โ Write ASE Control Point โ
โ Notify: ASE State=Releasing โ |
CIS torn down. ASE goes to Idle or Codec Configured depending on server caching preference. |
When you read a Sink ASE or Source ASE characteristic, this is the format you get back. The server also sends this same format in notifications whenever the ASE state changes.
Base Format (All States)
| Byte Offset | Field | Size | Notes |
|---|---|---|---|
| 0 | ASE_ID | 1 byte | Never 0x00. Unique per client namespace. Stable if server has bonded relationship with client. |
| 1 | ASE_State | 1 byte | 0x00=Idle, 0x01=Codec Configured, 0x02=QoS Configured, 0x03=Enabling, 0x04=Streaming, 0x05=Disabling, 0x06=Releasing |
| 2+ | Additional_ASE_Parameters | Variable | Empty in Idle and Releasing. Contents depend on state (see below). |
Additional_ASE_Parameters in Codec Configured State (0x01)
When ASE_State = 0x01, the Additional_ASE_Parameters include the server’s preferred QoS hints plus the codec configuration:
| Framing 1B |
Preferred_PHY 1B |
Pref_RTx_Num 1B |
Max_Transport_Latency 2B |
PD_Min 3B |
PD_Max 3B |
Pref_PD_Min 3B |
Pref_PD_Max 3B |
Codec_ID 5B |
CSC_Len 1B |
Codec_Specific_Config Variable |
PD = Presentation Delay, CSC = Codec Specific Configuration, RTx = Retransmission
Reading ASE Characteristic in Code
/*
* Callback when ASE characteristic notification is received
* Parse the ASE value to extract state and parameters
*/
void on_ase_notification(const uint8_t *data, uint16_t len)
{
if (len < 2) {
/* Minimum is ASE_ID + ASE_State */
return;
}
uint8_t ase_id = data[0];
uint8_t ase_state = data[1];
printf("ASE_ID: %d, State: %d\n", ase_id, ase_state);
switch (ase_state) {
case 0x00: /* Idle */
printf("ASE %d is Idle โ no configuration\n", ase_id);
break;
case 0x01: /* Codec Configured */
if (len >= 2 + 1 + 1 + 1 + 2 + 3 + 3 + 3 + 3 + 5 + 1) {
uint8_t framing = data[2];
uint8_t pref_phy = data[3];
uint8_t pref_rtx = data[4];
uint16_t max_latency = data[5] | (data[6] << 8);
/* Presentation_Delay_Min at data[7..9] (3 bytes LE) */
uint32_t pd_min = data[7] | (data[8] << 8) | (data[9] << 16);
/* Presentation_Delay_Max at data[10..12] */
uint32_t pd_max = data[10] | (data[11] << 8) | (data[12] << 16);
printf(" Framing: %s\n", framing ? "Framed" : "Unframed");
printf(" Preferred PHY: 0x%02X\n", pref_phy);
printf(" Preferred RTx Number: %d\n", pref_rtx);
printf(" Max Transport Latency: %d ms\n", max_latency);
printf(" Presentation Delay: %d us - %d us\n", pd_min, pd_max);
}
break;
case 0x02: /* QoS Configured */
printf("ASE %d โ QoS configured, CIG_ID=%d CIS_ID=%d\n",
ase_id, data[2], data[3]);
break;
case 0x03: /* Enabling */
printf("ASE %d โ Enabling, CIG_ID=%d CIS_ID=%d\n",
ase_id, data[2], data[3]);
break;
case 0x04: /* Streaming */
printf("ASE %d โ STREAMING! Audio data flowing.\n", ase_id);
break;
case 0x05: /* Disabling */
printf("ASE %d โ Disabling (Source ASE decoupling)\n", ase_id);
break;
case 0x06: /* Releasing */
printf("ASE %d โ Releasing resources\n", ase_id);
break;
default:
printf("ASE %d โ Unknown state 0x%02X\n", ase_id, ase_state);
}
}
When the server rejects or cannot complete an operation, it notifies the ASE Control Point with a Response_Code and a Reason. Understanding these helps you debug ASCS issues.
| Response_Code | Name | What Went Wrong |
|---|---|---|
| 0x00 | Success | Operation completed successfully. |
| 0x01 | Unsupported Opcode | You wrote an opcode the server does not recognize or support. |
| 0x02 | Invalid Length | Number_of_ASEs field does not match actual parameter count, or total length is wrong. |
| 0x03 | Invalid ASE_ID | You referenced an ASE_ID that does not exist for this client. |
| 0x04 | Invalid ASE State Machine Transition | You tried to do an operation that is not valid for the current state. E.g. Enable on an Idle ASE (must be QoS Configured first). |
| 0x05 | Invalid ASE Direction | Receiver Start Ready or Receiver Stop Ready called on a Sink ASE instead of Source ASE. |
| 0x06 | Unsupported Audio Capabilities | You requested a codec config that was not in any of the server’s PAC records. |
| 0x07 | Unsupported Config Parameter Value | Server does not support one of the values you gave. Reason byte tells you which parameter: 0x01=Codec_ID, 0x03=SDU_Interval, 0x05=PHY, 0x09=Presentation_Delay, etc. |
| 0x08 | Rejected Config Parameter Value | Server supports this parameter range in general, but rejects the specific value in the current context (e.g. resource conflict). |
| 0x09 | Invalid Config Parameter Value | The value you wrote is outside the allowed range entirely. Common with Presentation_Delay outside Min/Max range. |
| 0x0D | Insufficient Resources | Server cannot complete the operation โ it is out of ASEs, memory, or processing capacity. |
| 0x0E | Unspecified Error | Something went wrong on the server that does not fit any other category. |
Reading the Control Point Notification Response
/*
* ASE Control Point notification format when responding to a client operation:
*
* [Opcode: 1B] [Number_of_ASEs: 1B] [ASE_ID: 1B] [Response_Code: 1B] [Reason: 1B]
* ... repeated for each ASE in the original request ...
*
* Special case for 0x01 (Unsupported Opcode) and 0x02 (Invalid Length):
* Number_of_ASEs = 0xFF, ASE_ID = 0x00, Reason = 0x00
*/
void on_cp_notification(const uint8_t *data, uint16_t len)
{
if (len < 2) return;
uint8_t opcode = data[0];
uint8_t num_ases = data[1];
/* Check for whole-operation errors first */
if (num_ases == 0xFF) {
uint8_t resp_code = data[3]; /* 0x01 or 0x02 */
printf("Operation 0x%02X rejected: %s\n", opcode,
resp_code == 0x01 ? "Unsupported Opcode" : "Invalid Length");
return;
}
/* Per-ASE responses */
uint16_t offset = 2;
for (int i = 0; i < num_ases; i++) {
if (offset + 3 > len) break;
uint8_t ase_id = data[offset];
uint8_t resp_code = data[offset + 1];
uint8_t reason = data[offset + 2];
if (resp_code == 0x00) {
printf("ASE %d: Operation 0x%02X SUCCESS\n", ase_id, opcode);
} else {
printf("ASE %d: FAILED โ Response_Code=0x%02X Reason=0x%02X\n",
ase_id, resp_code, reason);
}
offset += 3;
}
}
This section is important if you are implementing ASCS on a server that can handle multiple connected clients at once.
- Each client gets its own ASE namespace: ASE_ID values are per-client. Client A’s ASE_ID=1 and Client B’s ASE_ID=1 are completely separate ASEs even if they share the same GATT attribute handle.
- Separate characteristic values per client: When a client reads the Sink ASE characteristic, it sees only the value associated with itself. It cannot see another client’s state.
- ASE_ID stability: If the server has a bonded (trusted) relationship with a client, the ASE_ID for that client must not change across reconnections. This allows the client to remember which ASE is which.
- ASE_ID must not be 0x00: The value 0x00 is reserved and the server must never assign it as an ASE_ID.
- Server controls resource allocation: If Client A is using all the server’s ASEs in Streaming state, the server can refuse to enable ASEs for Client B. Or it can decide to release Client A’s ASEs to accommodate Client B. This is implementation-defined.
These GATT sub-procedures are mandatory on any ASCS server:
| GATT Sub-Procedure | Requirement | Used For |
|---|---|---|
| Write Characteristic Value | Mandatory | Writing control operations to ASE Control Point |
| Write Without Response | Mandatory | Faster writes to ASE Control Point when ACK not needed |
| Notifications | Mandatory | Server notifies ASE state changes and CP responses |
| Read Characteristic Descriptors | Mandatory | Client reads CCCD to check if notifications are enabled |
| Write Characteristic Descriptors | Mandatory | Client writes CCCD to enable/disable notifications |
| Write Long Characteristic Value | Mandatory | For ASE Control Point writes that exceed ATT_MTU |
Encryption is Required: All three ASCS characteristics require an encrypted BLE connection. You must pair/bond before you can interact with ASCS. This is why ASCS works only on encrypted links.
If you are working with BlueZ on Linux, ASCS is handled by the bluetooth/audio/ subsystem. Here are the key points to know.
BlueZ DBus Interface for ASCS
In BlueZ, ASCS is exposed through the org.bluez.MediaEndpoint1 DBus interface. When you use A2DP LE Audio, BlueZ internally handles ASCS negotiations. However, for direct GATT-level access you interact with the GATT characteristics directly.
#!/bin/bash
# Using bluetoothctl to explore ASCS on a connected LE Audio device
# Connect to the device (must be BLE LE Audio capable)
bluetoothctl connect AA:BB:CC:DD:EE:FF
# List all GATT attributes - look for ASCS UUID: 0x184E
bluetoothctl -- list-attributes AA:BB:CC:DD:EE:FF
# Read the Sink ASE characteristic
# (replace the UUID/path with the actual one from list-attributes)
bluetoothctl -- select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service001a/char001b
bluetoothctl -- read
# Example output: [0x01, 0x00]
# ASE_ID=1, ASE_State=0x00 (Idle)
# Subscribe to notifications on Sink ASE
bluetoothctl -- notify on
Python Script to Interact with ASCS (using bluepy/dbus)
#!/usr/bin/env python3
"""
ASCS exploration script using BlueZ DBus interface
Reads ASE characteristics and subscribes to state change notifications
"""
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
# ASCS Service UUID
ASCS_UUID = "0000184e-0000-1000-8000-00805f9b34fb"
SINK_ASE_UUID = "00002bc4-0000-1000-8000-00805f9b34fb"
SOURCE_ASE_UUID = "00002bc5-0000-1000-8000-00805f9b34fb"
ASE_CP_UUID = "00002bc6-0000-1000-8000-00805f9b34fb"
# State names for human-readable output
ASE_STATES = {
0x00: "Idle",
0x01: "Codec Configured",
0x02: "QoS Configured",
0x03: "Enabling",
0x04: "Streaming",
0x05: "Disabling",
0x06: "Releasing"
}
def parse_ase_value(data):
"""Parse ASE characteristic value and print human-readable info"""
if len(data) < 2:
print(" [ERROR] ASE value too short")
return
ase_id = data[0]
ase_state = data[1]
state_name = ASE_STATES.get(ase_state, f"Unknown (0x{ase_state:02X})")
print(f" ASE_ID: {ase_id}")
print(f" ASE_State: {ase_state:#04x} = {state_name}")
if ase_state == 0x01: # Codec Configured
if len(data) >= 4:
framing = data[2]
pref_phy = data[3]
print(f" Framing: {'Unframed supported' if framing == 0 else 'Framed only'}")
print(f" Preferred PHY: {pref_phy:#04x}")
elif ase_state == 0x02: # QoS Configured
if len(data) >= 4:
cig_id = data[2]
cis_id = data[3]
print(f" CIG_ID: {cig_id}, CIS_ID: {cis_id}")
elif ase_state in (0x03, 0x04, 0x05): # Enabling/Streaming/Disabling
if len(data) >= 4:
cig_id = data[2]
cis_id = data[3]
print(f" CIG_ID: {cig_id}, CIS_ID: {cis_id}")
if len(data) > 4:
meta_len = data[4]
print(f" Metadata Length: {meta_len}")
def build_config_codec_op(ase_id):
"""
Build Config Codec operation bytes for LC3 at 16kHz, 10ms frames
Opcode=0x01, single ASE, LC3 codec, balanced latency, LE 2M PHY
"""
return bytes([
0x01, # Opcode: Config Codec
0x01, # Number_of_ASEs: 1
ase_id, # ASE_ID
0x02, # Target_Latency: Balanced
0x02, # Target_PHY: LE 2M
# Codec_ID (5 bytes): LC3 standard
0x06, 0x00, 0x00, 0x00, 0x00,
# Codec_Specific_Config (LTV format)
0x10, # Config length: 16 bytes
# LTV: Sampling_Frequency = 16000 Hz
0x02, 0x01, 0x03,
# LTV: Frame_Duration = 10ms
0x02, 0x02, 0x01,
# LTV: Octets_per_Codec_Frame = 40
0x03, 0x04, 0x28, 0x00,
# LTV: Codec_Frames_per_SDU = 1
0x02, 0x05, 0x01,
])
# Main interaction flow
if __name__ == "__main__":
print("ASCS Tutorial โ Read ASE state and print info")
print("In a real application, you would:")
print("1. Connect to the BLE device (must be bonded/encrypted)")
print("2. Discover ASCS service (UUID 0x184E)")
print("3. Find Sink ASE, Source ASE, ASE Control Point characteristics")
print("4. Subscribe to notifications on all three")
print("5. Write Config Codec to ASE Control Point to start setup")
print("6. Follow the state machine flow shown in this tutorial")
Common Implementation Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| Sending Enable before Config QoS | Server responds 0x04 (Invalid ASE State Machine Transition) | Always follow: Config Codec โ Config QoS โ Enable |
| Not subscribing to notifications before writing | You miss all response notifications. State changes are invisible. | Always write CCC=0x0001 on ASE and CP characteristics first |
| Presentation_Delay outside server’s supported range | Server responds 0x09, Reason 0x09 (Invalid_Presentation_Delay) | Read PD_Min and PD_Max from ASE characteristic after Config Codec |
| Two Sink ASEs with same CIG_ID+CIS_ID | Server responds 0x09, Reason 0x0A (Invalid_ASE_CIS_Mapping) | Each Sink ASE must have a unique CIS_ID within the same CIG |
| Trying to interact with ASCS without encryption | ATT error โ Insufficient Encryption | Bond/pair before accessing ASCS characteristics |
| Sending Receiver Stop Ready for a Sink ASE | Server responds 0x05 (Invalid ASE Direction) | Receiver Stop Ready is only for Source ASEs in Disabling state |
Summary โ What You Learned
ASCS is the GATT service that provides the control plane for LE Audio unicast streams. Here is what you should now know:
- โ What ASCS does: Lets a client configure and control Audio Stream Endpoints (ASEs) on a server device like earbuds.
- โ Three characteristics: Sink ASE (readable, notifiable), Source ASE (readable, notifiable), ASE Control Point (writable, notifiable).
- โ Seven ASE states: Idle โ Codec Configured โ QoS Configured โ Enabling โ Streaming (with Disabling for Source ASEs and Releasing for teardown).
- โ Nine control operations: Config Codec, Config QoS, Enable, Receiver Start Ready, Disable, Receiver Stop Ready, Update Metadata, Release, Released.
- โ Key sequence: Config Codec โ Config QoS โ Enable โ Receiver Start Ready โ Streaming.
- โ Sink vs Source ASE difference: Sink ASE skips Disabling state. Source ASE needs the client to send Receiver Stop Ready after Disable.
- โ Multi-client isolation: Each client gets its own private ASE namespace and characteristic values. Clients cannot interfere with each other.
- โ Encryption required: All ASCS characteristics require an encrypted BLE link.
ASCS is one piece of the LE Audio puzzle. Learn about the related specifications that ASCS depends on and works with.
Published Audio Capabilities Service (PACS) Basic Audio Profile (BAP) BLE GATT Deep Dive
