P1e: persist undo and redo history across reloads
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.
|
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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 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.
|
- **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
|
./scripts/check.sh # fmt, clippy, cargo tests, svelte-check, build
|
||||||
node scripts/wire-smoke.mjs # daemon running
|
node scripts/wire-smoke.mjs # daemon running
|
||||||
node scripts/material-smoke.mjs # daemon running: import, face, play
|
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 smoke # daemon + Vite: P0 regression, screenshots
|
||||||
npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, 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
|
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`.
|
`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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- No agent/MCP in this phase.
|
||||||
- The Tauri shell now builds and runs; the engine is still the separate daemon (embedding is next).
|
- 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).
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ pub struct ProjectData {
|
|||||||
pub cores: Vec<Core>,
|
pub cores: Vec<Core>,
|
||||||
pub journal: Vec<JournalEntry>,
|
pub journal: Vec<JournalEntry>,
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
|
pub history: Vec<ProjectEdit>,
|
||||||
|
pub redo: Vec<ProjectEdit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -271,8 +273,8 @@ impl Session {
|
|||||||
cores: data.cores,
|
cores: data.cores,
|
||||||
journal: data.journal,
|
journal: data.journal,
|
||||||
project_name: data.name,
|
project_name: data.name,
|
||||||
history: Vec::new(),
|
history: data.history,
|
||||||
redo: Vec::new(),
|
redo: data.redo,
|
||||||
engine,
|
engine,
|
||||||
};
|
};
|
||||||
session.sanitize_faces();
|
session.sanitize_faces();
|
||||||
@@ -296,6 +298,14 @@ impl Session {
|
|||||||
!self.redo.is_empty()
|
!self.redo.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn history(&self) -> &[ProjectEdit] {
|
||||||
|
&self.history
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&self) -> &[ProjectEdit] {
|
||||||
|
&self.redo
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bpm(&self) -> f32 {
|
pub fn bpm(&self) -> f32 {
|
||||||
self.bpm
|
self.bpm
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1237,8 @@ mod tests {
|
|||||||
cores: vec![test_core()],
|
cores: vec![test_core()],
|
||||||
journal: Vec::new(),
|
journal: Vec::new(),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
|
history: Vec::new(),
|
||||||
|
redo: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
engine.clear();
|
engine.clear();
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ async fn save_now(shared: &Shared) {
|
|||||||
pattern: session.pattern(),
|
pattern: session.pattern(),
|
||||||
cores: &state.cores,
|
cores: &state.cores,
|
||||||
journal: session.journal(),
|
journal: session.journal(),
|
||||||
|
history: session.history(),
|
||||||
|
redo: session.redo(),
|
||||||
};
|
};
|
||||||
let result = {
|
let result = {
|
||||||
let mut store = shared.store.lock().expect("project store lock poisoned");
|
let mut store = shared.store.lock().expect("project store lock poisoned");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use zhila_core::{JournalEntry, ProjectData};
|
use zhila_core::{JournalEntry, ProjectData, ProjectEdit};
|
||||||
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
|
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
|
||||||
|
|
||||||
const SCHEMA_VERSION: &str = "1";
|
const SCHEMA_VERSION: &str = "1";
|
||||||
@@ -45,6 +45,8 @@ pub struct ProjectSnapshot<'a> {
|
|||||||
pub pattern: &'a Pattern,
|
pub pattern: &'a Pattern,
|
||||||
pub cores: &'a [CoreSummary],
|
pub cores: &'a [CoreSummary],
|
||||||
pub journal: &'a [JournalEntry],
|
pub journal: &'a [JournalEntry],
|
||||||
|
pub history: &'a [ProjectEdit],
|
||||||
|
pub redo: &'a [ProjectEdit],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectStore {
|
impl ProjectStore {
|
||||||
@@ -68,6 +70,8 @@ impl ProjectStore {
|
|||||||
bpm REAL NOT NULL,
|
bpm REAL NOT NULL,
|
||||||
gains TEXT NOT NULL,
|
gains TEXT NOT NULL,
|
||||||
pattern TEXT NOT NULL,
|
pattern TEXT NOT NULL,
|
||||||
|
history TEXT NOT NULL DEFAULT '[]',
|
||||||
|
redo TEXT NOT NULL DEFAULT '[]',
|
||||||
saved_at INTEGER NOT NULL
|
saved_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS cores (
|
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 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(
|
conn.execute(
|
||||||
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
|
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -108,7 +120,7 @@ impl ProjectStore {
|
|||||||
let row = self
|
let row = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.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| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -117,11 +129,14 @@ impl ProjectStore {
|
|||||||
row.get::<_, f64>(2)?,
|
row.get::<_, f64>(2)?,
|
||||||
row.get::<_, String>(3)?,
|
row.get::<_, String>(3)?,
|
||||||
row.get::<_, String>(4)?,
|
row.get::<_, String>(4)?,
|
||||||
|
row.get::<_, String>(5)?,
|
||||||
|
row.get::<_, String>(6)?,
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()?;
|
.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);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +147,15 @@ impl ProjectStore {
|
|||||||
}
|
}
|
||||||
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
||||||
let mut warnings = Vec::new();
|
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();
|
let mut cores = Vec::new();
|
||||||
{
|
{
|
||||||
@@ -254,6 +278,8 @@ impl ProjectStore {
|
|||||||
cores,
|
cores,
|
||||||
journal,
|
journal,
|
||||||
seq,
|
seq,
|
||||||
|
history,
|
||||||
|
redo,
|
||||||
},
|
},
|
||||||
warnings,
|
warnings,
|
||||||
}))
|
}))
|
||||||
@@ -262,17 +288,21 @@ impl ProjectStore {
|
|||||||
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
|
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
|
||||||
let gains = serde_json::to_string(snapshot.gains)?;
|
let gains = serde_json::to_string(snapshot.gains)?;
|
||||||
let pattern = serde_json::to_string(snapshot.pattern)?;
|
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 saved_at = now_ms();
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO project (id, name, revision, bpm, gains, pattern, saved_at)
|
"INSERT INTO project (id, name, revision, bpm, gains, pattern, history, redo, saved_at)
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
revision = excluded.revision,
|
revision = excluded.revision,
|
||||||
bpm = excluded.bpm,
|
bpm = excluded.bpm,
|
||||||
gains = excluded.gains,
|
gains = excluded.gains,
|
||||||
pattern = excluded.pattern,
|
pattern = excluded.pattern,
|
||||||
|
history = excluded.history,
|
||||||
|
redo = excluded.redo,
|
||||||
saved_at = excluded.saved_at",
|
saved_at = excluded.saved_at",
|
||||||
params![
|
params![
|
||||||
snapshot.name,
|
snapshot.name,
|
||||||
@@ -280,6 +310,8 @@ impl ProjectStore {
|
|||||||
snapshot.bpm as f64,
|
snapshot.bpm as f64,
|
||||||
gains,
|
gains,
|
||||||
pattern,
|
pattern,
|
||||||
|
history,
|
||||||
|
redo,
|
||||||
saved_at as i64
|
saved_at as i64
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -423,7 +455,7 @@ fn now_ms() -> u64 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use zhila_core::Core;
|
use zhila_core::{Core, CoreEdit, ProjectEdit};
|
||||||
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
|
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
|
||||||
|
|
||||||
fn fixture() -> Vec<u8> {
|
fn fixture() -> Vec<u8> {
|
||||||
@@ -484,6 +516,15 @@ mod tests {
|
|||||||
at_ms: 12,
|
at_ms: 12,
|
||||||
command: Command::SetTempo { bpm: 132.0 },
|
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
|
store
|
||||||
.write_asset(&core.summary, "clicks-01.wav", &bytes)
|
.write_asset(&core.summary, "clicks-01.wav", &bytes)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -496,6 +537,8 @@ mod tests {
|
|||||||
pattern: &pattern,
|
pattern: &pattern,
|
||||||
cores: std::slice::from_ref(&core.summary),
|
cores: std::slice::from_ref(&core.summary),
|
||||||
journal: &journal,
|
journal: &journal,
|
||||||
|
history: &history,
|
||||||
|
redo: &[],
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -517,6 +560,9 @@ mod tests {
|
|||||||
core.summary.id
|
core.summary.id
|
||||||
);
|
);
|
||||||
assert_eq!(loaded.data.journal.len(), 1);
|
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_eq!(loaded.data.seq, 1);
|
||||||
assert!(loaded.warnings.is_empty());
|
assert!(loaded.warnings.is_empty());
|
||||||
assert!(store.asset_path("assets/../project.db").is_none());
|
assert!(store.asset_path("assets/../project.db").is_none());
|
||||||
|
|||||||
+24
@@ -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.
|
- 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.
|
- 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).
|
- 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
@@ -1,7 +1,7 @@
|
|||||||
# ZHILA — план проекта
|
# ZHILA — план проекта
|
||||||
|
|
||||||
Черновик v1.1 · 1 ноября 2026
|
Черновик 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.
|
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
|
||||||
|
|
||||||
|
|||||||
@@ -160,3 +160,7 @@ The project is loaded by the daemon from its `--project` folder and saved after
|
|||||||
## P1d additions
|
## 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.
|
`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.
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ try {
|
|||||||
const loadedEdge = second.cores[0].faces[7].end_sec;
|
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 (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.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({
|
console.log(JSON.stringify({
|
||||||
project: second.project_name,
|
project: second.project_name,
|
||||||
path: project,
|
path: project,
|
||||||
@@ -116,6 +124,7 @@ try {
|
|||||||
step: restored.velocities[0],
|
step: restored.velocities[0],
|
||||||
bpm: second.transport.bpm,
|
bpm: second.transport.bpm,
|
||||||
cut: { before: beforeCut, after: loadedEdge },
|
cut: { before: beforeCut, after: loadedEdge },
|
||||||
|
history: { restored: second.can_undo, undone: undoneEdge, redone: redoneEdge },
|
||||||
journal: second.journal_len,
|
journal: second.journal_len,
|
||||||
db: existsSync(join(project, 'project.db'))
|
db: existsSync(join(project, 'project.db'))
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ const report = await page.evaluate(() => ({
|
|||||||
}));
|
}));
|
||||||
await page.screenshot({ path: '../preview-p1b-restored.png' });
|
await page.screenshot({ path: '../preview-p1b-restored.png' });
|
||||||
console.log(JSON.stringify({ report, errors }, null, 2));
|
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();
|
await browser.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user