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. 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) ![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. - **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).
+14 -2
View File
@@ -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();
+2
View File
@@ -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");
+52 -6
View File
@@ -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
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. - 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
View File
@@ -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.
+4
View File
@@ -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.
+9
View File
@@ -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));
+1 -1
View File
@@ -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();