P1a: material import, sixteen faces, sample engine and quarry

This commit is contained in:
Emil
2026-11-01 00:56:51 +03:00
parent db4368efae
commit ebdeca3b92
28 changed files with 2799 additions and 114 deletions
+382 -7
View File
@@ -10,21 +10,57 @@ 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,
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<AudioFace>,
}
#[derive(Clone)]
pub struct AudioCore {
pub id: String,
pub sample_rate: u32,
pub samples: Arc<[f32]>,
pub faces: Vec<AudioFace>,
}
/// Immutable material view shared with the audio thread.
#[derive(Clone)]
pub struct MaterialSnapshot {
pub pattern: Pattern,
pub cores: Vec<AudioCore>,
}
/// 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<MaterialSnapshot>);
fn info(&self) -> AudioInfo;
fn meter(&self) -> MeterFrame;
}
@@ -37,6 +73,14 @@ pub enum CoreError {
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)]
@@ -55,6 +99,7 @@ pub struct Session {
playing: bool,
gains: [f32; VEIN_COUNT],
pattern: Pattern,
cores: Vec<Core>,
journal: Vec<JournalEntry>,
engine: Arc<dyn Engine>,
}
@@ -65,16 +110,19 @@ impl Session {
}
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
Self {
let session = Self {
revision: 0,
seq: 0,
bpm: 128.0,
playing: false,
gains: INITIAL_GAINS,
pattern,
cores: Vec::new(),
journal: Vec::new(),
engine,
}
};
session.sync_material();
session
}
pub fn revision(&self) -> u64 {
@@ -109,6 +157,7 @@ impl Session {
})
.collect(),
pattern: self.pattern.clone(),
cores: self.cores.iter().map(|core| core.summary.clone()).collect(),
audio: self.engine.info(),
journal_len: self.journal.len(),
}
@@ -223,9 +272,187 @@ impl Session {
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::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<Event>), 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 Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
return Err(CoreError::InvalidRow);
};
if row_ref.velocities[step as usize] == velocity {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
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<Event>), 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 Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
return Err(CoreError::InvalidRow);
};
if row_ref.face.as_ref() == Some(&reference) {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
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 {
@@ -278,7 +505,11 @@ pub fn default_pattern() -> Pattern {
velocities[13] = 60;
}
}
rows.push(PatternRow { vein, velocities });
rows.push(PatternRow {
vein,
face: None,
velocities,
});
}
Pattern {
steps: LOOP_STEPS as u16,
@@ -291,6 +522,7 @@ pub fn default_pattern() -> Pattern {
mod tests {
use super::*;
use std::sync::Mutex;
use zhila_protocol::{CoreSummary, FaceSummary, Seal};
struct TestEngine {
calls: Mutex<Vec<String>>,
@@ -320,6 +552,10 @@ mod tests {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn clear(&self) {
self.calls.lock().unwrap().clear();
}
}
impl Engine for TestEngine {
@@ -335,6 +571,12 @@ mod tests {
.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}"));
}
@@ -344,6 +586,12 @@ mod tests {
.unwrap()
.push(format!("gain:{}:{gain}", vein.name()));
}
fn set_material(&self, material: Arc<MaterialSnapshot>) {
self.calls
.lock()
.unwrap()
.push(format!("material:{}", material.cores.len()));
}
fn info(&self) -> AudioInfo {
self.info.clone()
}
@@ -354,7 +602,43 @@ mod tests {
fn session() -> (Session, Arc<TestEngine>) {
let engine = Arc::new(TestEngine::new());
(Session::new(engine.clone()), engine)
let session = Session::new(engine.clone());
engine.clear();
(session, engine)
}
fn test_core() -> Core {
let faces: Vec<AudioFace> = (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]
@@ -467,5 +751,96 @@ mod tests {
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 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)
));
}
}