7.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.
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.
P2a additions
State adds arrangement: Pattern[] (four sections in this phase) and active_section: u8; State.pattern is the active section. Commands select_section { index }, clear_section { index }, copy_section { from, to }; event arrangement { sections, active }. Pattern step/face commands edit the active section. The project row stores arrangement and active_section; old projects with only pattern are migrated to four copies of it.