stage 2
This commit is contained in:
commit
ca1910de60
39 changed files with 6328 additions and 0 deletions
337
IPC.md
Normal file
337
IPC.md
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
# 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` |
|
||||
| `subscribe` | `{ topics: string[] }` | `{ subscribed: string[] }` |
|
||||
| `unsubscribe` | `{ topics: string[] }` | `{ unsubscribed: string[] }` |
|
||||
|
||||
### Object schemas
|
||||
|
||||
#### `Status`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"protocol": 1,
|
||||
"uptime_s": 482,
|
||||
"profile": "default",
|
||||
"bypass": false,
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue