378 lines
13 KiB
Markdown
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.
|