Files
zhila/docs/PROTOCOL.md
T

171 lines
6.9 KiB
Markdown

# ZHILA wire protocol · v1
Loopback only, one daemon per session. Text frames are JSON; one binary frame type carries meters. The protocol is deliberately small in P0; the revision model and the command bus are the parts meant to survive into P1.
## Transport
- WebSocket at `ws://127.0.0.1:4317` by default.
- The daemon binds `127.0.0.1` only.
- The first client frame must be `hello` within 5 seconds.
- If the client sends an `Origin`, it must be `http://localhost[:port]`, `http://127.0.0.1[:port]`, or `https://tauri.localhost`. Native clients may omit it.
- The dev token is `dev`. Set `ZHILA_TOKEN` or `--token` to change it. Tokens are compared in the hello, never put in the URL.
## Client → daemon
```json
{"type":"hello","protocol":1,"token":"dev","client":"zhila-ui"}
{"type":"command","id":7,"command":{"type":"play"}}
```
Commands:
| command | args | notes |
|---|---|---|
| `get_snapshot` | — | returns a snapshot, no revision change |
| `play` | — | transport, idempotent |
| `stop` | — | transport, idempotent |
| `strike` | `vein` (bass/crack/voice/noise/silt), `velocity` 0..1 | performance; not journaled |
| `set_tempo` | `bpm` 40..220 | journaled |
| `set_vein_gain` | `vein`, `gain` 0..1.2 | journaled |
## Daemon → client
```json
{"type":"hello","protocol":1,"session":"zhila-1a193dfd50c","engine":{"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2}}
{"type":"snapshot","revision":0,"state":{...}}
{"type":"result","id":7,"result":{"kind":"state","state":{...}}}
{"type":"event","revision":1,"event":{"kind":"transport","playing":true}}
{"type":"error","id":7,"code":"bad_request","message":"tempo must be between 40 and 220 bpm"}
```
`state`:
```json
{
"revision": 1,
"transport": {"playing": true, "bpm": 128.0, "bar": 1.0, "loop_bars": 2.0},
"veins": [{"id":"bass","name":"bass","gain":0.82}, "..."],
"pattern": {"steps":16,"steps_per_bar":8,"rows":[{"vein":"bass","velocities":[112,0,0,86,...]}]},
"audio": {"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2},
"journal_len": 0
}
```
`result.kind` is `snapshot` or `state`; both carry the full state.
Events:
- `{"kind":"transport","playing":bool}`
- `{"kind":"tempo","bpm":number}`
- `{"kind":"vein_gain","vein":VeinId,"gain":number}`
- `{"kind":"struck","vein":VeinId,"velocity":number}`
Errors: `bad_request` (validation), `unauthorized`, `protocol` (version/hello), `internal`.
## Binary meter frame
Sent at ~30 Hz, 31 bytes, little-endian:
```
offset size field
0 1 kind = 1
1 2 count = 5
3 4 bar (f32)
7 4 master level (f32)
11 20 five vein levels (f32 each)
```
The browser sets `binaryType = "arraybuffer"` and reads a `DataView`. The frame is deliberately separate from JSON; no meter ever goes through text frames.
## Semantics
- `revision` increments on every state-changing command: transport, tempo, gains. It is a state version, not the journal length.
- `journal_len` counts project edits only: tempo and gains in P0. Play, stop and strike are performance.
- The daemon broadcasts events to every client. A command's `result` goes only to the requester.
- The server is authoritative. The surface holds a projection and updates it from snapshots, results and events.
- P0 has no persistence; a daemon restart starts a fresh session at 128 bpm, not playing, journal empty.
## Not in v1 yet
- Binary snapshots / CRDTs.
- Project/asset commands.
- Agent tools and MCP.
- Per-client cursors or collaboration.
## P1a additions
The protocol remains version 1; the additions are backward-compatible for the P0 surface (unknown fields are ignored).
### Cores
```json
{
"type": "import_core",
"id": 100,
"name": "clicks-01.wav",
"seal": "found"
}
```
The next binary frame from the client is the encoded audio. The daemon answers with:
```json
{"type":"imported","id":100,"core":{
"id":"core-f298c4956d","name":"clicks-01","seal":"found",
"sample_rate":48000,"duration_sec":2.6,
"faces":[{"index":0,"rank":9,"start_sec":0.0,"end_sec":0.14,"rms":0.14}],
"peaks":[0.0,0.32,...]
}}
```
A snapshot follows and is broadcast to every client. `state.cores` carries the same summaries.
### Pattern rows
```json
{"vein":"bass","face":{"core":"core-f298c4956d","face":10},"velocities":[112,0,...]}
```
`face: null` means the procedural voice for that vein.
### Commands
| command | args | journal |
|---|---|---|
| `strike_face` | `core`, `face`, `velocity` 0..1 | no |
| `set_pattern_step` | `row` 0..4, `step` 0..15, `velocity` 0..127 | yes |
| `set_pattern_face` | `row`, `core`, `face` | yes |
Events: `pattern_step`, `pattern_face`, `face_struck`.
### Import semantics
- The import header must arrive immediately before the binary payload on the same connection.
- The daemon decodes in a blocking task; the meter stream and other clients continue.
- The archive id is a content hash prefix (`core-` + 10 hex); the same bytes get the same id.
- The import is journaled on the session as `import_core` (journal-only; clients cannot send it as a command).
- If the file cannot be decoded or is too short, the requester receives `error` with the import id.
## P1b additions
`State` adds `project_name` (string), `can_undo` and `can_redo` (bools). Commands `undo` and `redo` take no arguments; event `history { can_undo, can_redo }` is broadcast after them.
The project is loaded by the daemon from its `--project` folder and saved after every project command or import. The protocol only exposes the project name; the path, SQLite file and assets stay server-side. There is no explicit save command: autosave is the contract. The journal is persisted, but undo history is not reconstructed on load.
## P1c addition
`Seal` gains `recorded`. Recording introduces no new wire messages: the surface encodes a WAV and uses the existing `import_core` header + binary payload. The recorded seal is persisted with the core row and is visible in the snapshot.
## P1d additions
`Command::set_core_faces` takes `{ core, boundaries: [f32; 17] }` (seconds, strictly ascending). The daemon answers with the state and broadcasts `core_faces { core: CoreSummary }`. The core rejects unordered or out-of-range boundaries. Boundaries are persisted in the project's `cores.faces` column and applied after the asset is decoded again on load; the source bytes and peaks stay unchanged.
## P1e addition
No new wire messages. The daemon now persists the session's bounded `history` and `redo` stacks in the project and restores them on load; `State.can_undo`/`can_redo` therefore describe the restored stacks after a restart. The stacks are server-side JSON snapshots of bpm, gains, pattern and core face boundaries.
## P1f note
The Tauri app starts the same host in-process, so the wire contract is unchanged. The embedded surface connects to `ws://127.0.0.1:4317` with the same hello, token and origin rules; the daemon remains the browser-development host.