P1a: material import, sixteen faces, sample engine and quarry
This commit is contained in:
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user