P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "zhila-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,340 @@
|
||||
//! ZHILA wire protocol, version 1.
|
||||
//!
|
||||
//! Text frames carry JSON control messages. Audio meters travel in a compact
|
||||
//! binary frame (see [`MeterFrame`]) at ~30 Hz, never inside JSON.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const VEIN_COUNT: usize = 5;
|
||||
pub const LOOP_STEPS: usize = 16;
|
||||
pub const STEPS_PER_BAR: usize = 8;
|
||||
pub const LOOP_BARS: f32 = 2.0;
|
||||
pub const MIN_BPM: f32 = 40.0;
|
||||
pub const MAX_BPM: f32 = 220.0;
|
||||
pub const MIN_GAIN: f32 = 0.0;
|
||||
pub const MAX_GAIN: f32 = 1.2;
|
||||
|
||||
pub const METER_FRAME_KIND: u8 = 1;
|
||||
/// `kind (1) | count (2) | bar (4) | master (4) | levels (5 * 4)`
|
||||
pub const METER_FRAME_LEN: usize = 3 + 4 * (2 + VEIN_COUNT);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VeinId {
|
||||
Bass,
|
||||
Crack,
|
||||
Voice,
|
||||
Noise,
|
||||
Silt,
|
||||
}
|
||||
|
||||
impl VeinId {
|
||||
pub const ALL: [VeinId; VEIN_COUNT] = [
|
||||
VeinId::Bass,
|
||||
VeinId::Crack,
|
||||
VeinId::Voice,
|
||||
VeinId::Noise,
|
||||
VeinId::Silt,
|
||||
];
|
||||
|
||||
pub const fn index(self) -> usize {
|
||||
match self {
|
||||
VeinId::Bass => 0,
|
||||
VeinId::Crack => 1,
|
||||
VeinId::Voice => 2,
|
||||
VeinId::Noise => 3,
|
||||
VeinId::Silt => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
VeinId::Bass => "bass",
|
||||
VeinId::Crack => "crack",
|
||||
VeinId::Voice => "voice",
|
||||
VeinId::Noise => "noise",
|
||||
VeinId::Silt => "silt",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn from_index(index: usize) -> Option<Self> {
|
||||
match index {
|
||||
0 => Some(VeinId::Bass),
|
||||
1 => Some(VeinId::Crack),
|
||||
2 => Some(VeinId::Voice),
|
||||
3 => Some(VeinId::Noise),
|
||||
4 => Some(VeinId::Silt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Actor {
|
||||
Hand,
|
||||
Api,
|
||||
Mcp,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Hello {
|
||||
protocol: u32,
|
||||
token: String,
|
||||
client: String,
|
||||
},
|
||||
Command {
|
||||
id: u64,
|
||||
command: Command,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
Hello {
|
||||
protocol: u32,
|
||||
session: String,
|
||||
engine: AudioInfo,
|
||||
},
|
||||
Snapshot {
|
||||
revision: u64,
|
||||
state: State,
|
||||
},
|
||||
Event {
|
||||
revision: u64,
|
||||
event: Event,
|
||||
},
|
||||
Result {
|
||||
id: u64,
|
||||
result: CommandResult,
|
||||
},
|
||||
Error {
|
||||
id: Option<u64>,
|
||||
code: ErrorCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Command {
|
||||
GetSnapshot,
|
||||
Play,
|
||||
Stop,
|
||||
Strike { vein: VeinId, velocity: f32 },
|
||||
SetTempo { bpm: f32 },
|
||||
SetVeinGain { vein: VeinId, gain: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CommandResult {
|
||||
Snapshot { state: State },
|
||||
State { state: State },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
Transport { playing: bool },
|
||||
Tempo { bpm: f32 },
|
||||
VeinGain { vein: VeinId, gain: f32 },
|
||||
Struck { vein: VeinId, velocity: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
Protocol,
|
||||
Audio,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioInfo {
|
||||
pub running: bool,
|
||||
pub device: String,
|
||||
pub sample_rate: u32,
|
||||
pub buffer_frames: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
pub fn offline() -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
device: "none".into(),
|
||||
sample_rate: 48_000,
|
||||
buffer_frames: 0,
|
||||
channels: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub revision: u64,
|
||||
pub transport: TransportState,
|
||||
pub veins: Vec<VeinState>,
|
||||
pub pattern: Pattern,
|
||||
pub audio: AudioInfo,
|
||||
pub journal_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TransportState {
|
||||
pub playing: bool,
|
||||
pub bpm: f32,
|
||||
pub bar: f32,
|
||||
pub loop_bars: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VeinState {
|
||||
pub id: VeinId,
|
||||
pub name: String,
|
||||
pub gain: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Pattern {
|
||||
pub steps: u16,
|
||||
pub steps_per_bar: u16,
|
||||
pub rows: Vec<PatternRow>,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn row(&self, vein: VeinId) -> Option<&PatternRow> {
|
||||
self.rows.iter().find(|row| row.vein == vein)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PatternRow {
|
||||
pub vein: VeinId,
|
||||
/// One velocity per step, 0..=127. Zero means the step is empty.
|
||||
pub velocities: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A single binary meter frame. Text frames never carry audio-rate data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MeterFrame {
|
||||
pub bar: f32,
|
||||
pub master: f32,
|
||||
pub veins: [f32; VEIN_COUNT],
|
||||
}
|
||||
|
||||
impl MeterFrame {
|
||||
pub fn silent() -> Self {
|
||||
Self {
|
||||
bar: 1.0,
|
||||
master: 0.0,
|
||||
veins: [0.0; VEIN_COUNT],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(METER_FRAME_LEN);
|
||||
out.push(METER_FRAME_KIND);
|
||||
out.extend_from_slice(&(VEIN_COUNT as u16).to_le_bytes());
|
||||
out.extend_from_slice(&self.bar.to_le_bytes());
|
||||
out.extend_from_slice(&self.master.to_le_bytes());
|
||||
for level in self.veins {
|
||||
out.extend_from_slice(&level.to_le_bytes());
|
||||
}
|
||||
debug_assert_eq!(out.len(), METER_FRAME_LEN);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != METER_FRAME_LEN || data[0] != METER_FRAME_KIND {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_le_bytes([data[1], data[2]]) as usize;
|
||||
if count != VEIN_COUNT {
|
||||
return None;
|
||||
}
|
||||
let bar = read_f32(data, 3)?;
|
||||
let master = read_f32(data, 7)?;
|
||||
let mut veins = [0.0; VEIN_COUNT];
|
||||
for (index, level) in veins.iter_mut().enumerate() {
|
||||
*level = read_f32(data, 11 + index * 4)?;
|
||||
}
|
||||
Some(Self { bar, master, veins })
|
||||
}
|
||||
}
|
||||
|
||||
fn read_f32(data: &[u8], offset: usize) -> Option<f32> {
|
||||
let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?;
|
||||
Some(f32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hello_roundtrip() {
|
||||
let message = ClientMessage::Hello {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
token: "dev".into(),
|
||||
client: "ui".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&message).unwrap();
|
||||
assert!(json.contains("\"type\":\"hello\""));
|
||||
let back: ClientMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_roundtrip() {
|
||||
let message = ClientMessage::Command {
|
||||
id: 7,
|
||||
command: Command::Strike {
|
||||
vein: VeinId::Bass,
|
||||
velocity: 0.8,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&message).unwrap();
|
||||
let back: ClientMessage = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_roundtrip_is_exact() {
|
||||
let frame = MeterFrame {
|
||||
bar: 2.75,
|
||||
master: 0.42,
|
||||
veins: [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
};
|
||||
let bytes = frame.encode();
|
||||
assert_eq!(bytes.len(), METER_FRAME_LEN);
|
||||
let back = MeterFrame::decode(&bytes).expect("valid frame");
|
||||
assert_eq!(back, frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_rejects_garbage() {
|
||||
assert!(MeterFrame::decode(&[]).is_none());
|
||||
assert!(MeterFrame::decode(&[METER_FRAME_KIND; 4]).is_none());
|
||||
let mut bytes = MeterFrame::silent().encode();
|
||||
bytes[0] = 9;
|
||||
assert!(MeterFrame::decode(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vein_indices_are_stable() {
|
||||
for (index, vein) in VeinId::ALL.iter().enumerate() {
|
||||
assert_eq!(vein.index(), index);
|
||||
assert_eq!(VeinId::from_index(index), Some(*vein));
|
||||
}
|
||||
assert_eq!(VeinId::from_index(VEIN_COUNT), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user