headroom/IPC.md
2026-05-24 18:12:31 +10:00

378 lines
13 KiB
Markdown

# Headroom IPC — wire protocol
Normative specification of Headroom's control protocol. Version `1`.
This is the contract between the daemon and any client (the first-party
`headroom` CLI, the first-party `headroom-client` Rust crate, and any third
party — Qt/QuickShell panel, Eww widget, shell script). The Rust
representation lives in the `headroom-ipc` crate; this document is the
authoritative source.
---
## 1. Transport
- **Type:** Unix-domain socket, `SOCK_STREAM`.
- **Path:** `${XDG_RUNTIME_DIR}/headroom/control.sock`, falling back to
`/run/user/${UID}/headroom/control.sock` if `XDG_RUNTIME_DIR` is unset.
- **Permissions:** the parent directory is created `0700`, the socket
itself `0600`. Authn/authz is purely filesystem-based, matching the
conventions of PipeWire and Wayland.
- **Encoding:** UTF-8 JSON, one message per frame.
## 2. Framing
Each frame is a 4-byte big-endian unsigned length followed by exactly
that many bytes of JSON payload.
```
+--------+--------+--------+--------+----...----+
| len high low | payload |
+--------+--------+--------+--------+----...----+
```
- Maximum frame size: **1 MiB** (1 048 576 bytes). Larger frames are a
protocol violation; the server closes the connection.
- The payload MUST be a single JSON value (object). Pretty-printing is
permitted but discouraged.
- No trailing newline or NUL terminator inside the frame.
## 3. Message shapes
Every payload is a JSON object with one of three top-level shapes,
distinguished by which discriminating field is present.
### 3.1 Request — client → server
```json
{
"id": <u64>,
"op": "<string>",
"args": <object | omitted>
}
```
- `id`: client-chosen identifier, **must be unique across in-flight
requests on a connection**. The server echoes this verbatim in the
paired response. Clients may reuse an `id` once they have received
the corresponding response.
- `op`: operation name. See §5.
- `args`: optional argument object. May be omitted if the operation
takes no arguments.
### 3.2 Response — server → client
Exactly one response is emitted per request, with the same `id`.
```json
{ "id": <u64>, "result": <value> }
```
or
```json
{ "id": <u64>, "error": { "code": "<string>", "message": "<string>" } }
```
- Mutually exclusive: either `result` or `error`, never both. (Both
fields together is a server bug.)
- `result` may be any JSON value, including `null` for operations that
succeed with no data.
- `error.code` is a stable machine-readable string from §6.
- `error.message` is human-readable English. Not stable; do not pattern
match.
### 3.3 Event — server → client
```json
{
"event": "<string>",
"topic": "<string>",
"data": <object>
}
```
- Events have no `id`. A client distinguishes events from responses by
presence of `event` / `topic` (events) vs. `id` (responses).
- `topic`: subscription topic the event belongs to (§4).
- `event`: name of the event within that topic.
- A client only receives events for topics it has explicitly
subscribed to, with one exception: every new connection receives a
`hello` event before any other traffic (see §7).
## 4. Subscriptions
A client opts in to event streams by calling `subscribe` with a list of
topics, and opts out with `unsubscribe`. Subscription state is
per-connection and resets when the connection closes.
### Topics
| Topic | Cadence | Purpose |
|-----------|-------------------|---------|
| `meters` | publish_hz (≤ 60) | Live loudness, peak, gain-reduction telemetry. |
| `profile` | on change | Profile use/reload events. |
| `routing` | on change | Rule changes; per-stream routing decisions. |
| `daemon` | on change | Lifecycle, errors, overflow notifications. |
### Backpressure
The server maintains a bounded queue per subscriber per topic
(default 64 messages; topic-overrideable in profile). When a queue is
full at publish time, the new event is **dropped**, the per-(subscriber,
topic) drop counter increments, and a `daemon` `overflow` event is
emitted to the affected subscriber describing the loss:
```json
{
"event": "overflow",
"topic": "daemon",
"data": { "lost_topic": "meters", "lost": 42, "total_lost": 197 }
}
```
`overflow` events themselves cannot be dropped — if the `daemon` queue
is also full, the server closes the connection. A well-behaved client
either drains promptly or filters topics it doesn't care about.
The control thread never blocks on a slow client. Audio is never
affected by subscriber behaviour: meter publishing is rate-limited,
runs on a dedicated thread, and reads from a non-blocking source.
---
## 5. Operations
All operations are listed below with their full argument and result
schemas. `args` is omitted from the request when its schema is empty.
### Catalogue
| op | args | result |
|--------------------|-----------------------------------------------|------------------------------|
| `status` | — | `Status` |
| `profile.list` | — | `{ profiles: ProfileInfo[] }`|
| `profile.use` | `{ name: string }` | `{ name: string }` |
| `profile.show` | `{ name?: string }` | `Profile` |
| `profile.reload` | — | `{ reloaded: string[] }` |
| `route.list` | — | `RouteList` |
| `route.set` | `{ app: string, to: "processed"\|"bypass" }` | `null` |
| `route.unset` | `{ app: string }` | `null` |
| `route.stream` | `{ node_id: u32, to: "processed"\|"bypass" }` | `null` |
| `setting.get` | `{ key: string }` | `{ key: string, value: any }`|
| `setting.set` | `{ key: string, value: any }` | `null` |
| `setting.list` | — | `{ settings: object }` |
| `bypass.set` | `{ enabled: bool }` | `null` |
| `per-app.list` | — | `{ layer_a: LayerASnapshot[] }`|
| `per-app.set` | `{ app: string, enabled: bool }` | `null` |
| `per-app.master` | `{ enabled: bool }` | `null` |
| `per-app.reset` | `{ node_id: u32 }` | `null` |
| `subscribe` | `{ topics: string[] }` | `{ subscribed: string[] }` |
| `unsubscribe` | `{ topics: string[] }` | `{ unsubscribed: string[] }` |
`per-app.set` / `per-app.master` persist to the user overlay (an
enable/disable override layered on the active profile's `[per_app]`).
`per-app.reset` is a one-shot that clears a managed stream's deference
lock (user-ceiling / strict mode) so the controller resumes normal
level control. Both `status` and `per-app.list` carry the
`LayerASnapshot[]` for currently-managed streams.
### Object schemas
#### `Status`
```json
{
"version": "0.1.0",
"protocol": 1,
"uptime_s": 482,
"profile": "default",
"bypass": false,
"per_app": true,
"sinks": {
"processed": { "node_id": 51, "ready": true },
"real": { "node_id": 35, "name": "alsa_output.pci-0000_00_1f.3.analog-stereo" }
},
"streams": [
{ "node_id": 73, "app": "firefox", "route": "processed" },
{ "node_id": 81, "app": "spotify", "route": "bypass" }
],
"layer_a": [
{ "node_id": 73, "app": "firefox", "managed": true, "volume_lin": 0.71,
"reduction_db": 2.9, "user_ceiling_lin": null, "deferred": false }
]
}
```
`per_app` is the Layer A master switch. `layer_a` lists per-app
controller state for managed streams (omitted when empty); see
`LayerASnapshot` below.
#### `LayerASnapshot`
```json
{
"node_id": 73,
"app": "firefox",
"managed": true,
"volume_lin": 0.71,
"reduction_db": 2.9,
"user_ceiling_lin": 0.6,
"deferred": false
}
```
`reduction_db` is the smoothed gain reduction the controller currently
asserts (`>= 0`; `0` = no cut). `volume_lin` is the last
`channelVolumes` value written (1.0 = unity). `user_ceiling_lin` is
present only while ceiling-mode deference is active; `deferred` is true
when strict-mode deference has locked the controller pending a
`per-app.reset`.
#### `ProfileInfo`
```json
{ "name": "default", "active": true, "description": "Gentle …" }
```
#### `Profile`
The full profile document. Identical to the TOML profile, serialized as
JSON. See `PLAN.md` §5 for the field set.
#### `RouteList`
```json
{
"rules": [
{ "match": { "process_binary": ["firefox"] }, "route": "processed" }
],
"current": [
{ "node_id": 73, "app": "firefox", "route": "processed" }
],
"default_route": "processed"
}
```
### Setting keys
`setting.get`/`setting.set` use dotted keys into the active profile.
Examples:
- `agc.target_lufs` (float)
- `agc.enabled` (bool)
- `compressor.threshold_db` (float)
- `compressor.ratio` (float)
- `limiter.ceiling_dbtp` (float)
- `limiter.lookahead_ms` (float)
- `limiter.oversample` (integer, one of 1/2/4/8)
- `meters.publish_hz` (float)
Headroom rejects sets that would violate invariants (e.g.
`limiter.ceiling_dbtp > 0.0`). See §6 for error codes.
---
## 6. Errors
`error.code` is one of:
| code | meaning |
|-------------------|--------------------------------------------------------------|
| `INVALID_FRAME` | Malformed framing or non-JSON payload. Connection is closed. |
| `INVALID_MESSAGE` | Valid JSON, but doesn't fit a known message shape. |
| `UNKNOWN_OP` | `op` does not name a known operation. |
| `INVALID_ARGS` | `args` missing a required field, wrong type, or out of range.|
| `NOT_FOUND` | Profile / app / stream / setting key does not exist. |
| `CONFLICT` | Operation would violate an invariant (e.g. ceiling > 0). |
| `BUSY` | Daemon transiently cannot serve the request (rare). |
| `INTERNAL` | Bug. Includes a `message` for debugging. |
Frame-level violations (`INVALID_FRAME` of size, framing, encoding)
cause the connection to be closed after the error is sent.
Message-level errors leave the connection open.
---
## 7. Connection lifecycle
1. Client connects. Server immediately emits a `hello` event:
```json
{
"event": "hello",
"topic": "control",
"data": {
"daemon": "headroom",
"version": "0.1.0",
"protocol": 1
}
}
```
This event is **not** gated on subscription — every client gets it.
2. Client sends requests; server replies. Client may `subscribe` to
topics at any time and will start receiving events for those
topics.
3. Either side may close the socket at any time. The server cleans up
subscription state. Outstanding requests are dropped (no response).
There is no formal `bye`. Closing the socket is the protocol.
---
## 8. Versioning
The protocol uses a single integer version number, currently `1`.
- **Additions** (new ops, new optional fields, new events, new error
codes) do not bump the protocol version. Clients MUST ignore unknown
fields on objects they receive and MUST be tolerant of new event
topics they did not subscribe to (they should never see those, but
belt and braces).
- **Removals or semantic changes** bump the protocol version. The
daemon may reject connections from clients that declare incompatible
versions (TBD: client may include `Hello` request with declared
version; not yet specified).
Clients SHOULD log a warning if the `protocol` value in `hello` does
not match the version they were built against, and proceed.
---
## 9. Example exchange
```
C → S len=58
{"id":1,"op":"profile.use","args":{"name":"night"}}
S → C len=24
{"id":1,"result":{"name":"night"}}
C → S len=49
{"id":2,"op":"subscribe","args":{"topics":["meters"]}}
S → C len=37
{"id":2,"result":{"subscribed":["meters"]}}
S → C len=137
{"event":"tick","topic":"meters","data":{
"momentary_lufs":-19.3,"shortterm_lufs":-20.1,
"integrated_lufs":-19.8,"true_peak_dbtp":-1.4,
"gain_reduction_db":-2.1,"agc_gain_db":0.5
}}
```
---
## 10. Reference
The authoritative Rust binding to this protocol is the `headroom-ipc`
crate; the `headroom-client` crate wraps it with a blocking `Client`
(and an optional async `AsyncClient` behind the `async` feature). Both
live in this repository.
Third-party clients should target this document, not the Rust types,
to remain interoperable across implementations.