6.3 KiB
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:4317by default. - The daemon binds
127.0.0.1only. - The first client frame must be
hellowithin 5 seconds. - If the client sends an
Origin, it must behttp://localhost[:port],http://127.0.0.1[:port], orhttps://tauri.localhost. Native clients may omit it. - The dev token is
dev. SetZHILA_TOKENor--tokento change it. Tokens are compared in the hello, never put in the URL.
Client → daemon
{"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
{"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:
{
"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
revisionincrements on every state-changing command: transport, tempo, gains. It is a state version, not the journal length.journal_lencounts project edits only: tempo and gains in P0. Play, stop and strike are performance.- The daemon broadcasts events to every client. A command's
resultgoes 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
{
"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:
{"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
{"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
errorwith 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.