diff --git a/README.md b/README.md index faa2ef4..3234932 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 **P1c**: the engine, the wire, the descent, the material room, a project that survives a restart, and a microphone. A found or recorded sound is cut into sixteen faces, laid on a vein and heard in the loop; tempo, gains and pattern edits are autosaved and reversible. 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 **P1d**: the engine, the wire, the descent, the material room, a project that survives a restart, a microphone, and a waveform you can cut. A found or recorded sound is cut into sixteen faces by hand or by analysis, laid on a vein and heard in the loop; tempo, gains, pattern and face edits are autosaved and reversible. 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) @@ -12,6 +12,8 @@ This repository is **P1c**: the engine, the wire, the descent, the material room ![P1c · a recorded take](preview-p1c-record.png) +![P1d · a cut and trimmed core](preview-p1d-faces.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. @@ -19,6 +21,7 @@ This repository is **P1c**: the engine, the wire, the descent, the material room - **Project.** A portable folder: `project.db` (SQLite) plus `assets/`. On daemon start it loads the cores, pattern, tempo, gains and journal; every project edit autosaves. The default is `~/.local/share/zhila/descent-04`; use `--project PATH` for another. The project name is shown in the top rail. - **Undo/redo.** Tempo, gains and pattern steps/faces push a small history; `Ctrl/⌘+Z` and `Shift+Ctrl/⌘+Z`, or the rail buttons. The journal records every edit. History is per session: after a reload the project is restored, the undo stack starts empty. - **Recording.** `record` in the quarry opens the microphone, captures mono PCM through an AudioWorklet, encodes a 16-bit WAV in the surface and sends it through the same import path. The take becomes a core with sixteen faces, seal `recorded`, and is autosaved in the project. Stops at 30 s; no monitoring. +- **Quarry editing.** Drag any of the seventeen seams on the waveform: the inner seams recut the sixteen faces, the outer seams trim the playable range. The core recomputes RMS and rank from the samples; the engine hears the new ranges on the next trigger; the project stores the cuts; the edit is undoable. - **Quarry.** Import, core strip, zoomed waveform with face boundaries, sixteen-face wall, face audition, seal and stats; assign a face to a pattern row; edit the sixteen steps; strike a face; all against the live session. - **Protocol v1.** JSON control frames, a 31-byte binary meter frame, and a binary import path (an import header followed by the audio bytes). Snapshot, results, events, token and origin guard. - **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains, pattern faces and steps); play, stop and strikes are performance, not history. @@ -61,7 +64,7 @@ cd app ./src-tauri/target/release/zhila-app ``` -The built window embeds the surface and connects to the daemon at `ws://127.0.0.1:4317`; start the daemon first. `tauri dev` uses the Vite server at `127.0.0.1:5173`. The engine is still the separate daemon in this phase. Rebuild the native app after any UI change; the binary embeds `ui/dist` at compile time. The recording UI is in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine. +The built window embeds the surface and connects to the daemon at `ws://127.0.0.1:4317`; start the daemon first. `tauri dev` uses the Vite server at `127.0.0.1:5173`. The engine is still the separate daemon in this phase. Rebuild the native app after any UI change; the binary embeds `ui/dist` at compile time. The recording and cutting UI are in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine. Controls: `space` play/stop · `s` strike the selected face in the quarry or the selected vein in the nave · `1`–`5` select a vein (nave) or a pattern row (quarry) · click a face to select, double-click to strike · tempo `−` / `+`. @@ -94,20 +97,22 @@ npm --prefix ui run smoke # daemon + Vite: P0 regression, screenshot npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, screenshots 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 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`. -## P1c limits (next, not hidden) +## P1d limits (next, not hidden) - The project persists, but undo history does not: after a reload the undo stack starts empty. The journal is stored for audit, not reconstructed into undo. - 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. -- No waveform trimming, no manual cut/loop points; the sixteen faces are automatic seams. +- 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. +- The Tauri binary still embeds an older surface and starts no engine; it is a viewer in this phase. - One fixed two-bar phrase and five veins; pattern rows are veins, one face per row, no per-step face or pitch. - The Tauri binary currently embeds an older surface build; rebuild it after UI changes (`npm --prefix ui run build`, then `tauri build`). The engine remains the separate daemon. - Formats depend on symphonia; WAV is the tested path. AIFF, multi-channel files and very long files are not specifically handled. - No agent/MCP in this phase. - The Tauri shell now builds and runs; the engine is still the separate daemon (embedding is next). -The next slice is P1d: waveform trimming and manual faces, reconstructed undo across reloads, and embedding the engine inside the Tauri backend. Then the arrangement and the agent. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md). +The next slice is P1e: undo history across reloads and the engine host inside the Tauri backend. Then the arrangement and the agent. 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 7728cf1..74bc7e1 100644 --- a/crates/zhila-core/src/lib.rs +++ b/crates/zhila-core/src/lib.rs @@ -10,9 +10,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use thiserror::Error; use zhila_protocol::{ - Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FaceRef, LOOP_BARS, LOOP_STEPS, - MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, - TransportState, VEIN_COUNT, VeinId, VeinState, + Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FACE_COUNT, FaceRef, FaceSummary, + LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, + STEPS_PER_BAR, State, TransportState, VEIN_COUNT, VeinId, VeinState, }; pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66]; @@ -36,6 +36,85 @@ pub struct Core { pub faces: Vec, } +impl Core { + pub fn face_boundaries(&self) -> Vec { + summary_boundaries(&self.summary) + } + + /// Replace the sixteen automatic faces with manual, contiguous cuts. The + /// source samples and the waveform peaks are untouched; the RMS and rank + /// are recomputed from the new ranges. + pub fn set_face_boundaries(&mut self, boundaries: &[f32]) -> Result<(), FaceError> { + if boundaries.len() != FACE_COUNT + 1 { + return Err(FaceError::Count); + } + let sample_rate = self.summary.sample_rate as f32; + let total = self.samples.len(); + if total < FACE_COUNT + 1 || !sample_rate.is_finite() || sample_rate <= 0.0 { + return Err(FaceError::Short); + } + let mut indices = [0usize; FACE_COUNT + 1]; + for (index, seconds) in boundaries.iter().enumerate() { + if !seconds.is_finite() || *seconds < 0.0 { + return Err(FaceError::Range); + } + let sample = (*seconds * sample_rate).round(); + if sample > total as f32 { + return Err(FaceError::Range); + } + let sample = sample as usize; + if index > 0 && sample <= indices[index - 1] { + return Err(FaceError::Order); + } + indices[index] = sample; + } + let mut faces = Vec::with_capacity(FACE_COUNT); + let mut summaries = Vec::with_capacity(FACE_COUNT); + for index in 0..FACE_COUNT { + let start = indices[index]; + let end = indices[index + 1]; + if end <= start { + return Err(FaceError::Short); + } + let segment = &self.samples[start..end]; + let rms = (segment.iter().map(|sample| sample * sample).sum::() + / segment.len() as f32) + .sqrt(); + faces.push(AudioFace { + index: index as u8, + start: start as u32, + end: end as u32, + rms, + }); + summaries.push(FaceSummary { + index: index as u8, + rank: 0, + start_sec: start as f32 / sample_rate, + end_sec: end as f32 / sample_rate, + rms, + }); + } + let mut ranked: Vec = (0..summaries.len()).collect(); + ranked.sort_by(|a, b| summaries[*b].rms.total_cmp(&summaries[*a].rms)); + for (rank, index) in ranked.into_iter().enumerate() { + summaries[index].rank = rank as u8; + } + self.faces = faces; + self.summary.faces = summaries; + Ok(()) + } +} + +pub fn summary_boundaries(summary: &CoreSummary) -> Vec { + let mut faces: Vec<&zhila_protocol::FaceSummary> = summary.faces.iter().collect(); + faces.sort_by_key(|face| face.index); + let mut boundaries: Vec = faces.iter().map(|face| face.start_sec).collect(); + if let Some(last) = faces.last() { + boundaries.push(last.end_sec); + } + boundaries +} + #[derive(Clone)] pub struct AudioCore { pub id: String, @@ -64,11 +143,18 @@ pub struct ProjectData { pub seq: u64, } -#[derive(Clone)] -struct ProjectEdit { - bpm: f32, - gains: [f32; VEIN_COUNT], - pattern: Pattern, +#[derive(Clone, Serialize, Deserialize)] +pub struct CoreEdit { + pub core: String, + pub boundaries: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct ProjectEdit { + pub bpm: f32, + pub gains: [f32; VEIN_COUNT], + pub pattern: Pattern, + pub cores: Vec, } const MAX_HISTORY: usize = 64; @@ -101,10 +187,31 @@ pub enum CoreError { InvalidStep, #[error("no such core or face")] UnknownFace, + #[error("face boundaries must be sixteen ordered cuts inside the core")] + InvalidFaces, #[error("this command only exists on the binary import path")] UnsupportedCommand, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FaceError { + Count, + Order, + Range, + Short, +} + +impl std::fmt::Display for FaceError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Count => write!(formatter, "expected {} boundaries", FACE_COUNT + 1), + Self::Order => write!(formatter, "boundaries must be strictly ascending"), + Self::Range => write!(formatter, "boundaries must lie inside the core"), + Self::Short => write!(formatter, "every face needs at least one sample"), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct JournalEntry { pub seq: u64, @@ -218,6 +325,14 @@ impl Session { bpm: self.bpm, gains: self.gains, pattern: self.pattern.clone(), + cores: self + .cores + .iter() + .map(|core| CoreEdit { + core: core.summary.id.clone(), + boundaries: core.face_boundaries(), + }) + .collect(), } } @@ -233,6 +348,15 @@ impl Session { self.bpm = edit.bpm; self.gains = edit.gains; self.pattern = edit.pattern; + for core_edit in &edit.cores { + if let Some(core) = self + .cores + .iter_mut() + .find(|core| core.summary.id == core_edit.core) + { + let _ = core.set_face_boundaries(&core_edit.boundaries); + } + } self.engine.set_tempo(self.bpm); for (index, gain) in self.gains.iter().enumerate() { if let Some(vein) = VeinId::from_index(index) { @@ -423,6 +547,9 @@ impl Session { Command::SetPatternFace { row, core, face } => { self.set_pattern_face(row, &core, face, actor) } + Command::SetCoreFaces { core, boundaries } => { + self.set_core_faces(&core, &boundaries, actor) + } Command::Undo => { let Some(edit) = self.history.pop() else { return Ok(( @@ -494,6 +621,61 @@ impl Session { } /// Called by the daemon after the binary import and the analysis. + pub fn set_core_faces( + &mut self, + core_id: &str, + boundaries: &[f32], + actor: Actor, + ) -> Result<(CommandResult, Vec), CoreError> { + let Some(existing) = self.cores.iter().find(|core| core.summary.id == core_id) else { + return Err(CoreError::UnknownFace); + }; + let current = existing.face_boundaries(); + if current.len() == boundaries.len() + && current + .iter() + .zip(boundaries) + .all(|(left, right)| (left - right).abs() < 0.0001) + { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + let mut preview = existing.clone(); + preview + .set_face_boundaries(boundaries) + .map_err(|_| CoreError::InvalidFaces)?; + self.push_history(); + let summary = { + let core = self + .cores + .iter_mut() + .find(|core| core.summary.id == core_id) + .expect("core was checked"); + core.set_face_boundaries(boundaries) + .map_err(|_| CoreError::InvalidFaces)?; + core.summary.clone() + }; + self.revision += 1; + self.record( + actor, + Command::SetCoreFaces { + core: core_id.to_string(), + boundaries: boundaries.to_vec(), + }, + ); + self.sync_material(); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::CoreFaces { core: summary }], + )) + } + pub fn import_core(&mut self, core: Core, actor: Actor) -> State { let name = core.summary.name.clone(); let seal = core.summary.seal; @@ -1057,6 +1239,55 @@ mod tests { assert!(!state.can_undo); } + #[test] + fn manual_faces_are_validated_and_undoable() { + let (mut session, _) = session(); + session.import_core(test_core(), Actor::Hand); + let before = session.snapshot(); + let original = before.cores[0].faces.clone(); + let original_boundaries: Vec = original + .iter() + .map(|face| face.start_sec) + .chain(std::iter::once(original.last().unwrap().end_sec)) + .collect(); + let mut boundaries = original_boundaries.clone(); + boundaries[8] += 0.01; + + let (result, events) = session + .set_core_faces("core-test", &boundaries, Actor::Hand) + .unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert!((state.cores[0].faces[7].end_sec - boundaries[8]).abs() < 0.001); + assert!(matches!(events.as_slice(), [Event::CoreFaces { .. }])); + assert!(state.can_undo); + + let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + let restored: Vec = state.cores[0] + .faces + .iter() + .map(|face| face.start_sec) + .chain(std::iter::once( + state.cores[0].faces.last().unwrap().end_sec, + )) + .collect(); + assert_eq!(restored.len(), original_boundaries.len()); + for (left, right) in restored.iter().zip(&original_boundaries) { + assert!((left - right).abs() < 1e-6); + } + + let mut bad = boundaries.clone(); + bad[3] = bad[2] - 0.01; + let error = session + .set_core_faces("core-test", &bad, Actor::Hand) + .unwrap_err(); + assert!(matches!(error, CoreError::InvalidFaces)); + } + #[test] fn faces_are_validated_and_can_be_struck() { let (mut session, engine) = session(); diff --git a/crates/zhila-daemon/src/main.rs b/crates/zhila-daemon/src/main.rs index 4c23439..4c16412 100644 --- a/crates/zhila-daemon/src/main.rs +++ b/crates/zhila-daemon/src/main.rs @@ -580,6 +580,7 @@ fn error_code(error: &CoreError) -> ErrorCode { | CoreError::InvalidRow | CoreError::InvalidStep | CoreError::UnknownFace + | CoreError::InvalidFaces | CoreError::UnsupportedCommand => ErrorCode::BadRequest, } } diff --git a/crates/zhila-project/src/lib.rs b/crates/zhila-project/src/lib.rs index 5018126..c060cac 100644 --- a/crates/zhila-project/src/lib.rs +++ b/crates/zhila-project/src/lib.rs @@ -76,6 +76,7 @@ impl ProjectStore { seal TEXT NOT NULL, asset TEXT NOT NULL DEFAULT '', source_name TEXT NOT NULL DEFAULT '', + faces TEXT, imported_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS journal ( @@ -86,6 +87,7 @@ impl ProjectStore { command TEXT NOT NULL );", )?; + let _ = conn.execute("ALTER TABLE cores ADD COLUMN faces TEXT", []); conn.execute( "INSERT INTO meta (key, value) VALUES ('schema', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -134,7 +136,7 @@ impl ProjectStore { let mut cores = Vec::new(); { let mut statement = self.conn.prepare( - "SELECT id, name, seal, asset, source_name FROM cores ORDER BY imported_at", + "SELECT id, name, seal, asset, source_name, faces FROM cores ORDER BY imported_at", )?; let rows = statement.query_map([], |row| { Ok(( @@ -143,10 +145,11 @@ impl ProjectStore { row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, )) })?; for row in rows { - let (id, name, seal_text, asset, source_name) = row?; + let (id, name, seal_text, asset, source_name, faces_json) = row?; if asset.is_empty() { warnings.push(format!("{id}: no asset recorded")); continue; @@ -166,6 +169,16 @@ impl ProjectStore { if core.summary.id != id { warnings.push(format!("{id}: content hash is {}", core.summary.id)); } + if let Some(json) = faces_json { + match serde_json::from_str::>(&json) { + Ok(boundaries) => { + if let Err(error) = core.set_face_boundaries(&boundaries) { + warnings.push(format!("{id}: manual faces: {error}")); + } + } + Err(error) => warnings.push(format!("{id}: faces JSON: {error}")), + } + } cores.push(core); } Err(error) => warnings.push(format!("{id}: {error}")), @@ -271,16 +284,20 @@ impl ProjectStore { ], )?; for core in snapshot.cores { + let boundaries = zhila_core::summary_boundaries(core); + let faces = serde_json::to_string(&boundaries)?; tx.execute( - "INSERT INTO cores (id, name, seal, imported_at) - VALUES (?1, ?2, ?3, ?4) + "INSERT INTO cores (id, name, seal, faces, imported_at) + VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(id) DO UPDATE SET name = excluded.name, - seal = excluded.seal", + seal = excluded.seal, + faces = excluded.faces", params![ core.id, core.name, serde_json::to_string(&core.seal)?, + faces, now_ms() as i64 ], )?; @@ -452,8 +469,12 @@ mod tests { assert!(store.load().unwrap().is_none()); let bytes = fixture(); - let core = + let mut core = zhila_material::import_bytes("clicks-01.wav", Seal::Found, bytes.clone()).unwrap(); + let mut boundaries = core.face_boundaries(); + boundaries[8] += 0.01; + core.set_face_boundaries(&boundaries).unwrap(); + let expected_edge = core.summary.faces[7].end_sec; let pattern = pattern_with_face(&core); let gains = [0.8, 0.7, 0.6, 0.5, 0.4]; let journal = vec![JournalEntry { @@ -488,6 +509,7 @@ mod tests { assert_eq!(loaded.data.cores.len(), 1); 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!( diff --git a/crates/zhila-protocol/src/lib.rs b/crates/zhila-protocol/src/lib.rs index a25e0fe..3e2e2c0 100644 --- a/crates/zhila-protocol/src/lib.rs +++ b/crates/zhila-protocol/src/lib.rs @@ -10,6 +10,7 @@ pub const VEIN_COUNT: usize = 5; pub const LOOP_STEPS: usize = 16; pub const STEPS_PER_BAR: usize = 8; pub const LOOP_BARS: f32 = 2.0; +pub const FACE_COUNT: usize = 16; pub const MIN_BPM: f32 = 40.0; pub const MAX_BPM: f32 = 220.0; pub const MIN_GAIN: f32 = 0.0; @@ -203,6 +204,10 @@ pub enum Command { core: String, face: u8, }, + SetCoreFaces { + core: String, + boundaries: Vec, + }, Undo, Redo, /// Journal-only: a core was imported through the binary path. @@ -255,6 +260,9 @@ pub enum Event { can_undo: bool, can_redo: bool, }, + CoreFaces { + core: CoreSummary, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/docs/P1.md b/docs/P1.md index 2fef78e..760bb86 100644 --- a/docs/P1.md +++ b/docs/P1.md @@ -143,3 +143,36 @@ Screenshot: `preview-p1c-record.png`. - WebKitGTK/Tauri microphone support is untested; the Tauri binary also embeds an older surface build and must be rebuilt to see the record button. - No trim, fade or manual loop points yet; the take is cut by the automatic seams. - The take is stored as the encoded WAV, not the float PCM; trimming will later add derived assets. + +--- + +# P1d · manual faces and trim + +The waveform is now an editor. A core's sixteen faces are seventeen contiguous boundaries; dragging any seam updates the core. + +## Backend + +- `Core::set_face_boundaries(&[f32])` validates seventeen strictly ascending cuts inside the core, recomputes each face's RMS from the source samples and reranks the faces. The source bytes and the waveform peaks are untouched. +- `Core::face_boundaries()` / `summary_boundaries()` expose the cuts. +- `Command::SetCoreFaces { core, boundaries }`, event `CoreFaces { core: CoreSummary }`. The core rejects unordered/out-of-range cuts with `InvalidFaces`; a no-op does not journal. +- Face edits are project edits: they journal, autosave and push the undo snapshot. `ProjectEdit` now snapshots every core's boundaries as well as bpm/gains/pattern, so undo restores cuts and trim. The edit remains valid when the pattern references the same face indices. +- The store gained a `faces` JSON column on `cores` (migrated with `ALTER TABLE`); it saves the boundaries and applies them after re-importing the asset on load. Invalid stored cuts warn and fall back to the analysis. + +## Surface + +- The waveform draws the seventeen seams; the outer seams are the trim, the inner are cuts. Drag with the pointer; the neighboring seams constrain the move (20 ms minimum); on release the command is sent. +- The side facts show the current `trim`; the selected face keeps its shade and label. +- The hint says "drag a seam to cut · outer seams trim". + +## Verification + +- `cargo test --workspace`: 13 core tests (manual faces, undo, invalid cuts), 1 store test with a manual face across save/load. +- `node scripts/persistence-smoke.mjs`: imports, cuts face 8 by 20 ms, restarts the daemon and verifies the loaded face edge and trim. +- `npm --prefix ui run faces-smoke`: drags the left outer seam in Chromium; checks the trim text, the `cut` wire command and the enabled undo; writes `preview-p1d-faces.png`. +- The default project now carries the cut `clicks-01` core. + +## Limits + +- Seventeen contiguous cuts only: no zoom, fade, loop points or per-face gain. +- The trim is the outer boundaries of the sixteen faces; the original asset is not rewritten. +- Cut history survives the session but not yet a reload (the next slice). diff --git a/docs/PLAN.md b/docs/PLAN.md index 0fd53ce..c5365fe 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **P0, P1a, P1b и P1c выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. +Статус: **P0, P1a, P1b, P1c и P1d выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. **Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 4c25654..6af3bf5 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -156,3 +156,7 @@ The project is loaded by the daemon from its `--project` folder and saved after ## P1c addition `Seal` gains `recorded`. Recording introduces no new wire messages: the surface encodes a WAV and uses the existing `import_core` header + binary payload. The recorded seal is persisted with the core row and is visible in the snapshot. + +## P1d additions + +`Command::set_core_faces` takes `{ core, boundaries: [f32; 17] }` (seconds, strictly ascending). The daemon answers with the state and broadcasts `core_faces { core: CoreSummary }`. The core rejects unordered or out-of-range boundaries. Boundaries are persisted in the project's `cores.faces` column and applied after the asset is decoded again on load; the source bytes and peaks stay unchanged. diff --git a/preview-p1d-faces.png b/preview-p1d-faces.png new file mode 100644 index 0000000..94f9fde Binary files /dev/null and b/preview-p1d-faces.png differ diff --git a/scripts/persistence-smoke.mjs b/scripts/persistence-smoke.mjs index 3624d3a..b2b7b85 100644 --- a/scripts/persistence-smoke.mjs +++ b/scripts/persistence-smoke.mjs @@ -84,6 +84,14 @@ 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 }); + 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); + const beforeCut = boundaries[8]; + boundaries[8] += 0.02; + await client.command(25, { type: 'set_core_faces', core: core.id, boundaries }); + const cutEdge = client.snapshot.cores[0].faces[7].end_sec; + if (Math.abs(cutEdge - boundaries[8]) > 0.002) throw new Error('manual face did not apply'); client.ws.close(); await new Promise((resolve) => { child.once('exit', resolve); child.kill('SIGTERM'); }); @@ -96,6 +104,8 @@ try { if (!restored.face || restored.face.face !== assigned.face) throw new Error('pattern face did not survive'); if (restored.velocities[0] !== 96) throw new Error('step did not survive'); if (second.transport.bpm !== 132) throw new Error('tempo did not survive'); + 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'); console.log(JSON.stringify({ project: second.project_name, @@ -105,6 +115,7 @@ try { face: restored.face, step: restored.velocities[0], bpm: second.transport.bpm, + cut: { before: beforeCut, after: loadedEdge }, journal: second.journal_len, db: existsSync(join(project, 'project.db')) }, null, 2)); diff --git a/ui/package.json b/ui/package.json index a59caba..8ac2eee 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "quarry-smoke": "node scripts/quarry-smoke.mjs", "project-smoke": "node scripts/project-smoke.mjs", "record-smoke": "node scripts/record-smoke.mjs", + "faces-smoke": "node scripts/faces-smoke.mjs", "restored-smoke": "node scripts/restored-smoke.mjs" }, "devDependencies": { diff --git a/ui/scripts/faces-smoke.mjs b/ui/scripts/faces-smoke.mjs new file mode 100644 index 0000000..6a72eef --- /dev/null +++ b/ui/scripts/faces-smoke.mjs @@ -0,0 +1,47 @@ +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'] }); +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); +await page.locator('.core-tab').first().click(); +await page.waitForTimeout(400); + +const stats = page.locator('.face-stats'); +const before = (await stats.textContent())?.replace(/\s+/g, ' ').trim(); +const canvas = page.locator('canvas.wave'); +const box = await canvas.boundingBox(); +if (!box) throw new Error('no waveform'); +await page.mouse.move(box.x + 3, box.y + 60); +await page.mouse.down(); +await page.mouse.move(box.x + 70, box.y + 60, { steps: 10 }); +await page.mouse.up(); +await page.waitForTimeout(900); +const after = (await stats.textContent())?.replace(/\s+/g, ' ').trim(); +await page.screenshot({ path: '../preview-p1d-faces.png' }); +const report = await page.evaluate(() => ({ + trim: document.querySelector('.face-stats')?.textContent?.replace(/\s+/g, ' ').trim(), + wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-5), + undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'), + face: document.querySelector('.face.is-on .face-index')?.textContent, + selected: document.querySelector('.core-tab.is-on .core-name')?.textContent +})); +console.log(JSON.stringify({ before, after, report, errors }, null, 2)); +const failures = []; +if (!before || !after || before === after) failures.push('trim did not change'); +if (!report.wire.some((label) => label?.startsWith('cut'))) failures.push('no cut command on the wire'); +if (report.undoDisabled) failures.push('undo did not enable after the cut'); +if (errors.length) failures.push('console errors'); +await browser.close(); +if (failures.length) { + console.error('FACES SMOKE FAILED: ' + failures.join('; ')); + process.exit(1); +} +console.log('faces smoke passed'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 2fd9a59..0008db6 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -102,6 +102,9 @@ } else if (event.kind === 'history') { project.can_undo = event.can_undo; project.can_redo = event.can_redo; + } 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; } } @@ -193,6 +196,10 @@ void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 }); } + function setCoreFaces(core: string, boundaries: number[]) { + void run({ type: 'set_core_faces', core, boundaries }); + } + function undo() { if (project?.can_undo) void run({ type: 'undo' }); } @@ -326,6 +333,7 @@ onassign={assignFace} onstrikeface={strikeFace} onstep={toggleStep} + onfaces={setCoreFaces} /> {/if} diff --git a/ui/src/app.css b/ui/src/app.css index 372743c..fac0c5f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1367,17 +1367,26 @@ button.room { margin-top: 7px; border-top: 1px solid rgba(155, 133, 121, 0.12); border-bottom: 1px solid rgba(155, 133, 121, 0.12); + cursor: ew-resize; + touch-action: none; } .wave-axis { display: flex; justify-content: space-between; - padding: 4px 0 9px; + padding: 4px 0 2px; font-size: 8px; color: var(--ash-2); font-variant-numeric: tabular-nums; } +.wave-hint { + padding: 2px 0 8px; + font-size: 8px; + letter-spacing: 0.05em; + color: var(--ash-2); +} + .face-wall { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); diff --git a/ui/src/components/Quarry.svelte b/ui/src/components/Quarry.svelte index 9e2aba3..473baf9 100644 --- a/ui/src/components/Quarry.svelte +++ b/ui/src/components/Quarry.svelte @@ -18,7 +18,8 @@ onimport, onassign, onstrikeface, - onstep + onstep, + onfaces }: { cores: CoreSummary[]; pattern: Pattern | null; @@ -33,6 +34,7 @@ onassign: () => void; onstrikeface: (core: string, face: number) => void; onstep: (row: number, step: number, on: boolean) => void; + onfaces: (core: string, boundaries: number[]) => void; } = $props(); let fileInput: HTMLInputElement | null = null; @@ -42,6 +44,8 @@ let recordError = $state(null); let finishing = $state(false); let takeIndex = $state(1); + let draft = $state(null); + let dragging: { index: number; pointer: number } | null = null; let observer: ResizeObserver | null = null; const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null); @@ -50,6 +54,8 @@ core?.faces.find((item) => item.rank === 0) ?? null ); + const boundaries = $derived(core ? faceBoundaries(core) : []); + const trim = $derived(draft ?? boundaries); onMount(() => { if (!wave) return; @@ -62,6 +68,7 @@ $effect(() => { core; face; + draft; draw(); }); @@ -82,20 +89,24 @@ const center = height / 2; const duration = Math.max(0.001, core.duration_sec); - for (const item of core.faces) { - const start = (item.start_sec / duration) * width; - const end = (item.end_sec / duration) * width; - if (face && item.index === face.index) { - context.fillStyle = 'rgba(232,64,42,0.08)'; - context.fillRect(start, 0, end - start, height); - } - context.strokeStyle = - item.index % 4 === 0 ? 'rgba(232,64,42,0.34)' : 'rgba(155,133,121,0.14)'; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(start + 0.5, 0); - context.lineTo(start + 0.5, height); - context.stroke(); + const limits = draft ?? boundaries; + const selected = face ? face.index : -1; + + if (limits.length) { + context.fillStyle = 'rgba(12,9,8,0.55)'; + const left = (limits[0] / duration) * width; + const right = (limits[limits.length - 1] / duration) * width; + if (left > 0) context.fillRect(0, 0, left, height); + if (right < width) context.fillRect(right, 0, width - right, height); + } + if (selected >= 0 && limits[selected] !== undefined && limits[selected + 1] !== undefined) { + context.fillStyle = 'rgba(232,64,42,0.08)'; + context.fillRect( + (limits[selected] / duration) * width, + 0, + ((limits[selected + 1] - limits[selected]) / duration) * width, + height + ); } const step = width / Math.max(1, core.peaks.length - 1); @@ -110,9 +121,24 @@ context.lineTo(width, center + 0.5); context.stroke(); - if (face) { - const start = (face.start_sec / duration) * width; - const end = (face.end_sec / duration) * width; + for (let index = 0; index < limits.length; index += 1) { + const x = (limits[index] / duration) * width; + const outer = index === 0 || index === limits.length - 1; + context.strokeStyle = outer + ? 'rgba(232,64,42,0.5)' + : index % 4 === 0 + ? 'rgba(232,64,42,0.28)' + : 'rgba(155,133,121,0.16)'; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(x + 0.5, 0); + context.lineTo(x + 0.5, height); + context.stroke(); + } + + if (face && limits[face.index] !== undefined) { + const start = (limits[face.index] / duration) * width; + const end = (limits[face.index + 1] / duration) * width; context.strokeStyle = 'rgba(239,228,212,0.72)'; context.lineWidth = 1; context.strokeRect(start + 0.5, 0.5, Math.max(1, end - start - 1), height - 1); @@ -131,6 +157,77 @@ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; } + function faceBoundaries(item: CoreSummary): number[] { + const faces = [...item.faces].sort((left, right) => left.index - right.index); + if (!faces.length) return []; + return [...faces.map((entry) => entry.start_sec), faces[faces.length - 1].end_sec]; + } + + function canvasPoint(event: PointerEvent): number { + const canvas = wave; + if (!canvas) return 0; + return event.clientX - canvas.getBoundingClientRect().left; + } + + function nearestBoundary(x: number): number | null { + const canvas = wave; + if (!canvas || !core) return null; + const width = Math.max(1, canvas.clientWidth); + const duration = Math.max(0.001, core.duration_sec); + let best: number | null = null; + let distance = 9; + for (let index = 0; index < boundaries.length; index += 1) { + const candidate = Math.abs((boundaries[index] / duration) * width - x); + if (candidate < distance) { + best = index; + distance = candidate; + } + } + return best; + } + + function onBoundaryDown(event: PointerEvent) { + if (!core || !wave) return; + const index = nearestBoundary(canvasPoint(event)); + if (index === null) return; + event.preventDefault(); + wave.setPointerCapture(event.pointerId); + dragging = { index, pointer: event.pointerId }; + draft = [...boundaries]; + } + + function onBoundaryMove(event: PointerEvent) { + const current = dragging; + if (!current || !core || !wave || event.pointerId !== current.pointer) return; + const width = Math.max(1, wave.clientWidth); + const duration = Math.max(0.001, core.duration_sec); + const x = Math.max(0, Math.min(width, canvasPoint(event))); + const seconds = (x / width) * duration; + const limits = draft ?? boundaries; + const min = Math.min(0.02, duration / 64); + const lower = current.index === 0 ? 0 : limits[current.index - 1] + min; + const upper = + current.index === limits.length - 1 ? duration : limits[current.index + 1] - min; + const value = Math.max(lower, Math.min(upper, seconds)); + draft = limits.map((entry, index) => (index === current.index ? value : entry)); + } + + function onBoundaryUp(event: PointerEvent) { + if (!dragging) return; + const limits = draft; + if (wave?.hasPointerCapture(event.pointerId)) wave.releasePointerCapture(event.pointerId); + dragging = null; + draft = null; + if ( + limits && + core && + limits.length === boundaries.length && + limits.some((entry, index) => Math.abs(entry - boundaries[index]) > 0.0005) + ) { + onfaces(core.id, limits); + } + } + async function toggleRecording() { if (finishing) return; if (!recording) { @@ -271,11 +368,20 @@ - +
0.0 s {core.duration_sec.toFixed(1)} s
+
drag a seam to cut · outer seams trim
{#each core.faces as item} @@ -349,6 +455,10 @@ rank 01.

+
+ trim + {trim.length ? `${trim[0].toFixed(2)}–${trim[trim.length - 1].toFixed(2)} s` : '—'} +
rank{face ? String(face.rank + 1).padStart(2, '0') : '—'}
rms{face ? face.rms.toFixed(3) : '—'}
length{face ? `${(face.end_sec - face.start_sec).toFixed(2)} s` : '—'}
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts index 08628a7..e32ff00 100644 --- a/ui/src/lib/format.ts +++ b/ui/src/lib/format.ts @@ -33,6 +33,7 @@ export function commandLabel(command: Command): string { case 'strike_face': return `strike ${command.core} f${command.face + 1}`; 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 '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 b939baa..f6809fd 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -80,7 +80,8 @@ export type Event = | { kind: 'pattern_step'; row: number; step: number; velocity: number } | { 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: 'history'; can_undo: boolean; can_redo: boolean } + | { kind: 'core_faces'; core: CoreSummary }; export type Command = | { type: 'get_snapshot' } @@ -92,6 +93,7 @@ export type Command = | { type: 'strike_face'; core: string; face: number; velocity: number } | { 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: 'undo' } | { type: 'redo' };