diff --git a/README.md b/README.md index 1a03e7d..36f4f5d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs. -This repository is **P1 complete**: the Rust engine, the wire, the descent, the quarry, a project that survives restarts, recording, manual faces and trim, an undo stack that comes back with the project, and a native Tauri window that hosts the engine itself. A found or recorded sound is cut into sixteen faces by hand or by analysis, laid on a vein and heard in the loop; every project edit is autosaved and reversible. The next phase is the arrangement and the agent. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). +This repository is **P2 in progress**: the engine, the wire, the descent, the quarry, a persistent project, recording, manual faces, undo across restarts, a native host — and an **8-bar arrangement of four sections**. A found or recorded sound is cut into sixteen faces, laid on a vein, arranged across A–D, and heard as an eight-bar loop; the render and the agent are the remaining P2 slices. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). ![The quarry](preview-p1-quarry.png) @@ -16,6 +16,8 @@ This repository is **P1 complete**: the Rust engine, the wire, the descent, the ![P1d · a cut and trimmed core](preview-p1d-faces.png) +![P2 · eight bars, four sections](preview-p2-arrangement.png) + ## What is real - **Rust engine.** `cpal` output at 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein levels and gains, a bar clock. Procedural voices for empty pattern rows; real sample voices for rows with a face. No Web Audio. @@ -29,6 +31,7 @@ This repository is **P1 complete**: the Rust engine, the wire, the descent, the - **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains, pattern faces and steps); play, stop and strikes are performance, not history. - **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gains; transport; keyboard. - **Native host.** The Tauri app starts the same host inside its own process: engine, session, SQLite project, assets and loopback wire. The built window is self-contained; the browser path uses the standalone daemon. One process owns `127.0.0.1:4317` at a time. +- **Arrangement.** Four 2-bar sections (A–D) make an 8-bar loop. The engine plays the sections in order; the descent draws all eight bars with section seams and letters; the quarry edits the active section, selects, copies and clears. The sections, the active section and their edits are persisted and undoable; the pattern rows and faces stay per section. - **Verification.** Rust unit tests, a live wire test, a material import test, a browser quarry test and the P0 regression smoke, plus `svelte-check` and a production build. The Tauri 2 app is **built and running with its own engine**: the native window loads the embedded surface and the host starts the engine, session, project and wire in the app process. It is built with Tauri 2.12.5 / CLI 2.12.4 against WebKitGTK 2.54.2, GTK 3.24.52 and dbus 1.16.2. The binary is `app/src-tauri/target/release/zhila-app` (14 MB); installers are a later step. @@ -101,13 +104,19 @@ npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, scr npm --prefix ui run project-smoke # daemon + Vite: project, undo, import, screenshots npm --prefix ui run record-smoke # daemon + Vite: fake microphone take, faces, seal npm --prefix ui run faces-smoke # daemon + Vite: drag a seam, trim, save, screenshot +npm --prefix ui run arrangement-smoke # daemon + Vite: sections, edit B, 8-bar descent cd app && ./node_modules/.bin/tauri build --no-bundle # native app with the embedded host npm --prefix ui run restored-smoke # after restarting the daemon on the saved project ``` `npm --prefix ui run smoke` writes `preview-p0*.png`; `quarry-smoke` writes `preview-p1-*.png`. -## P1 limits (next, not hidden) +## P2 limits (in progress, not hidden) + +- The arrangement is exactly four 2-bar sections; no section length, reorder, insert or per-section mute yet. +- Render and the agent are the next P2 slices; they are not in this commit. + +## P1 limits (kept honest) - Recording is mono, 16-bit, requested 48 kHz, up to 30 s, no monitoring and no input device choice. WebKitGTK/Tauri microphone permission is untested; the browser path is verified with Chromium's fake capture. - Face editing is seventeen contiguous cuts: no zoom, fades or per-face loop points. The trim is the outer pair of cuts; the source bytes are untouched. @@ -120,4 +129,4 @@ npm --prefix ui run restored-smoke # after restarting the daemon on the saved - No agent/MCP in this phase. - The Tauri shell now builds and runs; the engine is still the separate daemon (embedding is next). -P1 is complete at the "first playing loop" milestone: material, sampler, project, recording, cutting, history and the native host. The next phase is P2: the arrangement across bars, render to WAV, and the agent on the same verbs. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md). +P2 is the arrangement, the offline WAV render and the in-app agent. P2a (the arrangement) is in this tree; P2b and P2c follow. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P2.md`](docs/P2.md). The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md). diff --git a/crates/zhila-core/src/lib.rs b/crates/zhila-core/src/lib.rs index c91eae1..5eaab2c 100644 --- a/crates/zhila-core/src/lib.rs +++ b/crates/zhila-core/src/lib.rs @@ -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, pub cores: Vec, } @@ -137,7 +137,8 @@ pub struct ProjectData { pub revision: u64, pub bpm: f32, pub gains: [f32; VEIN_COUNT], - pub pattern: Pattern, + pub sections: Vec, + pub active_section: u8, pub cores: Vec, pub journal: Vec, 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, + pub active_section: u8, pub cores: Vec, } 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, + active_section: u8, cores: Vec, journal: Vec, 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), 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), 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), 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), 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), 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(); diff --git a/crates/zhila-daemon/src/lib.rs b/crates/zhila-daemon/src/lib.rs index 8d9cd71..a3147f8 100644 --- a/crates/zhila-daemon/src/lib.rs +++ b/crates/zhila-daemon/src/lib.rs @@ -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, } } diff --git a/crates/zhila-engine/src/lib.rs b/crates/zhila-engine/src/lib.rs index 025b103..772cfe4 100644 --- a/crates/zhila-engine/src/lib.rs +++ b/crates/zhila-engine/src/lib.rs @@ -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(&mut self, data: &mut [T], levels: &Levels) diff --git a/crates/zhila-project/src/lib.rs b/crates/zhila-project/src/lib.rs index 061ee7f..f1222c5 100644 --- a/crates/zhila-project/src/lib.rs +++ b/crates/zhila-project/src/lib.rs @@ -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 = + 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 = 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); diff --git a/crates/zhila-protocol/src/lib.rs b/crates/zhila-protocol/src/lib.rs index 3e2e2c0..5a0b0c0 100644 --- a/crates/zhila-protocol/src/lib.rs +++ b/crates/zhila-protocol/src/lib.rs @@ -208,6 +208,16 @@ pub enum Command { core: String, boundaries: Vec, }, + 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, + active: u8, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -305,6 +319,8 @@ pub struct State { pub transport: TransportState, pub veins: Vec, pub pattern: Pattern, + pub arrangement: Vec, + pub active_section: u8, pub cores: Vec, pub audio: AudioInfo, pub journal_len: usize, diff --git a/docs/P2.md b/docs/P2.md new file mode 100644 index 0000000..beba27c --- /dev/null +++ b/docs/P2.md @@ -0,0 +1,27 @@ +# P2 · arrangement, render, agent + +P2 turns the loop into a thing that can be played, rendered and performed with an agent. It is delivered in three slices. + +## P2a · the arrangement + +Four 2-bar sections (A–D) form an 8-bar loop. Each section is a full `Pattern` (5 rows × 16 steps, with per-row faces). The engine walks 64 steps, picks the section and the pattern step, and triggers the section's rows; the bar clock runs 1–8. The state carries `arrangement: [Pattern; 4]`, `active_section`, and `pattern` = the active section for the editors. + +Commands: `select_section { index }`, `clear_section { index }`, `copy_section { from, to }`, event `arrangement { sections, active }`. Pattern edits apply to the active section. Select is persisted but not undoable; clear/copy push history. Sections, the active index and their history are stored in SQLite (`arrangement`, `active_section` columns, migrated with `ensure_column`) and restored on load. + +Surface: the quarry gets a section strip (A–D with note counts, copy →, clear); the pattern grid edits the active section. The descent draws the 64-step road, bar labels 01–08, section seams and letters A–D, with the active section emphasized; the playhead follows the 8-bar clock. + +Verification: + +- `cargo test -p zhila-core arrangement`: sections independent, select/copy/clear, undo, material carries four sections, loop_bars = 8. +- `node scripts/persistence-smoke.mjs`: imports, edits section B, copies A→C, cuts, restarts, verifies the arrangement, active B, B's step 0 = 0, C = A, and undo/redo. +- `npm --prefix ui run arrangement-smoke`: four sections, select B, edit a step, switch to the nave, play, screenshot `preview-p2-arrangement.png`. + +Limits: exactly four sections of two bars; no insert/reorder/length; select is not undoable; no per-section mute. + +## P2b · render (next) + +Offline render of the 8-bar arrangement to a WAV inside the project, with a command and a surface control. + +## P2c · agent (next) + +An in-app LLM agent on the same verbs, with state/sound context and approval for project edits. diff --git a/docs/PLAN.md b/docs/PLAN.md index af1fb86..d967ea5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **P1 полностью выполнен** (P0, P1a–P1f; см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. Следующая фаза — аранжировка, рендер и агент. +Статус: **P2 в работе** (P0, P1a–P1f, P2a; см. `P0.md`, `P1.md`, `P2.md`). Демо `reference/demo/` — референс, не кодовая база. Осталось: рендер WAV и агент. **Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index afb2769..03db42e 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -168,3 +168,7 @@ No new wire messages. The daemon now persists the session's bounded `history` an ## P1f note The Tauri app starts the same host in-process, so the wire contract is unchanged. The embedded surface connects to `ws://127.0.0.1:4317` with the same hello, token and origin rules; the daemon remains the browser-development host. + +## P2a additions + +`State` adds `arrangement: Pattern[]` (four sections in this phase) and `active_section: u8`; `State.pattern` is the active section. Commands `select_section { index }`, `clear_section { index }`, `copy_section { from, to }`; event `arrangement { sections, active }`. Pattern step/face commands edit the active section. The project row stores `arrangement` and `active_section`; old projects with only `pattern` are migrated to four copies of it. diff --git a/preview-p2-arrangement.png b/preview-p2-arrangement.png new file mode 100644 index 0000000..a13c0fc Binary files /dev/null and b/preview-p2-arrangement.png differ diff --git a/scripts/persistence-smoke.mjs b/scripts/persistence-smoke.mjs index 323da18..c6e73e5 100644 --- a/scripts/persistence-smoke.mjs +++ b/scripts/persistence-smoke.mjs @@ -84,6 +84,9 @@ try { if (!assigned) throw new Error('redo did not restore the face'); await client.command(23, { type: 'set_pattern_step', row: 0, step: 0, velocity: 96 }); await client.command(24, { type: 'set_tempo', bpm: 132 }); + await client.command(28, { type: 'select_section', index: 1 }); + await client.command(29, { type: 'set_pattern_step', row: 0, step: 0, velocity: 0 }); + await client.command(30, { type: 'copy_section', from: 0, to: 2 }); const sortedFaces = [...core.faces].sort((a, b) => a.index - b.index); const boundaries = sortedFaces.map((face) => face.start_sec); boundaries.push(sortedFaces[sortedFaces.length - 1].end_sec); @@ -98,7 +101,7 @@ try { await start(); client = await connect(); const second = client.snapshot; - const restored = second.pattern.rows[0]; + const restored = second.arrangement[0].rows[0]; if (second.cores.length !== 1) throw new Error('core did not survive the restart'); if (second.cores[0].name !== 'clicks-01') throw new Error('core name changed'); if (!restored.face || restored.face.face !== assigned.face) throw new Error('pattern face did not survive'); @@ -107,6 +110,11 @@ try { const loadedEdge = second.cores[0].faces[7].end_sec; if (Math.abs(loadedEdge - cutEdge) > 0.002) throw new Error('manual face did not survive'); if (second.project_name !== first.project_name) throw new Error('project name changed'); + if (second.arrangement.length !== 4) throw new Error('arrangement did not survive'); + if (second.active_section !== 1) throw new Error('active section did not survive'); + if (second.arrangement[1].rows[0].velocities[0] !== 0) throw new Error('section B edit did not survive'); + if (JSON.stringify(second.arrangement[2]) !== JSON.stringify(second.arrangement[0])) + throw new Error('copied section did not survive'); if (!second.can_undo) throw new Error('undo history did not survive'); await client.command(26, { type: 'undo' }); const undoneEdge = client.snapshot.cores[0].faces[7].end_sec; @@ -124,6 +132,7 @@ try { step: restored.velocities[0], bpm: second.transport.bpm, cut: { before: beforeCut, after: loadedEdge }, + sections: { count: second.arrangement.length, active: second.active_section, bNotes: second.arrangement[1].rows[0].velocities[0] }, history: { restored: second.can_undo, undone: undoneEdge, redone: redoneEdge }, journal: second.journal_len, db: existsSync(join(project, 'project.db')) diff --git a/ui/package.json b/ui/package.json index 8ac2eee..25379c2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,7 @@ "project-smoke": "node scripts/project-smoke.mjs", "record-smoke": "node scripts/record-smoke.mjs", "faces-smoke": "node scripts/faces-smoke.mjs", + "arrangement-smoke": "node scripts/arrangement-smoke.mjs", "restored-smoke": "node scripts/restored-smoke.mjs" }, "devDependencies": { diff --git a/ui/scripts/arrangement-smoke.mjs b/ui/scripts/arrangement-smoke.mjs new file mode 100644 index 0000000..72133a0 --- /dev/null +++ b/ui/scripts/arrangement-smoke.mjs @@ -0,0 +1,42 @@ +import { chromium } from 'playwright-core'; +const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome'; +const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 }); +const errors = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); }); +await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' }); +await page.waitForTimeout(2200); +await page.locator('.room', { hasText: 'quarry' }).click(); +await page.waitForTimeout(600); +const sectionCount = await page.locator('.section').count(); +const notesBefore = await page.locator('.section-notes').allTextContents(); +await page.locator('.section').nth(1).click(); +await page.waitForTimeout(350); +const active = await page.locator('.section.is-on .section-letter').textContent(); +await page.locator('.pattern .step').first().click(); +await page.waitForTimeout(500); +const notesAfter = await page.locator('.section-notes').allTextContents(); +await page.locator('.room', { hasText: 'nave' }).click(); +await page.waitForTimeout(600); +if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click(); +await page.waitForTimeout(1800); +await page.screenshot({ path: '../preview-p2-arrangement.png' }); +const report = await page.evaluate(() => ({ + sections: document.querySelectorAll('.section').length, + active: document.querySelector('.section.is-on .section-letter')?.textContent, + notes: [...document.querySelectorAll('.section-notes')].map((el) => el.textContent?.trim()), + wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-6), + bar: document.querySelector('.clock-bar')?.textContent ?? document.querySelector('.transport')?.textContent?.slice(0, 40), + master: document.querySelector('.master-db')?.textContent +})); +console.log(JSON.stringify({ sectionCount, notesBefore, active, notesAfter, report, errors }, null, 2)); +const failures = []; +if (sectionCount !== 4) failures.push('expected four sections'); +if (active !== 'B') failures.push('section B was not selected'); +if (notesAfter[0] === notesBefore[0] && notesAfter[1] === notesBefore[1]) failures.push('section notes did not change'); +if (!report.wire.some((label) => label?.startsWith('step'))) failures.push('no step command on the wire'); +if (errors.length) failures.push('console errors'); +await browser.close(); +if (failures.length) { console.error('ARRANGEMENT SMOKE FAILED: ' + failures.join('; ')); process.exit(1); } +console.log('arrangement smoke passed'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 0008db6..cd0f171 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -73,7 +73,7 @@ } if (!snapshotLogged) { snapshotLogged = true; - addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`); + addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.arrangement.length} sections · ${cores.length} cores · rev ${next.revision}`); } } @@ -105,6 +105,10 @@ } else if (event.kind === 'core_faces') { const index = project.cores.findIndex((core) => core.id === event.core.id); if (index >= 0) project.cores[index] = event.core; + } else if (event.kind === 'arrangement') { + project.arrangement = event.sections; + project.active_section = event.active; + project.pattern = event.sections[event.active] ?? project.pattern; } } @@ -200,6 +204,18 @@ void run({ type: 'set_core_faces', core, boundaries }); } + function selectSection(index: number) { + void run({ type: 'select_section', index }); + } + + function clearSection(index: number) { + void run({ type: 'clear_section', index }); + } + + function copySection(from: number, to: number) { + void run({ type: 'copy_section', from, to }); + } + function undo() { if (project?.can_undo) void run({ type: 'undo' }); } @@ -334,6 +350,11 @@ onstrikeface={strikeFace} onstep={toggleStep} onfaces={setCoreFaces} + sections={project?.arrangement ?? []} + activeSection={project?.active_section ?? 0} + onselectsection={selectSection} + onclearsection={clearSection} + oncopysection={copySection} /> {/if} diff --git a/ui/src/app.css b/ui/src/app.css index fac0c5f..bd39f17 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1486,6 +1486,74 @@ button.room { color: var(--ash-2); } +.sections { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px; + margin-top: 12px; + padding: 10px 0 7px; + border-top: 1px solid rgba(155, 133, 121, 0.12); +} + +.sections-label { + margin-right: auto; + font-size: 8px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.section { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 7px 4px; + border: 1px solid rgba(155, 133, 121, 0.18); + background: transparent; + color: var(--ash-2); + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; +} + +.section:hover, +.section.is-on { + color: var(--bone); +} + +.section.is-on { + border-color: var(--ember); + background: rgba(232, 64, 42, 0.05); +} + +.section-letter { + font-family: var(--font-display); + font-size: 15px; + font-style: italic; + line-height: 1; +} + +.section-notes { + font-size: 8px; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.section-action { + padding: 4px 6px; + border-bottom: 1px solid rgba(155, 133, 121, 0.2); + font-size: 8px; + color: var(--ash); +} + +.section-action:hover:not(:disabled) { + border-color: var(--ember); + color: var(--ember); +} + +.section-action:disabled { + opacity: 0.3; + cursor: default; +} + .pattern-head { display: flex; justify-content: space-between; @@ -1497,6 +1565,12 @@ button.room { color: var(--ash-2); } +.sections + .pattern-head { + margin-top: 0; + padding-top: 2px; + border-top: 0; +} + .pattern { display: grid; grid-template-columns: 94px minmax(0, 1fr); diff --git a/ui/src/components/Quarry.svelte b/ui/src/components/Quarry.svelte index 473baf9..4b2172f 100644 --- a/ui/src/components/Quarry.svelte +++ b/ui/src/components/Quarry.svelte @@ -19,7 +19,12 @@ onassign, onstrikeface, onstep, - onfaces + onfaces, + sections, + activeSection, + onselectsection, + onclearsection, + oncopysection }: { cores: CoreSummary[]; pattern: Pattern | null; @@ -35,6 +40,11 @@ onstrikeface: (core: string, face: number) => void; onstep: (row: number, step: number, on: boolean) => void; onfaces: (core: string, boundaries: number[]) => void; + sections: Pattern[]; + activeSection: number; + onselectsection: (index: number) => void; + onclearsection: (index: number) => void; + oncopysection: (from: number, to: number) => void; } = $props(); let fileInput: HTMLInputElement | null = null; @@ -412,8 +422,36 @@ select a face, then lay it on the chosen vein +
+ arrangement · 8 bars + {#each sections as section, index} + + {/each} + + +
- pattern + pattern · section {String.fromCharCode(65 + activeSection)} {pattern?.steps ?? 16} steps · two bars
diff --git a/ui/src/lib/descent.ts b/ui/src/lib/descent.ts index 9747bbc..14f3e82 100644 --- a/ui/src/lib/descent.ts +++ b/ui/src/lib/descent.ts @@ -34,6 +34,7 @@ export class Descent { private phaseStart = 0; private wasPlaying = false; private displayStep = 0; + private arrangementSteps = LOOP_STEPS; private bpm = 128; private raf = 0; private last = 0; @@ -74,6 +75,7 @@ export class Descent { this.bpm = state.transport.bpm; if (!state.transport.playing) this.wasPlaying = false; } + this.arrangementSteps = Math.max(1, state?.arrangement?.length ?? 4) * LOOP_STEPS; this.meter = meter; this.selected = selected; } @@ -338,12 +340,13 @@ export class Descent { this.phaseStart = now; this.wasPlaying = true; } - let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS; - const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % LOOP_STEPS + LOOP_STEPS) % LOOP_STEPS; - let diff = (step - meterStep + LOOP_STEPS * 1.5) % LOOP_STEPS - LOOP_STEPS / 2; + const total = this.arrangementSteps; + let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % total; + const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % total + total) % total; + let diff = (step - meterStep + total * 1.5) % total - total / 2; if (Math.abs(diff) > 2.4) { this.phaseStart += (diff / stepsPerSecond) * 1000; - step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS; + step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % total; } this.displayStep = step; } else { @@ -450,7 +453,7 @@ export class Descent { const { ctx, w } = this; const { cx, bottom, horizon, bhw, thw } = this.road; const levels = this.smooth.veins; - const active = Math.floor(this.displayStep) % LOOP_STEPS; + const active = Math.floor(this.displayStep) % this.arrangementSteps; const roadFill = ctx.createLinearGradient(0, bottom, 0, horizon); roadFill.addColorStop(0, 'rgba(24,10,7,0.66)'); @@ -484,13 +487,16 @@ export class Descent { } ctx.restore(); - for (let step = 0; step <= LOOP_STEPS; step += 1) { - const point = this.roadPoint(step / LOOP_STEPS); + for (let step = 0; step <= this.arrangementSteps; step += 1) { + const point = this.roadPoint(step / this.arrangementSteps); + const sectionLine = step % LOOP_STEPS === 0; const barLine = step % STEPS_PER_BAR === 0; - ctx.strokeStyle = barLine - ? `rgba(190,160,140,${0.18 + master * 0.12})` - : `rgba(155,133,121,${0.07 + master * 0.05})`; - ctx.lineWidth = barLine ? 1 : 0.65; + ctx.strokeStyle = sectionLine + ? `rgba(232,64,42,${0.18 + master * 0.14})` + : barLine + ? `rgba(190,160,140,${0.18 + master * 0.12})` + : `rgba(155,133,121,${0.07 + master * 0.05})`; + ctx.lineWidth = sectionLine ? 1.15 : barLine ? 1 : 0.65; ctx.beginPath(); ctx.moveTo(cx - point.half, point.y); ctx.lineTo(cx + point.half, point.y); @@ -498,7 +504,7 @@ export class Descent { } if (this.displayStep > 0) { - const z = this.displayStep / LOOP_STEPS; + const z = this.displayStep / this.arrangementSteps; const point = this.roadPoint(z); ctx.fillStyle = `rgba(232,64,42,${0.04 + master * 0.08})`; ctx.beginPath(); @@ -541,12 +547,12 @@ export class Descent { } } - const pattern = this.state?.pattern; - if (pattern) { - this.drawPattern(pattern, active, now); + const sections = this.state?.arrangement ?? (this.state?.pattern ? [this.state.pattern] : []); + if (sections.length) { + this.drawArrangement(sections, active, now); } - const play = this.roadPoint(this.displayStep / LOOP_STEPS); + const play = this.roadPoint(this.displayStep / this.arrangementSteps); const playing = this.state?.transport.playing ?? false; const playAlpha = playing ? 0.5 + master * 0.45 : 0.18; ctx.save(); @@ -568,28 +574,44 @@ export class Descent { ctx.lineTo(cx + play.half, play.y + 2); ctx.stroke(); - this.drawBarLabels(ctx, cx, bhw); + this.drawBarLabels(ctx); } - private drawPattern(pattern: Pattern, active: number, now: number): void { + private drawArrangement(sections: Pattern[], active: number, now: number): void { const { ctx } = this; const cx = this.road.cx; + const total = Math.max(1, sections.length) * LOOP_STEPS; const playing = this.state?.transport.playing ?? false; - for (const row of pattern.rows) { - const lane = VEINS.indexOf(row.vein); - if (lane < 0) continue; - for (let step = 0; step < row.velocities.length; step += 1) { - const velocity = row.velocities[step]; - if (!velocity) continue; - const z = (step + 0.5) / LOOP_STEPS; - const point = this.roadPoint(z); - const laneCenter = (lane + 0.5) / 5; - const x = cx + (laneCenter - 0.5) * 2 * point.half; - const isActive = playing && step === active; - const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6); - this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, step); + const activeSection = this.state?.active_section ?? 0; + sections.forEach((pattern, sectionIndex) => { + for (const row of pattern.rows) { + const lane = VEINS.indexOf(row.vein); + if (lane < 0) continue; + for (let step = 0; step < row.velocities.length; step += 1) { + const velocity = row.velocities[step]; + if (!velocity) continue; + const global = sectionIndex * LOOP_STEPS + step; + const z = (global + 0.5) / total; + const point = this.roadPoint(z); + const laneCenter = (lane + 0.5) / 5; + const x = cx + (laneCenter - 0.5) * 2 * point.half; + const isActive = playing && global === active; + const emphasis = sectionIndex === activeSection ? 1.08 : 0.9; + const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6) * emphasis; + this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, global); + } } - } + }); + ctx.save(); + ctx.font = '9px "JetBrains Mono", monospace'; + ctx.textAlign = 'left'; + sections.forEach((_, index) => { + const point = this.roadPoint((index * LOOP_STEPS) / total); + ctx.fillStyle = + index === activeSection ? 'rgba(232,64,42,0.9)' : 'rgba(190,160,140,0.38)'; + ctx.fillText(String.fromCharCode(65 + index), cx + point.half + 8, point.y + 3); + }); + ctx.restore(); } private drawStone(x: number, y: number, size: number, lane: number, velocity: number, active: boolean, z: number, now: number, step: number): void { @@ -639,15 +661,17 @@ export class Descent { ctx.restore(); } - private drawBarLabels(ctx: CanvasRenderingContext2D, cx: number, bhw: number): void { + private drawBarLabels(ctx: CanvasRenderingContext2D): void { ctx.save(); + const cx = this.road.cx; ctx.font = '10px "JetBrains Mono", monospace'; - ctx.fillStyle = 'rgba(190,160,140,0.78)'; + ctx.fillStyle = 'rgba(190,160,140,0.62)'; ctx.textAlign = 'right'; - const first = this.roadPoint(0.0); - ctx.fillText('01', cx - bhw - 10, first.y - 2); - const second = this.roadPoint(0.5); - ctx.fillText('02', cx - second.half - 10, second.y - 2); + const bars = Math.max(1, Math.round(this.arrangementSteps / STEPS_PER_BAR)); + for (let bar = 0; bar < bars; bar += 1) { + const point = this.roadPoint((bar * STEPS_PER_BAR) / this.arrangementSteps); + ctx.fillText(String(bar + 1).padStart(2, '0'), cx - point.half - 9, point.y - 2); + } ctx.restore(); } } diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts index e32ff00..77480b5 100644 --- a/ui/src/lib/format.ts +++ b/ui/src/lib/format.ts @@ -34,6 +34,9 @@ export function commandLabel(command: Command): string { case 'set_pattern_step': return `step ${command.row + 1}:${command.step + 1} at ${command.velocity}`; case 'set_pattern_face': return `${command.core} f${command.face + 1} on row ${command.row + 1}`; case 'set_core_faces': return `cut ${command.core}`; + case 'select_section': return `section ${String.fromCharCode(65 + command.index)}`; + case 'clear_section': return `clear section ${String.fromCharCode(65 + command.index)}`; + case 'copy_section': return `copy ${String.fromCharCode(65 + command.from)} → ${String.fromCharCode(65 + command.to)}`; case 'undo': return 'undo'; case 'redo': return 'redo'; case 'get_snapshot': return 'snapshot'; diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index f6809fd..6b9ab98 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -67,6 +67,8 @@ export interface State { transport: TransportState; veins: VeinState[]; pattern: Pattern; + arrangement: Pattern[]; + active_section: number; cores: CoreSummary[]; audio: AudioInfo; journal_len: number; @@ -81,7 +83,8 @@ export type Event = | { kind: 'pattern_face'; row: number; core: string; face: number } | { kind: 'face_struck'; core: string; face: number; velocity: number } | { kind: 'history'; can_undo: boolean; can_redo: boolean } - | { kind: 'core_faces'; core: CoreSummary }; + | { kind: 'core_faces'; core: CoreSummary } + | { kind: 'arrangement'; sections: Pattern[]; active: number }; export type Command = | { type: 'get_snapshot' } @@ -94,6 +97,9 @@ export type Command = | { type: 'set_pattern_step'; row: number; step: number; velocity: number } | { type: 'set_pattern_face'; row: number; core: string; face: number } | { type: 'set_core_faces'; core: string; boundaries: number[] } + | { type: 'select_section'; index: number } + | { type: 'clear_section'; index: number } + | { type: 'copy_section'; from: number; to: number } | { type: 'undo' } | { type: 'redo' };