P1d: manual face cuts and trim in the quarry

This commit is contained in:
Emil
2026-11-01 01:51:47 +03:00
parent 88663a384e
commit 346035e869
17 changed files with 534 additions and 41 deletions
+10 -5
View File
@@ -2,7 +2,7 @@
Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs.
This repository is **P1c**: the engine, the wire, the descent, the material room, a project that survives a restart, and a microphone. A found or recorded sound is 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).
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).
![The quarry](preview-p1-quarry.png)
@@ -12,6 +12,8 @@ This repository is **P1c**: the engine, the wire, the descent, the material room
![P1c · a recorded take](preview-p1c-record.png)
![P1d · a cut and trimmed core](preview-p1d-faces.png)
## 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.
@@ -19,6 +21,7 @@ This repository is **P1c**: the engine, the wire, the descent, the material room
- **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.
- **Recording.** `record` in the quarry opens the microphone, captures mono PCM through an AudioWorklet, encodes a 16-bit WAV in the surface and sends it through the same import path. The take becomes a core with sixteen faces, seal `recorded`, and is autosaved in the project. Stops at 30 s; no monitoring.
- **Quarry editing.** Drag any of the seventeen seams on the waveform: the inner seams recut the sixteen faces, the outer seams trim the playable range. The core recomputes RMS and rank from the samples; the engine hears the new ranges on the next trigger; the project stores the cuts; the edit is undoable.
- **Quarry.** Import, core strip, zoomed waveform with face boundaries, sixteen-face wall, face audition, seal and stats; assign a face to a pattern row; edit the sixteen steps; strike a face; all against the live session.
- **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.
@@ -61,7 +64,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. Rebuild the native app after any UI change; the binary embeds `ui/dist` at compile time. The recording UI is in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine.
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. The recording and cutting UI are in the shared surface; WebKitGTK microphone permission still needs a manual check on this machine.
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 `−` / `+`.
@@ -94,20 +97,22 @@ npm --prefix ui run smoke # daemon + Vite: P0 regression, screenshot
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 record-smoke # daemon + Vite: fake microphone take, faces, seal
npm --prefix ui run faces-smoke # daemon + Vite: drag a seam, trim, save, screenshot
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`.
## P1c limits (next, not hidden)
## P1d 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.
- No waveform trimming, no manual cut/loop points; the sixteen faces are automatic seams.
- 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.
- The Tauri binary still embeds an older surface and starts no engine; it is a viewer in this phase.
- One fixed two-bar phrase and five veins; pattern rows are veins, one face per row, no per-step face or pitch.
- The Tauri binary currently embeds an older surface build; rebuild it after UI changes (`npm --prefix ui run build`, then `tauri build`). The engine remains the separate daemon.
- 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 P1d: waveform trimming and manual faces, 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).
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).
+239 -8
View File
@@ -10,9 +10,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zhila_protocol::{
Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FaceRef, LOOP_BARS, LOOP_STEPS,
MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State,
TransportState, VEIN_COUNT, VeinId, VeinState,
Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FACE_COUNT, FaceRef, FaceSummary,
LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow,
STEPS_PER_BAR, State, TransportState, VEIN_COUNT, VeinId, VeinState,
};
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
@@ -36,6 +36,85 @@ pub struct Core {
pub faces: Vec<AudioFace>,
}
impl Core {
pub fn face_boundaries(&self) -> Vec<f32> {
summary_boundaries(&self.summary)
}
/// Replace the sixteen automatic faces with manual, contiguous cuts. The
/// source samples and the waveform peaks are untouched; the RMS and rank
/// are recomputed from the new ranges.
pub fn set_face_boundaries(&mut self, boundaries: &[f32]) -> Result<(), FaceError> {
if boundaries.len() != FACE_COUNT + 1 {
return Err(FaceError::Count);
}
let sample_rate = self.summary.sample_rate as f32;
let total = self.samples.len();
if total < FACE_COUNT + 1 || !sample_rate.is_finite() || sample_rate <= 0.0 {
return Err(FaceError::Short);
}
let mut indices = [0usize; FACE_COUNT + 1];
for (index, seconds) in boundaries.iter().enumerate() {
if !seconds.is_finite() || *seconds < 0.0 {
return Err(FaceError::Range);
}
let sample = (*seconds * sample_rate).round();
if sample > total as f32 {
return Err(FaceError::Range);
}
let sample = sample as usize;
if index > 0 && sample <= indices[index - 1] {
return Err(FaceError::Order);
}
indices[index] = sample;
}
let mut faces = Vec::with_capacity(FACE_COUNT);
let mut summaries = Vec::with_capacity(FACE_COUNT);
for index in 0..FACE_COUNT {
let start = indices[index];
let end = indices[index + 1];
if end <= start {
return Err(FaceError::Short);
}
let segment = &self.samples[start..end];
let rms = (segment.iter().map(|sample| sample * sample).sum::<f32>()
/ segment.len() as f32)
.sqrt();
faces.push(AudioFace {
index: index as u8,
start: start as u32,
end: end as u32,
rms,
});
summaries.push(FaceSummary {
index: index as u8,
rank: 0,
start_sec: start as f32 / sample_rate,
end_sec: end as f32 / sample_rate,
rms,
});
}
let mut ranked: Vec<usize> = (0..summaries.len()).collect();
ranked.sort_by(|a, b| summaries[*b].rms.total_cmp(&summaries[*a].rms));
for (rank, index) in ranked.into_iter().enumerate() {
summaries[index].rank = rank as u8;
}
self.faces = faces;
self.summary.faces = summaries;
Ok(())
}
}
pub fn summary_boundaries(summary: &CoreSummary) -> Vec<f32> {
let mut faces: Vec<&zhila_protocol::FaceSummary> = summary.faces.iter().collect();
faces.sort_by_key(|face| face.index);
let mut boundaries: Vec<f32> = faces.iter().map(|face| face.start_sec).collect();
if let Some(last) = faces.last() {
boundaries.push(last.end_sec);
}
boundaries
}
#[derive(Clone)]
pub struct AudioCore {
pub id: String,
@@ -64,11 +143,18 @@ pub struct ProjectData {
pub seq: u64,
}
#[derive(Clone)]
struct ProjectEdit {
bpm: f32,
gains: [f32; VEIN_COUNT],
pattern: Pattern,
#[derive(Clone, Serialize, Deserialize)]
pub struct CoreEdit {
pub core: String,
pub boundaries: Vec<f32>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProjectEdit {
pub bpm: f32,
pub gains: [f32; VEIN_COUNT],
pub pattern: Pattern,
pub cores: Vec<CoreEdit>,
}
const MAX_HISTORY: usize = 64;
@@ -101,10 +187,31 @@ pub enum CoreError {
InvalidStep,
#[error("no such core or face")]
UnknownFace,
#[error("face boundaries must be sixteen ordered cuts inside the core")]
InvalidFaces,
#[error("this command only exists on the binary import path")]
UnsupportedCommand,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaceError {
Count,
Order,
Range,
Short,
}
impl std::fmt::Display for FaceError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Count => write!(formatter, "expected {} boundaries", FACE_COUNT + 1),
Self::Order => write!(formatter, "boundaries must be strictly ascending"),
Self::Range => write!(formatter, "boundaries must lie inside the core"),
Self::Short => write!(formatter, "every face needs at least one sample"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JournalEntry {
pub seq: u64,
@@ -218,6 +325,14 @@ impl Session {
bpm: self.bpm,
gains: self.gains,
pattern: self.pattern.clone(),
cores: self
.cores
.iter()
.map(|core| CoreEdit {
core: core.summary.id.clone(),
boundaries: core.face_boundaries(),
})
.collect(),
}
}
@@ -233,6 +348,15 @@ impl Session {
self.bpm = edit.bpm;
self.gains = edit.gains;
self.pattern = edit.pattern;
for core_edit in &edit.cores {
if let Some(core) = self
.cores
.iter_mut()
.find(|core| core.summary.id == core_edit.core)
{
let _ = core.set_face_boundaries(&core_edit.boundaries);
}
}
self.engine.set_tempo(self.bpm);
for (index, gain) in self.gains.iter().enumerate() {
if let Some(vein) = VeinId::from_index(index) {
@@ -423,6 +547,9 @@ impl Session {
Command::SetPatternFace { row, core, face } => {
self.set_pattern_face(row, &core, face, actor)
}
Command::SetCoreFaces { core, boundaries } => {
self.set_core_faces(&core, &boundaries, actor)
}
Command::Undo => {
let Some(edit) = self.history.pop() else {
return Ok((
@@ -494,6 +621,61 @@ impl Session {
}
/// Called by the daemon after the binary import and the analysis.
pub fn set_core_faces(
&mut self,
core_id: &str,
boundaries: &[f32],
actor: Actor,
) -> Result<(CommandResult, Vec<Event>), CoreError> {
let Some(existing) = self.cores.iter().find(|core| core.summary.id == core_id) else {
return Err(CoreError::UnknownFace);
};
let current = existing.face_boundaries();
if current.len() == boundaries.len()
&& current
.iter()
.zip(boundaries)
.all(|(left, right)| (left - right).abs() < 0.0001)
{
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
let mut preview = existing.clone();
preview
.set_face_boundaries(boundaries)
.map_err(|_| CoreError::InvalidFaces)?;
self.push_history();
let summary = {
let core = self
.cores
.iter_mut()
.find(|core| core.summary.id == core_id)
.expect("core was checked");
core.set_face_boundaries(boundaries)
.map_err(|_| CoreError::InvalidFaces)?;
core.summary.clone()
};
self.revision += 1;
self.record(
actor,
Command::SetCoreFaces {
core: core_id.to_string(),
boundaries: boundaries.to_vec(),
},
);
self.sync_material();
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::CoreFaces { core: summary }],
))
}
pub fn import_core(&mut self, core: Core, actor: Actor) -> State {
let name = core.summary.name.clone();
let seal = core.summary.seal;
@@ -1057,6 +1239,55 @@ mod tests {
assert!(!state.can_undo);
}
#[test]
fn manual_faces_are_validated_and_undoable() {
let (mut session, _) = session();
session.import_core(test_core(), Actor::Hand);
let before = session.snapshot();
let original = before.cores[0].faces.clone();
let original_boundaries: Vec<f32> = original
.iter()
.map(|face| face.start_sec)
.chain(std::iter::once(original.last().unwrap().end_sec))
.collect();
let mut boundaries = original_boundaries.clone();
boundaries[8] += 0.01;
let (result, events) = session
.set_core_faces("core-test", &boundaries, Actor::Hand)
.unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert!((state.cores[0].faces[7].end_sec - boundaries[8]).abs() < 0.001);
assert!(matches!(events.as_slice(), [Event::CoreFaces { .. }]));
assert!(state.can_undo);
let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
let restored: Vec<f32> = state.cores[0]
.faces
.iter()
.map(|face| face.start_sec)
.chain(std::iter::once(
state.cores[0].faces.last().unwrap().end_sec,
))
.collect();
assert_eq!(restored.len(), original_boundaries.len());
for (left, right) in restored.iter().zip(&original_boundaries) {
assert!((left - right).abs() < 1e-6);
}
let mut bad = boundaries.clone();
bad[3] = bad[2] - 0.01;
let error = session
.set_core_faces("core-test", &bad, Actor::Hand)
.unwrap_err();
assert!(matches!(error, CoreError::InvalidFaces));
}
#[test]
fn faces_are_validated_and_can_be_struck() {
let (mut session, engine) = session();
+1
View File
@@ -580,6 +580,7 @@ fn error_code(error: &CoreError) -> ErrorCode {
| CoreError::InvalidRow
| CoreError::InvalidStep
| CoreError::UnknownFace
| CoreError::InvalidFaces
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
}
}
+28 -6
View File
@@ -76,6 +76,7 @@ impl ProjectStore {
seal TEXT NOT NULL,
asset TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
faces TEXT,
imported_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS journal (
@@ -86,6 +87,7 @@ impl ProjectStore {
command TEXT NOT NULL
);",
)?;
let _ = conn.execute("ALTER TABLE cores ADD COLUMN faces TEXT", []);
conn.execute(
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -134,7 +136,7 @@ impl ProjectStore {
let mut cores = Vec::new();
{
let mut statement = self.conn.prepare(
"SELECT id, name, seal, asset, source_name FROM cores ORDER BY imported_at",
"SELECT id, name, seal, asset, source_name, faces FROM cores ORDER BY imported_at",
)?;
let rows = statement.query_map([], |row| {
Ok((
@@ -143,10 +145,11 @@ impl ProjectStore {
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
))
})?;
for row in rows {
let (id, name, seal_text, asset, source_name) = row?;
let (id, name, seal_text, asset, source_name, faces_json) = row?;
if asset.is_empty() {
warnings.push(format!("{id}: no asset recorded"));
continue;
@@ -166,6 +169,16 @@ impl ProjectStore {
if core.summary.id != id {
warnings.push(format!("{id}: content hash is {}", core.summary.id));
}
if let Some(json) = faces_json {
match serde_json::from_str::<Vec<f32>>(&json) {
Ok(boundaries) => {
if let Err(error) = core.set_face_boundaries(&boundaries) {
warnings.push(format!("{id}: manual faces: {error}"));
}
}
Err(error) => warnings.push(format!("{id}: faces JSON: {error}")),
}
}
cores.push(core);
}
Err(error) => warnings.push(format!("{id}: {error}")),
@@ -271,16 +284,20 @@ impl ProjectStore {
],
)?;
for core in snapshot.cores {
let boundaries = zhila_core::summary_boundaries(core);
let faces = serde_json::to_string(&boundaries)?;
tx.execute(
"INSERT INTO cores (id, name, seal, imported_at)
VALUES (?1, ?2, ?3, ?4)
"INSERT INTO cores (id, name, seal, faces, imported_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
seal = excluded.seal",
seal = excluded.seal,
faces = excluded.faces",
params![
core.id,
core.name,
serde_json::to_string(&core.seal)?,
faces,
now_ms() as i64
],
)?;
@@ -452,8 +469,12 @@ mod tests {
assert!(store.load().unwrap().is_none());
let bytes = fixture();
let core =
let mut core =
zhila_material::import_bytes("clicks-01.wav", Seal::Found, bytes.clone()).unwrap();
let mut boundaries = core.face_boundaries();
boundaries[8] += 0.01;
core.set_face_boundaries(&boundaries).unwrap();
let expected_edge = core.summary.faces[7].end_sec;
let pattern = pattern_with_face(&core);
let gains = [0.8, 0.7, 0.6, 0.5, 0.4];
let journal = vec![JournalEntry {
@@ -488,6 +509,7 @@ mod tests {
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!((loaded.data.cores[0].summary.faces[7].end_sec - expected_edge).abs() < 1e-4);
assert_eq!(loaded.data.pattern.rows.len(), 5);
assert_eq!(loaded.data.pattern.rows[0].velocities[0], 96);
assert_eq!(
+8
View File
@@ -10,6 +10,7 @@ pub const VEIN_COUNT: usize = 5;
pub const LOOP_STEPS: usize = 16;
pub const STEPS_PER_BAR: usize = 8;
pub const LOOP_BARS: f32 = 2.0;
pub const FACE_COUNT: usize = 16;
pub const MIN_BPM: f32 = 40.0;
pub const MAX_BPM: f32 = 220.0;
pub const MIN_GAIN: f32 = 0.0;
@@ -203,6 +204,10 @@ pub enum Command {
core: String,
face: u8,
},
SetCoreFaces {
core: String,
boundaries: Vec<f32>,
},
Undo,
Redo,
/// Journal-only: a core was imported through the binary path.
@@ -255,6 +260,9 @@ pub enum Event {
can_undo: bool,
can_redo: bool,
},
CoreFaces {
core: CoreSummary,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+33
View File
@@ -143,3 +143,36 @@ Screenshot: `preview-p1c-record.png`.
- WebKitGTK/Tauri microphone support is untested; the Tauri binary also embeds an older surface build and must be rebuilt to see the record button.
- No trim, fade or manual loop points yet; the take is cut by the automatic seams.
- The take is stored as the encoded WAV, not the float PCM; trimming will later add derived assets.
---
# P1d · manual faces and trim
The waveform is now an editor. A core's sixteen faces are seventeen contiguous boundaries; dragging any seam updates the core.
## Backend
- `Core::set_face_boundaries(&[f32])` validates seventeen strictly ascending cuts inside the core, recomputes each face's RMS from the source samples and reranks the faces. The source bytes and the waveform peaks are untouched.
- `Core::face_boundaries()` / `summary_boundaries()` expose the cuts.
- `Command::SetCoreFaces { core, boundaries }`, event `CoreFaces { core: CoreSummary }`. The core rejects unordered/out-of-range cuts with `InvalidFaces`; a no-op does not journal.
- Face edits are project edits: they journal, autosave and push the undo snapshot. `ProjectEdit` now snapshots every core's boundaries as well as bpm/gains/pattern, so undo restores cuts and trim. The edit remains valid when the pattern references the same face indices.
- The store gained a `faces` JSON column on `cores` (migrated with `ALTER TABLE`); it saves the boundaries and applies them after re-importing the asset on load. Invalid stored cuts warn and fall back to the analysis.
## Surface
- The waveform draws the seventeen seams; the outer seams are the trim, the inner are cuts. Drag with the pointer; the neighboring seams constrain the move (20 ms minimum); on release the command is sent.
- The side facts show the current `trim`; the selected face keeps its shade and label.
- The hint says "drag a seam to cut · outer seams trim".
## Verification
- `cargo test --workspace`: 13 core tests (manual faces, undo, invalid cuts), 1 store test with a manual face across save/load.
- `node scripts/persistence-smoke.mjs`: imports, cuts face 8 by 20 ms, restarts the daemon and verifies the loaded face edge and trim.
- `npm --prefix ui run faces-smoke`: drags the left outer seam in Chromium; checks the trim text, the `cut` wire command and the enabled undo; writes `preview-p1d-faces.png`.
- The default project now carries the cut `clicks-01` core.
## Limits
- Seventeen contiguous cuts only: no zoom, fade, loop points or per-face gain.
- The trim is the outer boundaries of the sixteen faces; the original asset is not rewritten.
- Cut history survives the session but not yet a reload (the next slice).
+1 -1
View File
@@ -1,7 +1,7 @@
# ZHILA — план проекта
Черновик v1.1 · 1 ноября 2026
Статус: **P0, P1a, P1b и P1c выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база.
Статус: **P0, P1a, P1b, P1c и P1d выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база.
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
+4
View File
@@ -156,3 +156,7 @@ The project is loaded by the daemon from its `--project` folder and saved after
## P1c addition
`Seal` gains `recorded`. Recording introduces no new wire messages: the surface encodes a WAV and uses the existing `import_core` header + binary payload. The recorded seal is persisted with the core row and is visible in the snapshot.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

+11
View File
@@ -84,6 +84,14 @@ try {
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 });
const sortedFaces = [...core.faces].sort((a, b) => a.index - b.index);
const boundaries = sortedFaces.map((face) => face.start_sec);
boundaries.push(sortedFaces[sortedFaces.length - 1].end_sec);
const beforeCut = boundaries[8];
boundaries[8] += 0.02;
await client.command(25, { type: 'set_core_faces', core: core.id, boundaries });
const cutEdge = client.snapshot.cores[0].faces[7].end_sec;
if (Math.abs(cutEdge - boundaries[8]) > 0.002) throw new Error('manual face did not apply');
client.ws.close();
await new Promise((resolve) => { child.once('exit', resolve); child.kill('SIGTERM'); });
@@ -96,6 +104,8 @@ try {
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');
const loadedEdge = second.cores[0].faces[7].end_sec;
if (Math.abs(loadedEdge - cutEdge) > 0.002) throw new Error('manual face did not survive');
if (second.project_name !== first.project_name) throw new Error('project name changed');
console.log(JSON.stringify({
project: second.project_name,
@@ -105,6 +115,7 @@ try {
face: restored.face,
step: restored.velocities[0],
bpm: second.transport.bpm,
cut: { before: beforeCut, after: loadedEdge },
journal: second.journal_len,
db: existsSync(join(project, 'project.db'))
}, null, 2));
+1
View File
@@ -12,6 +12,7 @@
"quarry-smoke": "node scripts/quarry-smoke.mjs",
"project-smoke": "node scripts/project-smoke.mjs",
"record-smoke": "node scripts/record-smoke.mjs",
"faces-smoke": "node scripts/faces-smoke.mjs",
"restored-smoke": "node scripts/restored-smoke.mjs"
},
"devDependencies": {
+47
View File
@@ -0,0 +1,47 @@
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(`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);
await page.locator('.room', { hasText: 'quarry' }).click();
await page.waitForTimeout(600);
await page.locator('.core-tab').first().click();
await page.waitForTimeout(400);
const stats = page.locator('.face-stats');
const before = (await stats.textContent())?.replace(/\s+/g, ' ').trim();
const canvas = page.locator('canvas.wave');
const box = await canvas.boundingBox();
if (!box) throw new Error('no waveform');
await page.mouse.move(box.x + 3, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + 70, box.y + 60, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(900);
const after = (await stats.textContent())?.replace(/\s+/g, ' ').trim();
await page.screenshot({ path: '../preview-p1d-faces.png' });
const report = await page.evaluate(() => ({
trim: document.querySelector('.face-stats')?.textContent?.replace(/\s+/g, ' ').trim(),
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-5),
undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'),
face: document.querySelector('.face.is-on .face-index')?.textContent,
selected: document.querySelector('.core-tab.is-on .core-name')?.textContent
}));
console.log(JSON.stringify({ before, after, report, errors }, null, 2));
const failures = [];
if (!before || !after || before === after) failures.push('trim did not change');
if (!report.wire.some((label) => label?.startsWith('cut'))) failures.push('no cut command on the wire');
if (report.undoDisabled) failures.push('undo did not enable after the cut');
if (errors.length) failures.push('console errors');
await browser.close();
if (failures.length) {
console.error('FACES SMOKE FAILED: ' + failures.join('; '));
process.exit(1);
}
console.log('faces smoke passed');
+8
View File
@@ -102,6 +102,9 @@
} else if (event.kind === 'history') {
project.can_undo = event.can_undo;
project.can_redo = event.can_redo;
} else if (event.kind === 'core_faces') {
const index = project.cores.findIndex((core) => core.id === event.core.id);
if (index >= 0) project.cores[index] = event.core;
}
}
@@ -193,6 +196,10 @@
void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 });
}
function setCoreFaces(core: string, boundaries: number[]) {
void run({ type: 'set_core_faces', core, boundaries });
}
function undo() {
if (project?.can_undo) void run({ type: 'undo' });
}
@@ -326,6 +333,7 @@
onassign={assignFace}
onstrikeface={strikeFace}
onstep={toggleStep}
onfaces={setCoreFaces}
/>
{/if}
<Wire {entries} {status} onreconnect={reconnect} />
+10 -1
View File
@@ -1367,17 +1367,26 @@ button.room {
margin-top: 7px;
border-top: 1px solid rgba(155, 133, 121, 0.12);
border-bottom: 1px solid rgba(155, 133, 121, 0.12);
cursor: ew-resize;
touch-action: none;
}
.wave-axis {
display: flex;
justify-content: space-between;
padding: 4px 0 9px;
padding: 4px 0 2px;
font-size: 8px;
color: var(--ash-2);
font-variant-numeric: tabular-nums;
}
.wave-hint {
padding: 2px 0 8px;
font-size: 8px;
letter-spacing: 0.05em;
color: var(--ash-2);
}
.face-wall {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
+129 -19
View File
@@ -18,7 +18,8 @@
onimport,
onassign,
onstrikeface,
onstep
onstep,
onfaces
}: {
cores: CoreSummary[];
pattern: Pattern | null;
@@ -33,6 +34,7 @@
onassign: () => void;
onstrikeface: (core: string, face: number) => void;
onstep: (row: number, step: number, on: boolean) => void;
onfaces: (core: string, boundaries: number[]) => void;
} = $props();
let fileInput: HTMLInputElement | null = null;
@@ -42,6 +44,8 @@
let recordError = $state<string | null>(null);
let finishing = $state(false);
let takeIndex = $state(1);
let draft = $state<number[] | null>(null);
let dragging: { index: number; pointer: number } | null = null;
let observer: ResizeObserver | null = null;
const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null);
@@ -50,6 +54,8 @@
core?.faces.find((item) => item.rank === 0) ??
null
);
const boundaries = $derived(core ? faceBoundaries(core) : []);
const trim = $derived(draft ?? boundaries);
onMount(() => {
if (!wave) return;
@@ -62,6 +68,7 @@
$effect(() => {
core;
face;
draft;
draw();
});
@@ -82,20 +89,24 @@
const center = height / 2;
const duration = Math.max(0.001, core.duration_sec);
for (const item of core.faces) {
const start = (item.start_sec / duration) * width;
const end = (item.end_sec / duration) * width;
if (face && item.index === face.index) {
context.fillStyle = 'rgba(232,64,42,0.08)';
context.fillRect(start, 0, end - start, height);
}
context.strokeStyle =
item.index % 4 === 0 ? 'rgba(232,64,42,0.34)' : 'rgba(155,133,121,0.14)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(start + 0.5, 0);
context.lineTo(start + 0.5, height);
context.stroke();
const limits = draft ?? boundaries;
const selected = face ? face.index : -1;
if (limits.length) {
context.fillStyle = 'rgba(12,9,8,0.55)';
const left = (limits[0] / duration) * width;
const right = (limits[limits.length - 1] / duration) * width;
if (left > 0) context.fillRect(0, 0, left, height);
if (right < width) context.fillRect(right, 0, width - right, height);
}
if (selected >= 0 && limits[selected] !== undefined && limits[selected + 1] !== undefined) {
context.fillStyle = 'rgba(232,64,42,0.08)';
context.fillRect(
(limits[selected] / duration) * width,
0,
((limits[selected + 1] - limits[selected]) / duration) * width,
height
);
}
const step = width / Math.max(1, core.peaks.length - 1);
@@ -110,9 +121,24 @@
context.lineTo(width, center + 0.5);
context.stroke();
if (face) {
const start = (face.start_sec / duration) * width;
const end = (face.end_sec / duration) * width;
for (let index = 0; index < limits.length; index += 1) {
const x = (limits[index] / duration) * width;
const outer = index === 0 || index === limits.length - 1;
context.strokeStyle = outer
? 'rgba(232,64,42,0.5)'
: index % 4 === 0
? 'rgba(232,64,42,0.28)'
: 'rgba(155,133,121,0.16)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(x + 0.5, 0);
context.lineTo(x + 0.5, height);
context.stroke();
}
if (face && limits[face.index] !== undefined) {
const start = (limits[face.index] / duration) * width;
const end = (limits[face.index + 1] / duration) * width;
context.strokeStyle = 'rgba(239,228,212,0.72)';
context.lineWidth = 1;
context.strokeRect(start + 0.5, 0.5, Math.max(1, end - start - 1), height - 1);
@@ -131,6 +157,77 @@
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
function faceBoundaries(item: CoreSummary): number[] {
const faces = [...item.faces].sort((left, right) => left.index - right.index);
if (!faces.length) return [];
return [...faces.map((entry) => entry.start_sec), faces[faces.length - 1].end_sec];
}
function canvasPoint(event: PointerEvent): number {
const canvas = wave;
if (!canvas) return 0;
return event.clientX - canvas.getBoundingClientRect().left;
}
function nearestBoundary(x: number): number | null {
const canvas = wave;
if (!canvas || !core) return null;
const width = Math.max(1, canvas.clientWidth);
const duration = Math.max(0.001, core.duration_sec);
let best: number | null = null;
let distance = 9;
for (let index = 0; index < boundaries.length; index += 1) {
const candidate = Math.abs((boundaries[index] / duration) * width - x);
if (candidate < distance) {
best = index;
distance = candidate;
}
}
return best;
}
function onBoundaryDown(event: PointerEvent) {
if (!core || !wave) return;
const index = nearestBoundary(canvasPoint(event));
if (index === null) return;
event.preventDefault();
wave.setPointerCapture(event.pointerId);
dragging = { index, pointer: event.pointerId };
draft = [...boundaries];
}
function onBoundaryMove(event: PointerEvent) {
const current = dragging;
if (!current || !core || !wave || event.pointerId !== current.pointer) return;
const width = Math.max(1, wave.clientWidth);
const duration = Math.max(0.001, core.duration_sec);
const x = Math.max(0, Math.min(width, canvasPoint(event)));
const seconds = (x / width) * duration;
const limits = draft ?? boundaries;
const min = Math.min(0.02, duration / 64);
const lower = current.index === 0 ? 0 : limits[current.index - 1] + min;
const upper =
current.index === limits.length - 1 ? duration : limits[current.index + 1] - min;
const value = Math.max(lower, Math.min(upper, seconds));
draft = limits.map((entry, index) => (index === current.index ? value : entry));
}
function onBoundaryUp(event: PointerEvent) {
if (!dragging) return;
const limits = draft;
if (wave?.hasPointerCapture(event.pointerId)) wave.releasePointerCapture(event.pointerId);
dragging = null;
draft = null;
if (
limits &&
core &&
limits.length === boundaries.length &&
limits.some((entry, index) => Math.abs(entry - boundaries[index]) > 0.0005)
) {
onfaces(core.id, limits);
}
}
async function toggleRecording() {
if (finishing) return;
if (!recording) {
@@ -271,11 +368,20 @@
</div>
</div>
<canvas class="wave" bind:this={wave} aria-hidden="true"></canvas>
<canvas
class="wave"
bind:this={wave}
aria-hidden="true"
onpointerdown={onBoundaryDown}
onpointermove={onBoundaryMove}
onpointerup={onBoundaryUp}
onpointercancel={onBoundaryUp}
></canvas>
<div class="wave-axis">
<span>0.0 s</span>
<span>{core.duration_sec.toFixed(1)} s</span>
</div>
<div class="wave-hint">drag a seam to cut · outer seams trim</div>
<div class="face-wall" role="group" aria-label="sixteen faces">
{#each core.faces as item}
@@ -349,6 +455,10 @@
rank 01.
</p>
<div class="face-stats">
<div>
<span>trim</span>
<b>{trim.length ? `${trim[0].toFixed(2)}–${trim[trim.length - 1].toFixed(2)} s` : '—'}</b>
</div>
<div><span>rank</span><b>{face ? String(face.rank + 1).padStart(2, '0') : '—'}</b></div>
<div><span>rms</span><b>{face ? face.rms.toFixed(3) : '—'}</b></div>
<div><span>length</span><b>{face ? `${(face.end_sec - face.start_sec).toFixed(2)} s` : '—'}</b></div>
+1
View File
@@ -33,6 +33,7 @@ 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 'set_core_faces': return `cut ${command.core}`;
case 'undo': return 'undo';
case 'redo': return 'redo';
case 'get_snapshot': return 'snapshot';
+3 -1
View File
@@ -80,7 +80,8 @@ export type Event =
| { 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: 'history'; can_undo: boolean; can_redo: boolean };
| { kind: 'history'; can_undo: boolean; can_redo: boolean }
| { kind: 'core_faces'; core: CoreSummary };
export type Command =
| { type: 'get_snapshot' }
@@ -92,6 +93,7 @@ export type Command =
| { 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_core_faces'; core: string; boundaries: number[] }
| { type: 'undo' }
| { type: 'redo' };