BLE Audio: Volume, Input & Mic Control Understanding VCS · VOCS · AICS · MICS — the four services that manage sound in Bluetooth LE Audio devices

BLE Audio: Volume, Input & Mic Control
Understanding VCS · VOCS · AICS · MICS — the four services that manage sound in Bluetooth LE Audio devices
4
Services Covered
GATT
Based Architecture
Beginner
Friendly

Why is Volume Control Complicated in BLE Audio?

Think about your earbuds. You can change the volume from three places: the earbud tap gesture, your phone’s volume buttons, and maybe a smartwatch. All three need to stay in sync. If they don’t, you get into a mess — one controller turns the source signal down while another cranks the earbud output to the max. Result: distorted audio and a user who can no longer raise the volume.

BLE Audio solves this with a set of standardised GATT services. The core idea is simple: keep the master volume control as close to the speaker as possible, and make sure every remote controller always knows the current state before touching anything.

Key Terms in This Post

VCS VOCS AICS MICS VCP Change_Counter Volume_Setting Gain_Mode AGC GATT Notify Acceptor Initiator

The Four Services at a Glance

BLE Audio volume management is split into four pieces, each doing a distinct job:

Service Short Name Think of it as…
Volume Control Service VCS The main volume knob
Volume Offset Control Service VOCS Per-speaker balance trim
Audio Input Control Service AICS Gain knob per audio source (mic, BT stream, telecoil…)
Microphone Control Service MICS One big mute button for all microphones

There is also a Volume Control Profile (VCP) — a set of rules that defines how GATT Clients interact with VCS, VOCS, and AICS together on a remote device.

Audio Signal Flow — Stereo Headphone Example

BT Audio Stream
Telecoil Input
Microphone

→→→
AICS
(Gain + Mute)
AICS
(Gain + Mute)
AICS
(Gain + Mute)

VCS
Global Volume
+ Global Mute

VOCS [L]
Offset Trim

🔊 Left
VOCS [R]
Offset Trim

🔊 Right

Effective volume per ear = Volume_Setting (VCS) + Volume_Offset (VOCS[L or R])

1. Volume Control Service (VCS) — The Master Knob

VCS is the central authority for volume on an Acceptor (the device playing audio — your earbuds, headphone, hearing aid). It has one GATT Characteristic that stores the current state called the Volume State, and another called the Volume Control Point that Clients write to in order to change that state.

Volume State Characteristic Fields

Field Size Meaning
Volume_Setting 1 byte 0 = silent, 255 = loudest. Manufacturer decides the actual dB mapping.
Mute 1 byte 0 = not muted, 1 = muted. Independent of Volume_Setting — unmuting restores the old level.
Change_Counter 1 byte 0–255 rolling counter. Increments on every change. Prevents stale writes.

What is Change_Counter and why does it exist?

Imagine three controllers (phone, smartwatch, earbud button) all trying to adjust volume at the same time. Without a guard, one controller might issue a “volume down” command based on stale information. Change_Counter is the guard:

Change_Counter Sync Mechanism
Client reads VCS

Gets: Volume=100, Change_Counter=7
Client writes VCS

Sends: opcode=Vol_Up, Change_Counter=7
Server checks

Counter matches ✓ → applies change → counter becomes 8 → notifies all clients
Stale client writes

Sends stale counter=7, server holds counter=8 → command ignored → client must re-read

Volume Control Point Opcodes

To change volume, a Client writes one of these opcodes to the Volume Control Point characteristic:

Opcode Operation Notes
0x00 Relative Volume Down Step down; does not affect mute state
0x01 Relative Volume Up Step up; does not affect mute state
0x02 Unmute + Volume Down Both actions together
0x03 Unmute + Volume Up Both actions together
0x04 Set Absolute Volume Also passes a specific Volume_Setting value
0x05 Unmute Mute off; volume level unchanged
0x06 Mute Mute on; volume level unchanged

Step Size: Because most real devices only have ~20 discrete volume levels, the Server defines a Step Size = 256 ÷ number_of_steps. Relative Up/Down opcodes move by one step at a time using these formulas:

