P2a: eight-bar arrangement with four editable sections
This commit is contained in:
+198
-23
@@ -126,7 +126,7 @@ pub struct AudioCore {
|
||||
/// Immutable material view shared with the audio thread.
|
||||
#[derive(Clone)]
|
||||
pub struct MaterialSnapshot {
|
||||
pub pattern: Pattern,
|
||||
pub sections: Vec<Pattern>,
|
||||
pub cores: Vec<AudioCore>,
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@ pub struct ProjectData {
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: [f32; VEIN_COUNT],
|
||||
pub pattern: Pattern,
|
||||
pub sections: Vec<Pattern>,
|
||||
pub active_section: u8,
|
||||
pub cores: Vec<Core>,
|
||||
pub journal: Vec<JournalEntry>,
|
||||
pub seq: u64,
|
||||
@@ -155,11 +156,13 @@ pub struct CoreEdit {
|
||||
pub struct ProjectEdit {
|
||||
pub bpm: f32,
|
||||
pub gains: [f32; VEIN_COUNT],
|
||||
pub pattern: Pattern,
|
||||
pub sections: Vec<Pattern>,
|
||||
pub active_section: u8,
|
||||
pub cores: Vec<CoreEdit>,
|
||||
}
|
||||
|
||||
const MAX_HISTORY: usize = 64;
|
||||
pub const ARRANGEMENT_SECTIONS: usize = 4;
|
||||
|
||||
/// The audio engine as seen by the session. Implementations must be real-time
|
||||
/// safe on the audio thread and non-blocking on these methods.
|
||||
@@ -191,6 +194,8 @@ pub enum CoreError {
|
||||
UnknownFace,
|
||||
#[error("face boundaries must be sixteen ordered cuts inside the core")]
|
||||
InvalidFaces,
|
||||
#[error("section must be 0..3")]
|
||||
InvalidSection,
|
||||
#[error("this command only exists on the binary import path")]
|
||||
UnsupportedCommand,
|
||||
}
|
||||
@@ -229,7 +234,8 @@ pub struct Session {
|
||||
bpm: f32,
|
||||
playing: bool,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
pattern: Pattern,
|
||||
sections: Vec<Pattern>,
|
||||
active_section: u8,
|
||||
cores: Vec<Core>,
|
||||
journal: Vec<JournalEntry>,
|
||||
project_name: String,
|
||||
@@ -250,7 +256,8 @@ impl Session {
|
||||
bpm: 128.0,
|
||||
playing: false,
|
||||
gains: INITIAL_GAINS,
|
||||
pattern,
|
||||
sections: vec![pattern; ARRANGEMENT_SECTIONS],
|
||||
active_section: 0,
|
||||
cores: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
project_name: "descent-04".into(),
|
||||
@@ -269,7 +276,8 @@ impl Session {
|
||||
bpm: data.bpm,
|
||||
playing: false,
|
||||
gains: data.gains,
|
||||
pattern: data.pattern,
|
||||
sections: data.sections,
|
||||
active_section: data.active_section,
|
||||
cores: data.cores,
|
||||
journal: data.journal,
|
||||
project_name: data.name,
|
||||
@@ -315,7 +323,11 @@ impl Session {
|
||||
}
|
||||
|
||||
fn sanitize_faces(&mut self) {
|
||||
for row in &mut self.pattern.rows {
|
||||
for row in self
|
||||
.sections
|
||||
.iter_mut()
|
||||
.flat_map(|section| &mut section.rows)
|
||||
{
|
||||
let Some(face) = &row.face else { continue };
|
||||
let exists = self.cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
@@ -334,7 +346,8 @@ impl Session {
|
||||
ProjectEdit {
|
||||
bpm: self.bpm,
|
||||
gains: self.gains,
|
||||
pattern: self.pattern.clone(),
|
||||
sections: self.sections.clone(),
|
||||
active_section: self.active_section,
|
||||
cores: self
|
||||
.cores
|
||||
.iter()
|
||||
@@ -357,7 +370,8 @@ impl Session {
|
||||
fn apply_edit(&mut self, edit: ProjectEdit) {
|
||||
self.bpm = edit.bpm;
|
||||
self.gains = edit.gains;
|
||||
self.pattern = edit.pattern;
|
||||
self.sections = edit.sections;
|
||||
self.active_section = edit.active_section;
|
||||
for core_edit in &edit.cores {
|
||||
if let Some(core) = self
|
||||
.cores
|
||||
@@ -381,7 +395,15 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn pattern(&self) -> &Pattern {
|
||||
&self.pattern
|
||||
&self.sections[self.active_section as usize]
|
||||
}
|
||||
|
||||
pub fn sections(&self) -> &[Pattern] {
|
||||
&self.sections
|
||||
}
|
||||
|
||||
pub fn active_section(&self) -> u8 {
|
||||
self.active_section
|
||||
}
|
||||
|
||||
pub fn journal(&self) -> &[JournalEntry] {
|
||||
@@ -399,7 +421,7 @@ impl Session {
|
||||
playing: self.playing,
|
||||
bpm: self.bpm,
|
||||
bar: meter.bar,
|
||||
loop_bars: LOOP_BARS,
|
||||
loop_bars: self.sections.len().max(1) as f32 * LOOP_BARS,
|
||||
},
|
||||
veins: VeinId::ALL
|
||||
.iter()
|
||||
@@ -410,7 +432,9 @@ impl Session {
|
||||
gain: self.gains[index],
|
||||
})
|
||||
.collect(),
|
||||
pattern: self.pattern.clone(),
|
||||
pattern: self.pattern().clone(),
|
||||
arrangement: self.sections.clone(),
|
||||
active_section: self.active_section,
|
||||
cores: self.cores.iter().map(|core| core.summary.clone()).collect(),
|
||||
audio: self.engine.info(),
|
||||
journal_len: self.journal.len(),
|
||||
@@ -560,6 +584,9 @@ impl Session {
|
||||
Command::SetCoreFaces { core, boundaries } => {
|
||||
self.set_core_faces(&core, &boundaries, actor)
|
||||
}
|
||||
Command::SelectSection { index } => self.select_section(index, actor),
|
||||
Command::ClearSection { index } => self.clear_section(index, actor),
|
||||
Command::CopySection { from, to } => self.copy_section(from, to, actor),
|
||||
Command::Undo => {
|
||||
let Some(edit) = self.history.pop() else {
|
||||
return Ok((
|
||||
@@ -616,7 +643,7 @@ impl Session {
|
||||
|
||||
pub fn material_snapshot(&self) -> MaterialSnapshot {
|
||||
MaterialSnapshot {
|
||||
pattern: self.pattern.clone(),
|
||||
sections: self.sections.clone(),
|
||||
cores: self
|
||||
.cores
|
||||
.iter()
|
||||
@@ -631,6 +658,109 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Called by the daemon after the binary import and the analysis.
|
||||
pub fn select_section(
|
||||
&mut self,
|
||||
index: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if index as usize >= self.sections.len() {
|
||||
return Err(CoreError::InvalidSection);
|
||||
}
|
||||
if index == self.active_section {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.active_section = index;
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::SelectSection { index });
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Arrangement {
|
||||
sections: self.sections.clone(),
|
||||
active: index,
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn clear_section(
|
||||
&mut self,
|
||||
index: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if index as usize >= self.sections.len() {
|
||||
return Err(CoreError::InvalidSection);
|
||||
}
|
||||
if self.sections[index as usize]
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.velocities.iter().all(|velocity| *velocity == 0) && row.face.is_none())
|
||||
{
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
for row in &mut self.sections[index as usize].rows {
|
||||
row.velocities.fill(0);
|
||||
row.face = None;
|
||||
}
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::ClearSection { index });
|
||||
self.sync_material();
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Arrangement {
|
||||
sections: self.sections.clone(),
|
||||
active: self.active_section,
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn copy_section(
|
||||
&mut self,
|
||||
from: u8,
|
||||
to: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if from as usize >= self.sections.len() || to as usize >= self.sections.len() {
|
||||
return Err(CoreError::InvalidSection);
|
||||
}
|
||||
if from == to || self.sections[from as usize] == self.sections[to as usize] {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let source = self.sections[from as usize].clone();
|
||||
self.sections[to as usize] = source;
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::CopySection { from, to });
|
||||
self.sync_material();
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::Arrangement {
|
||||
sections: self.sections.clone(),
|
||||
active: self.active_section,
|
||||
}],
|
||||
))
|
||||
}
|
||||
|
||||
pub fn set_core_faces(
|
||||
&mut self,
|
||||
core_id: &str,
|
||||
@@ -703,15 +833,15 @@ impl Session {
|
||||
velocity: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if row as usize >= self.pattern.rows.len() {
|
||||
let section = self.active_section as usize;
|
||||
if row as usize >= self.sections[section].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
|
||||
let unchanged = self.sections[section]
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.is_some_and(|row| row.velocities[step as usize] == velocity);
|
||||
@@ -724,7 +854,7 @@ impl Session {
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
let Some(row_ref) = self.sections[section].rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.velocities[step as usize] = velocity;
|
||||
@@ -757,7 +887,8 @@ impl Session {
|
||||
face: u8,
|
||||
actor: Actor,
|
||||
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||
if row as usize >= self.pattern.rows.len() {
|
||||
let section = self.active_section as usize;
|
||||
if row as usize >= self.sections[section].rows.len() {
|
||||
return Err(CoreError::InvalidRow);
|
||||
}
|
||||
self.find_face(core, face)?;
|
||||
@@ -765,8 +896,7 @@ impl Session {
|
||||
core: core.to_string(),
|
||||
face,
|
||||
};
|
||||
let unchanged = self
|
||||
.pattern
|
||||
let unchanged = self.sections[section]
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.and_then(|row| row.face.as_ref())
|
||||
@@ -780,7 +910,7 @@ impl Session {
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
let Some(row_ref) = self.sections[section].rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.face = Some(reference);
|
||||
@@ -1119,7 +1249,7 @@ mod tests {
|
||||
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.transport.loop_bars, 8.0);
|
||||
assert_eq!(state.veins.len(), VEIN_COUNT);
|
||||
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
||||
assert_eq!(state.audio.device, "test");
|
||||
@@ -1233,7 +1363,8 @@ mod tests {
|
||||
revision: 7,
|
||||
bpm: 120.0,
|
||||
gains: [0.5; VEIN_COUNT],
|
||||
pattern: default_pattern(),
|
||||
sections: vec![default_pattern(); ARRANGEMENT_SECTIONS],
|
||||
active_section: 0,
|
||||
cores: vec![test_core()],
|
||||
journal: Vec::new(),
|
||||
seq: 0,
|
||||
@@ -1251,6 +1382,50 @@ mod tests {
|
||||
assert!(!state.can_undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_sections_are_independent() {
|
||||
let (mut session, engine) = session();
|
||||
engine.clear();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.arrangement.len(), ARRANGEMENT_SECTIONS);
|
||||
assert_eq!(state.active_section, 0);
|
||||
assert_eq!(state.transport.loop_bars, 8.0);
|
||||
|
||||
session
|
||||
.apply(Command::SelectSection { index: 1 }, Actor::Hand)
|
||||
.unwrap();
|
||||
assert_eq!(session.active_section(), 1);
|
||||
session.set_pattern_step(0, 0, 0, Actor::Hand).unwrap();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.pattern.rows[0].velocities[0], 0);
|
||||
assert_eq!(state.arrangement[0].rows[0].velocities[0], 112);
|
||||
assert_eq!(state.arrangement[1].rows[0].velocities[0], 0);
|
||||
|
||||
session
|
||||
.apply(Command::CopySection { from: 0, to: 2 }, Actor::Hand)
|
||||
.unwrap();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.arrangement[2], state.arrangement[0]);
|
||||
session
|
||||
.apply(Command::ClearSection { index: 2 }, Actor::Hand)
|
||||
.unwrap();
|
||||
assert!(
|
||||
session.snapshot().arrangement[2].rows.iter().all(|row| row
|
||||
.velocities
|
||||
.iter()
|
||||
.all(|velocity| *velocity == 0)
|
||||
&& row.face.is_none())
|
||||
);
|
||||
session.apply(Command::Undo, Actor::Hand).unwrap();
|
||||
assert_eq!(
|
||||
session.snapshot().arrangement[2],
|
||||
session.snapshot().arrangement[0]
|
||||
);
|
||||
|
||||
assert_eq!(session.material_snapshot().sections.len(), 4);
|
||||
assert!(engine.calls().iter().any(|call| call.contains("material")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_faces_are_validated_and_undoable() {
|
||||
let (mut session, _) = session();
|
||||
|
||||
@@ -203,7 +203,8 @@ async fn save_now(shared: &Shared) {
|
||||
revision: state.revision,
|
||||
bpm: session.bpm(),
|
||||
gains: &gains,
|
||||
pattern: session.pattern(),
|
||||
sections: session.sections(),
|
||||
active_section: session.active_section(),
|
||||
cores: &state.cores,
|
||||
journal: session.journal(),
|
||||
history: session.history(),
|
||||
@@ -565,6 +566,7 @@ fn error_code(error: &CoreError) -> ErrorCode {
|
||||
| CoreError::InvalidStep
|
||||
| CoreError::UnknownFace
|
||||
| CoreError::InvalidFaces
|
||||
| CoreError::InvalidSection
|
||||
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,9 +353,9 @@ impl AudioState {
|
||||
return;
|
||||
};
|
||||
let vein_index = material
|
||||
.pattern
|
||||
.rows
|
||||
.sections
|
||||
.iter()
|
||||
.flat_map(|section| §ion.rows)
|
||||
.find(|row| {
|
||||
row.face.as_ref().is_some_and(|reference| {
|
||||
reference.core == core && reference.face == face
|
||||
@@ -374,6 +374,8 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
AudioCommand::SetMaterial(material) => {
|
||||
let total = material.sections.len().max(1) * LOOP_STEPS;
|
||||
self.step %= total;
|
||||
self.material = Some(material);
|
||||
}
|
||||
}
|
||||
@@ -436,19 +438,24 @@ impl AudioState {
|
||||
|
||||
fn trigger_step(&mut self) {
|
||||
let step = self.step;
|
||||
let (plan, count) = {
|
||||
let (plan, count, section_count) = {
|
||||
let material = self.material.as_ref().cloned();
|
||||
let pattern = material
|
||||
.as_ref()
|
||||
.map(|material| &material.pattern)
|
||||
.unwrap_or(&self.fallback);
|
||||
let (sections, section_count) = match material.as_ref() {
|
||||
Some(material) if !material.sections.is_empty() => {
|
||||
(material.sections.as_slice(), material.sections.len())
|
||||
}
|
||||
_ => (std::slice::from_ref(&self.fallback), 4),
|
||||
};
|
||||
let section = (step / LOOP_STEPS) % section_count;
|
||||
let pattern = sections.get(section).unwrap_or(&self.fallback);
|
||||
let pattern_step = step % LOOP_STEPS;
|
||||
let mut plan: [StepTrigger; 8] = [(0, 0, None); 8];
|
||||
let mut count = 0;
|
||||
for row in &pattern.rows {
|
||||
if count >= plan.len() {
|
||||
break;
|
||||
}
|
||||
let velocity = row.velocities.get(step).copied().unwrap_or(0);
|
||||
let velocity = row.velocities.get(pattern_step).copied().unwrap_or(0);
|
||||
if velocity == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -464,7 +471,7 @@ impl AudioState {
|
||||
plan[count] = (row.vein.index(), velocity, face);
|
||||
count += 1;
|
||||
}
|
||||
(plan, count)
|
||||
(plan, count, section_count)
|
||||
};
|
||||
for (vein_index, velocity, face) in plan.into_iter().take(count) {
|
||||
if let Some((core_index, face_index)) = face {
|
||||
@@ -474,7 +481,7 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
self.bar = step as f32 / STEPS_PER_BAR as f32 + 1.0;
|
||||
self.step = (step + 1) % LOOP_STEPS;
|
||||
self.step = (step + 1) % (section_count * LOOP_STEPS);
|
||||
}
|
||||
|
||||
fn render<T>(&mut self, data: &mut [T], levels: &Levels)
|
||||
|
||||
@@ -42,7 +42,8 @@ pub struct ProjectSnapshot<'a> {
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: &'a [f32; VEIN_COUNT],
|
||||
pub pattern: &'a Pattern,
|
||||
pub sections: &'a [Pattern],
|
||||
pub active_section: u8,
|
||||
pub cores: &'a [CoreSummary],
|
||||
pub journal: &'a [JournalEntry],
|
||||
pub history: &'a [ProjectEdit],
|
||||
@@ -70,6 +71,8 @@ impl ProjectStore {
|
||||
bpm REAL NOT NULL,
|
||||
gains TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
arrangement TEXT NOT NULL DEFAULT '[]',
|
||||
active_section INTEGER NOT NULL DEFAULT 0,
|
||||
history TEXT NOT NULL DEFAULT '[]',
|
||||
redo TEXT NOT NULL DEFAULT '[]',
|
||||
saved_at INTEGER NOT NULL
|
||||
@@ -91,15 +94,21 @@ impl ProjectStore {
|
||||
command TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
let _ = conn.execute("ALTER TABLE cores ADD COLUMN faces TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE project ADD COLUMN history TEXT NOT NULL DEFAULT '[]'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE project ADD COLUMN redo TEXT NOT NULL DEFAULT '[]'",
|
||||
[],
|
||||
);
|
||||
ensure_column(&conn, "cores", "faces", "TEXT")?;
|
||||
ensure_column(&conn, "project", "history", "TEXT NOT NULL DEFAULT '[]'")?;
|
||||
ensure_column(&conn, "project", "redo", "TEXT NOT NULL DEFAULT '[]'")?;
|
||||
ensure_column(
|
||||
&conn,
|
||||
"project",
|
||||
"arrangement",
|
||||
"TEXT NOT NULL DEFAULT '[]'",
|
||||
)?;
|
||||
ensure_column(
|
||||
&conn,
|
||||
"project",
|
||||
"active_section",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -120,7 +129,7 @@ impl ProjectStore {
|
||||
let row = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT name, revision, bpm, gains, pattern, history, redo FROM project WHERE id = 1",
|
||||
"SELECT name, revision, bpm, gains, pattern, arrangement, active_section, history, redo FROM project WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
@@ -130,12 +139,24 @@ impl ProjectStore {
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((name, revision, bpm, gains_json, pattern_json, history_json, redo_json)) = row
|
||||
let Some((
|
||||
name,
|
||||
revision,
|
||||
bpm,
|
||||
gains_json,
|
||||
pattern_json,
|
||||
arrangement_json,
|
||||
active_section,
|
||||
history_json,
|
||||
redo_json,
|
||||
)) = row
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -145,7 +166,13 @@ impl ProjectStore {
|
||||
for (slot, value) in gains.iter_mut().zip(gains_value.iter()) {
|
||||
*slot = *value;
|
||||
}
|
||||
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
||||
let pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
||||
let mut sections: Vec<Pattern> =
|
||||
serde_json::from_str(&arrangement_json).unwrap_or_default();
|
||||
if sections.is_empty() {
|
||||
sections = vec![pattern; 4];
|
||||
}
|
||||
let active_section = (active_section.max(0) as usize).min(sections.len() - 1) as u8;
|
||||
let mut warnings = Vec::new();
|
||||
let history: Vec<ProjectEdit> =
|
||||
serde_json::from_str(&history_json).unwrap_or_else(|error| {
|
||||
@@ -210,7 +237,7 @@ impl ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
for row in &mut pattern.rows {
|
||||
for row in sections.iter_mut().flat_map(|section| &mut section.rows) {
|
||||
let missing = row.face.as_ref().and_then(|face| {
|
||||
let valid = cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
@@ -274,7 +301,8 @@ impl ProjectStore {
|
||||
revision: revision as u64,
|
||||
bpm: bpm as f32,
|
||||
gains,
|
||||
pattern,
|
||||
sections,
|
||||
active_section,
|
||||
cores,
|
||||
journal,
|
||||
seq,
|
||||
@@ -287,20 +315,30 @@ impl ProjectStore {
|
||||
|
||||
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
|
||||
let gains = serde_json::to_string(snapshot.gains)?;
|
||||
let pattern = serde_json::to_string(snapshot.pattern)?;
|
||||
let active = snapshot
|
||||
.sections
|
||||
.get(snapshot.active_section as usize)
|
||||
.or_else(|| snapshot.sections.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(zhila_core::default_pattern);
|
||||
let pattern = serde_json::to_string(&active)?;
|
||||
let arrangement = serde_json::to_string(snapshot.sections)?;
|
||||
let active_section = snapshot.active_section as i64;
|
||||
let history = serde_json::to_string(snapshot.history)?;
|
||||
let redo = serde_json::to_string(snapshot.redo)?;
|
||||
let saved_at = now_ms();
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO project (id, name, revision, bpm, gains, pattern, history, redo, saved_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
"INSERT INTO project (id, name, revision, bpm, gains, pattern, arrangement, active_section, history, redo, saved_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
revision = excluded.revision,
|
||||
bpm = excluded.bpm,
|
||||
gains = excluded.gains,
|
||||
pattern = excluded.pattern,
|
||||
arrangement = excluded.arrangement,
|
||||
active_section = excluded.active_section,
|
||||
history = excluded.history,
|
||||
redo = excluded.redo,
|
||||
saved_at = excluded.saved_at",
|
||||
@@ -310,6 +348,8 @@ impl ProjectStore {
|
||||
snapshot.bpm as f64,
|
||||
gains,
|
||||
pattern,
|
||||
arrangement,
|
||||
active_section,
|
||||
history,
|
||||
redo,
|
||||
saved_at as i64
|
||||
@@ -445,6 +485,28 @@ impl ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
definition: &str,
|
||||
) -> Result<(), ProjectError> {
|
||||
let exists = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2",
|
||||
params![table, column],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?;
|
||||
if exists.is_none() {
|
||||
conn.execute(
|
||||
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -519,7 +581,8 @@ mod tests {
|
||||
let history = vec![ProjectEdit {
|
||||
bpm: 120.0,
|
||||
gains,
|
||||
pattern: pattern.clone(),
|
||||
sections: vec![pattern.clone()],
|
||||
active_section: 0,
|
||||
cores: vec![CoreEdit {
|
||||
core: core.summary.id.clone(),
|
||||
boundaries: core.face_boundaries(),
|
||||
@@ -534,7 +597,8 @@ mod tests {
|
||||
revision: 4,
|
||||
bpm: 132.0,
|
||||
gains: &gains,
|
||||
pattern: &pattern,
|
||||
sections: std::slice::from_ref(&pattern),
|
||||
active_section: 0,
|
||||
cores: std::slice::from_ref(&core.summary),
|
||||
journal: &journal,
|
||||
history: &history,
|
||||
@@ -553,10 +617,11 @@ mod tests {
|
||||
assert_eq!(loaded.data.cores[0].summary.id, core.summary.id);
|
||||
assert_eq!(loaded.data.cores[0].faces.len(), 16);
|
||||
assert!((loaded.data.cores[0].summary.faces[7].end_sec - expected_edge).abs() < 1e-4);
|
||||
assert_eq!(loaded.data.pattern.rows.len(), 5);
|
||||
assert_eq!(loaded.data.pattern.rows[0].velocities[0], 96);
|
||||
assert_eq!(loaded.data.sections.len(), 1);
|
||||
assert_eq!(loaded.data.sections[0].rows.len(), 5);
|
||||
assert_eq!(loaded.data.sections[0].rows[0].velocities[0], 96);
|
||||
assert_eq!(
|
||||
loaded.data.pattern.rows[0].face.as_ref().unwrap().core,
|
||||
loaded.data.sections[0].rows[0].face.as_ref().unwrap().core,
|
||||
core.summary.id
|
||||
);
|
||||
assert_eq!(loaded.data.journal.len(), 1);
|
||||
|
||||
@@ -208,6 +208,16 @@ pub enum Command {
|
||||
core: String,
|
||||
boundaries: Vec<f32>,
|
||||
},
|
||||
SelectSection {
|
||||
index: u8,
|
||||
},
|
||||
ClearSection {
|
||||
index: u8,
|
||||
},
|
||||
CopySection {
|
||||
from: u8,
|
||||
to: u8,
|
||||
},
|
||||
Undo,
|
||||
Redo,
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
@@ -263,6 +273,10 @@ pub enum Event {
|
||||
CoreFaces {
|
||||
core: CoreSummary,
|
||||
},
|
||||
Arrangement {
|
||||
sections: Vec<Pattern>,
|
||||
active: u8,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -305,6 +319,8 @@ pub struct State {
|
||||
pub transport: TransportState,
|
||||
pub veins: Vec<VeinState>,
|
||||
pub pattern: Pattern,
|
||||
pub arrangement: Vec<Pattern>,
|
||||
pub active_section: u8,
|
||||
pub cores: Vec<CoreSummary>,
|
||||
pub audio: AudioInfo,
|
||||
pub journal_len: usize,
|
||||
|
||||
Reference in New Issue
Block a user