//! 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, CoreSummary, Event, FaceRef, 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]; /// A face as the engine needs it: half-open sample range in the core's source /// rate. #[derive(Debug, Clone)] pub struct AudioFace { pub index: u8, pub start: u32, pub end: u32, pub rms: f32, } /// A core with its samples. The archive keeps this; the engine receives a /// snapshot of it through the control layer. #[derive(Clone)] pub struct Core { pub summary: CoreSummary, pub samples: Arc<[f32]>, pub faces: Vec, } #[derive(Clone)] pub struct AudioCore { pub id: String, pub sample_rate: u32, pub samples: Arc<[f32]>, pub faces: Vec, } /// Immutable material view shared with the audio thread. #[derive(Clone)] pub struct MaterialSnapshot { pub pattern: Pattern, pub cores: Vec, } /// A complete project as loaded from the store. #[derive(Clone)] pub struct ProjectData { pub name: String, pub revision: u64, pub bpm: f32, pub gains: [f32; VEIN_COUNT], pub pattern: Pattern, pub cores: Vec, pub journal: Vec, pub seq: u64, } #[derive(Clone)] struct ProjectEdit { bpm: f32, gains: [f32; VEIN_COUNT], pattern: Pattern, } const MAX_HISTORY: usize = 64; /// 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 strike_face(&self, core: &str, face: u8, velocity: f32); fn set_tempo(&self, bpm: f32); fn set_gain(&self, vein: VeinId, gain: f32); fn set_material(&self, material: Arc); 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, #[error("pattern row must be 0..4")] InvalidRow, #[error("step must be 0..15")] InvalidStep, #[error("no such core or face")] UnknownFace, #[error("this command only exists on the binary import path")] UnsupportedCommand, } #[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, cores: Vec, journal: Vec, project_name: String, history: Vec, redo: Vec, engine: Arc, } impl Session { pub fn new(engine: Arc) -> Self { Self::with_pattern(engine, default_pattern()) } pub fn with_pattern(engine: Arc, pattern: Pattern) -> Self { let session = Self { revision: 0, seq: 0, bpm: 128.0, playing: false, gains: INITIAL_GAINS, pattern, cores: Vec::new(), journal: Vec::new(), project_name: "descent-04".into(), history: Vec::new(), redo: Vec::new(), engine, }; session.sync_material(); session } pub fn from_project(engine: Arc, data: ProjectData) -> Self { let mut session = Self { revision: data.revision, seq: data.seq, bpm: data.bpm, playing: false, gains: data.gains, pattern: data.pattern, cores: data.cores, journal: data.journal, project_name: data.name, history: Vec::new(), redo: Vec::new(), engine, }; session.sanitize_faces(); session.sync_material(); session } pub fn project_name(&self) -> &str { &self.project_name } pub fn set_project_name(&mut self, name: String) { self.project_name = name; } pub fn can_undo(&self) -> bool { !self.history.is_empty() } pub fn can_redo(&self) -> bool { !self.redo.is_empty() } pub fn bpm(&self) -> f32 { self.bpm } pub fn gains(&self) -> [f32; VEIN_COUNT] { self.gains } fn sanitize_faces(&mut self) { for row in &mut self.pattern.rows { let Some(face) = &row.face else { continue }; let exists = self.cores.iter().any(|core| { core.summary.id == face.core && core .faces .iter() .any(|candidate| candidate.index == face.face) }); if !exists { row.face = None; } } } fn edit_snapshot(&self) -> ProjectEdit { ProjectEdit { bpm: self.bpm, gains: self.gains, pattern: self.pattern.clone(), } } fn push_history(&mut self) { if self.history.len() >= MAX_HISTORY { self.history.remove(0); } self.history.push(self.edit_snapshot()); self.redo.clear(); } fn apply_edit(&mut self, edit: ProjectEdit) { self.bpm = edit.bpm; self.gains = edit.gains; self.pattern = edit.pattern; self.engine.set_tempo(self.bpm); for (index, gain) in self.gains.iter().enumerate() { if let Some(vein) = VeinId::from_index(index) { self.engine.set_gain(vein, *gain); } } self.sync_material(); } 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, project_name: self.project_name.clone(), can_undo: self.can_undo(), can_redo: self.can_redo(), 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(), cores: self.cores.iter().map(|core| core.summary.clone()).collect(), audio: self.engine.info(), journal_len: self.journal.len(), } } pub fn apply( &mut self, command: Command, actor: Actor, ) -> Result<(CommandResult, Vec), 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.push_history(); 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.push_history(); 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 }], )) } Command::StrikeFace { core, face, velocity, } => { if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) { return Err(CoreError::InvalidVelocity); } self.find_face(&core, face)?; self.engine.strike_face(&core, face, velocity); Ok(( CommandResult::State { state: self.snapshot(), }, vec![Event::FaceStruck { core, face, velocity, }], )) } Command::SetPatternStep { row, step, velocity, } => self.set_pattern_step(row, step, velocity, actor), Command::SetPatternFace { row, core, face } => { self.set_pattern_face(row, &core, face, actor) } Command::Undo => { let Some(edit) = self.history.pop() else { return Ok(( CommandResult::State { state: self.snapshot(), }, Vec::new(), )); }; self.redo.push(self.edit_snapshot()); self.apply_edit(edit); self.revision += 1; self.record(actor, Command::Undo); Ok(( CommandResult::State { state: self.snapshot(), }, vec![Event::History { can_undo: self.can_undo(), can_redo: self.can_redo(), }], )) } Command::Redo => { let Some(edit) = self.redo.pop() else { return Ok(( CommandResult::State { state: self.snapshot(), }, Vec::new(), )); }; self.history.push(self.edit_snapshot()); self.apply_edit(edit); self.revision += 1; self.record(actor, Command::Redo); Ok(( CommandResult::State { state: self.snapshot(), }, vec![Event::History { can_undo: self.can_undo(), can_redo: self.can_redo(), }], )) } Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand), } } pub fn cores(&self) -> &[Core] { &self.cores } pub fn material_snapshot(&self) -> MaterialSnapshot { MaterialSnapshot { pattern: self.pattern.clone(), cores: self .cores .iter() .map(|core| AudioCore { id: core.summary.id.clone(), sample_rate: core.summary.sample_rate, samples: core.samples.clone(), faces: core.faces.clone(), }) .collect(), } } /// Called by the daemon after the binary import and the analysis. pub fn import_core(&mut self, core: Core, actor: Actor) -> State { let name = core.summary.name.clone(); let seal = core.summary.seal; self.cores.push(core); self.revision += 1; self.record(actor, Command::ImportCore { name, seal }); self.sync_material(); self.snapshot() } pub fn set_pattern_step( &mut self, row: u8, step: u8, velocity: u8, actor: Actor, ) -> Result<(CommandResult, Vec), CoreError> { if row as usize >= self.pattern.rows.len() { return Err(CoreError::InvalidRow); } if step as usize >= LOOP_STEPS { return Err(CoreError::InvalidStep); } let velocity = velocity.min(127); let unchanged = self .pattern .rows .get(row as usize) .is_some_and(|row| row.velocities[step as usize] == velocity); if unchanged { return Ok(( CommandResult::State { state: self.snapshot(), }, Vec::new(), )); } self.push_history(); let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else { return Err(CoreError::InvalidRow); }; row_ref.velocities[step as usize] = velocity; self.revision += 1; self.record( actor, Command::SetPatternStep { row, step, velocity, }, ); self.sync_material(); Ok(( CommandResult::State { state: self.snapshot(), }, vec![Event::PatternStep { row, step, velocity, }], )) } pub fn set_pattern_face( &mut self, row: u8, core: &str, face: u8, actor: Actor, ) -> Result<(CommandResult, Vec), CoreError> { if row as usize >= self.pattern.rows.len() { return Err(CoreError::InvalidRow); } self.find_face(core, face)?; let reference = FaceRef { core: core.to_string(), face, }; let unchanged = self .pattern .rows .get(row as usize) .and_then(|row| row.face.as_ref()) .is_some_and(|face| face == &reference); if unchanged { return Ok(( CommandResult::State { state: self.snapshot(), }, Vec::new(), )); } self.push_history(); let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else { return Err(CoreError::InvalidRow); }; row_ref.face = Some(reference); self.revision += 1; self.record( actor, Command::SetPatternFace { row, core: core.to_string(), face, }, ); self.sync_material(); Ok(( CommandResult::State { state: self.snapshot(), }, vec![Event::PatternFace { row, core: core.to_string(), face, }], )) } fn find_face(&self, core_id: &str, face: u8) -> Result<(), CoreError> { let Some(core) = self .cores .iter() .find(|candidate| candidate.summary.id.as_str() == core_id) else { return Err(CoreError::UnknownFace); }; if core.faces.iter().any(|candidate| candidate.index == face) { Ok(()) } else { Err(CoreError::UnknownFace) } } fn sync_material(&self) { let snapshot = self.material_snapshot(); self.engine.set_material(Arc::new(snapshot)); } 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, face: None, 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; use zhila_protocol::{CoreSummary, FaceSummary, Seal}; struct TestEngine { calls: Mutex>, 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 { self.calls.lock().unwrap().clone() } fn clear(&self) { self.calls.lock().unwrap().clear(); } } 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 strike_face(&self, core: &str, face: u8, velocity: f32) { self.calls .lock() .unwrap() .push(format!("face:{core}:{face}:{velocity}")); } 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 set_material(&self, material: Arc) { self.calls .lock() .unwrap() .push(format!("material:{}", material.cores.len())); } fn info(&self) -> AudioInfo { self.info.clone() } fn meter(&self) -> MeterFrame { self.meter } } fn session() -> (Session, Arc) { let engine = Arc::new(TestEngine::new()); let session = Session::new(engine.clone()); engine.clear(); (session, engine) } fn test_core() -> Core { let faces: Vec = (0..16) .map(|index| AudioFace { index, start: index as u32 * 3_000, end: (index as u32 + 1) * 3_000, rms: 0.5, }) .collect(); let summary = CoreSummary { id: "core-test".into(), name: "test material".into(), seal: Seal::Found, sample_rate: 48_000, duration_sec: 1.0, faces: faces .iter() .map(|face| FaceSummary { index: face.index, rank: face.index, start_sec: face.start as f32 / 48_000.0, end_sec: face.end as f32 / 48_000.0, rms: face.rms, }) .collect(), peaks: vec![0.0, 0.5, 1.0], }; Core { summary, samples: Arc::from(vec![0.0f32; 48_000]), faces, } } #[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); assert!(state.cores.is_empty()); } #[test] fn import_adds_a_core_and_syncs_material() { let (mut session, engine) = session(); let state = session.import_core(test_core(), Actor::Hand); assert_eq!(state.cores.len(), 1); assert_eq!(state.cores[0].id, "core-test"); assert_eq!(session.journal().len(), 1); assert!(matches!( session.journal()[0].command, Command::ImportCore { .. } )); assert!(engine.calls().iter().any(|call| call == "material:1")); } #[test] fn pattern_edits_are_journaled_and_synced() { let (mut session, engine) = session(); session.import_core(test_core(), Actor::Hand); engine.clear(); let (result, events) = session .set_pattern_face(0, "core-test", 3, Actor::Hand) .unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.pattern.rows[0].face.as_ref().unwrap().face, 3); assert_eq!( events, vec![Event::PatternFace { row: 0, core: "core-test".into(), face: 3 }] ); let (result, events) = session.set_pattern_step(0, 1, 100, Actor::Hand).unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.pattern.rows[0].velocities[1], 100); assert_eq!( events, vec![Event::PatternStep { row: 0, step: 1, velocity: 100 }] ); assert_eq!(session.journal().len(), 3); assert!( engine .calls() .iter() .any(|call| call.starts_with("material:")) ); } #[test] fn undo_and_redo_project_edits() { let (mut session, _) = session(); session .apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand) .unwrap(); session.set_pattern_step(0, 0, 0, Actor::Hand).unwrap(); assert!(session.can_undo()); assert!(!session.can_redo()); let (result, events) = session.apply(Command::Undo, Actor::Hand).unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.pattern.rows[0].velocities[0], 112); assert_eq!(state.transport.bpm, 132.0); assert!(state.can_redo); assert_eq!( events, vec![Event::History { can_undo: true, can_redo: true }] ); let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.transport.bpm, 128.0); assert!(!state.can_undo); let (result, _) = session.apply(Command::Redo, Actor::Hand).unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.transport.bpm, 132.0); assert!(session.journal().len() >= 4); } #[test] fn project_data_loads_cores_pattern_and_name() { let engine = Arc::new(TestEngine::new()); let session = Session::from_project( engine.clone(), ProjectData { name: "loaded project".into(), revision: 7, bpm: 120.0, gains: [0.5; VEIN_COUNT], pattern: default_pattern(), cores: vec![test_core()], journal: Vec::new(), seq: 0, }, ); engine.clear(); let state = session.snapshot(); assert_eq!(state.project_name, "loaded project"); assert_eq!(state.transport.bpm, 120.0); assert_eq!(state.veins[0].gain, 0.5); assert_eq!(state.cores.len(), 1); assert_eq!(state.journal_len, 0); assert!(!state.can_undo); } #[test] fn faces_are_validated_and_can_be_struck() { let (mut session, engine) = session(); session.import_core(test_core(), Actor::Hand); engine.clear(); let (result, events) = session .apply( Command::StrikeFace { core: "core-test".into(), face: 5, velocity: 0.75, }, Actor::Hand, ) .unwrap(); let CommandResult::State { state } = result else { panic!("expected state"); }; assert_eq!(state.cores.len(), 1); assert_eq!(events.len(), 1); assert_eq!(engine.calls(), vec!["face:core-test:5:0.75"]); assert!(matches!( session.apply( Command::StrikeFace { core: "missing".into(), face: 0, velocity: 0.5 }, Actor::Hand ), Err(CoreError::UnknownFace) )); } }