/* VCS relative volume step formulas */
Volume_Down: new_setting = MAX(current - step_size, 0);
Volume_Up:   new_setting = MIN(current + step_size, 255);

Volume Flags — Persist or Reset?

One final VCS characteristic — Volume Flags — tells a Client whether to apply a default volume on connection (flag = 0) or reuse the last remembered volume level (flag = 1). Setting flag = 1 gives the “it remembered my volume” experience users expect.

BlueZ: Reading and Writing VCS via D-Bus

/*
 * BlueZ VCS interaction — reading Volume State via D-Bus GLib proxy
 * Assumes you have obtained the org.bluez.VolumeControl1 interface
 * on the remote Acceptor's D-Bus object path.
 *
 * The BlueZ VolumeControl1 interface exposes:
 *   Volume  (byte)   — maps to Volume_Setting
 *   Mute    (bool)   — maps to Mute field
 */

#include <gio/gio.h>
#include <stdio.h>

static void vcs_volume_up(GDBusProxy *vcp_proxy)
{
    GError *err = NULL;

    /* Call the VolumeUp method — BlueZ internally handles
     * Change_Counter and writes opcode 0x01 to the remote VCS. */
    g_dbus_proxy_call_sync(vcp_proxy,
                           "VolumeUp",
                           NULL,             /* no parameters */
                           G_DBUS_CALL_FLAGS_NONE,
                           -1, NULL, &err);

    if (err) {
        g_printerr("VolumeUp failed: %s\n", err->message);
        g_error_free(err);
    } else {
        g_print("Volume up sent to Acceptor.\n");
    }
}

static void vcs_set_absolute(GDBusProxy *vcp_proxy, guint8 level)
{
    GError *err = NULL;
    GVariant *params = g_variant_new("(y)", level); /* byte param */

    g_dbus_proxy_call_sync(vcp_proxy,
                           "SetAbsoluteVolume",
                           params,
                           G_DBUS_CALL_FLAGS_NONE,
                           -1, NULL, &err);
    if (err) {
        g_printerr("SetAbsoluteVolume failed: %s\n", err->message);
        g_error_free(err);
    }
}

static void vcs_mute(GDBusProxy *vcp_proxy)
{
    GError *err = NULL;
    g_dbus_proxy_call_sync(vcp_proxy, "Mute",
                           NULL, G_DBUS_CALL_FLAGS_NONE,
                           -1, NULL, &err);
    if (err) {
        g_printerr("Mute failed: %s\n", err->message);
        g_error_free(err);
    }
}

2. Volume Offset Control Service (VOCS) — Per-Speaker Trim

Once VCS sets the global volume, VOCS fine-tunes the level for each individual speaker or audio output location. You need one VOCS instance per audio output location — so a stereo headset needs two VOCS instances (left ear, right ear).

The effective level reaching each speaker is: Final = Volume_Setting (VCS) + Volume_Offset (VOCS)

VOCS Offset in Action — Hearing Aid Use Case
VCS
Volume_Setting = 120
+
VOCS[L]
Offset = +20
(weaker left ear)
VOCS[R]
Offset = 0
=
🔊 Left: 140
🔊 Right: 120

Volume_Offset range: −255 to +255 (2 bytes signed)

VOCS also has an Audio Location characteristic (a 4-byte bitmap from the BT Assigned Numbers) and an Audio Output Description (a friendly name like “Bedroom Left Speaker”).

Writing a Volume Offset via BlueZ

/*
 * VOCS — Set Volume Offset via BlueZ MediaControl / VolumeOffsetControl1
 *
 * The GATT write to Volume Offset Control Point carries:
 *   Opcode (0x01)  | Change_Counter (1 byte) | Volume_Offset (2 bytes signed)
 *
 * BlueZ exposes this through the VolumeOffsetControl1 D-Bus interface.
 */

