P2a: eight-bar arrangement with four editable sections
This commit is contained in:
@@ -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).
|
||||
|
||||

|
||||
|
||||
@@ -16,6 +16,8 @@ This repository is **P1 complete**: the Rust engine, the wire, the descent, the
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
+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,
|
||||
|
||||
+27
@@ -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.
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
@@ -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'))
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
+22
-1
@@ -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}
|
||||
<Wire {entries} {status} onreconnect={reconnect} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
<span class="assign-note">select a face, then lay it on the chosen vein</span>
|
||||
</div>
|
||||
|
||||
<div class="sections" role="group" aria-label="arrangement sections">
|
||||
<span class="sections-label">arrangement · 8 bars</span>
|
||||
{#each sections as section, index}
|
||||
<button
|
||||
class="section"
|
||||
class:is-on={index === activeSection}
|
||||
onclick={() => onselectsection(index)}
|
||||
aria-pressed={index === activeSection}
|
||||
title={`section ${String.fromCharCode(65 + index)}`}
|
||||
>
|
||||
<span class="section-letter">{String.fromCharCode(65 + index)}</span>
|
||||
<span class="section-notes">
|
||||
{section.rows.reduce(
|
||||
(total, row) => total + row.velocities.filter((value) => value > 0).length,
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class="section-action"
|
||||
onclick={() => oncopysection(activeSection, (activeSection + 1) % sections.length)}
|
||||
disabled={sections.length < 2}
|
||||
>copy →</button>
|
||||
<button class="section-action" onclick={() => onclearsection(activeSection)}
|
||||
>clear</button
|
||||
>
|
||||
</div>
|
||||
<div class="pattern-head">
|
||||
<span>pattern</span>
|
||||
<span>pattern · section {String.fromCharCode(65 + activeSection)}</span>
|
||||
<span>{pattern?.steps ?? 16} steps · two bars</span>
|
||||
</div>
|
||||
<div class="pattern">
|
||||
|
||||
+62
-38
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+7
-1
@@ -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' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user