P1b: SQLite project, content-addressed assets, autosave and undo

This commit is contained in:
Emil
2026-11-01 01:19:55 +03:00
parent 4f4ee05a3a
commit 0acf11ccb0
22 changed files with 1405 additions and 32 deletions
+255 -8
View File
@@ -51,6 +51,28 @@ pub struct MaterialSnapshot {
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 {
@@ -101,6 +123,9 @@ pub struct Session {
pattern: Pattern,
cores: Vec<Core>,
journal: Vec<JournalEntry>,
project_name: String,
history: Vec<ProjectEdit>,
redo: Vec<ProjectEdit>,
engine: Arc<dyn Engine>,
}
@@ -119,12 +144,104 @@ impl Session {
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
}
@@ -141,6 +258,9 @@ impl Session {
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,
@@ -237,6 +357,7 @@ impl Session {
Vec::new(),
));
}
self.push_history();
self.bpm = bpm;
self.engine.set_tempo(bpm);
self.revision += 1;
@@ -261,6 +382,7 @@ impl Session {
Vec::new(),
));
}
self.push_history();
self.gains[index] = gain;
self.engine.set_gain(vein, gain);
self.revision += 1;
@@ -301,6 +423,52 @@ impl Session {
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),
}
}
@@ -350,10 +518,12 @@ impl Session {
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 {
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(),
@@ -361,6 +531,10 @@ impl Session {
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(
@@ -399,10 +573,13 @@ impl Session {
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) {
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(),
@@ -410,6 +587,10 @@ impl Session {
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(
@@ -810,6 +991,72 @@ mod tests {
);
}
#[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();