Services Covered
Based Architecture
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.
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.
(Gain + Mute)
(Gain + Mute)
(Gain + Mute)
Global Volume
+ Global Mute
Offset Trim
→
Offset Trim
→
Effective volume per ear = Volume_Setting (VCS) + Volume_Offset (VOCS[L or R])
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:
→
→
→
→
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);
}
}
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)
Volume_Setting = 120
Offset = +20
(weaker left ear)
Offset = 0
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);
}
}
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:
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
*/
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 |
→
(individual gain)
→
→
(individual gain)
→
Device-wide
Mute
(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);
}
}
Think of it as a layered gain structure from source to ear:
Set gain per input. Mute/unmute per input. AGC mode.
Global volume (0–255). Global mute. Change_Counter sync.
Fine trim per speaker (−255 to +255). Balance or hearing compensation.
Key Design Rules to Remember
| 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.
