P1e: persist undo and redo history across reloads

This commit is contained in:
Emil
2026-11-01 01:53:05 +03:00
parent 346035e869
commit 9d84c2f162
9 changed files with 113 additions and 16 deletions
+6 -6
View File
@@ -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).
+14 -2
View File
@@ -141,6 +141,8 @@ pub struct ProjectData {
pub cores: Vec<Core>,
pub journal: Vec<JournalEntry>,
pub seq: u64,
pub history: Vec<ProjectEdit>,
pub redo: Vec<ProjectEdit>,
}
#[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();
+2
View File
@@ -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");
+52 -6
View File
@@ -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<ProjectEdit> =
serde_json::from_str(&history_json).unwrap_or_else(|error| {
warnings.push(format!("history: {error}"));
Vec::new()
});
let redo: Vec<ProjectEdit> = 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<u8> {
@@ -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());
+24
View File
@@ -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.
+1 -1
View File
@@ -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.
+4
View File
@@ -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.
+9
View File
@@ -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));
+1 -1
View File
@@ -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();