BLE AUDIO SERIES · PART 7
Volume Offset Control Service (VOCS)
How Bluetooth LE Audio fine-tunes per-output volume — balance, offsets, and the Change Counter explained for embedded developers
What You Will Learn
The Problem VOCS Solves
Imagine a pair of true wireless earbuds. The left earbud sits a little further from your ear canal than the right one. The Volume Control Service (VCS) lets a phone set the overall loudness, but it has no way to say “left speaker, please be 10 units quieter than whatever the overall volume is.” That gap is exactly what the Volume Offset Control Service (VOCS) fills.
VOCS lives as a secondary service — it is never discovered standalone. It is always included by a primary service such as VCS or the Audio Input Control Service (AICS). Each audio output (left speaker, right speaker, line-out jack) gets its own VOCS instance carrying an independent offset value. A client like a phone can read the offset, change it, and name each output with a human-readable label.
This post walks through every characteristic, explains the Change Counter race-guard mechanism, and shows practical BlueZ C code so you can build or debug VOCS on Linux.
1 · Where VOCS Sits in the GATT Hierarchy
VOCS is declared with the «Secondary Service» attribute type. The spec is explicit: VOCS shall only be instantiated as an included service. A primary service (e.g., VCS) holds an Include Declaration that points to each VOCS instance.
Service Hierarchy Diagram
| Primary Service — Volume Control Service (VCS) [UUID: 0x1844] | ||
| Include Declaration → VOCS Instance #1 (Left Speaker) | ||
| Secondary Service — VOCS [UUID: 0x1845] Characteristics: Volume Offset State · Audio Location · Control Point · Audio Output Description |
||
| Include Declaration → VOCS Instance #2 (Right Speaker) | ||
| Secondary Service — VOCS [UUID: 0x1845] Independent offset for the right channel |
||
Each VOCS instance is fully independent. The left and right channels carry different offset values, different location bitmasks, and different human-readable description strings. This design lets a device expose fine-grained per-output control without duplicating a full primary service.
2 · The Four Characteristics at a Glance
Every VOCS instance carries exactly four mandatory characteristics. The table below summarises their properties and security requirements.
| Characteristic | UUID | Mandatory Props | Optional Props | Security |
|---|---|---|---|---|
| Volume Offset State | 0x2B82 | Read, Notify | — | Encryption Required |
| Audio Location | 0x2B81 | Read, Notify* | Write Without Response | Encryption Required |
| Volume Offset Control Point | 0x2B83 | Write | — | Encryption Required |
| Audio Output Description | 0x2B84 | Read, Notify* | Write Without Response | Encryption Required |
* Notify is mandatory when Write Without Response is supported, otherwise optional (Condition C.1)
The encryption requirement applies to all four characteristics — VOCS is always used over an encrypted link. This matters in BlueZ: you must pair the device before GATT discovery will expose these attributes.
3 · Volume Offset State — Packet Layout
This characteristic is the core of VOCS. It is 3 bytes long and carries two tightly coupled fields.
Volume Offset State Characteristic — Wire Format (3 bytes, little-endian)
| Byte 0 Volume_Offset LSB |
Byte 1 Volume_Offset MSB |
Byte 2 Change_Counter |
| int16 · range -255 to +255 · unitless offset applied to audio output volume | uint8 · increments on every offset change · rolls over 255 to 0 | |
Volume_Offset Field
A signed 16-bit integer transmitted little-endian. The permissible range is -255 to +255. The server applies this value as an additive offset on top of the current output volume — a positive value boosts the output, a negative value attenuates it. This is a unitless adjustment, so its actual audible effect depends on the device implementation (dB mapping is vendor-defined).
A typical use-case: a stereo TWS headset sets the left VOCS instance to -10 and the right to +10 to correct a hardware channel imbalance without touching the master VCS volume.
Change_Counter Field — The Race Guard
This is a concurrency guard. Multiple clients can be connected simultaneously, and two of them could try to write a new offset at the same moment. Without a guard, the second write might overwrite the first with stale data.
The rule is simple: when a client sends a Set Volume Offset command, it must include the Change_Counter value it read from the Volume Offset State characteristic. The server compares this against its live counter. If they match, the write proceeds and the counter increments. If they do not match, the server rejects with error 0x80 Invalid Change Counter.
Change Counter Mechanism — Two Concurrent Clients
| Client A | Time | VOCS Server | Client B |
| Read State CC=5, Off=0 |
t1 | CC=5 | Read State CC=5, Off=0 |
| Write CC=5, Off=+20 Accepted |
t2 | CC becomes 6 Off=+20 Notify all |
(still holds CC=5) |
| t3 | CC=6 | Write CC=5, Off=-10 Error 0x80 |
|
| t4 | CC=6 | Re-read, retry CC=6, Off=+20 |
Client B must re-read the Volume Offset State after the error, get the fresh CC=6, and retry the write with that updated counter. This prevents stale-data overwrites across concurrent connections.
4 · Audio Location Characteristic
This 32-bit bitmask tells the client which physical output location this VOCS instance represents. The values are defined in the Bluetooth Assigned Numbers document. Common bits include Mono, Left, Right, Front Left, Front Right, and so on.
The client can optionally write to Audio Location using Write Without Response — for instance, re-assigning a speaker from Front Left to Front Center. If the server supports notifications for this characteristic, it notifies subscribed clients whenever the location changes.
Audio Location — Example Bitmask Assignments
| Location Name | Bitmask Value | Typical Use |
|---|---|---|
| Mono | 0x00000001 | Single speaker, no channel designation |
| Front Left | 0x00000002 | Left channel of stereo TWS |
| Front Right | 0x00000004 | Right channel of stereo TWS |
| Front Left + Right | 0x00000006 | Both channels on one speaker unit |
5 · Volume Offset Control Point — Set Volume Offset Procedure
The control point has exactly one opcode: 0x01 Set Volume Offset. The client writes a 4-byte command and the server either applies the new offset or returns an ATT Application Error.
Set Volume Offset Command — Wire Format (4 bytes)
| Byte 0 Opcode = 0x01 |
Byte 1 Change_Counter |
Byte 2 Volume_Offset LSB |
Byte 3 Volume_Offset MSB |
| Set Volume Offset | 0x00 to 0xFF | int16 · -255 to +255 · little-endian | |
Set Volume Offset — Server Decision Flow
| Client Writes Control Point 0x01 | CC | Offset_L | Offset_H |
Server Receives Write | |
| Check 1: Is Opcode = 0x01? No — ATT Error 0x81 Opcode Not Supported | Yes — continue |
||
| Check 2: CC operand == live Change_Counter? No — ATT Error 0x80 Invalid Change Counter | Yes — continue |
||
| Check 3: -255 to +255? No — ATT Error 0x82 Value Out of Range | Yes — continue |
||
| Apply New Volume_Offset · Increment Change_Counter · Notify All Subscribed Clients | ||
6 · Audio Output Description
This characteristic holds a UTF-8 string that names the audio output — for example Left Speaker, Right Earbud, or Subwoofer. Zero-length strings are allowed. The client can optionally rename the output by writing a new string via Write Without Response.
This string is purely informational — it helps an app show the user a meaningful label instead of a UUID. If the label changes, subscribed clients receive a notification carrying the new UTF-8 string as the characteristic value.
7 · Application Error Codes Reference
VOCS defines three ATT Application Error codes that the server returns when a control point write fails validation. These fall in the application-specific error range (0x80–0xFF) of the ATT error namespace.
| Code | Name | When Returned | Client Recovery |
|---|---|---|---|
| 0x80 | Invalid Change Counter | Change_Counter operand does not match the live server counter | Re-read Volume Offset State, retry with fresh CC |
| 0x81 | Opcode Not Supported | Opcode byte is unknown or not defined in this service version | Fix the opcode — only 0x01 is valid in VOCS v1.0 |
| 0x82 | Value Out of Range | Volume_Offset operand is less than -255 or greater than +255 | Clamp the value to the range -255 to +255 and retry |
8 · BlueZ Implementation — Hands-On Code
BlueZ implements VOCS server-side in profiles/audio/vocs.c and exposes it over D-Bus. Below are practical C snippets showing the BlueZ architecture for registering a VOCS instance and handling the Set Volume Offset command.
8.1 · Data Structure and Attribute Registration
VOCS uses BlueZ’s gatt_db API. The secondary service attribute carries the VOCS UUID, and each characteristic is added with its required properties and security flags. Note the use of gatt_db_add_secondary_service() — this is what distinguishes VOCS from a primary service in the attribute database.
/* Based on BlueZ profiles/audio/vocs.c architecture */
#include "lib/bluetooth.h"
#include "src/shared/gatt-db.h"
/* VOCS and characteristic UUIDs from Bluetooth Assigned Numbers */
#define VOCS_UUID 0x1845
#define VOL_OFFSET_STATE_UUID 0x2B82
#define AUDIO_LOCATION_UUID 0x2B81
#define VOL_OFFSET_CP_UUID 0x2B83
#define AUDIO_OUTPUT_DESC_UUID 0x2B84
struct bt_vocs {
struct gatt_db_attribute *svc; /* secondary service attribute */
struct gatt_db_attribute *state; /* Volume Offset State char */
struct gatt_db_attribute *location; /* Audio Location char */
struct gatt_db_attribute *cp; /* Volume Offset Control Point */
struct gatt_db_attribute *description;/* Audio Output Description */
int16_t volume_offset; /* current offset, range -255..+255 */
uint8_t change_counter; /* increments on each offset change */
uint32_t location_bits; /* Audio Location bitmask */
char *output_desc; /* UTF-8 human-readable label */
};
static void vocs_register_service(struct gatt_db *db, struct bt_vocs *vocs)
{
bt_uuid_t uuid;
/* VOCS must be a secondary service — never primary */
bt_uuid16_create(&uuid, VOCS_UUID);
vocs->svc = gatt_db_add_secondary_service(db, &uuid);
/* Volume Offset State: Read + Notify, encryption required */
bt_uuid16_create(&uuid, VOL_OFFSET_STATE_UUID);
vocs->state = gatt_db_service_add_characteristic(
vocs->svc, &uuid,
BT_ATT_PERM_READ | BT_ATT_PERM_READ_ENCRYPT,
BT_GATT_CHRC_PROP_READ | BT_GATT_CHRC_PROP_NOTIFY,
vocs_state_read_cb, NULL, vocs);
/* Add the CCCD so clients can subscribe to notifications */
gatt_db_service_add_ccc(vocs->svc,
BT_ATT_PERM_READ | BT_ATT_PERM_WRITE |
BT_ATT_PERM_READ_ENCRYPT | BT_ATT_PERM_WRITE_ENCRYPT);
/* Volume Offset Control Point: Write only, encryption required */
bt_uuid16_create(&uuid, VOL_OFFSET_CP_UUID);
vocs->cp = gatt_db_service_add_characteristic(
vocs->svc, &uuid,
BT_ATT_PERM_WRITE_ENCRYPT,
BT_GATT_CHRC_PROP_WRITE,
NULL, vocs_cp_write_cb, vocs);
gatt_db_service_set_active(vocs->svc, true);
}
8.2 · Read Handler for Volume Offset State
The read callback serialises the 3-byte state (int16 offset + uint8 counter, little-endian) and passes it back through the ATT layer using gatt_db_attribute_read_result().
static void vocs_state_read_cb(struct gatt_db_attribute *attrib,
unsigned int id, uint16_t offset,
uint8_t opcode, struct bt_att *att,
void *user_data)
{
struct bt_vocs *vocs = user_data;
uint8_t pdu[3];
/*
* Volume Offset State wire format — little-endian:
* Bytes 0-1 : Volume_Offset (int16)
* Byte 2 : Change_Counter (uint8)
*/
put_le16(vocs->volume_offset, &pdu[0]); /* int16 -> 2 bytes LE */
pdu[2] = vocs->change_counter;
gatt_db_attribute_read_result(attrib, id, 0, pdu, sizeof(pdu));
}
8.3 · Control Point Write Handler
This is where the three validation checks live. On success, the offset is updated, the counter increments with 255-to-0 wrap-around, and all subscribed clients receive a GATT notification.
/* VOCS ATT Application Error codes (spec-defined range 0x80-0xFF) */
#define VOCS_ERR_INVALID_CHANGE_COUNTER 0x80
#define VOCS_ERR_OPCODE_NOT_SUPPORTED 0x81
#define VOCS_ERR_VALUE_OUT_OF_RANGE 0x82
#define VOCS_OP_SET_VOLUME_OFFSET 0x01
static void vocs_cp_write_cb(struct gatt_db_attribute *attrib,
unsigned int id, uint16_t offset,
const uint8_t *value, size_t len,
uint8_t opcode, struct bt_att *att,
void *user_data)
{
struct bt_vocs *vocs = user_data;
uint8_t cmd_op;
uint8_t cmd_cc;
int16_t new_offset;
/* Minimum length: opcode(1) + Change_Counter(1) + Volume_Offset(2) */
if (len < 4) {
gatt_db_attribute_write_result(attrib, id,
BT_ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LEN);
return;
}
cmd_op = value[0];
cmd_cc = value[1];
new_offset = (int16_t) get_le16(&value[2]);
/* Check 1 — reject unknown opcodes */
if (cmd_op != VOCS_OP_SET_VOLUME_OFFSET) {
gatt_db_attribute_write_result(attrib, id,
VOCS_ERR_OPCODE_NOT_SUPPORTED);
return;
}
/* Check 2 — stale Change_Counter means another client changed state */
if (cmd_cc != vocs->change_counter) {
gatt_db_attribute_write_result(attrib, id,
VOCS_ERR_INVALID_CHANGE_COUNTER);
return;
}
/* Check 3 — spec-mandated range limit despite int16 wire type */
if (new_offset < -255 || new_offset > 255) {
gatt_db_attribute_write_result(attrib, id,
VOCS_ERR_VALUE_OUT_OF_RANGE);
return;
}
/* All checks passed — apply and propagate */
if (vocs->volume_offset != new_offset) {
vocs->volume_offset = new_offset;
/* Increment with explicit wrap at 255 */
vocs->change_counter = (vocs->change_counter == 255)
? 0
: vocs->change_counter + 1;
/* Notify all clients subscribed via CCCD */
vocs_notify_state(vocs);
}
gatt_db_attribute_write_result(attrib, id, 0); /* success */
}
8.4 · Sending Notifications
When the offset changes, the server must notify every client that has subscribed via its CCCD. In BlueZ, calling gatt_db_attribute_notify() on the characteristic attribute automatically fans the notification out to all subscribed connections.
static void vocs_notify_state(struct bt_vocs *vocs)
{
uint8_t pdu[3];
put_le16(vocs->volume_offset, &pdu[0]);
pdu[2] = vocs->change_counter;
/*
* gatt_db_attribute_notify() fans the notification to every
* connection whose CCCD has notifications enabled.
* BlueZ GATT server tracks per-connection CCCD state internally.
*/
gatt_db_attribute_notify(vocs->state, pdu, sizeof(pdu), NULL);
}
8.5 · Exploring VOCS from the Terminal with gatttool
Once your BLE device is paired and connected, you can inspect VOCS characteristics from a Linux terminal. Encryption must be active — pair first with bluetoothctl.
# 1. Pair and connect (encryption is required by all VOCS characteristics)
bluetoothctl pair AA:BB:CC:DD:EE:FF
bluetoothctl connect AA:BB:CC:DD:EE:FF
# 2. Read Volume Offset State (replace 0x0025 with the actual handle)
# 3-byte response: bytes 0-1 = int16 offset LE, byte 2 = Change_Counter
gatttool -b AA:BB:CC:DD:EE:FF --char-read -a 0x0025
# Example output: 00 00 07 -> Volume_Offset=0, Change_Counter=7
# 3. Set Volume Offset to +20 (0x0014 LE) with Change_Counter=7
# Command bytes: Opcode=01 | CC=07 | Offset_LSB=14 | Offset_MSB=00
gatttool -b AA:BB:CC:DD:EE:FF --char-write-req -a 0x0027 -n 01071400
# 4. Enable notifications on the Volume Offset State CCCD
# CCCD is at state_handle + 1 or + 2 — use -I mode to discover exact handle
gatttool -b AA:BB:CC:DD:EE:FF --char-write-req -a 0x0026 -n 0100
# 5. Read the Audio Output Description (UTF-8 string)
gatttool -b AA:BB:CC:DD:EE:FF --char-read -a 0x002b
# Example output: 4c 65 66 74 20 53 70 65 61 6b 65 72 -> "Left Speaker"
9 · Summary — Key Takeaways
VOCS is a compact but carefully designed secondary service. Here are the points to remember when implementing or debugging it:
VOCS is never a primary service. It is exposed only via an Include Declaration from a primary service like VCS.
Every Set Volume Offset command must echo the current Change_Counter. A stale counter returns error 0x80. Always re-read after rejection.
Despite the int16 wire format, valid range is only -255 to +255. Values outside this range trigger error 0x82.
VOCS v1.0 defines a single control point opcode: 0x01 Set Volume Offset. All other values return error 0x81.
All four characteristics require an encrypted ATT bearer. Pair the device before attempting discovery or reads.
Whenever Volume_Offset changes, the server must notify all subscribed clients with the fresh 3-byte state to keep all connections in sync.
Keep Exploring BLE Audio
VOCS is a building block of the full LE Audio stack. The next articles in this series cover VCS (the primary service that includes VOCS) and AICS (Audio Input Control Service).
