P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user