static void vocs_set_offset(GDBusProxy *vocs_proxy, gint16 offset)
{
    GError *err = NULL;
    /*
     * SetVolumeOffset takes a int16 offset value.
     * BlueZ wraps the Change_Counter and opcode internally.
     */
    GVariant *params = g_variant_new("(n)", (gint16)offset);

    g_dbus_proxy_call_sync(vocs_proxy,
                           "SetVolumeOffset",
                           params,
                           G_DBUS_CALL_FLAGS_NONE,
                           -1, NULL, &err);
    if (err) {
        g_printerr("VOCS SetVolumeOffset error: %s\n", err->message);
        g_error_free(err);
    } else {
        g_print("Volume offset set to %d on this audio location.\n", offset);
    }
}

3. Audio Input Control Service (AICS) — Per-Source Gain

AICS goes one step upstream: before the signals are mixed together. A hearing aid might simultaneously receive a BT audio stream, pick up ambient sound via a microphone, and receive telecoil input. AICS gives each of those streams its own independent gain and mute control.

You create one AICS instance per audio input that is rendered (sent to the speaker). A noise-cancellation microphone that only feeds into signal processing — and never directly to the speaker — does not get its own AICS instance.

AICS: How Gain is Defined

Unlike VCS (which uses an arbitrary 0–255 scale), AICS works in decibel-based units. The Gain_Setting_Properties characteristic tells you three things:

Property Meaning
Gain_Setting_Units Each step = N × 0.1 dB (manufacturer decides N)
Gain_Setting_Minimum Lowest permitted gain value
Gain_Setting_Maximum Highest permitted gain value

Automatic vs Manual Gain Control

AICS supports both manual gain (set by the user or a Client) and Automatic Gain Control (AGC — the device manages it automatically). The Gain_Mode field inside the Audio Input State tells you which mode is active and whether the Client is allowed to switch modes:

Gain_Mode Values
0
Manual Only
Client cannot switch mode
1
Auto Only
Client cannot switch mode
2
Manual
Client can switch to Auto
3
Automatic
Client can switch to Manual

AICS Audio Input Control Point Opcodes

Opcode Name Effect
0x01 Set Gain Setting Sets gain in increments of Gain_Setting_Units × 0.1dB
0x02 Unmute Unmutes this input stream (if mute is not disabled)
0x03 Mute Mutes this input stream (if mute is not disabled)
0x04 Set Manual Gain Mode Switch from Auto to Manual (if Gain_Mode = 3)
0x05 Set Auto Gain Mode Switch from Manual to Auto (if Gain_Mode = 2)

BlueZ: AICS Gain Setting Write

/*
 * AICS — Write Gain Setting via GATT directly (using BlueZ GATT client)
 *
 * Packet layout for opcode 0x01 (Set Gain Setting):
 *   [0] = 0x01         (opcode)
 *   [1] = change_ctr   (read from Audio Input State first)
 *   [2] = gain_setting (signed byte, −128 to 127)
 *
 * All other opcodes (0x02–0x05) use:
 *   [0] = opcode
 *   [1] = change_ctr
 */

#include <stdint.h>
#include <string.h>

/* Build raw GATT write buffer for AICS Set Gain Setting */
static int aics_build_set_gain(uint8_t *buf, size_t buf_len,
                                uint8_t change_ctr, int8_t gain)
{
    if (buf_len < 3)
        return -1;

    buf[0] = 0x01;        /* Set Gain Setting opcode */
    buf[1] = change_ctr;  /* current Change_Counter from Audio Input State */
    buf[2] = (uint8_t)gain; /* signed gain value */
    return 3;             /* number of bytes written */
}

/* Build raw GATT write buffer for AICS Mute / Unmute / Mode switch */
static int aics_build_simple_cmd(uint8_t *buf, size_t buf_len,
                                  uint8_t opcode, uint8_t change_ctr)
{
    if (buf_len < 2)
        return -1;

    buf[0] = opcode;      /* 0x02=Unmute 0x03=Mute 0x04=Manual 0x05=Auto */
    buf[1] = change_ctr;
    return 2;
}

/*
 * Usage example:
 *   uint8_t pkt[3];
 *   int len = aics_build_set_gain(pkt, sizeof(pkt), current_cc, 10);
 *   // then write pkt[0..len-1] to the AICS Control Point characteristic
 */

