Service
PACS
Codec
LC3
Role
Acceptor / Initiator
Format
LTV Structures
Why does PACS exist?
In Bluetooth Classic Audio (A2DP, HFP), two devices had to go back and forth in loops negotiating codec settings β sometimes ending in a deadlock with no audio at all. PACS (Published Audio Capabilities Service) is the LE Audio solution to that problem.
Think of PACS as a device’s audio resume. Before any stream is set up, an Acceptor (earbuds, speaker, hearing aid) publishes exactly what codecs and configurations it supports. The Initiator (phone, laptop) reads this once and uses it to make smart decisions β no back-and-forth needed.
Before diving into PACS, you need to understand the two roles:
β’ Hosts PACS (GATT Server)
β’ Publishes its capabilities
β’ Receives or transmits audio
β’ Manages ASE state machine
β’ GATT Client
β’ Reads PACS characteristics
β’ Decides codec / QoS config
β’ Drives stream setup
PACS is a GATT service that lives on the Acceptor. It exposes two key characteristics:
- Sink PAC β capabilities when the device receives audio (plays back)
- Source PAC β capabilities when the device transmits audio (microphone)
Each characteristic contains one or more PAC Records. A PAC record describes one codec and the configurations it supports. PACS is typically set at manufacture time and only changes with a firmware update.
| Field | Size | Description |
|---|---|---|
| Number_of_PAC_Records | 1 octet | How many PAC records follow |
| Codec_ID [i] | 5 octets | 0x06 = LC3, or vendor-specific |
| Codec_Specific_Capabilities_Length [i] | 1 octet | Length of capabilities data |
| Codec_Specific_Capabilities [i] | variable | LTV bitfields for sampling freq, frame duration, channels, etc. |
| Metadata_Length [i] | 1 octet | 0x00 if no metadata |
| Metadata [i] | variable | LTV metadata (e.g. preferred audio context) |
Every LE Audio device must support LC3 (Low Complexity Communication Codec). The Codec_Specific_Capabilities for LC3 is a chain of LTV (Length-Type-Value) structures. There are 5 of them.
π‘ LTV means: First byte = Length, Second byte = Type, remaining bytes = Value. Each LTV is self-describing, so you can parse it without knowing the full schema in advance.
Sampling Freq
Frame Duration
Channel Count
Octets/Frame
Max Frames/SDU
β Supported_Sampling_Frequencies (Type 0x01) β Mandatory
A 2-byte bitfield. Each bit corresponds to a sampling frequency. LC3 supports: 8, 16, 24, 32, 44.1, 48 kHz.
| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| kHz | 8 | 11.025 | 16 | 22.05 | 24 | 32 | 44.1 | 48 | 88.2 | 96 | 176.4 | 192 |
π’ Green = LC3 supported frequencies. To indicate support for 16 kHz, set Bit 2 β value = 0x0004
β‘ Supported_Frame_Durations (Type 0x02) β Mandatory
LC3 supports two frame sizes: 7.5 ms and 10 ms.
| Bit | 0 | 1 | 2β3 | 4 | 5 | 6β7 |
| Meaning | 7.5ms supported | 10ms supported | RFU | 7.5ms preferred | 10ms preferred | RFU |
Example: 10ms only β 0x02 (bit 1 set)
β’ Supported_Audio_Channel_Counts (Type 0x03)
Bits 0β7 indicate how many audio channels can be multiplexed in one Isochronous Stream. Bit 0 = 1 channel, Bit 1 = 2 channels, etc. If only mono is supported, this LTV can be omitted.
β£ Supported_Octets_Per_Codec_Frame (Type 0x04) β Mandatory
4 bytes total: 2 bytes minimum + 2 bytes maximum octets per codec frame. This indirectly defines the supported bitrate range. For 16 kHz / 10 ms: 40 octets = 32 kbps. For 24 kHz / 10 ms: 60 octets = 48 kbps.
β€ Supported_Max_Codec_Frames_Per_SDU (Type 0x05) β Optional
A single byte stating the maximum number of codec frames per SDU packet. Default is 1 (one frame per packet), so this LTV can often be omitted.
Here’s what a minimal BAP-compliant Sink PAC record looks like for 16 kHz and 24 kHz at 10 ms (the mandatory minimum for LE Audio). Reading raw GATT bytes as a packet:
Codec_ID: 06 00 00 00 00 β LC3 (0x06)
Cap_Len: 0B β 11 bytes of capabilities follow
/* LTV 1: Supported_Sampling_Frequencies */
02 01 14 00 β Len=2, Type=0x01, Value=0x0014 (bits 2+4 = 16kHz + 24kHz)
/* LTV 2: Supported_Frame_Durations */
02 02 02 β Len=2, Type=0x02, Value=0x02 (10ms only)
/* LTV 3: Supported_Audio_Channel_Counts */
02 03 03 β Len=2, Type=0x03, Value=0x03 (1 and 2 channels)
/* LTV 4: Supported_Octets_Per_Codec_Frame (min=40, max=60) */
05 04 28 00 3C 00 β Len=5, Type=0x04, Min=40(0x0028), Max=60(0x003C)
/* LTV 5: Supported_Max_Codec_Frames_Per_SDU (optional) */
02 05 01 β Len=2, Type=0x05, Value=0x01 (default)
Metadata_Len: 00 β No metadata
β οΈ Compact vs Separate Records: When you pack multiple values (like 16+24 kHz) into one PAC record, the Acceptor must support all combinations of those values. If that’s not possible (e.g. 40 octets only works with 16 kHz), use separate PAC records β one for each valid configuration.
In BlueZ, PACS characteristics are accessible over GATT. From a client (Initiator) side, you discover them using the standard GATT discovery and then read/notify them. Below is a C example using the BlueZ HCI + GATT approach, similar to how BlueZ internally handles LE Audio PACS discovery:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lib/bluetooth.h"
#include "lib/hci.h"
#include "lib/hci_lib.h"
#include "lib/uuid.h"
/* PACS Service UUID: 0x1850 */
#define PACS_UUID 0x1850
/* Sink PAC Characteristic UUID: 0x2BC9 */
#define SINK_PAC_UUID 0x2BC9
/* Source PAC Characteristic UUID: 0x2BCB */
#define SOURCE_PAC_UUID 0x2BCB
/* Sink Audio Location UUID: 0x2BCA */
#define SINK_AUDIO_LOC_UUID 0x2BCA
/* LC3 Codec ID */
#define CODEC_LC3 0x06
/* Codec_Specific_Capabilities LTV Types */
#define LTV_SAMPLING_FREQ 0x01
#define LTV_FRAME_DURATION 0x02
#define LTV_CHANNEL_COUNTS 0x03
#define LTV_OCTETS_PER_FRAME 0x04
#define LTV_MAX_FRAMES_SDU 0x05
/* PAC Record structure */
struct pac_record {
uint8_t codec_id; /* e.g. 0x06 for LC3 */
uint16_t company_id; /* 0x0000 for standard codecs */
uint16_t vendor_codec_id; /* 0x0000 for standard codecs */
uint8_t cap_len; /* length of codec_specific_capabilities */
uint8_t caps[64]; /* raw LTV bytes */
uint8_t meta_len;
uint8_t metadata[32];
};
/*
* parse_ltv - walk through LTV bytes and extract a specific type
* @data: raw LTV byte array
* @len: total length of data
* @type: LTV type to search for
* @out: buffer to store value bytes
* @out_len: size of out buffer
*
* Returns number of value bytes copied, -1 if not found.
*/
int parse_ltv(const uint8_t *data, uint8_t len,
uint8_t type, uint8_t *out, uint8_t out_len)
{
uint8_t pos = 0;
while (pos + 1 < len) {
uint8_t ltv_len = data[pos]; /* L: length of T+V */
uint8_t ltv_type = data[pos + 1]; /* T: type code */
if (ltv_len == 0)
break; /* malformed */
if (ltv_type == type) {
/* V starts at pos+2, value bytes = ltv_len - 1 */
uint8_t val_len = ltv_len - 1;
uint8_t copy = (val_len < out_len) ? val_len : out_len;
memcpy(out, &data[pos + 2], copy);
return copy;
}
pos += 1 + ltv_len; /* jump to next LTV */
}
return -1; /* not found */
}
/*
* decode_pac_record - parse and print one PAC record
*/
void decode_pac_record(const uint8_t *data, int len)
{
if (len < 7) {
printf(" PAC record too short\n");
return;
}
struct pac_record rec;
int pos = 0;
/* Codec ID: 5 bytes (format, company, vendor) */
rec.codec_id = data[pos]; pos++;
rec.company_id = data[pos] | (data[pos+1] << 8); pos += 2;
rec.vendor_codec_id= data[pos] | (data[pos+1] << 8); pos += 2;
rec.cap_len = data[pos++];
if (pos + rec.cap_len > len) {
printf(" Truncated capabilities\n");
return;
}
memcpy(rec.caps, &data[pos], rec.cap_len);
pos += rec.cap_len;
printf(" Codec: %s (0x%02X)\n",
rec.codec_id == CODEC_LC3 ? "LC3" : "Vendor",
rec.codec_id);
/* Parse Sampling Frequencies */
uint8_t sf_val[2] = {0};
if (parse_ltv(rec.caps, rec.cap_len, LTV_SAMPLING_FREQ,
sf_val, sizeof(sf_val)) > 0) {
uint16_t sf = sf_val[0] | (sf_val[1] << 8);
printf(" Sampling Frequencies: 0x%04X =>", sf);
if (sf & (1 << 0)) printf(" 8kHz");
if (sf & (1 << 2)) printf(" 16kHz");
if (sf & (1 << 4)) printf(" 24kHz");
if (sf & (1 << 5)) printf(" 32kHz");
if (sf & (1 << 6)) printf(" 44.1kHz");
if (sf & (1 << 7)) printf(" 48kHz");
printf("\n");
}
/* Parse Frame Durations */
uint8_t fd_val = 0;
if (parse_ltv(rec.caps, rec.cap_len, LTV_FRAME_DURATION,
&fd_val, 1) > 0) {
printf(" Frame Durations:");
if (fd_val & 0x01) printf(" 7.5ms");
if (fd_val & 0x02) printf(" 10ms");
if (fd_val & 0x10) printf(" (prefer 7.5ms)");
if (fd_val & 0x20) printf(" (prefer 10ms)");
printf("\n");
}
/* Parse Octets per Codec Frame */
uint8_t octs[4] = {0};
if (parse_ltv(rec.caps, rec.cap_len, LTV_OCTETS_PER_FRAME,
octs, sizeof(octs)) >= 4) {
uint16_t min_octs = octs[0] | (octs[1] << 8);
uint16_t max_octs = octs[2] | (octs[3] << 8);
printf(" Octets/Frame: min=%u max=%u (bitrate %u-%u kbps)\n",
min_octs, max_octs,
(min_octs * 8 * 100) / 1000, /* approximate */
(max_octs * 8 * 100) / 1000);
}
}
/*
* Example: parse Sink PAC characteristic value read from GATT
* In BlueZ this data arrives via bt_gatt_client read or notify callback
*/
void on_sink_pac_read(const uint8_t *value, uint16_t value_len)
{
if (value_len < 1) return;
uint8_t num_records = value[0];
printf("Sink PAC: %u record(s)\n", num_records);
int pos = 1;
for (int i = 0; i < num_records && pos < value_len; i++) {
printf("--- PAC Record %d ---\n", i);
decode_pac_record(&value[pos], value_len - pos);
/* Advance pos: 5 (codec_id) + 1 (cap_len) + cap_len + 1 (meta_len) */
if (pos + 6 > value_len) break;
uint8_t cap_len = value[pos + 5];
if (pos + 6 + cap_len >= value_len) break;
uint8_t meta_len = value[pos + 6 + cap_len];
pos += 5 + 1 + cap_len + 1 + meta_len;
}
}
Each Acceptor can declare which physical audio locations it supports using Sink_Audio_Locations and Source_Audio_Locations characteristics. These are 4-byte bitfields.
Practical example: A right earbud sets its Sink_Audio_Locations to 0x00000004 (Front Right only). The phone sees this and automatically sends the right stereo channel to it, while sending the left channel to the left earbud. If both earbuds claim both Front Left and Front Right, the user’s app decides which is which.
Context Types are use-case tags attached to audio streams. Examples: Media, Conversational, Ringtone, Emergency Alarm, Game, Live, Voice Assistants.
PACS has two context characteristics:
- Supported_Audio_Contexts β static list of contexts this device understands (set at manufacture)
- Available_Audio_Contexts β dynamic: which contexts are currently open for streaming (can change at runtime)
| Supported | Available | What Initiator Can Do |
|---|---|---|
| 0 | 0 | Map this context to Β«UnspecifiedΒ» and try |
| 0 | 1 | β Not allowed (contradiction) |
| 1 | 0 | π« Shall NOT stream this context (explicitly blocked) |
| 1 | 1 | β Go ahead β stream this context |
π‘ Key rule: Β«UnspecifiedΒ» context MUST always be in Supported_Audio_Contexts. It acts as a wildcard β any unsupported context can be mapped to Unspecified if Unspecified is Available. But if an Acceptor marks a context as Supported + Not Available, the Initiator cannot bypass it using Unspecified β it is explicitly blocked.
BlueZ: Reading Available Audio Contexts
/*
* Audio Context Type bit positions (from Bluetooth Assigned Numbers)
* These are used in Supported_Audio_Contexts and Available_Audio_Contexts
* characteristics (2 bytes each).
*/
#define CONTEXT_TYPE_PROHIBITED 0x0000
#define CONTEXT_TYPE_UNSPECIFIED (1 << 0) /* 0x0001 - always supported */
#define CONTEXT_TYPE_CONVERSATIONAL (1 << 1) /* 0x0002 - phone calls */
#define CONTEXT_TYPE_MEDIA (1 << 2) /* 0x0004 - music streaming */
#define CONTEXT_TYPE_GAME (1 << 3) /* 0x0008 */
#define CONTEXT_TYPE_INSTRUCTIONAL (1 << 4) /* 0x0010 */
#define CONTEXT_TYPE_VOICE_ASSIST (1 << 5) /* 0x0020 */
#define CONTEXT_TYPE_LIVE (1 << 6) /* 0x0040 */
#define CONTEXT_TYPE_SOUND_EFFECTS (1 << 7) /* 0x0080 */
#define CONTEXT_TYPE_NOTIFICATIONS (1 << 8) /* 0x0100 */
#define CONTEXT_TYPE_RINGTONE (1 << 9) /* 0x0200 */
#define CONTEXT_TYPE_ALERTS (1 << 10) /* 0x0400 */
#define CONTEXT_TYPE_EMERGENCY (1 << 11) /* 0x0800 */
/*
* check_context_availability - can an Initiator use a given context?
* @supported: value of Supported_Audio_Contexts characteristic
* @available: value of Available_Audio_Contexts characteristic
* @context: the context type we want to use
*
* Returns:
* 1 - go ahead, stream this context directly
* 0 - map to Unspecified (if Unspecified is available)
* -1 - blocked, cannot stream this context at all
*/
int check_context_availability(uint16_t supported, uint16_t available,
uint16_t context)
{
int is_supported = !!(supported & context);
int is_available = !!(available & context);
int unspec_avail = !!(available & CONTEXT_TYPE_UNSPECIFIED);
if (is_supported && is_available)
return 1; /* β
allowed directly */
if (is_supported && !is_available)
return -1; /* π« explicitly blocked */
/* Not supported β can we fall back to Unspecified? */
if (!is_supported && unspec_avail)
return 0; /* map to Unspecified */
return -1; /* Unspecified also unavailable */
}
/* Example usage */
void example_context_check(void)
{
/* Acceptor supports: Unspecified, Media, Conversational, Ringtone */
uint16_t supported = CONTEXT_TYPE_UNSPECIFIED |
CONTEXT_TYPE_MEDIA |
CONTEXT_TYPE_CONVERSATIONAL |
CONTEXT_TYPE_RINGTONE;
/* Currently available: Unspecified, Media (Ringtone is busy) */
uint16_t available = CONTEXT_TYPE_UNSPECIFIED |
CONTEXT_TYPE_MEDIA;
int rc;
rc = check_context_availability(supported, available, CONTEXT_TYPE_MEDIA);
printf("Media: %s\n", rc == 1 ? "Allowed" :
rc == 0 ? "Use Unspecified" : "Blocked");
/* Output: Media: Allowed */
rc = check_context_availability(supported, available, CONTEXT_TYPE_RINGTONE);
printf("Ringtone: %s\n", rc == 1 ? "Allowed" :
rc == 0 ? "Use Unspecified" : "Blocked");
/* Output: Ringtone: Blocked (supported but not available) */
rc = check_context_availability(supported, available, CONTEXT_TYPE_GAME);
printf("Game: %s\n", rc == 1 ? "Allowed" :
rc == 0 ? "Use Unspecified" : "Blocked");
/* Output: Game: Use Unspecified (not supported, but Unspecified is available) */
}
PAC records are set at manufacture. They describe what the device CAN do β not what it’s doing right now.
Every LE Audio device MUST have at least one PAC record with Codec_ID = 0x06 (LC3), supporting 16 kHz at 10 ms (Audio Sink) or 16 kHz at 10 ms (Audio Source).
Available_Audio_Contexts changes at runtime. An Acceptor can block specific use cases while remaining open for others.
Tells the Initiator where this device sits (Left/Right/Mono). Used to direct stereo channels to the correct earbud.
Now that you know what PACS exposes, the next part covers how ASCS and BAP actually configure and establish a streaming connection using ASE state machines.
