P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "zhila-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,471 @@
|
||||
//! ZHILA session core: state, journal, validation, and the commands that the
|
||||
//! hand, the API and MCP will all share.
|
||||
//!
|
||||
//! The engine is a trait here so the core can be tested without an audio
|
||||
//! device. The real engine lives in `zhila-engine`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use zhila_protocol::{
|
||||
Actor, AudioInfo, Command, CommandResult, Event, LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN,
|
||||
MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, TransportState,
|
||||
VEIN_COUNT, VeinId, VeinState,
|
||||
};
|
||||
|
||||
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
|
||||
|
||||
/// The audio engine as seen by the session. Implementations must be real-time
|
||||
/// safe on the audio thread and non-blocking on these methods.
|
||||
pub trait Engine: Send + Sync {
|
||||
fn play(&self);
|
||||
fn stop(&self);
|
||||
fn strike(&self, vein: VeinId, velocity: f32);
|
||||
fn set_tempo(&self, bpm: f32);
|
||||
fn set_gain(&self, vein: VeinId, gain: f32);
|
||||
fn info(&self) -> AudioInfo;
|
||||
fn meter(&self) -> MeterFrame;
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CoreError {
|
||||
#[error("tempo must be between 40 and 220 bpm")]
|
||||
InvalidTempo,
|
||||
#[error("velocity must be between 0 and 1")]
|
||||
InvalidVelocity,
|
||||
#[error("gain must be between 0 and 1.2")]
|
||||
InvalidGain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalEntry {
|
||||
pub seq: u64,
|
||||
pub revision: u64,
|
||||
pub actor: Actor,
|
||||
pub at_ms: u64,
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
revision: u64,
|
||||
seq: u64,
|
||||
bpm: f32,
|
||||
playing: bool,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
pattern: Pattern,
|
||||
journal: Vec<JournalEntry>,
|
||||
engine: Arc<dyn Engine>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(engine: Arc<dyn Engine>) -> Self {
|
||||
Self::with_pattern(engine, default_pattern())
|
||||
}
|
||||
|
||||
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
|
||||
Self {
|
||||
revision: 0,
|
||||
seq: 0,
|
||||
bpm: 128.0,
|
||||
playing: false,
|
||||
gains: INITIAL_GAINS,
|
||||
pattern,
|
||||
journal: Vec::new(),
|
||||
engine,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn pattern(&self) -> &Pattern {
|
||||
&self.pattern
|
||||
}
|
||||
|
||||
pub fn journal(&self) -> &[JournalEntry] {
|
||||
&self.journal
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> State {
|
||||
let meter = self.engine.meter();
|
||||
State {
|
||||
revision: self.revision,
|
||||
transport: TransportState {
|
||||
playing: self.playing,
|
||||
bpm: self.bpm,
|
||||
bar: meter.bar,
|
||||
loop_bars: LOOP_BARS,
|
||||
},
|
||||
veins: VeinId::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, vein)| VeinState {
|
||||
id: *vein,
|
||||
name: vein.name().to_string(),
|
||||
gain: self.gains[index],
|
||||
})
|
||||
.collect(),
|
||||
pattern: self.pattern.clone(),
|
||||
audio: self.engine.info(),
|
||||
journal_len: self.journal.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
command: Command,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
match command {
|
||||
Command::GetSnapshot => Ok((
|
||||
CommandResult::Snapshot {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
)),
|
||||
Command::Play => {
|
||||
if self.playing {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.playing = true;
|
||||
self.engine.play();
|
||||
self.revision += 1;
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Transport { playing: true }],
|
||||
))
|
||||
}
|
||||
Command::Stop => {
|
||||
if !self.playing {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.playing = false;
|
||||
self.engine.stop();
|
||||
self.revision += 1;
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Transport { playing: false }],
|
||||
))
|
||||
}
|
||||
Command::Strike { vein, velocity } => {
|
||||
if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) {
|
||||
return Err(CoreError::InvalidVelocity);
|
||||
}
|
||||
self.engine.strike(vein, velocity);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Struck { vein, velocity }],
|
||||
))
|
||||
}
|
||||
Command::SetTempo { bpm } => {
|
||||
if !bpm.is_finite() || !(MIN_BPM..=MAX_BPM).contains(&bpm) {
|
||||
return Err(CoreError::InvalidTempo);
|
||||
}
|
||||
if (self.bpm - bpm).abs() < 1e-3 {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.bpm = bpm;
|
||||
self.engine.set_tempo(bpm);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::SetTempo { bpm });
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Tempo { bpm }],
|
||||
))
|
||||
}
|
||||
Command::SetVeinGain { vein, gain } => {
|
||||
if !gain.is_finite() || !(MIN_GAIN..=MAX_GAIN).contains(&gain) {
|
||||
return Err(CoreError::InvalidGain);
|
||||
}
|
||||
let index = vein.index();
|
||||
if (self.gains[index] - gain).abs() < 1e-3 {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.gains[index] = gain;
|
||||
self.engine.set_gain(vein, gain);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::SetVeinGain { vein, gain });
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::VeinGain { vein, gain }],
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, actor: Actor, command: Command) {
|
||||
self.seq += 1;
|
||||
self.journal.push(JournalEntry {
|
||||
seq: self.seq,
|
||||
revision: self.revision,
|
||||
actor,
|
||||
at_ms: now_ms(),
|
||||
command,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The first descent: a two-bar phrase in five veins.
|
||||
pub fn default_pattern() -> Pattern {
|
||||
let mut rows = Vec::with_capacity(VEIN_COUNT);
|
||||
for vein in VeinId::ALL {
|
||||
let mut velocities = vec![0u8; LOOP_STEPS];
|
||||
match vein {
|
||||
VeinId::Bass => {
|
||||
velocities[0] = 112;
|
||||
velocities[3] = 86;
|
||||
velocities[6] = 102;
|
||||
velocities[10] = 94;
|
||||
velocities[14] = 68;
|
||||
}
|
||||
VeinId::Crack => {
|
||||
velocities[2] = 72;
|
||||
velocities[7] = 108;
|
||||
velocities[9] = 58;
|
||||
velocities[15] = 92;
|
||||
}
|
||||
VeinId::Voice => {
|
||||
velocities[0] = 64;
|
||||
velocities[8] = 84;
|
||||
}
|
||||
VeinId::Noise => {
|
||||
velocities[4] = 54;
|
||||
velocities[11] = 62;
|
||||
velocities[13] = 42;
|
||||
}
|
||||
VeinId::Silt => {
|
||||
velocities[3] = 76;
|
||||
velocities[13] = 60;
|
||||
}
|
||||
}
|
||||
rows.push(PatternRow { vein, velocities });
|
||||
}
|
||||
Pattern {
|
||||
steps: LOOP_STEPS as u16,
|
||||
steps_per_bar: STEPS_PER_BAR as u16,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct TestEngine {
|
||||
calls: Mutex<Vec<String>>,
|
||||
info: AudioInfo,
|
||||
meter: MeterFrame,
|
||||
}
|
||||
|
||||
impl TestEngine {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
info: AudioInfo {
|
||||
running: true,
|
||||
device: "test".into(),
|
||||
sample_rate: 48_000,
|
||||
buffer_frames: 256,
|
||||
channels: 2,
|
||||
},
|
||||
meter: MeterFrame {
|
||||
bar: 2.25,
|
||||
master: 0.3,
|
||||
veins: [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine for TestEngine {
|
||||
fn play(&self) {
|
||||
self.calls.lock().unwrap().push("play".into());
|
||||
}
|
||||
fn stop(&self) {
|
||||
self.calls.lock().unwrap().push("stop".into());
|
||||
}
|
||||
fn strike(&self, vein: VeinId, velocity: f32) {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("strike:{}:{velocity}", vein.name()));
|
||||
}
|
||||
fn set_tempo(&self, bpm: f32) {
|
||||
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
|
||||
}
|
||||
fn set_gain(&self, vein: VeinId, gain: f32) {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("gain:{}:{gain}", vein.name()));
|
||||
}
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
fn meter(&self) -> MeterFrame {
|
||||
self.meter
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<TestEngine>) {
|
||||
let engine = Arc::new(TestEngine::new());
|
||||
(Session::new(engine.clone()), engine)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_pattern_is_complete() {
|
||||
let pattern = default_pattern();
|
||||
assert_eq!(pattern.steps as usize, LOOP_STEPS);
|
||||
assert_eq!(pattern.rows.len(), VEIN_COUNT);
|
||||
for row in &pattern.rows {
|
||||
assert_eq!(row.velocities.len(), LOOP_STEPS);
|
||||
assert!(row.velocities.iter().any(|v| *v > 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_updates_state_without_journaling() {
|
||||
let (mut session, engine) = session();
|
||||
let (result, events) = session.apply(Command::Play, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert!(state.transport.playing);
|
||||
assert_eq!(state.revision, 1);
|
||||
assert_eq!(session.journal().len(), 0);
|
||||
assert_eq!(engine.calls(), vec!["play"]);
|
||||
assert_eq!(events, vec![Event::Transport { playing: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_is_validated_and_journaled() {
|
||||
let (mut session, engine) = session();
|
||||
let (result, events) = session
|
||||
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
|
||||
.unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.transport.bpm, 132.0);
|
||||
assert_eq!(session.journal().len(), 1);
|
||||
assert_eq!(session.journal()[0].actor, Actor::Hand);
|
||||
assert_eq!(session.journal()[0].revision, 1);
|
||||
assert_eq!(events, vec![Event::Tempo { bpm: 132.0 }]);
|
||||
assert_eq!(engine.calls(), vec!["tempo:132"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_out_of_range_is_rejected() {
|
||||
let (mut session, engine) = session();
|
||||
assert!(matches!(
|
||||
session.apply(Command::SetTempo { bpm: 12.0 }, Actor::Hand),
|
||||
Err(CoreError::InvalidTempo)
|
||||
));
|
||||
assert!(engine.calls().is_empty());
|
||||
assert_eq!(session.journal().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strikes_are_performance_not_history() {
|
||||
let (mut session, engine) = session();
|
||||
let (result, events) = session
|
||||
.apply(
|
||||
Command::Strike {
|
||||
vein: VeinId::Bass,
|
||||
velocity: 0.8,
|
||||
},
|
||||
Actor::Hand,
|
||||
)
|
||||
.unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.revision, 0);
|
||||
assert_eq!(session.journal().len(), 0);
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(engine.calls()[0], "strike:bass:0.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_is_validated() {
|
||||
let (mut session, _) = session();
|
||||
assert!(matches!(
|
||||
session.apply(
|
||||
Command::SetVeinGain {
|
||||
vein: VeinId::Noise,
|
||||
gain: 2.0
|
||||
},
|
||||
Actor::Api
|
||||
),
|
||||
Err(CoreError::InvalidGain)
|
||||
));
|
||||
session
|
||||
.apply(
|
||||
Command::SetVeinGain {
|
||||
vein: VeinId::Noise,
|
||||
gain: 0.55,
|
||||
},
|
||||
Actor::Api,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session.journal().len(), 1);
|
||||
assert_eq!(session.journal()[0].actor, Actor::Api);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_carries_meter_and_pattern() {
|
||||
let (session, _) = session();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.transport.bar, 2.25);
|
||||
assert_eq!(state.transport.loop_bars, LOOP_BARS);
|
||||
assert_eq!(state.veins.len(), VEIN_COUNT);
|
||||
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
||||
assert_eq!(state.audio.device, "test");
|
||||
assert_eq!(state.journal_len, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "zhila-daemon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "zhila-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-engine = { path = "../zhila-engine" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
futures-util.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,437 @@
|
||||
//! ZHILA local daemon: one WebSocket endpoint, one session, one engine.
|
||||
//!
|
||||
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||
//! binds to loopback only and requires a token in the hello.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use futures_util::stream::SplitSink;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use tokio::time::{interval, timeout};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const METER_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "zhila-daemon",
|
||||
version,
|
||||
about = "ZHILA local engine daemon (P0)"
|
||||
)]
|
||||
struct Args {
|
||||
/// WebSocket port on loopback.
|
||||
#[arg(long, default_value_t = 4317)]
|
||||
port: u16,
|
||||
/// Shared token for the hello. Dev default; change it for anything real.
|
||||
#[arg(long, default_value = "dev")]
|
||||
token: String,
|
||||
/// Run without an output device; useful for UI tests.
|
||||
#[arg(long)]
|
||||
no_audio: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Outbound {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
session: Arc<Mutex<Session>>,
|
||||
out: broadcast::Sender<Outbound>,
|
||||
token: Arc<String>,
|
||||
session_id: Arc<String>,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "zhila_daemon=info,zhila_engine=info".into());
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token);
|
||||
|
||||
let pattern = zhila_core::default_pattern();
|
||||
let audio_holder = if args.no_audio {
|
||||
None
|
||||
} else {
|
||||
match AudioEngine::start(&pattern) {
|
||||
Ok(engine) => Some(engine),
|
||||
Err(error) => {
|
||||
warn!("audio offline: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let control: Arc<dyn Engine> = match &audio_holder {
|
||||
Some(engine) => engine.control(),
|
||||
None => Arc::new(NullEngine::new()),
|
||||
};
|
||||
let shutdown = control.clone();
|
||||
let info = control.info();
|
||||
let session = Arc::new(Mutex::new(Session::with_pattern(control.clone(), pattern)));
|
||||
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||
let session_id = format!("zhila-{:x}", now_ms());
|
||||
let shared = Shared {
|
||||
session,
|
||||
out: out.clone(),
|
||||
token: Arc::new(token.clone()),
|
||||
session_id: Arc::new(session_id.clone()),
|
||||
};
|
||||
|
||||
let meter_engine = control.clone();
|
||||
let meter_out = out.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = interval(METER_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let frame = meter_engine.meter();
|
||||
let _ = meter_out.send(Outbound::Binary(frame.encode()));
|
||||
}
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", args.port))
|
||||
.await
|
||||
.with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?;
|
||||
|
||||
print_banner(args.port, &token, &info, &session_id);
|
||||
if token == "dev" {
|
||||
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let (stream, peer) = accepted.context("accept failed")?;
|
||||
let shared = shared.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = handle(stream, shared).await {
|
||||
warn!("connection {peer}: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, session_id: &str) {
|
||||
println!();
|
||||
println!(" ZHILA · P0 engine");
|
||||
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||
if info.running {
|
||||
let buffer = if info.buffer_frames == 0 {
|
||||
"default".to_string()
|
||||
} else {
|
||||
info.buffer_frames.to_string()
|
||||
};
|
||||
println!(
|
||||
" audio {} · {} Hz · {} ch · buffer {}",
|
||||
info.device, info.sample_rate, info.channels, buffer
|
||||
);
|
||||
} else {
|
||||
println!(" audio offline · the surface will connect, but nothing will sound");
|
||||
}
|
||||
println!(" session {session_id}");
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let ws = accept_hdr_async(stream, handshake)
|
||||
.await
|
||||
.context("websocket handshake failed")?;
|
||||
|
||||
let (mut sink, mut source) = ws.split();
|
||||
|
||||
let first = match timeout(HELLO_TIMEOUT, source.next()).await {
|
||||
Ok(Some(Ok(message))) => message,
|
||||
Ok(Some(Err(error))) => return Err(error.into()),
|
||||
Ok(None) => return Ok(()),
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
"expected a hello within 5 seconds",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let Message::Text(text) = first else {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "the first frame must be a hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let hello: ClientMessage = match serde_json::from_str(text.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::BadRequest, "malformed hello"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let (client, protocol, token) = match hello {
|
||||
ClientMessage::Hello {
|
||||
client,
|
||||
protocol,
|
||||
token,
|
||||
} => (client, protocol, token),
|
||||
ClientMessage::Command { .. } => {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if protocol != PROTOCOL_VERSION {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(
|
||||
None,
|
||||
ErrorCode::Protocol,
|
||||
format!(
|
||||
"protocol mismatch: this daemon speaks {PROTOCOL_VERSION}, the client sent {protocol}"
|
||||
),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
if token.as_str() != shared.token.as_str() {
|
||||
let _ = send_out(
|
||||
&mut sink,
|
||||
error_message(None, ErrorCode::Unauthorized, "the token was not accepted"),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = shared.session.lock().await.snapshot();
|
||||
info!(?peer, client = %client, "wire opened");
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Hello {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
session: shared.session_id.as_ref().clone(),
|
||||
engine: state.audio.clone(),
|
||||
})
|
||||
.expect("hello serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let revision = state.revision;
|
||||
send_out(
|
||||
&mut sink,
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Snapshot { revision, state })
|
||||
.expect("snapshot serializes"),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::<Outbound>();
|
||||
let mut events = shared.out.subscribe();
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
direct = direct_rx.recv() => {
|
||||
match direct {
|
||||
Some(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
broadcast = events.recv() => {
|
||||
match broadcast {
|
||||
Ok(outbound) => {
|
||||
if send_out(&mut sink, outbound).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(frame) = source.next().await {
|
||||
let message = match frame {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => continue,
|
||||
Err(error) => {
|
||||
warn!("wire read error: {error}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let message: ClientMessage = match serde_json::from_str(message.as_str()) {
|
||||
Ok(message) => message,
|
||||
Err(_) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
None,
|
||||
ErrorCode::BadRequest,
|
||||
"malformed message",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match message {
|
||||
ClientMessage::Hello { .. } => {
|
||||
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.apply(command, Actor::Hand)
|
||||
};
|
||||
match outcome {
|
||||
Ok((result, emitted)) => {
|
||||
let revision = match &result {
|
||||
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||
state.revision
|
||||
}
|
||||
};
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Result { id, result })
|
||||
.expect("result serializes"),
|
||||
));
|
||||
for event in emitted {
|
||||
let message = ServerMessage::Event { revision, event };
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&message).unwrap()));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
error_code(&error),
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(direct_tx);
|
||||
let _ = writer.await;
|
||||
info!(?peer, "wire closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn handshake(request: &Request, response: Response) -> Result<Response, ErrorResponse> {
|
||||
if let Some(origin) = request
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& !origin_allowed(origin)
|
||||
{
|
||||
return Err(ErrorResponse::new(Some(
|
||||
"origin is not allowed to talk to ZHILA".into(),
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn origin_allowed(origin: &str) -> bool {
|
||||
if origin == "null" {
|
||||
return true;
|
||||
}
|
||||
let Some((scheme, rest)) = origin.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(scheme, "http" | "https" | "tauri") {
|
||||
return false;
|
||||
}
|
||||
let authority = rest.split('/').next().unwrap_or_default();
|
||||
let host = authority.split(':').next().unwrap_or_default();
|
||||
matches!(host, "localhost" | "127.0.0.1" | "tauri.localhost")
|
||||
}
|
||||
|
||||
fn error_code(error: &CoreError) -> ErrorCode {
|
||||
match error {
|
||||
CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => {
|
||||
ErrorCode::BadRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_message(id: Option<u64>, code: ErrorCode, message: impl Into<String>) -> Outbound {
|
||||
Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Error {
|
||||
id,
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
.expect("error serializes"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_out(sink: &mut WsSink, outbound: Outbound) -> anyhow::Result<()> {
|
||||
let message = match outbound {
|
||||
Outbound::Text(text) => Message::Text(text.into()),
|
||||
Outbound::Binary(bytes) => Message::Binary(bytes.into()),
|
||||
};
|
||||
sink.send(message).await.context("websocket send failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "zhila-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
cpal.workspace = true
|
||||
rtrb.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,538 @@
|
||||
//! ZHILA audio engine, P0.
|
||||
//!
|
||||
//! A real cpal stream with a fixed voice pool, a two-bar phrase, per-vein
|
||||
//! levels and a bar position. Everything that runs in the callback is
|
||||
//! allocation-free. The control surface is lock-free on the audio side.
|
||||
|
||||
use std::f32::consts::{FRAC_PI_4, TAU};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig};
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
use thiserror::Error;
|
||||
use zhila_core::Engine as EngineControl;
|
||||
use zhila_protocol::{
|
||||
AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern,
|
||||
STEPS_PER_BAR, VEIN_COUNT, VeinId,
|
||||
};
|
||||
|
||||
const VOICES: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AudioCommand {
|
||||
Play,
|
||||
Stop,
|
||||
Strike { vein: u8, velocity: f32 },
|
||||
SetTempo(f32),
|
||||
SetGain { vein: u8, gain: f32 },
|
||||
}
|
||||
|
||||
struct Levels {
|
||||
master: AtomicU32,
|
||||
veins: [AtomicU32; VEIN_COUNT],
|
||||
bar: AtomicU32,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
master: AtomicU32::new(0.0f32.to_bits()),
|
||||
veins: std::array::from_fn(|_| AtomicU32::new(0.0f32.to_bits())),
|
||||
bar: AtomicU32::new(1.0f32.to_bits()),
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self, frame: &MeterFrame) {
|
||||
self.master.store(frame.master.to_bits(), Ordering::Relaxed);
|
||||
self.bar.store(frame.bar.to_bits(), Ordering::Relaxed);
|
||||
for (level, slot) in frame.veins.iter().zip(self.veins.iter()) {
|
||||
slot.store(level.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&self) -> MeterFrame {
|
||||
let master = f32::from_bits(self.master.load(Ordering::Relaxed));
|
||||
let bar = f32::from_bits(self.bar.load(Ordering::Relaxed));
|
||||
let mut veins = [0.0; VEIN_COUNT];
|
||||
for (level, slot) in veins.iter_mut().zip(self.veins.iter()) {
|
||||
*level = f32::from_bits(slot.load(Ordering::Relaxed));
|
||||
}
|
||||
MeterFrame { bar, master, veins }
|
||||
}
|
||||
}
|
||||
|
||||
struct Voice {
|
||||
active: bool,
|
||||
vein: usize,
|
||||
phase: f32,
|
||||
freq: f32,
|
||||
t: f32,
|
||||
attack: f32,
|
||||
decay: f32,
|
||||
velocity: f32,
|
||||
gain_l: f32,
|
||||
gain_r: f32,
|
||||
lp: f32,
|
||||
hp: f32,
|
||||
prev: f32,
|
||||
rng: u32,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
fn inactive() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
vein: 0,
|
||||
phase: 0.0,
|
||||
freq: 0.0,
|
||||
t: 0.0,
|
||||
attack: 0.01,
|
||||
decay: 4.0,
|
||||
velocity: 0.0,
|
||||
gain_l: 0.707,
|
||||
gain_r: 0.707,
|
||||
lp: 0.0,
|
||||
hp: 0.0,
|
||||
prev: 0.0,
|
||||
rng: 0x9e37_79b9,
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger(&mut self, vein: VeinId, velocity: f32, seed: u32) {
|
||||
self.active = true;
|
||||
self.vein = vein.index();
|
||||
self.phase = 0.0;
|
||||
self.t = 0.0;
|
||||
self.velocity = velocity.clamp(0.0, 1.0);
|
||||
self.rng = seed | 1;
|
||||
self.lp = 0.0;
|
||||
self.hp = 0.0;
|
||||
self.prev = 0.0;
|
||||
let (freq, attack, decay, pan) = match vein {
|
||||
VeinId::Bass => (55.0, 0.004, 3.2, 0.0),
|
||||
VeinId::Crack => (0.0, 0.001, 38.0, 0.35),
|
||||
VeinId::Voice => (220.0, 0.09, 1.1, -0.25),
|
||||
VeinId::Noise => (0.0, 0.004, 5.0, 0.55),
|
||||
VeinId::Silt => (1318.5, 0.002, 14.0, -0.45),
|
||||
};
|
||||
self.freq = freq;
|
||||
self.attack = attack;
|
||||
self.decay = decay;
|
||||
let angle = (pan + 1.0) * FRAC_PI_4;
|
||||
self.gain_l = angle.cos();
|
||||
self.gain_r = angle.sin();
|
||||
}
|
||||
|
||||
fn noise(&mut self) -> f32 {
|
||||
self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
fn next(&mut self, sample_rate: f32) -> f32 {
|
||||
self.t += 1.0 / sample_rate;
|
||||
let attack = (self.t / self.attack).min(1.0);
|
||||
let env = attack * (-self.t * self.decay).exp();
|
||||
if env < 0.0002 || self.t > 8.0 {
|
||||
self.active = false;
|
||||
return 0.0;
|
||||
}
|
||||
let sample = match self.vein {
|
||||
0 => {
|
||||
let fundamental = (TAU * self.phase).sin();
|
||||
let sub = (TAU * self.phase * 0.5).sin();
|
||||
fundamental + sub * 0.45
|
||||
}
|
||||
1 => {
|
||||
let white = self.noise();
|
||||
let hp = white - self.prev + 0.94 * self.hp;
|
||||
self.prev = white;
|
||||
self.hp = hp;
|
||||
hp * 0.7
|
||||
}
|
||||
2 => {
|
||||
let fundamental = (TAU * self.phase).sin();
|
||||
let overtone = (TAU * self.phase * 2.0).sin();
|
||||
fundamental + overtone * 0.18
|
||||
}
|
||||
3 => {
|
||||
let white = self.noise();
|
||||
self.lp += (white - self.lp) * 0.06;
|
||||
self.lp
|
||||
}
|
||||
_ => (TAU * self.phase).sin(),
|
||||
};
|
||||
if self.freq > 0.0 {
|
||||
self.phase += self.freq / sample_rate;
|
||||
if self.phase >= 1.0 {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
}
|
||||
sample * env * self.velocity
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioState {
|
||||
rx: Consumer<AudioCommand>,
|
||||
sample_rate: f32,
|
||||
channels: usize,
|
||||
bpm: f32,
|
||||
playing: bool,
|
||||
step: usize,
|
||||
pending: f64,
|
||||
pattern: [[u8; LOOP_STEPS]; VEIN_COUNT],
|
||||
voices: [Voice; VOICES],
|
||||
gains: [f32; VEIN_COUNT],
|
||||
master_env: f32,
|
||||
vein_env: [f32; VEIN_COUNT],
|
||||
bar: f32,
|
||||
rng: u32,
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer<AudioCommand>) -> Self {
|
||||
let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT];
|
||||
for row in &pattern.rows {
|
||||
let index = row.vein.index();
|
||||
for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() {
|
||||
grid[index][step] = *velocity;
|
||||
}
|
||||
}
|
||||
Self {
|
||||
rx,
|
||||
sample_rate: sample_rate as f32,
|
||||
channels: channels as usize,
|
||||
bpm: 128.0,
|
||||
playing: false,
|
||||
step: 0,
|
||||
pending: 0.0,
|
||||
pattern: grid,
|
||||
voices: std::array::from_fn(|_| Voice::inactive()),
|
||||
gains: zhila_core::INITIAL_GAINS,
|
||||
master_env: 0.0,
|
||||
vein_env: [0.0; VEIN_COUNT],
|
||||
bar: 1.0,
|
||||
rng: 0x51ed_2701,
|
||||
}
|
||||
}
|
||||
|
||||
fn step_samples(&self) -> f64 {
|
||||
self.sample_rate as f64 * 60.0 / self.bpm as f64 / 2.0
|
||||
}
|
||||
|
||||
fn handle(&mut self, command: AudioCommand) {
|
||||
match command {
|
||||
AudioCommand::Play => {
|
||||
self.playing = true;
|
||||
self.step = 0;
|
||||
self.pending = 0.0;
|
||||
self.bar = 1.0;
|
||||
}
|
||||
AudioCommand::Stop => {
|
||||
self.playing = false;
|
||||
self.step = 0;
|
||||
self.pending = 0.0;
|
||||
self.bar = 1.0;
|
||||
self.master_env = 0.0;
|
||||
self.vein_env = [0.0; VEIN_COUNT];
|
||||
for voice in &mut self.voices {
|
||||
voice.active = false;
|
||||
}
|
||||
}
|
||||
AudioCommand::Strike { vein, velocity } => {
|
||||
let Some(vein) = VeinId::from_index(vein as usize) else {
|
||||
return;
|
||||
};
|
||||
self.trigger(vein, velocity);
|
||||
}
|
||||
AudioCommand::SetTempo(bpm) => {
|
||||
self.bpm = bpm.clamp(MIN_BPM, MAX_BPM);
|
||||
}
|
||||
AudioCommand::SetGain { vein, gain } => {
|
||||
if let Some(index) = VeinId::from_index(vein as usize) {
|
||||
self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger(&mut self, vein: VeinId, velocity: f32) {
|
||||
if velocity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let index = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|voice| !voice.active)
|
||||
.unwrap_or_else(|| {
|
||||
self.voices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.t.total_cmp(&b.1.t))
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
self.voices[index].trigger(vein, velocity, self.rng);
|
||||
}
|
||||
|
||||
fn trigger_step(&mut self) {
|
||||
let step = self.step;
|
||||
for vein in 0..VEIN_COUNT {
|
||||
let velocity = self.pattern[vein][step];
|
||||
if velocity > 0
|
||||
&& let Some(vein) = VeinId::from_index(vein)
|
||||
{
|
||||
self.trigger(vein, velocity as f32 / 127.0);
|
||||
}
|
||||
}
|
||||
self.bar = step as f32 / STEPS_PER_BAR as f32 + 1.0;
|
||||
self.step = (step + 1) % LOOP_STEPS;
|
||||
}
|
||||
|
||||
fn render<T>(&mut self, data: &mut [T], levels: &Levels)
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
while let Ok(command) = self.rx.pop() {
|
||||
self.handle(command);
|
||||
}
|
||||
|
||||
for frame in data.chunks_mut(self.channels) {
|
||||
if self.playing {
|
||||
if self.pending <= 0.0 {
|
||||
self.trigger_step();
|
||||
self.pending += self.step_samples();
|
||||
}
|
||||
self.pending -= 1.0;
|
||||
}
|
||||
|
||||
let mut left = 0.0f32;
|
||||
let mut right = 0.0f32;
|
||||
let mut sums = [0.0f32; VEIN_COUNT];
|
||||
for voice in &mut self.voices {
|
||||
if !voice.active {
|
||||
continue;
|
||||
}
|
||||
let sample = voice.next(self.sample_rate);
|
||||
let index = voice.vein;
|
||||
let amplitude = sample * self.gains[index];
|
||||
left += amplitude * voice.gain_l;
|
||||
right += amplitude * voice.gain_r;
|
||||
sums[index] += amplitude.abs();
|
||||
}
|
||||
|
||||
let peak = (left.abs() + right.abs()) * 0.5;
|
||||
self.master_env += (peak - self.master_env) * 0.0015;
|
||||
for (env, sum) in self.vein_env.iter_mut().zip(sums.iter()) {
|
||||
*env += (*sum - *env) * 0.002;
|
||||
}
|
||||
|
||||
let out_l = (left * 0.9).tanh() * 0.8;
|
||||
let out_r = (right * 0.9).tanh() * 0.8;
|
||||
for (channel, sample) in frame.iter_mut().enumerate() {
|
||||
let value = match (self.channels, channel) {
|
||||
(1, _) => (out_l + out_r) * 0.5,
|
||||
(_, 0) => out_l,
|
||||
(_, 1) => out_r,
|
||||
_ => (out_l + out_r) * 0.5,
|
||||
};
|
||||
*sample = T::from_sample(value);
|
||||
}
|
||||
}
|
||||
|
||||
levels.store(&MeterFrame {
|
||||
bar: self.bar,
|
||||
master: self.master_env,
|
||||
veins: self.vein_env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The send side of the engine. Cheap, lock-free for the audio thread and
|
||||
/// safe to share with the session and the WebSocket daemon.
|
||||
pub struct Control {
|
||||
tx: Mutex<Producer<AudioCommand>>,
|
||||
levels: Arc<Levels>,
|
||||
info: AudioInfo,
|
||||
}
|
||||
|
||||
impl EngineControl for Control {
|
||||
fn play(&self) {
|
||||
self.send(AudioCommand::Play);
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.send(AudioCommand::Stop);
|
||||
}
|
||||
|
||||
fn strike(&self, vein: VeinId, velocity: f32) {
|
||||
self.send(AudioCommand::Strike {
|
||||
vein: vein.index() as u8,
|
||||
velocity,
|
||||
});
|
||||
}
|
||||
|
||||
fn set_tempo(&self, bpm: f32) {
|
||||
self.send(AudioCommand::SetTempo(bpm));
|
||||
}
|
||||
|
||||
fn set_gain(&self, vein: VeinId, gain: f32) {
|
||||
self.send(AudioCommand::SetGain {
|
||||
vein: vein.index() as u8,
|
||||
gain,
|
||||
});
|
||||
}
|
||||
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
|
||||
fn meter(&self) -> MeterFrame {
|
||||
self.levels.load()
|
||||
}
|
||||
}
|
||||
|
||||
impl Control {
|
||||
fn send(&self, command: AudioCommand) {
|
||||
if let Ok(mut tx) = self.tx.lock() {
|
||||
let _ = tx.push(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EngineError {
|
||||
#[error("no default audio output device")]
|
||||
NoDevice,
|
||||
#[error("audio backend: {0}")]
|
||||
Backend(#[from] cpal::Error),
|
||||
#[error("unsupported sample format: {0:?}")]
|
||||
UnsupportedFormat(SampleFormat),
|
||||
}
|
||||
|
||||
struct Started {
|
||||
stream: Stream,
|
||||
control: Arc<Control>,
|
||||
}
|
||||
|
||||
/// Keeps the cpal stream alive. The control handle can be shared freely; the
|
||||
/// stream itself must stay on the thread that created it.
|
||||
pub struct AudioEngine {
|
||||
_stream: Stream,
|
||||
control: Arc<Control>,
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
pub fn start(pattern: &Pattern) -> Result<Self, EngineError> {
|
||||
let started = match Self::try_start(pattern, BufferSize::Fixed(256)) {
|
||||
Ok(started) => started,
|
||||
Err(EngineError::Backend(_)) => Self::try_start(pattern, BufferSize::Default)?,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(Self {
|
||||
_stream: started.stream,
|
||||
control: started.control,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn control(&self) -> Arc<Control> {
|
||||
self.control.clone()
|
||||
}
|
||||
|
||||
pub fn info(&self) -> AudioInfo {
|
||||
self.control.info.clone()
|
||||
}
|
||||
|
||||
fn try_start(pattern: &Pattern, buffer: BufferSize) -> Result<Started, EngineError> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device().ok_or(EngineError::NoDevice)?;
|
||||
let supported = device.default_output_config()?;
|
||||
let format = supported.sample_format();
|
||||
let mut config: StreamConfig = supported.into();
|
||||
config.buffer_size = buffer;
|
||||
let sample_rate = config.sample_rate;
|
||||
let channels = config.channels;
|
||||
let device_name = device.to_string();
|
||||
let (producer, consumer) = RingBuffer::new(256);
|
||||
let levels = Arc::new(Levels::new());
|
||||
let state = AudioState::new(sample_rate, channels, pattern, consumer);
|
||||
let stream = match format {
|
||||
SampleFormat::F32 => build_stream::<f32>(&device, config, state, levels.clone())?,
|
||||
SampleFormat::I16 => build_stream::<i16>(&device, config, state, levels.clone())?,
|
||||
other => return Err(EngineError::UnsupportedFormat(other)),
|
||||
};
|
||||
let info = AudioInfo {
|
||||
running: true,
|
||||
device: device_name,
|
||||
sample_rate,
|
||||
buffer_frames: match buffer {
|
||||
BufferSize::Fixed(frames) => frames,
|
||||
BufferSize::Default => 0,
|
||||
},
|
||||
channels,
|
||||
};
|
||||
let control = Arc::new(Control {
|
||||
tx: Mutex::new(producer),
|
||||
levels,
|
||||
info,
|
||||
});
|
||||
Ok(Started { stream, control })
|
||||
}
|
||||
}
|
||||
|
||||
fn build_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: StreamConfig,
|
||||
mut state: AudioState,
|
||||
levels: Arc<Levels>,
|
||||
) -> Result<Stream, EngineError>
|
||||
where
|
||||
T: SizedSample + FromSample<f32> + 'static,
|
||||
{
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
state.render(data, &levels);
|
||||
},
|
||||
|error| eprintln!("zhila audio: {error}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// A silent engine for headless protocol tests and machines without an output
|
||||
/// device. It reports itself as offline so the surface never pretends to play.
|
||||
pub struct NullEngine {
|
||||
info: AudioInfo,
|
||||
}
|
||||
|
||||
impl Default for NullEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NullEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
info: AudioInfo::offline(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EngineControl for NullEngine {
|
||||
fn play(&self) {}
|
||||
fn stop(&self) {}
|
||||
fn strike(&self, _vein: VeinId, _velocity: f32) {}
|
||||
fn set_tempo(&self, _bpm: f32) {}
|
||||
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
|
||||
fn info(&self) -> AudioInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
fn meter(&self) -> MeterFrame {
|
||||
MeterFrame::silent()
|
||||
}
|
||||
}
|
||||
@@ -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