1094 lines
33 KiB
Rust
1094 lines
33 KiB
Rust
//! 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<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>,
|
|
}
|
|
|
|
/// 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<Core>,
|
|
pub journal: Vec<JournalEntry>,
|
|
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<MaterialSnapshot>);
|
|
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<Core>,
|
|
journal: Vec<JournalEntry>,
|
|
project_name: String,
|
|
history: Vec<ProjectEdit>,
|
|
redo: Vec<ProjectEdit>,
|
|
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 {
|
|
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<dyn Engine>, 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<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.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<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 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<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 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<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()
|
|
}
|
|
|
|
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<MaterialSnapshot>) {
|
|
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<TestEngine>) {
|
|
let engine = Arc::new(TestEngine::new());
|
|
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]
|
|
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)
|
|
));
|
|
}
|
|
}
|