BLE Audio: PACS Explained Published Audio Capabilities Service β€” How Bluetooth LE Audio devices announce what they can do

BLE Audio: PACS Explained
Published Audio Capabilities Service β€” How Bluetooth LE Audio devices announce what they can do
πŸ“‘
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.

Key Terms
Acceptor Initiator PAC Record Sink PAC Source PAC LTV Structure LC3 Codec Audio Context Audio Location

Acceptor vs Initiator β€” Who Does What?

Before diving into PACS, you need to understand the two roles:

Acceptor vs Initiator Roles
🎧
Acceptor
Earbuds, speakers, hearing aids

β€’ Hosts PACS (GATT Server)
β€’ Publishes its capabilities
β€’ Receives or transmits audio
β€’ Manages ASE state machine

⟷
πŸ“±
Initiator
Phone, laptop, TV

β€’ GATT Client
β€’ Reads PACS characteristics
β€’ Decides codec / QoS config
β€’ Drives stream setup

What is PACS?

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.

PAC Record Structure (GATT Characteristic)
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)

The 5 Codec_Specific_Capabilities LTV Structures (LC3)

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.

LTV Chain β€” Codec_Specific_Capabilities
Type 0x01
Sampling Freq
Bitfield
β†’
Type 0x02
Frame Duration
7.5ms / 10ms
β†’
Type 0x03
Channel Count
Bitfield 1–8
β†’
Type 0x04
Octets/Frame
Min–Max Range
β†’
Type 0x05
Max Frames/SDU
Optional (def=1)

β‘  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.

Sampling Frequency Bitfield
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.

Frame Duration Bitfield
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.

Real PAC Record Example β€” Decoding the Bytes

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:

Single PAC Record (Compact LTV Format)
/* Sink PAC Characteristic (compact single record) */
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.

BlueZ Code: Reading PAC Characteristics via GATT

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;
    }
}

Audio Locations β€” Left Ear, Right Ear, Front Left…

Each Acceptor can declare which physical audio locations it supports using Sink_Audio_Locations and Source_Audio_Locations characteristics. These are 4-byte bitfields.

Common Audio Location Values
πŸ‘‚
Front Left
Bit 1 = 0x02
πŸ‘‚
Front Right
Bit 2 = 0x04
πŸ”Š
Mono
Bit 0 = 0x01
πŸ”ŠπŸ”Š
Stereo Speaker
0x02 | 0x04 = 0x06

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.

Audio Contexts β€” Use Case Control

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)

Context Type Decision Matrix
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) */
}

PACS Summary β€” What You Must Remember
πŸ“– Static Capabilities

PAC records are set at manufacture. They describe what the device CAN do β€” not what it’s doing right now.

🎯 Mandatory LC3

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).

⚑ Dynamic Availability

Available_Audio_Contexts changes at runtime. An Acceptor can block specific use cases while remaining open for others.

πŸ“ Audio Locations

Tells the Initiator where this device sits (Left/Right/Mono). Used to direct stereo channels to the correct earbud.

Next: ASCS + BAP β€” Setting Up the Audio Stream

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.

Continue to Part 2 β†’

Leave a Reply

Your email address will not be published. Required fields are marked *