P2a: eight-bar arrangement with four editable sections

This commit is contained in:
Emil
2026-11-01 02:15:14 +03:00
parent 9add2610d7
commit c85e3cc723
19 changed files with 628 additions and 105 deletions
+198 -23
View File
@@ -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();
+3 -1
View File
@@ -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,
}
}
+17 -10
View File
@@ -353,9 +353,9 @@ impl AudioState {
return;
};
let vein_index = material
.pattern
.rows
.sections
.iter()
.flat_map(|section| &section.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)
+89 -24
View File
@@ -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);
+16
View File
@@ -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,