P0: Rust engine, wire daemon and audio-reactive descent surface

This commit is contained in:
Emil
2026-11-01 00:36:04 +03:00
parent cd967cced4
commit db4368efae
68 changed files with 19507 additions and 3 deletions
+471
View File
@@ -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);
}
}