Files
zhila/docs/PROTOCOL.md
T

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: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

{"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

  • 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

{
  "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 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.