4. Microphone Control Service (MICS) — The Global Mic Mute Button

MICS is the simplest of the four services — it has a single GATT characteristic: Mute. That’s it. Its purpose is to provide one device-wide control to silence all microphones at once.

Unlike VCS, MICS has no Control Point characteristic. The Mute characteristic is written directly. It supports Read, Write, and Notify.

Value Meaning
0x00 Not Muted — microphone is active
0x01 Muted — all microphones silenced
0x02 Mute Disabled — the device does not allow muting

MICS + AICS Combined — Two Microphone Setup
Mic 1

AICS
(individual gain)

Mic 2

AICS
(individual gain)

MICS
Device-wide
Mute

🎙 Mic Out
(to remote party)

MICS mutes everything at once. AICS gives per-mic control before that point.

BlueZ: Writing to MICS Mute Characteristic

/*
 * MICS — Directly write the Mute characteristic via BlueZ GATT client
 *
 * The MICS Mute characteristic UUID: 0x2BC3
 * Values: 0x00 = Not Muted, 0x01 = Muted, 0x02 = Mute Disabled (server-set)
 *
 * Unlike VCS, there is NO Control Point — you write directly.
 * There is also no Change_Counter requirement.
 */

#include <gio/gio.h>
#include <stdint.h>

static void mics_set_mute(GDBusProxy *mics_char_proxy, uint8_t mute_val)
{
    GError *err = NULL;
    /* Build byte array variant for the WriteValue D-Bus call */
    GVariantBuilder builder;
    g_variant_builder_init(&builder, G_VARIANT_TYPE("ay"));
    g_variant_builder_add(&builder, "y", mute_val);

    GVariant *value = g_variant_builder_end(&builder);
    GVariant *options = g_variant_new("a{sv}", NULL); /* empty options */
    GVariant *params  = g_variant_new("(@ay@a{sv})", value, options);

    g_dbus_proxy_call_sync(mics_char_proxy,
                           "WriteValue",
                           params,
                           G_DBUS_CALL_FLAGS_NONE,
                           -1, NULL, &err);
    if (err) {
        g_printerr("MICS write failed: %s\n", err->message);
        g_error_free(err);
    } else {
        g_print("MICS Mute set to 0x%02X\n", mute_val);
    }
}

5. How All Four Services Work Together

Think of it as a layered gain structure from source to ear:

Layered Gain Chain — Source to Ear
Audio Source
AICS
Set gain per input. Mute/unmute per input. AGC mode.
↓ mixed signal
VCS
Global volume (0–255). Global mute. Change_Counter sync.
↓ per channel split (stereo)
VOCS [L] and VOCS [R]
Fine trim per speaker (−255 to +255). Balance or hearing compensation.
🔊 Left Speaker & 🔊 Right Speaker
Microphone path (separate): Mic → AICS (individual gain/mute) → MICS (global mute) → Transmitted to remote party

Key Design Rules to Remember

Volume control lives at the sink (speaker side), not the source Change_Counter prevents stale writes — always read before you write Muting does not change Volume_Setting — unmuting restores previous level VOCS is per audio location, not per device AICS gain is dB-based; VCS/VOCS use a manufacturer-defined 0–255 scale MICS has no Control Point — write Mute characteristic directly In AGC mode, AICS server ignores Client gain writes

Quick Reference: Services at a Glance
Service Scope Control Point? Change_Counter? Instances
VCS Global volume + mute Yes (Volume Control Point) Yes 1 per device
VOCS Per-speaker offset Yes (Offset Control Point) Yes (own instance) 1 per audio location
AICS Per-input gain + mute Yes (Input Control Point) Yes (own instance) 1 per rendered audio input
MICS Device-wide mic mute No — write Mute directly No 1 per device

Explore More BLE Audio Topics

This post is part of the Bluetooth LE Audio series on EmbeddedPathashala.

Visit EmbeddedPathashala BLE Audio Series →

Leave a Reply

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