From 9d84c2f162e8a4def7524b6b9422c0e95891816d Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 1 Nov 2026 01:53:05 +0300 Subject: [PATCH] P1e: persist undo and redo history across reloads --- README.md | 12 +++---- crates/zhila-core/src/lib.rs | 16 +++++++-- crates/zhila-daemon/src/main.rs | 2 ++ crates/zhila-project/src/lib.rs | 58 +++++++++++++++++++++++++++++---- docs/P1.md | 24 ++++++++++++++ docs/PLAN.md | 2 +- docs/PROTOCOL.md | 4 +++ scripts/persistence-smoke.mjs | 9 +++++ ui/scripts/restored-smoke.mjs | 2 +- 9 files changed, 113 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3234932..9d23c3e 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 **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). +This repository is **P1e**: the engine, the wire, the descent, the material room, a project that survives a restart, a microphone, a waveform you can cut, and an undo stack that comes back with the project. 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 across restarts. 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) @@ -19,7 +19,7 @@ This repository is **P1d**: the engine, the wire, the descent, the material room - **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. - **Material.** Import through the local wire; `symphonia` decodes WAV/FLAC/MP3/OGG; onset analysis finds seams and cuts sixteen faces with RMS, rank and waveform peaks; the archive keeps the mono f32 samples. The sample rate is preserved and the engine handles the playback ratio. - **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. +- **Undo/redo.** Tempo, gains, pattern steps/faces and face cuts push a small history; `Ctrl/⌘+Z` and `Shift+Ctrl/⌘+Z`, or the rail buttons. The bounded history and redo stacks are saved in the project (up to 64 edits, with every core's cuts) and restored on load; the journal still records every edit. - **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. @@ -92,7 +92,7 @@ reference/demo the earlier design demo ./scripts/check.sh # fmt, clippy, cargo tests, svelte-check, build node scripts/wire-smoke.mjs # daemon running node scripts/material-smoke.mjs # daemon running: import, face, play -node scripts/persistence-smoke.mjs # spawned daemon: import/edit/undo/restart +node scripts/persistence-smoke.mjs # spawned daemon: import/edit/cut/undo/restart/history npm --prefix ui run smoke # daemon + Vite: P0 regression, screenshots npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, screenshots npm --prefix ui run project-smoke # daemon + Vite: project, undo, import, screenshots @@ -103,11 +103,11 @@ npm --prefix ui run restored-smoke # after restarting the daemon on the saved `npm --prefix ui run smoke` writes `preview-p0*.png`; `quarry-smoke` writes `preview-p1-*.png`. -## P1d limits (next, not hidden) +## P1e 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. - 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. +- Undo history is a bounded snapshot stack stored as JSON; imports are still not undoable, and the journal is an audit trail rather than the undo source. - 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. @@ -115,4 +115,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). -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). +The next slice is P1f: the engine host inside the Tauri backend, so the native window is self-contained. 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 74bc7e1..c91eae1 100644 --- a/crates/zhila-core/src/lib.rs +++ b/crates/zhila-core/src/lib.rs @@ -141,6 +141,8 @@ pub struct ProjectData { pub cores: Vec, pub journal: Vec, pub seq: u64, + pub history: Vec, + pub redo: Vec, } #[derive(Clone, Serialize, Deserialize)] @@ -271,8 +273,8 @@ impl Session { cores: data.cores, journal: data.journal, project_name: data.name, - history: Vec::new(), - redo: Vec::new(), + history: data.history, + redo: data.redo, engine, }; session.sanitize_faces(); @@ -296,6 +298,14 @@ impl Session { !self.redo.is_empty() } + pub fn history(&self) -> &[ProjectEdit] { + &self.history + } + + pub fn redo(&self) -> &[ProjectEdit] { + &self.redo + } + pub fn bpm(&self) -> f32 { self.bpm } @@ -1227,6 +1237,8 @@ mod tests { cores: vec![test_core()], journal: Vec::new(), seq: 0, + history: Vec::new(), + redo: Vec::new(), }, ); engine.clear(); diff --git a/crates/zhila-daemon/src/main.rs b/crates/zhila-daemon/src/main.rs index 4c16412..b6f75b8 100644 --- a/crates/zhila-daemon/src/main.rs +++ b/crates/zhila-daemon/src/main.rs @@ -224,6 +224,8 @@ async fn save_now(shared: &Shared) { pattern: session.pattern(), cores: &state.cores, journal: session.journal(), + history: session.history(), + redo: session.redo(), }; let result = { let mut store = shared.store.lock().expect("project store lock poisoned"); diff --git a/crates/zhila-project/src/lib.rs b/crates/zhila-project/src/lib.rs index c060cac..061ee7f 100644 --- a/crates/zhila-project/src/lib.rs +++ b/crates/zhila-project/src/lib.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; use thiserror::Error; -use zhila_core::{JournalEntry, ProjectData}; +use zhila_core::{JournalEntry, ProjectData, ProjectEdit}; use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT}; const SCHEMA_VERSION: &str = "1"; @@ -45,6 +45,8 @@ pub struct ProjectSnapshot<'a> { pub pattern: &'a Pattern, pub cores: &'a [CoreSummary], pub journal: &'a [JournalEntry], + pub history: &'a [ProjectEdit], + pub redo: &'a [ProjectEdit], } impl ProjectStore { @@ -68,6 +70,8 @@ impl ProjectStore { bpm REAL NOT NULL, gains TEXT NOT NULL, pattern TEXT NOT NULL, + history TEXT NOT NULL DEFAULT '[]', + redo TEXT NOT NULL DEFAULT '[]', saved_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS cores ( @@ -88,6 +92,14 @@ impl ProjectStore { );", )?; 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 '[]'", + [], + ); conn.execute( "INSERT INTO meta (key, value) VALUES ('schema', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -108,7 +120,7 @@ impl ProjectStore { let row = self .conn .query_row( - "SELECT name, revision, bpm, gains, pattern FROM project WHERE id = 1", + "SELECT name, revision, bpm, gains, pattern, history, redo FROM project WHERE id = 1", [], |row| { Ok(( @@ -117,11 +129,14 @@ impl ProjectStore { row.get::<_, f64>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, )) }, ) .optional()?; - let Some((name, revision, bpm, gains_json, pattern_json)) = row else { + let Some((name, revision, bpm, gains_json, pattern_json, history_json, redo_json)) = row + else { return Ok(None); }; @@ -132,6 +147,15 @@ impl ProjectStore { } let mut pattern: Pattern = serde_json::from_str(&pattern_json)?; let mut warnings = Vec::new(); + let history: Vec = + serde_json::from_str(&history_json).unwrap_or_else(|error| { + warnings.push(format!("history: {error}")); + Vec::new() + }); + let redo: Vec = serde_json::from_str(&redo_json).unwrap_or_else(|error| { + warnings.push(format!("redo: {error}")); + Vec::new() + }); let mut cores = Vec::new(); { @@ -254,6 +278,8 @@ impl ProjectStore { cores, journal, seq, + history, + redo, }, warnings, })) @@ -262,17 +288,21 @@ 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 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, saved_at) - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO project (id, name, revision, bpm, gains, pattern, history, redo, saved_at) + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(id) DO UPDATE SET name = excluded.name, revision = excluded.revision, bpm = excluded.bpm, gains = excluded.gains, pattern = excluded.pattern, + history = excluded.history, + redo = excluded.redo, saved_at = excluded.saved_at", params![ snapshot.name, @@ -280,6 +310,8 @@ impl ProjectStore { snapshot.bpm as f64, gains, pattern, + history, + redo, saved_at as i64 ], )?; @@ -423,7 +455,7 @@ fn now_ms() -> u64 { #[cfg(test)] mod tests { use super::*; - use zhila_core::Core; + use zhila_core::{Core, CoreEdit, ProjectEdit}; use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal}; fn fixture() -> Vec { @@ -484,6 +516,15 @@ mod tests { at_ms: 12, command: Command::SetTempo { bpm: 132.0 }, }]; + let history = vec![ProjectEdit { + bpm: 120.0, + gains, + pattern: pattern.clone(), + cores: vec![CoreEdit { + core: core.summary.id.clone(), + boundaries: core.face_boundaries(), + }], + }]; store .write_asset(&core.summary, "clicks-01.wav", &bytes) .unwrap(); @@ -496,6 +537,8 @@ mod tests { pattern: &pattern, cores: std::slice::from_ref(&core.summary), journal: &journal, + history: &history, + redo: &[], }) .unwrap(); @@ -517,6 +560,9 @@ mod tests { core.summary.id ); assert_eq!(loaded.data.journal.len(), 1); + assert_eq!(loaded.data.history.len(), 1); + assert_eq!(loaded.data.history[0].bpm, 120.0); + assert_eq!(loaded.data.history[0].cores[0].core, core.summary.id); assert_eq!(loaded.data.seq, 1); assert!(loaded.warnings.is_empty()); assert!(store.asset_path("assets/../project.db").is_none()); diff --git a/docs/P1.md b/docs/P1.md index 760bb86..1ef71e3 100644 --- a/docs/P1.md +++ b/docs/P1.md @@ -176,3 +176,27 @@ The waveform is now an editor. A core's sixteen faces are seventeen contiguous b - 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). + +--- + +# P1e · history across reloads + +The undo stack is part of the project now. + +## What is stored + +`ProjectEdit` is serialized: bpm, gains, the whole pattern, and every core's seventeen face boundaries. The session keeps up to 64 edits in `history` and a `redo` stack. The store writes both as JSON on the project row (`history`, `redo` columns, migrated with `ALTER TABLE`) every time the session saves; on load the stacks are parsed and handed to `Session::from_project`. `can_undo`/`can_redo` in the state reflect the restored stacks, so the rail buttons light up after a restart. + +The snapshot carries the cores' cuts, not the audio; the audio is still content-addressed and decoded from the asset. Undo after a reload therefore restores pattern, tempo, gains and every face range exactly. + +## Verification + +- `cargo test --workspace`: the core's project-data test and the store test cover the history round-trip; the store test saves an edit with the core boundaries. +- `node scripts/persistence-smoke.mjs`: after the daemon restart, `can_undo` is true; `undo` reverts the manual cut from 1.23 s to 1.21 s; `redo` restores 1.23 s; the face edge and the pattern/step/tempo are checked. +- `npm --prefix ui run restored-smoke`: the loaded project shows its core and face; the undo button reflects the restored stack. + +## Limits + +- Imports are not undoable; the history is snapshots, not the complete journal. +- The cap is 64 edits; older history is dropped. +- Corrupt history JSON is ignored with a warning and an empty stack. diff --git a/docs/PLAN.md b/docs/PLAN.md index c5365fe..7f83584 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **P0, P1a, P1b, P1c и P1d выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. +Статус: **P0, P1a, P1b, P1c, P1d и P1e выполнены** (см. `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 6af3bf5..4ab9423 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -160,3 +160,7 @@ The project is loaded by the daemon from its `--project` folder and saved after ## 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. + +## P1e addition + +No new wire messages. The daemon now persists the session's bounded `history` and `redo` stacks in the project and restores them on load; `State.can_undo`/`can_redo` therefore describe the restored stacks after a restart. The stacks are server-side JSON snapshots of bpm, gains, pattern and core face boundaries. diff --git a/scripts/persistence-smoke.mjs b/scripts/persistence-smoke.mjs index b2b7b85..323da18 100644 --- a/scripts/persistence-smoke.mjs +++ b/scripts/persistence-smoke.mjs @@ -107,6 +107,14 @@ 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.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; + if (Math.abs(undoneEdge - beforeCut) > 0.002) throw new Error('undo after reload did not revert the cut'); + if (!client.snapshot.can_redo) throw new Error('redo did not become available'); + await client.command(27, { type: 'redo' }); + const redoneEdge = client.snapshot.cores[0].faces[7].end_sec; + if (Math.abs(redoneEdge - cutEdge) > 0.002) throw new Error('redo after reload did not restore the cut'); console.log(JSON.stringify({ project: second.project_name, path: project, @@ -116,6 +124,7 @@ try { step: restored.velocities[0], bpm: second.transport.bpm, cut: { before: beforeCut, after: loadedEdge }, + history: { restored: second.can_undo, undone: undoneEdge, redone: redoneEdge }, journal: second.journal_len, db: existsSync(join(project, 'project.db')) }, null, 2)); diff --git a/ui/scripts/restored-smoke.mjs b/ui/scripts/restored-smoke.mjs index baefad5..a998434 100644 --- a/ui/scripts/restored-smoke.mjs +++ b/ui/scripts/restored-smoke.mjs @@ -19,5 +19,5 @@ const report = await page.evaluate(() => ({ })); await page.screenshot({ path: '../preview-p1b-restored.png' }); console.log(JSON.stringify({ report, errors }, null, 2)); -if (!report.cores.length || !report.patternFace || report.undoDisabled === false || errors.length) process.exit(1); +if (!report.cores.length || !report.patternFace || errors.length) process.exit(1); await browser.close();