P1d: manual face cuts and trim in the quarry
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs.
|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
@@ -12,6 +12,8 @@ This repository is **P1c**: the engine, the wire, the descent, the material room
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -580,6 +580,7 @@ fn error_code(error: &CoreError) -> ErrorCode {
|
||||
| CoreError::InvalidRow
|
||||
| CoreError::InvalidStep
|
||||
| CoreError::UnknownFace
|
||||
| CoreError::InvalidFaces
|
||||
| CoreError::UnsupportedCommand => ErrorCode::BadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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));
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user