P1b: SQLite project, content-addressed assets, autosave and undo
This commit is contained in:
Generated
+133
@@ -394,12 +394,36 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -460,6 +484,18 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -601,6 +637,23 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
@@ -906,12 +959,36 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtrb"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5974e397833d890f2f74963408e0d4a6cd8eda2f6a445ae06750291b5d7a997e"
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.41.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -921,6 +998,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1065,6 +1155,16 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"rsqlite-vfs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -1226,6 +1326,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1445,6 +1558,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -1676,6 +1795,7 @@ dependencies = [
|
||||
"zhila-core",
|
||||
"zhila-engine",
|
||||
"zhila-material",
|
||||
"zhila-project",
|
||||
"zhila-protocol",
|
||||
]
|
||||
|
||||
@@ -1702,6 +1822,19 @@ dependencies = [
|
||||
"zhila-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zhila-project"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"zhila-core",
|
||||
"zhila-material",
|
||||
"zhila-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zhila-protocol"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"crates/zhila-engine",
|
||||
"crates/zhila-daemon",
|
||||
"crates/zhila-material",
|
||||
"crates/zhila-project",
|
||||
]
|
||||
exclude = ["app"]
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
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 **P1a**: the engine, the wire, the descent, and the material room. A found sound can be imported, decoded and cut into sixteen faces, struck on its own, or laid on a vein and heard in the loop. 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 **P1b**: the engine, the wire, the descent, the material room, and a project that survives a restart. A found sound can be imported, 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).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -54,7 +58,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.
|
||||
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.
|
||||
|
||||
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 `−` / `+`.
|
||||
|
||||
@@ -67,7 +71,8 @@ crates/zhila-protocol wire types, binary meter frame, cores/faces, tests
|
||||
crates/zhila-core session, journal, material snapshot, engine trait
|
||||
crates/zhila-engine cpal stream, procedural + sample voices, levels
|
||||
crates/zhila-material decode, onset analysis, sixteen faces, peaks
|
||||
crates/zhila-daemon WebSocket daemon, auth, binary import
|
||||
crates/zhila-project SQLite project, assets, load/save, undo persistence
|
||||
crates/zhila-daemon WebSocket daemon, auth, binary import, autosave
|
||||
ui Svelte 5 surface: descent + quarry
|
||||
fixtures a small click track for the smoke tests
|
||||
app Tauri 2 shell (built; engine remains the daemon)
|
||||
@@ -81,20 +86,23 @@ 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
|
||||
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
|
||||
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`.
|
||||
|
||||
## P1a limits (next, not hidden)
|
||||
## P1b limits (next, not hidden)
|
||||
|
||||
- The archive is in memory. There is no save/load yet; a daemon restart loses imported cores and pattern edits. Persistence is the next slice.
|
||||
- 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.
|
||||
- No recording, no waveform trimming, no manual cut/loop points; the sixteen faces are automatic seams.
|
||||
- No undo/redo for pattern edits yet; the journal records them, the surface does not expose history.
|
||||
- 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 P1b: a project file and SQLite journal, an asset archive, autosave and reload, undo for pattern edits, recording, and waveform trimming. Then the arrangement and the agent. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the P1a notes are in [`docs/P1.md`](docs/P1.md).
|
||||
The next slice is P1c: recording, waveform trimming, 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).
|
||||
|
||||
@@ -51,6 +51,28 @@ pub struct MaterialSnapshot {
|
||||
pub cores: Vec<AudioCore>,
|
||||
}
|
||||
|
||||
/// A complete project as loaded from the store.
|
||||
#[derive(Clone)]
|
||||
pub struct ProjectData {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: [f32; VEIN_COUNT],
|
||||
pub pattern: Pattern,
|
||||
pub cores: Vec<Core>,
|
||||
pub journal: Vec<JournalEntry>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProjectEdit {
|
||||
bpm: f32,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
pattern: Pattern,
|
||||
}
|
||||
|
||||
const MAX_HISTORY: usize = 64;
|
||||
|
||||
/// The audio engine as seen by the session. Implementations must be real-time
|
||||
/// safe on the audio thread and non-blocking on these methods.
|
||||
pub trait Engine: Send + Sync {
|
||||
@@ -101,6 +123,9 @@ pub struct Session {
|
||||
pattern: Pattern,
|
||||
cores: Vec<Core>,
|
||||
journal: Vec<JournalEntry>,
|
||||
project_name: String,
|
||||
history: Vec<ProjectEdit>,
|
||||
redo: Vec<ProjectEdit>,
|
||||
engine: Arc<dyn Engine>,
|
||||
}
|
||||
|
||||
@@ -119,12 +144,104 @@ impl Session {
|
||||
pattern,
|
||||
cores: Vec::new(),
|
||||
journal: Vec::new(),
|
||||
project_name: "descent-04".into(),
|
||||
history: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
engine,
|
||||
};
|
||||
session.sync_material();
|
||||
session
|
||||
}
|
||||
|
||||
pub fn from_project(engine: Arc<dyn Engine>, data: ProjectData) -> Self {
|
||||
let mut session = Self {
|
||||
revision: data.revision,
|
||||
seq: data.seq,
|
||||
bpm: data.bpm,
|
||||
playing: false,
|
||||
gains: data.gains,
|
||||
pattern: data.pattern,
|
||||
cores: data.cores,
|
||||
journal: data.journal,
|
||||
project_name: data.name,
|
||||
history: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
engine,
|
||||
};
|
||||
session.sanitize_faces();
|
||||
session.sync_material();
|
||||
session
|
||||
}
|
||||
|
||||
pub fn project_name(&self) -> &str {
|
||||
&self.project_name
|
||||
}
|
||||
|
||||
pub fn set_project_name(&mut self, name: String) {
|
||||
self.project_name = name;
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.history.is_empty()
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
pub fn bpm(&self) -> f32 {
|
||||
self.bpm
|
||||
}
|
||||
|
||||
pub fn gains(&self) -> [f32; VEIN_COUNT] {
|
||||
self.gains
|
||||
}
|
||||
|
||||
fn sanitize_faces(&mut self) {
|
||||
for row in &mut self.pattern.rows {
|
||||
let Some(face) = &row.face else { continue };
|
||||
let exists = self.cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
&& core
|
||||
.faces
|
||||
.iter()
|
||||
.any(|candidate| candidate.index == face.face)
|
||||
});
|
||||
if !exists {
|
||||
row.face = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_snapshot(&self) -> ProjectEdit {
|
||||
ProjectEdit {
|
||||
bpm: self.bpm,
|
||||
gains: self.gains,
|
||||
pattern: self.pattern.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_history(&mut self) {
|
||||
if self.history.len() >= MAX_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history.push(self.edit_snapshot());
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
fn apply_edit(&mut self, edit: ProjectEdit) {
|
||||
self.bpm = edit.bpm;
|
||||
self.gains = edit.gains;
|
||||
self.pattern = edit.pattern;
|
||||
self.engine.set_tempo(self.bpm);
|
||||
for (index, gain) in self.gains.iter().enumerate() {
|
||||
if let Some(vein) = VeinId::from_index(index) {
|
||||
self.engine.set_gain(vein, *gain);
|
||||
}
|
||||
}
|
||||
self.sync_material();
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
@@ -141,6 +258,9 @@ impl Session {
|
||||
let meter = self.engine.meter();
|
||||
State {
|
||||
revision: self.revision,
|
||||
project_name: self.project_name.clone(),
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
transport: TransportState {
|
||||
playing: self.playing,
|
||||
bpm: self.bpm,
|
||||
@@ -237,6 +357,7 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
self.bpm = bpm;
|
||||
self.engine.set_tempo(bpm);
|
||||
self.revision += 1;
|
||||
@@ -261,6 +382,7 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
self.gains[index] = gain;
|
||||
self.engine.set_gain(vein, gain);
|
||||
self.revision += 1;
|
||||
@@ -301,6 +423,52 @@ impl Session {
|
||||
Command::SetPatternFace { row, core, face } => {
|
||||
self.set_pattern_face(row, &core, face, actor)
|
||||
}
|
||||
Command::Undo => {
|
||||
let Some(edit) = self.history.pop() else {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
};
|
||||
self.redo.push(self.edit_snapshot());
|
||||
self.apply_edit(edit);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::Undo);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::History {
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
Command::Redo => {
|
||||
let Some(edit) = self.redo.pop() else {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
};
|
||||
self.history.push(self.edit_snapshot());
|
||||
self.apply_edit(edit);
|
||||
self.revision += 1;
|
||||
self.record(actor, Command::Redo);
|
||||
Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
vec![Event::History {
|
||||
can_undo: self.can_undo(),
|
||||
can_redo: self.can_redo(),
|
||||
}],
|
||||
))
|
||||
}
|
||||
Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand),
|
||||
}
|
||||
}
|
||||
@@ -350,10 +518,12 @@ impl Session {
|
||||
return Err(CoreError::InvalidStep);
|
||||
}
|
||||
let velocity = velocity.min(127);
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.velocities[step as usize] == velocity {
|
||||
let unchanged = self
|
||||
.pattern
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.is_some_and(|row| row.velocities[step as usize] == velocity);
|
||||
if unchanged {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
@@ -361,6 +531,10 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.velocities[step as usize] = velocity;
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
@@ -399,10 +573,13 @@ impl Session {
|
||||
core: core.to_string(),
|
||||
face,
|
||||
};
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
if row_ref.face.as_ref() == Some(&reference) {
|
||||
let unchanged = self
|
||||
.pattern
|
||||
.rows
|
||||
.get(row as usize)
|
||||
.and_then(|row| row.face.as_ref())
|
||||
.is_some_and(|face| face == &reference);
|
||||
if unchanged {
|
||||
return Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
@@ -410,6 +587,10 @@ impl Session {
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
self.push_history();
|
||||
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
|
||||
return Err(CoreError::InvalidRow);
|
||||
};
|
||||
row_ref.face = Some(reference);
|
||||
self.revision += 1;
|
||||
self.record(
|
||||
@@ -810,6 +991,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_project_edits() {
|
||||
let (mut session, _) = session();
|
||||
session
|
||||
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
|
||||
.unwrap();
|
||||
session.set_pattern_step(0, 0, 0, Actor::Hand).unwrap();
|
||||
assert!(session.can_undo());
|
||||
assert!(!session.can_redo());
|
||||
|
||||
let (result, events) = session.apply(Command::Undo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.pattern.rows[0].velocities[0], 112);
|
||||
assert_eq!(state.transport.bpm, 132.0);
|
||||
assert!(state.can_redo);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::History {
|
||||
can_undo: true,
|
||||
can_redo: true
|
||||
}]
|
||||
);
|
||||
|
||||
let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.transport.bpm, 128.0);
|
||||
assert!(!state.can_undo);
|
||||
|
||||
let (result, _) = session.apply(Command::Redo, Actor::Hand).unwrap();
|
||||
let CommandResult::State { state } = result else {
|
||||
panic!("expected state");
|
||||
};
|
||||
assert_eq!(state.transport.bpm, 132.0);
|
||||
assert!(session.journal().len() >= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_data_loads_cores_pattern_and_name() {
|
||||
let engine = Arc::new(TestEngine::new());
|
||||
let session = Session::from_project(
|
||||
engine.clone(),
|
||||
ProjectData {
|
||||
name: "loaded project".into(),
|
||||
revision: 7,
|
||||
bpm: 120.0,
|
||||
gains: [0.5; VEIN_COUNT],
|
||||
pattern: default_pattern(),
|
||||
cores: vec![test_core()],
|
||||
journal: Vec::new(),
|
||||
seq: 0,
|
||||
},
|
||||
);
|
||||
engine.clear();
|
||||
let state = session.snapshot();
|
||||
assert_eq!(state.project_name, "loaded project");
|
||||
assert_eq!(state.transport.bpm, 120.0);
|
||||
assert_eq!(state.veins[0].gain, 0.5);
|
||||
assert_eq!(state.cores.len(), 1);
|
||||
assert_eq!(state.journal_len, 0);
|
||||
assert!(!state.can_undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_are_validated_and_can_be_struck() {
|
||||
let (mut session, engine) = session();
|
||||
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-engine = { path = "../zhila-engine" }
|
||||
zhila-material = { path = "../zhila-material" }
|
||||
zhila-project = { path = "../zhila-project" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||
//! binds to loopback only and requires a token in the hello.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -17,10 +18,11 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session};
|
||||
use zhila_core::{CoreError, Engine, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_project::{ProjectSnapshot, ProjectStore};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -44,6 +46,9 @@ struct Args {
|
||||
/// Run without an output device; useful for UI tests.
|
||||
#[arg(long)]
|
||||
no_audio: bool,
|
||||
/// Project directory. Defaults to ~/.local/share/zhila/descent-04.
|
||||
#[arg(long)]
|
||||
project: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -55,6 +60,7 @@ enum Outbound {
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
session: Arc<Mutex<Session>>,
|
||||
store: Arc<std::sync::Mutex<ProjectStore>>,
|
||||
out: broadcast::Sender<Outbound>,
|
||||
token: Arc<String>,
|
||||
session_id: Arc<String>,
|
||||
@@ -72,7 +78,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token);
|
||||
|
||||
let pattern = zhila_core::default_pattern();
|
||||
let pattern = default_pattern();
|
||||
let audio_holder = if args.no_audio {
|
||||
None
|
||||
} else {
|
||||
@@ -90,15 +96,43 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
let shutdown = control.clone();
|
||||
let info = control.info();
|
||||
let session = Arc::new(Mutex::new(Session::with_pattern(control.clone(), pattern)));
|
||||
|
||||
let project_path = args.project.clone().unwrap_or_else(default_project_dir);
|
||||
let store = ProjectStore::open(&project_path).context("could not open the project")?;
|
||||
let loaded = store.load().context("could not load the project")?;
|
||||
let (session, project_name) = match loaded {
|
||||
Some(loaded) => {
|
||||
for warning in &loaded.warnings {
|
||||
warn!("project: {warning}");
|
||||
}
|
||||
let name = loaded.data.name.clone();
|
||||
info!(project = %name, path = %project_path.display(), "project loaded");
|
||||
(Session::from_project(control.clone(), loaded.data), name)
|
||||
}
|
||||
None => {
|
||||
let name = project_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("descent-04")
|
||||
.to_string();
|
||||
let mut session = Session::with_pattern(control.clone(), pattern.clone());
|
||||
session.set_project_name(name.clone());
|
||||
info!(project = %name, path = %project_path.display(), "new project");
|
||||
(session, name)
|
||||
}
|
||||
};
|
||||
let session = Arc::new(Mutex::new(session));
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||
let session_id = format!("zhila-{:x}", now_ms());
|
||||
let shared = Shared {
|
||||
session,
|
||||
store,
|
||||
out: out.clone(),
|
||||
token: Arc::new(token.clone()),
|
||||
session_id: Arc::new(session_id.clone()),
|
||||
};
|
||||
save_now(&shared).await;
|
||||
|
||||
let meter_engine = control.clone();
|
||||
let meter_out = out.clone();
|
||||
@@ -115,7 +149,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?;
|
||||
|
||||
print_banner(args.port, &token, &info, &session_id);
|
||||
print_banner(
|
||||
args.port,
|
||||
&token,
|
||||
&info,
|
||||
&session_id,
|
||||
&project_name,
|
||||
&project_path,
|
||||
);
|
||||
if token == "dev" {
|
||||
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||
}
|
||||
@@ -142,10 +183,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, session_id: &str) {
|
||||
fn print_banner(
|
||||
port: u16,
|
||||
token: &str,
|
||||
info: &zhila_protocol::AudioInfo,
|
||||
session_id: &str,
|
||||
project_name: &str,
|
||||
project_path: &std::path::Path,
|
||||
) {
|
||||
println!();
|
||||
println!(" ZHILA · P0 engine");
|
||||
println!(" ZHILA engine");
|
||||
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||
println!(" project {project_name} · {}", project_path.display());
|
||||
if info.running {
|
||||
let buffer = if info.buffer_frames == 0 {
|
||||
"default".to_string()
|
||||
@@ -163,6 +212,37 @@ fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, sessio
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn save_now(shared: &Shared) {
|
||||
let session = shared.session.lock().await;
|
||||
let state = session.snapshot();
|
||||
let gains = session.gains();
|
||||
let snapshot = ProjectSnapshot {
|
||||
name: session.project_name(),
|
||||
revision: state.revision,
|
||||
bpm: session.bpm(),
|
||||
gains: &gains,
|
||||
pattern: session.pattern(),
|
||||
cores: &state.cores,
|
||||
journal: session.journal(),
|
||||
};
|
||||
let result = {
|
||||
let mut store = shared.store.lock().expect("project store lock poisoned");
|
||||
store.save(&snapshot)
|
||||
};
|
||||
drop(session);
|
||||
if let Err(error) = result {
|
||||
warn!("project save failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_project_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("zhila").join("descent-04")
|
||||
}
|
||||
|
||||
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
let ws = accept_hdr_async(stream, handshake)
|
||||
@@ -316,17 +396,35 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let source_name = name.clone();
|
||||
let audio = bytes.to_vec();
|
||||
let asset_audio = audio.clone();
|
||||
let import = tokio::task::spawn_blocking(move || {
|
||||
zhila_material::import_bytes(&name, seal, bytes.to_vec())
|
||||
zhila_material::import_bytes(&name, seal, audio)
|
||||
})
|
||||
.await;
|
||||
match import {
|
||||
Ok(Ok(core)) => {
|
||||
let summary = core.summary.clone();
|
||||
let asset = {
|
||||
let mut store =
|
||||
shared.store.lock().expect("project store lock poisoned");
|
||||
store.write_asset(&summary, &source_name, &asset_audio)
|
||||
};
|
||||
if let Err(error) = asset {
|
||||
warn!("project asset failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the asset could not be saved",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let state = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.import_core(core, Actor::Hand)
|
||||
};
|
||||
save_now(&shared).await;
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Imported { id, core: summary })
|
||||
.expect("import serializes"),
|
||||
@@ -393,12 +491,23 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
let should_save = !matches!(
|
||||
&command,
|
||||
Command::Play
|
||||
| Command::Stop
|
||||
| Command::Strike { .. }
|
||||
| Command::StrikeFace { .. }
|
||||
| Command::GetSnapshot
|
||||
);
|
||||
let outcome = {
|
||||
let mut session = shared.session.lock().await;
|
||||
session.apply(command, Actor::Hand)
|
||||
};
|
||||
match outcome {
|
||||
Ok((result, emitted)) => {
|
||||
if should_save {
|
||||
save_now(&shared).await;
|
||||
}
|
||||
let revision = match &result {
|
||||
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||
state.revision
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "zhila-project"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
zhila-core = { path = "../zhila-core" }
|
||||
zhila-protocol = { path = "../zhila-protocol" }
|
||||
zhila-material = { path = "../zhila-material" }
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,502 @@
|
||||
//! ZHILA project store: SQLite plus content-addressed audio assets.
|
||||
//!
|
||||
//! The database is the mutable session; the assets are the source material.
|
||||
//! On load a core's audio is decoded again, so the faces and peaks cannot drift
|
||||
//! from the bytes. The project directory is portable: `project.db` + `assets/`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use zhila_core::{JournalEntry, ProjectData};
|
||||
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
|
||||
|
||||
const SCHEMA_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProjectError {
|
||||
#[error("project filesystem: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("project database: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("project JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("material: {0}")]
|
||||
Material(String),
|
||||
}
|
||||
|
||||
pub struct LoadedProject {
|
||||
pub data: ProjectData,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ProjectStore {
|
||||
root: PathBuf,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
pub struct ProjectSnapshot<'a> {
|
||||
pub name: &'a str,
|
||||
pub revision: u64,
|
||||
pub bpm: f32,
|
||||
pub gains: &'a [f32; VEIN_COUNT],
|
||||
pub pattern: &'a Pattern,
|
||||
pub cores: &'a [CoreSummary],
|
||||
pub journal: &'a [JournalEntry],
|
||||
}
|
||||
|
||||
impl ProjectStore {
|
||||
pub fn open(root: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
fs::create_dir_all(root.join("assets"))?;
|
||||
let conn = Connection::open(root.join("project.db"))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(3))?;
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS project (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
bpm REAL NOT NULL,
|
||||
gains TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
saved_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cores (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
seal TEXT NOT NULL,
|
||||
asset TEXT NOT NULL DEFAULT '',
|
||||
source_name TEXT NOT NULL DEFAULT '',
|
||||
imported_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS journal (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
at_ms INTEGER NOT NULL,
|
||||
command TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![SCHEMA_VERSION],
|
||||
)?;
|
||||
Ok(Self { root, conn })
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn project_db(&self) -> PathBuf {
|
||||
self.root.join("project.db")
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<LoadedProject>, ProjectError> {
|
||||
let row = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT name, revision, bpm, gains, pattern FROM project WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, f64>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((name, revision, bpm, gains_json, pattern_json)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let gains_value: Vec<f32> = serde_json::from_str(&gains_json)?;
|
||||
let mut gains = zhila_core::INITIAL_GAINS;
|
||||
for (slot, value) in gains.iter_mut().zip(gains_value.iter()) {
|
||||
*slot = *value;
|
||||
}
|
||||
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let mut cores = Vec::new();
|
||||
{
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT id, name, seal, asset, source_name FROM cores ORDER BY imported_at",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (id, name, seal_text, asset, source_name) = row?;
|
||||
if asset.is_empty() {
|
||||
warnings.push(format!("{id}: no asset recorded"));
|
||||
continue;
|
||||
}
|
||||
let path = self.root.join(&asset);
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
warnings.push(format!("{id}: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let seal: Seal = serde_json::from_str(&seal_text).unwrap_or(Seal::Unknown);
|
||||
match zhila_material::import_bytes(&source_name, seal, bytes) {
|
||||
Ok(mut core) => {
|
||||
core.summary.name = name;
|
||||
if core.summary.id != id {
|
||||
warnings.push(format!("{id}: content hash is {}", core.summary.id));
|
||||
}
|
||||
cores.push(core);
|
||||
}
|
||||
Err(error) => warnings.push(format!("{id}: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for row in &mut pattern.rows {
|
||||
let missing = row.face.as_ref().and_then(|face| {
|
||||
let valid = cores.iter().any(|core| {
|
||||
core.summary.id == face.core
|
||||
&& core
|
||||
.faces
|
||||
.iter()
|
||||
.any(|candidate| candidate.index == face.face)
|
||||
});
|
||||
(!valid).then(|| format!("pattern {}: face {:?} missing", row.vein.name(), face))
|
||||
});
|
||||
if let Some(message) = missing {
|
||||
row.face = None;
|
||||
warnings.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let mut journal = Vec::new();
|
||||
{
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT seq, revision, actor, at_ms, command FROM journal ORDER BY seq")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (seq, revision, actor_text, at_ms, command_text) = row?;
|
||||
let actor: zhila_protocol::Actor = match serde_json::from_str(&actor_text) {
|
||||
Ok(actor) => actor,
|
||||
Err(_) => {
|
||||
warnings.push(format!("journal {seq}: actor unreadable"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let command: zhila_protocol::Command = match serde_json::from_str(&command_text) {
|
||||
Ok(command) => command,
|
||||
Err(_) => {
|
||||
warnings.push(format!("journal {seq}: command unreadable"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
journal.push(JournalEntry {
|
||||
seq: seq as u64,
|
||||
revision: revision as u64,
|
||||
actor,
|
||||
at_ms: at_ms as u64,
|
||||
command,
|
||||
});
|
||||
}
|
||||
}
|
||||
let seq = journal.last().map(|entry| entry.seq).unwrap_or(0);
|
||||
|
||||
Ok(Some(LoadedProject {
|
||||
data: ProjectData {
|
||||
name,
|
||||
revision: revision as u64,
|
||||
bpm: bpm as f32,
|
||||
gains,
|
||||
pattern,
|
||||
cores,
|
||||
journal,
|
||||
seq,
|
||||
},
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
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 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)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
revision = excluded.revision,
|
||||
bpm = excluded.bpm,
|
||||
gains = excluded.gains,
|
||||
pattern = excluded.pattern,
|
||||
saved_at = excluded.saved_at",
|
||||
params![
|
||||
snapshot.name,
|
||||
snapshot.revision as i64,
|
||||
snapshot.bpm as f64,
|
||||
gains,
|
||||
pattern,
|
||||
saved_at as i64
|
||||
],
|
||||
)?;
|
||||
for core in snapshot.cores {
|
||||
tx.execute(
|
||||
"INSERT INTO cores (id, name, seal, imported_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
seal = excluded.seal",
|
||||
params![
|
||||
core.id,
|
||||
core.name,
|
||||
serde_json::to_string(&core.seal)?,
|
||||
now_ms() as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.execute("DELETE FROM journal", [])?;
|
||||
for entry in snapshot.journal {
|
||||
tx.execute(
|
||||
"INSERT INTO journal (seq, revision, actor, at_ms, command)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
entry.seq as i64,
|
||||
entry.revision as i64,
|
||||
serde_json::to_string(&entry.actor)?,
|
||||
entry.at_ms as i64,
|
||||
serde_json::to_string(&entry.command)?
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the encoded source bytes and record the core row. Called once per
|
||||
/// import; `save` later keeps the name/seal in sync.
|
||||
pub fn write_asset(
|
||||
&mut self,
|
||||
summary: &CoreSummary,
|
||||
source_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), ProjectError> {
|
||||
let extension = Path::new(source_name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.take(8)
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "bin".into());
|
||||
let asset = format!("assets/{}.{}", summary.id, extension);
|
||||
let path = self.root.join(&asset);
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, bytes)?;
|
||||
fs::rename(&temporary, &path)?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO cores (id, name, seal, asset, source_name, imported_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
seal = excluded.seal,
|
||||
asset = excluded.asset,
|
||||
source_name = excluded.source_name,
|
||||
imported_at = excluded.imported_at",
|
||||
params![
|
||||
summary.id,
|
||||
summary.name,
|
||||
serde_json::to_string(&summary.seal)?,
|
||||
asset,
|
||||
source_name,
|
||||
now_ms() as i64
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn asset_path(&self, asset: &str) -> Option<PathBuf> {
|
||||
let relative = Path::new(asset);
|
||||
if relative.is_absolute()
|
||||
|| relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = self.root.join(relative);
|
||||
path.starts_with(&self.root).then_some(path)
|
||||
}
|
||||
|
||||
pub fn read_asset(&self, asset: &str) -> Result<Vec<u8>, ProjectError> {
|
||||
let path = self
|
||||
.asset_path(asset)
|
||||
.ok_or_else(|| ProjectError::Material("asset escapes the project".into()))?;
|
||||
Ok(fs::read(path)?)
|
||||
}
|
||||
|
||||
pub fn project_name(&self) -> Result<Option<String>, ProjectError> {
|
||||
let name = self
|
||||
.conn
|
||||
.query_row("SELECT name FROM project WHERE id = 1", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub fn debug_value(&self, sql: &str) -> Result<Value, ProjectError> {
|
||||
let mut statement = self.conn.prepare(sql)?;
|
||||
let mut rows = statement.query([])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let value: String = row.get(0)?;
|
||||
return Ok(serde_json::from_str(&value).unwrap_or(Value::String(value)));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use zhila_core::Core;
|
||||
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/clicks-01.wav");
|
||||
std::fs::read(path).expect("fixture")
|
||||
}
|
||||
|
||||
fn pattern_with_face(core: &Core) -> Pattern {
|
||||
let top = core
|
||||
.summary
|
||||
.faces
|
||||
.iter()
|
||||
.find(|face| face.rank == 0)
|
||||
.map(|face| face.index)
|
||||
.unwrap_or(0);
|
||||
let mut rows = Vec::new();
|
||||
for vein in zhila_protocol::VeinId::ALL {
|
||||
let mut velocities = vec![0u8; 16];
|
||||
if vein == zhila_protocol::VeinId::Bass {
|
||||
velocities[0] = 96;
|
||||
velocities[8] = 72;
|
||||
}
|
||||
rows.push(PatternRow {
|
||||
vein,
|
||||
face: (vein == zhila_protocol::VeinId::Bass).then(|| FaceRef {
|
||||
core: core.summary.id.clone(),
|
||||
face: top,
|
||||
}),
|
||||
velocities,
|
||||
});
|
||||
}
|
||||
Pattern {
|
||||
steps: 16,
|
||||
steps_per_bar: 8,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_the_project_with_assets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = ProjectStore::open(dir.path()).unwrap();
|
||||
assert!(store.load().unwrap().is_none());
|
||||
|
||||
let bytes = fixture();
|
||||
let core =
|
||||
zhila_material::import_bytes("clicks-01.wav", Seal::Found, bytes.clone()).unwrap();
|
||||
let pattern = pattern_with_face(&core);
|
||||
let gains = [0.8, 0.7, 0.6, 0.5, 0.4];
|
||||
let journal = vec![JournalEntry {
|
||||
seq: 1,
|
||||
revision: 1,
|
||||
actor: Actor::Hand,
|
||||
at_ms: 12,
|
||||
command: Command::SetTempo { bpm: 132.0 },
|
||||
}];
|
||||
store
|
||||
.write_asset(&core.summary, "clicks-01.wav", &bytes)
|
||||
.unwrap();
|
||||
store
|
||||
.save(&ProjectSnapshot {
|
||||
name: "clicks project",
|
||||
revision: 4,
|
||||
bpm: 132.0,
|
||||
gains: &gains,
|
||||
pattern: &pattern,
|
||||
cores: std::slice::from_ref(&core.summary),
|
||||
journal: &journal,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
drop(store);
|
||||
let store = ProjectStore::open(dir.path()).unwrap();
|
||||
let loaded = store.load().unwrap().expect("project");
|
||||
assert_eq!(loaded.data.name, "clicks project");
|
||||
assert_eq!(loaded.data.revision, 4);
|
||||
assert_eq!(loaded.data.bpm, 132.0);
|
||||
assert_eq!(loaded.data.gains, gains);
|
||||
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_eq!(loaded.data.pattern.rows.len(), 5);
|
||||
assert_eq!(loaded.data.pattern.rows[0].velocities[0], 96);
|
||||
assert_eq!(
|
||||
loaded.data.pattern.rows[0].face.as_ref().unwrap().core,
|
||||
core.summary.id
|
||||
);
|
||||
assert_eq!(loaded.data.journal.len(), 1);
|
||||
assert_eq!(loaded.data.seq, 1);
|
||||
assert!(loaded.warnings.is_empty());
|
||||
assert!(store.asset_path("assets/../project.db").is_none());
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,8 @@ pub enum Command {
|
||||
core: String,
|
||||
face: u8,
|
||||
},
|
||||
Undo,
|
||||
Redo,
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
ImportCore {
|
||||
name: String,
|
||||
@@ -248,6 +250,10 @@ pub enum Event {
|
||||
face: u8,
|
||||
velocity: f32,
|
||||
},
|
||||
History {
|
||||
can_undo: bool,
|
||||
can_redo: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -284,6 +290,9 @@ impl AudioInfo {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub revision: u64,
|
||||
pub project_name: String,
|
||||
pub can_undo: bool,
|
||||
pub can_redo: bool,
|
||||
pub transport: TransportState,
|
||||
pub veins: Vec<VeinState>,
|
||||
pub pattern: Pattern,
|
||||
|
||||
+45
@@ -53,3 +53,48 @@ The screenshots are `preview-p1-quarry.png`, `preview-p1-nave.png` and the empty
|
||||
## Next
|
||||
|
||||
P1b: a project folder with SQLite, content-addressed assets, autosave and reload, pattern-edit undo, recording, and waveform trim. Then the arrangement and the agent.
|
||||
|
||||
---
|
||||
|
||||
# P1b · project and history
|
||||
|
||||
P1b makes the session durable and reversible.
|
||||
|
||||
## Project folder
|
||||
|
||||
The daemon loads and saves a portable folder, by default `~/.local/share/zhila/descent-04` (override with `--project PATH`):
|
||||
|
||||
```
|
||||
project.db SQLite: project row, cores, journal
|
||||
assets/<id>.<ext> the encoded source bytes, content-hash named
|
||||
```
|
||||
|
||||
The project row stores name, revision, bpm, gains and the pattern JSON. Cores store id, name, seal, asset path and source name. The journal stores every project edit with its actor and timestamp. On load the material crate decodes each asset again, so faces and peaks are derived from the bytes rather than trusted metadata; patterns that reference a missing face are sanitised with a warning.
|
||||
|
||||
Autosave happens after imports and after every project command (tempo, gains, pattern step/face, undo/redo). The save runs under the session lock; the project is small, the write is a transaction. Play/stop/strike are performance and do not trigger a save.
|
||||
|
||||
## Undo
|
||||
|
||||
The core keeps up to 64 snapshots of `(bpm, gains, pattern)`. Tempo, gain, step and face edits push a snapshot before applying. `undo` restores the previous snapshot and pushes the current one to redo; `redo` reverses. Imports are not undoable in P1b (the archive and the pattern faces would need a coordinated removal); the import remains. The journal records `undo`/`redo`; the history itself is not persisted, so a reloaded project starts with an empty undo stack.
|
||||
|
||||
## Protocol
|
||||
|
||||
- `State.project_name`, `State.can_undo`, `State.can_redo`.
|
||||
- Commands `undo`, `redo`; event `history { can_undo, can_redo }`.
|
||||
- The project path is server-side; only the name is in the state.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo test --workspace`: 12 core tests (including undo/redo and project loading), 1 project store test, 2 material, 5 protocol.
|
||||
- `node scripts/persistence-smoke.mjs`: spawns a daemon on a temporary project, imports the fixture, assigns a face, undoes/redoes, edits a step and tempo, restarts the daemon, and verifies the core (16 faces), face, step 96, 132 bpm, journal and `project.db`.
|
||||
- `npm --prefix ui run project-smoke`: browser project name, import, face assign, undo → procedural, redo → face, screenshot.
|
||||
- `npm --prefix ui run restored-smoke`: after restarting the daemon on the saved project, the browser shows the core, sixteen faces and the face on the row, with undo disabled.
|
||||
|
||||
Screenshots: `preview-p1b-quarry.png`, `preview-p1b-restored.png`.
|
||||
|
||||
## Limits
|
||||
|
||||
- Undo history is per session; no reconstruction from the journal yet.
|
||||
- Only the source bytes are archived; trimming/recording will add derived assets.
|
||||
- The engine is still the standalone daemon; the Tauri backend does not host it yet.
|
||||
- A failed autosave logs a warning and leaves the in-memory session running; the surface does not yet show a save error.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# ZHILA — план проекта
|
||||
|
||||
Черновик v1.1 · 1 ноября 2026
|
||||
Статус: **P0 и P1a выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база.
|
||||
Статус: **P0, P1a и P1b выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база.
|
||||
|
||||
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
|
||||
|
||||
|
||||
@@ -146,3 +146,9 @@ Events: `pattern_step`, `pattern_face`, `face_struck`.
|
||||
- The archive id is a content hash prefix (`core-` + 10 hex); the same bytes get the same id.
|
||||
- The import is journaled on the session as `import_core` (journal-only; clients cannot send it as a command).
|
||||
- If the file cannot be decoded or is too short, the requester receives `error` with the import id.
|
||||
|
||||
## P1b additions
|
||||
|
||||
`State` adds `project_name` (string), `can_undo` and `can_redo` (bools). Commands `undo` and `redo` take no arguments; event `history { can_undo, can_redo }` is broadcast after them.
|
||||
|
||||
The project is loaded by the daemon from its `--project` folder and saved after every project command or import. The protocol only exposes the project name; the path, SQLite file and assets stay server-side. There is no explicit save command: autosave is the contract. The journal is persisted, but undo history is not reconstructed on load.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// Restart the daemon and verify the core, pattern and tempo survived.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const PORT = 4318;
|
||||
const DAEMON = './target/debug/zhila-daemon';
|
||||
const project = mkdtempSync(join(tmpdir(), 'zhila-persist-'));
|
||||
const fixture = readFileSync('fixtures/clicks-01.wav');
|
||||
let child = null;
|
||||
|
||||
function start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
child = spawn(DAEMON, ['--port', String(PORT), '--project', project, '--no-audio'], {
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
});
|
||||
child.stderr.on('data', (data) => process.stderr.write(data));
|
||||
child.on('exit', (code) => { if (code && code !== 0) console.error('daemon exited', code); });
|
||||
const attempt = () => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${PORT}`);
|
||||
socket.onopen = () => { socket.close(); resolve(); };
|
||||
socket.onerror = () => setTimeout(attempt, 150);
|
||||
};
|
||||
setTimeout(attempt, 200);
|
||||
setTimeout(() => reject(new Error('daemon did not start')), 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const pending = new Map();
|
||||
let snapshot = null;
|
||||
let imported = null;
|
||||
const waiters = [];
|
||||
const waitFor = (predicate) => new Promise((resolve) => waiters.push({ predicate, resolve }));
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: 'dev', client: 'persistence-smoke' }));
|
||||
ws.onmessage = (message) => {
|
||||
if (typeof message.data !== 'string') return;
|
||||
const parsed = JSON.parse(message.data);
|
||||
if (parsed.type === 'snapshot') {
|
||||
snapshot = parsed.state;
|
||||
for (const waiter of [...waiters]) if (waiter.predicate(parsed)) waiters.splice(waiters.indexOf(waiter), 1), waiter.resolve(parsed);
|
||||
} else if (parsed.type === 'imported') {
|
||||
imported = parsed;
|
||||
for (const waiter of [...waiters]) if (waiter.predicate(parsed)) waiters.splice(waiters.indexOf(waiter), 1), waiter.resolve(parsed);
|
||||
} else if (parsed.type === 'result') {
|
||||
const entry = pending.get(parsed.id);
|
||||
if (entry) { pending.delete(parsed.id); if (parsed.result.state) snapshot = parsed.result.state; entry.resolve(parsed.result); }
|
||||
} else if (parsed.type === 'error') {
|
||||
const entry = parsed.id != null ? pending.get(parsed.id) : null;
|
||||
if (entry) { pending.delete(parsed.id); entry.reject(new Error(parsed.message)); }
|
||||
}
|
||||
};
|
||||
await waitFor((message) => message.type === 'snapshot');
|
||||
const command = (id, command) => new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ type: 'command', id, command }));
|
||||
});
|
||||
const importFixture = async () => {
|
||||
ws.send(JSON.stringify({ type: 'import_core', id: 10, name: 'clicks-01.wav', seal: 'found' }));
|
||||
ws.send(fixture);
|
||||
await waitFor((message) => message.type === 'imported');
|
||||
return imported.core;
|
||||
};
|
||||
return { ws, command, importFixture, get snapshot() { return snapshot; } };
|
||||
}
|
||||
|
||||
try {
|
||||
await start();
|
||||
let client = await connect();
|
||||
const first = client.snapshot;
|
||||
if (!first.project_name) throw new Error('no project name in the snapshot');
|
||||
const core = await client.importFixture();
|
||||
if (core.faces.length !== 16) throw new Error('no sixteen faces');
|
||||
const top = core.faces.find((face) => face.rank === 0);
|
||||
await client.command(20, { type: 'set_pattern_face', row: 0, core: core.id, face: top.index });
|
||||
await client.command(21, { type: 'undo' });
|
||||
if (client.snapshot.pattern.rows[0].face) throw new Error('undo did not revert the face');
|
||||
await client.command(22, { type: 'redo' });
|
||||
const assigned = client.snapshot.pattern.rows[0].face;
|
||||
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 });
|
||||
client.ws.close();
|
||||
await new Promise((resolve) => { child.once('exit', resolve); child.kill('SIGTERM'); });
|
||||
|
||||
await start();
|
||||
client = await connect();
|
||||
const second = client.snapshot;
|
||||
const restored = second.pattern.rows[0];
|
||||
if (second.cores.length !== 1) throw new Error('core did not survive the restart');
|
||||
if (second.cores[0].name !== 'clicks-01') throw new Error('core name changed');
|
||||
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');
|
||||
if (second.project_name !== first.project_name) throw new Error('project name changed');
|
||||
console.log(JSON.stringify({
|
||||
project: second.project_name,
|
||||
path: project,
|
||||
cores: second.cores.length,
|
||||
faces: second.cores[0].faces.length,
|
||||
face: restored.face,
|
||||
step: restored.velocities[0],
|
||||
bpm: second.transport.bpm,
|
||||
journal: second.journal_len,
|
||||
db: existsSync(join(project, 'project.db'))
|
||||
}, null, 2));
|
||||
client.ws.close();
|
||||
child.kill('SIGTERM');
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
console.log('persistence smoke passed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
if (child) child.kill('SIGTERM');
|
||||
rmSync(project, { recursive: true, force: true });
|
||||
console.error('FAIL', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
+3
-1
@@ -9,7 +9,9 @@
|
||||
"preview": "vite preview --port 5173 --strictPort",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"quarry-smoke": "node scripts/quarry-smoke.mjs"
|
||||
"quarry-smoke": "node scripts/quarry-smoke.mjs",
|
||||
"project-smoke": "node scripts/project-smoke.mjs",
|
||||
"restored-smoke": "node scripts/restored-smoke.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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', '--autoplay-policy=no-user-gesture-required'] });
|
||||
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);
|
||||
const projectName = (await page.locator('.project').textContent())?.trim();
|
||||
const undoInitially = await page.locator('.history-button[aria-label="undo"]').isDisabled();
|
||||
|
||||
await page.locator('.room', { hasText: 'quarry' }).click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('input[type=file]').setInputFiles('../fixtures/clicks-01.wav');
|
||||
await page.waitForFunction(() => document.querySelector('.core-name')?.textContent?.includes('clicks-01'), null, { timeout: 20000 });
|
||||
await page.waitForTimeout(700);
|
||||
await page.locator('.face').nth(10).click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator('.assign').click();
|
||||
await page.waitForTimeout(500);
|
||||
const assigned = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
|
||||
const undoEnabled = !(await page.locator('.history-button[aria-label="undo"]').isDisabled());
|
||||
await page.locator('.history-button[aria-label="undo"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
const undone = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
|
||||
await page.locator('.history-button[aria-label="redo"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
const redone = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
|
||||
if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click();
|
||||
await page.waitForTimeout(1600);
|
||||
await page.screenshot({ path: '../preview-p1b-quarry.png' });
|
||||
|
||||
const report = await page.evaluate(() => ({
|
||||
project: document.querySelector('.project')?.textContent?.trim(),
|
||||
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
|
||||
faces: document.querySelectorAll('.face').length,
|
||||
patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(),
|
||||
undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'),
|
||||
redoDisabled: document.querySelector('.history-button[aria-label="redo"]')?.hasAttribute('disabled'),
|
||||
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-6),
|
||||
master: document.querySelector('.master-db')?.textContent
|
||||
}));
|
||||
console.log(JSON.stringify({ projectName, undoInitially, assigned, undoEnabled, undone, redone, report }, null, 2));
|
||||
|
||||
const failures = [];
|
||||
if (!projectName) failures.push('no project name');
|
||||
if (!undoInitially) failures.push('undo was enabled on a fresh project');
|
||||
if (!report.cores.length) failures.push('no core imported');
|
||||
if (report.faces !== 16) failures.push('expected 16 faces');
|
||||
if (!assigned || assigned.startsWith('procedural')) failures.push('face was not assigned');
|
||||
if (!undoEnabled) failures.push('undo did not enable');
|
||||
if (!undone || !undone.startsWith('procedural')) failures.push('undo did not revert the face');
|
||||
if (!redone || redone.startsWith('procedural')) failures.push('redo did not restore the face');
|
||||
if (errors.length) failures.push('console errors');
|
||||
await browser.close();
|
||||
if (failures.length) {
|
||||
console.error('PROJECT SMOKE FAILED: ' + failures.join('; '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('project smoke passed');
|
||||
@@ -0,0 +1,23 @@
|
||||
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(error.message));
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(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(700);
|
||||
const report = await page.evaluate(() => ({
|
||||
project: document.querySelector('.project')?.textContent?.trim(),
|
||||
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
|
||||
faces: document.querySelectorAll('.face').length,
|
||||
patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(),
|
||||
undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'),
|
||||
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(0, 4)
|
||||
}));
|
||||
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);
|
||||
await browser.close();
|
||||
+29
-5
@@ -72,7 +72,7 @@
|
||||
}
|
||||
if (!snapshotLogged) {
|
||||
snapshotLogged = true;
|
||||
addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
|
||||
addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
} else if (event.kind === 'pattern_face') {
|
||||
const row = project.pattern.rows[event.row];
|
||||
if (row) row.face = { core: event.core, face: event.face };
|
||||
} else if (event.kind === 'history') {
|
||||
project.can_undo = event.can_undo;
|
||||
project.can_redo = event.can_redo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +192,14 @@
|
||||
void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 });
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (project?.can_undo) void run({ type: 'undo' });
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (project?.can_redo) void run({ type: 'redo' });
|
||||
}
|
||||
|
||||
function select(vein: VeinId) {
|
||||
selected = vein;
|
||||
}
|
||||
@@ -209,6 +220,12 @@
|
||||
else play();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redo();
|
||||
else undo();
|
||||
return;
|
||||
}
|
||||
if (event.key === 's' || event.key === 'S') {
|
||||
event.preventDefault();
|
||||
strikeSelected();
|
||||
@@ -257,6 +274,7 @@
|
||||
<div class="brand">
|
||||
<span class="mark">ZHILA</span>
|
||||
<span class="version">0.1</span>
|
||||
<span class="project">{project?.project_name ?? 'descent-04'}</span>
|
||||
</div>
|
||||
<div class="rail-center">
|
||||
<button class="room" class:is-on={view === 'nave'} aria-pressed={view === 'nave'} onclick={() => (view = 'nave')}>
|
||||
@@ -276,10 +294,16 @@
|
||||
: 'five veins'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="net" class:is-live={status === 'live'}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="net-label">engine</span>
|
||||
<b>{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}</b>
|
||||
<div class="rail-right">
|
||||
<div class="history" aria-label="history">
|
||||
<button class="history-button" onclick={undo} disabled={!project?.can_undo} aria-label="undo" title="undo · ctrl+z">undo</button>
|
||||
<button class="history-button" onclick={redo} disabled={!project?.can_redo} aria-label="redo" title="redo · shift+ctrl+z">redo</button>
|
||||
</div>
|
||||
<div class="net" class:is-live={status === 'live'}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="net-label">engine</span>
|
||||
<b>{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}</b>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -131,6 +131,49 @@ a:focus-visible,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.project {
|
||||
overflow: hidden;
|
||||
max-width: 190px;
|
||||
padding-left: 9px;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ash-2);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.history-button {
|
||||
padding: 3px 5px;
|
||||
border: 1px solid rgba(155, 133, 121, 0.16);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ash);
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.history-button:hover:not(:disabled) {
|
||||
border-color: rgba(232, 64, 42, 0.4);
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.history-button:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rail-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1669,6 +1712,9 @@ button.room {
|
||||
.brand {
|
||||
min-width: 0;
|
||||
}
|
||||
.project {
|
||||
max-width: 110px;
|
||||
}
|
||||
.mark {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2em;
|
||||
@@ -1682,6 +1728,15 @@ button.room {
|
||||
.rail-note {
|
||||
display: none;
|
||||
}
|
||||
.project {
|
||||
display: none;
|
||||
}
|
||||
.rail-right {
|
||||
gap: 8px;
|
||||
}
|
||||
.history-button {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.net {
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
|
||||
@@ -33,6 +33,8 @@ 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 'undo': return 'undo';
|
||||
case 'redo': return 'redo';
|
||||
case 'get_snapshot': return 'snapshot';
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -61,6 +61,9 @@ export interface Pattern {
|
||||
|
||||
export interface State {
|
||||
revision: number;
|
||||
project_name: string;
|
||||
can_undo: boolean;
|
||||
can_redo: boolean;
|
||||
transport: TransportState;
|
||||
veins: VeinState[];
|
||||
pattern: Pattern;
|
||||
@@ -76,7 +79,8 @@ export type Event =
|
||||
| { kind: 'struck'; vein: VeinId; velocity: number }
|
||||
| { 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: 'face_struck'; core: string; face: number; velocity: number }
|
||||
| { kind: 'history'; can_undo: boolean; can_redo: boolean };
|
||||
|
||||
export type Command =
|
||||
| { type: 'get_snapshot' }
|
||||
@@ -87,7 +91,9 @@ export type Command =
|
||||
| { type: 'set_vein_gain'; vein: VeinId; gain: number }
|
||||
| { 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_pattern_face'; row: number; core: string; face: number }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' };
|
||||
|
||||
export type CommandResult =
|
||||
| { kind: 'snapshot'; state: State }
|
||||
|
||||
Reference in New Issue
Block a user