# P1a · material and the quarry P0 proved the spine: Rust audio, a session, a protocol, a surface. P1a puts material into it: a file becomes a core, a core becomes sixteen faces, a face becomes a voice in the pattern. ## The path of a sound ``` file input (browser) → ArrayBuffer → ws: import_core { id, name, seal } + binary audio → zhila-material: symphonia decode → mono f32 at the source rate → onset analysis: 10 ms energy hops, positive flux, local peaks → sixteen faces (sample ranges + RMS + rank) → waveform peaks (1400 bins, normalised) → zhila-core: Core { summary, Arc<[f32]>, faces } → Session::import_core: archive + journal + sync_material → engine: Arc through the control queue → snapshot broadcast → the quarry ``` The pattern keeps its procedural voices until a row is given a face. `set_pattern_face` writes the reference and syncs a new material snapshot; the audio thread triggers a sample voice for that row's steps and leaves the other rows procedural. `set_pattern_step` edits the rhythm; both are journaled. `strike_face` auditions a face without touching history. ## Pieces | Piece | What it does | |---|---| | `zhila-material` | decode (symphonia), downmix to mono, onset analysis, sixteen faces, ranks, waveform peaks, content-hash id | | `zhila-core` | `Core`, `AudioCore`, `AudioFace`, `MaterialSnapshot`; archive; pattern face/step commands; journal; engine sync | | `zhila-engine` | sample voices alongside procedural voices; a step plan that resolves a row's face to a core/face; rate-correct playback; 4 ms fades | | `zhila-daemon` | binary import after a text header, decoded in `spawn_blocking`; broadcast snapshot; new commands flow through the session | | `zhila-protocol` | core/face summaries, face references, import messages, pattern commands/events | | `ui` | quarry: import, core strip, waveform, face wall, audition, assign, pattern grid; the descent remains the nave | ## Verification - `cargo test --workspace`: 10 core tests, 2 material tests, 5 protocol tests. - `cargo clippy --workspace --all-targets -- -D warnings` and `cargo fmt --check`. - `node scripts/material-smoke.mjs`: a live import of `fixtures/clicks-01.wav`, sixteen faces, a face laid on row 1, playback, non-zero meters, `face_struck`. - `npm --prefix ui run quarry-smoke`: the browser imports the fixture, selects a face, assigns it, plays, screenshots the quarry and the nave, checks 80 steps and 16 faces. - `npm --prefix ui run smoke`: the P0 regression still passes. The screenshots are `preview-p1-quarry.png`, `preview-p1-nave.png` and the empty state `preview-p1-quarry-empty.png`. ## Limits - In-memory archive and pattern; no project file, no save/load, no asset persistence. A daemon restart loses the material and edits. - No recording, no waveform trim, no manual cut/loop points. Seams are automatic. - No undo/redo for pattern edits; the journal is written but not exposed. - One face per pattern row; no per-step face, pitch or probability yet. - Formats are whatever symphonia decodes; WAV is the tested path. - The Tauri shell now builds and runs; the engine remains the separate daemon (embedding is next). ## 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/. 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. --- # P1c · recording The quarry can now take a microphone take and turn it into material. ## Path ``` record click → getUserMedia({ mono, no AGC/NS/echo }) → AudioContext (requested 48 kHz) → AudioWorklet 'zhila-recorder' → Float32 chunks on the main thread → 16-bit PCM WAV encoder (in the surface) → the existing binary import path with seal 'recorded' → material decode + onset analysis + sixteen faces → project asset + autosave ``` The worklet copies each 128-frame render quantum; the main thread owns the growing chunk list so the audio thread never allocates the take. A mute GainNode keeps the input out of the speakers. The take name is `take-01.wav`, `take-02.wav`, …; the core name drops the extension. The recorder stops at 30 s; the user can stop earlier. Errors (permission denied, no device, worklet failure) surface under the quarry header. The WAV is mono 16-bit PCM at the AudioContext rate (requested 48 kHz). The material crate already handles WAV; no daemon or protocol change was needed beyond `Seal::Recorded`. ## UI - `record` in the quarry header and in the empty state: dot + label; `stop 0:12` while active with a pulsing dot. - `take a core` is disabled while recording; import is disabled while recording. - The take is imported with the recorded seal; the core strip and side facts show `recorded`. - The timer is in the button; no modal, no separate recorder panel. ## Verification - `npm --prefix ui run record-smoke` launches Chromium with `--use-fake-ui-for-media-stream --use-fake-device-for-media-stream --use-file-for-fake-audio-capture=fixtures/clicks-01.wav`, records 2.3 s through the real UI/worklet/encoder, stops, waits for `take-01`, and checks 16 faces, the recorded seal, and the wire import. - The take is persisted in `~/.local/share/zhila/descent-04` (`core-4525165cd5.wav`, 16 faces, seal recorded) and shown by the state. - `./scripts/check.sh` remains green. Screenshot: `preview-p1c-record.png`. ## Limits - Mono, 16-bit, 30 s max, no monitor, no device picker, no gain control. - 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). --- # P1e · history across reloads The undo stack is part of the project now. ## What is stored `ProjectEdit` is serialized: bpm, gains, the whole pattern, and every core's seventeen face boundaries. The session keeps up to 64 edits in `history` and a `redo` stack. The store writes both as JSON on the project row (`history`, `redo` columns, migrated with `ALTER TABLE`) every time the session saves; on load the stacks are parsed and handed to `Session::from_project`. `can_undo`/`can_redo` in the state reflect the restored stacks, so the rail buttons light up after a restart. The snapshot carries the cores' cuts, not the audio; the audio is still content-addressed and decoded from the asset. Undo after a reload therefore restores pattern, tempo, gains and every face range exactly. ## Verification - `cargo test --workspace`: the core's project-data test and the store test cover the history round-trip; the store test saves an edit with the core boundaries. - `node scripts/persistence-smoke.mjs`: after the daemon restart, `can_undo` is true; `undo` reverts the manual cut from 1.23 s to 1.21 s; `redo` restores 1.23 s; the face edge and the pattern/step/tempo are checked. - `npm --prefix ui run restored-smoke`: the loaded project shows its core and face; the undo button reflects the restored stack. ## Limits - Imports are not undoable; the history is snapshots, not the complete journal. - The cap is 64 edits; older history is dropped. - Corrupt history JSON is ignored with a warning and an empty stack. --- # P1f · the host inside Tauri The native window is now self-contained. ## Shape - `crates/zhila-daemon` became a library (`zhila_daemon::run(HostConfig)`) plus the thin daemon binary. `HostConfig` is port, token, no_audio and project. - `app/src-tauri` depends on the daemon library. On start it spawns one thread with a current-thread Tokio runtime and calls `run`; the Tauri window runs on the main thread. The engine, session, SQLite store, assets and WebSocket server live in the app process. - The embedded Svelte surface talks to `ws://127.0.0.1:4317` exactly as the browser does; the token is `dev`, the project is the default one. - The standalone daemon still exists for browser-first development; the two cannot share port 4317. ## Verification - `cd app && ./node_modules/.bin/tauri build --no-bundle` builds `zhila-app` with the host; the release build passes. - The standalone daemon was stopped; `ss -ltnp` shows `zhila-app` owning `127.0.0.1:4317`. - The app log shows `project loaded ... descent-04`, the audio device, and `wire opened ... client=zhila-ui`; the window shows `engine live`, the project name, the 2 cores and the pattern. - Screenshot: `preview-p1-tauri-host.png`. - Final P1 checks: `./scripts/check.sh`, `node scripts/persistence-smoke.mjs` (manual cut + undo + redo across a daemon restart), the browser smokes. ## Limits - The app uses the development token and the default project; no first-run project picker. - The app and the browser daemon contend for 4317. - WebKitGTK microphone permission for recording is not verified on this machine. - Installers (deb/AppImage) are still not built; the binary is the deliverable. ## P1 acceptance The "first playing loop" is reachable end-to-end: import or record → sixteen faces, cut and trimmed by hand → assign to a vein → pattern plays → save/load, undo/redo across restarts → the same material and history in the self-contained native app.