diff --git a/Cargo.lock b/Cargo.lock index 9e2e3c9..3ba589c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags", + "bitflags 2.13.2", "cfg-if", "libc", ] @@ -89,18 +89,43 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -125,12 +150,28 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -210,13 +251,25 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "coreaudio-rs" version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags", + "bitflags 2.13.2", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -301,10 +354,24 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", ] +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -321,6 +388,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "futures-core" version = "0.3.34" @@ -388,6 +467,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "http" version = "1.5.0" @@ -554,13 +639,19 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -649,7 +740,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags", + "bitflags 2.13.2", "libc", "objc2", "objc2-core-audio", @@ -664,7 +755,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -688,7 +779,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", ] @@ -698,7 +789,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.2", "block2", "dispatch2", "libc", @@ -717,7 +808,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -845,6 +936,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -914,6 +1011,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -968,6 +1071,139 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "2.0.119" @@ -1439,6 +1675,7 @@ dependencies = [ "tracing-subscriber", "zhila-core", "zhila-engine", + "zhila-material", "zhila-protocol", ] @@ -1453,6 +1690,18 @@ dependencies = [ "zhila-protocol", ] +[[package]] +name = "zhila-material" +version = "0.1.0" +dependencies = [ + "blake3", + "hound", + "symphonia", + "thiserror 2.0.21", + "zhila-core", + "zhila-protocol", +] + [[package]] name = "zhila-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 88b6576..45b564c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/zhila-core", "crates/zhila-engine", "crates/zhila-daemon", + "crates/zhila-material", ] exclude = ["app"] diff --git a/README.md b/README.md index 0df07f6..9343941 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ # ZHILA -Sound is terrain. A sampling instrument where the material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs. +Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs. -This repository is **P0**: the engine, the wire, and the first descent surface. The visual demo lives in [`reference/demo`](reference/demo) as a design reference — not as the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). +This repository is **P1a**: the engine, the wire, the descent, and the material room. A found sound can be imported, decoded and cut into sixteen faces, struck on its own, or laid on a vein and heard in the loop. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). -![P0 surface](preview-p0-playing.png) +![The quarry](preview-p1-quarry.png) -## What is real in P0 +## What is real -- **Rust engine.** `cpal` output stream, 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein gains and levels, a bar clock. No Web Audio. -- **Protocol v1.** JSON control frames and a 31-byte binary meter frame over a local WebSocket. -- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains), performance events that are not history. -- **Daemon.** Loopback-only, token in the hello, origin guard, snapshot on connect, results and events, binary meters at 30 Hz. -- **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five vein levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gain sliders; transport; keyboard control. -- **Verification.** Rust unit tests, a wire smoke test against live audio, a browser smoke test with screenshots, `svelte-check`, a production build. +- **Rust engine.** `cpal` output at 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein levels and gains, a bar clock. Procedural voices for empty pattern rows; real sample voices for rows with a face. No Web Audio. +- **Material.** Import through the local wire; `symphonia` decodes WAV/FLAC/MP3/OGG; onset analysis finds seams and cuts sixteen faces with RMS, rank and waveform peaks; the archive keeps the mono f32 samples. The sample rate is preserved and the engine handles the playback ratio. +- **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. +- **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gains; transport; keyboard. +- **Verification.** Rust unit tests, a live wire test, a material import test, a browser quarry test and the P0 regression smoke, plus `svelte-check` and a production build. -The Tauri 2 shell is scaffolded in [`app`](app), but **it has not been built on this machine**: the system is missing the GTK/WebKit/dbus development packages and `sudo` requires a password. The exact blocker and the install command are in [`docs/P0.md`](docs/P0.md). The browser surface is the working P0 path. +The Tauri 2 shell is scaffolded in [`app`](app), but **it has not been built on this machine**: the system is missing the GTK/WebKit/dbus development packages and `sudo` requires a password. The exact blocker and the install command are in [`docs/P0.md`](docs/P0.md). The browser surface is the working path. ## Quick start -Requirements: Rust (tested on 1.99), Node 22, ALSA (present on this machine). The daemon uses the default output device; if there is none it reports offline and the surface still connects. +Requirements: Rust (tested on 1.99), Node 22, ALSA. The daemon uses the default output device; without one it reports offline and the surface still connects. ```bash ./scripts/dev.sh @@ -27,51 +28,60 @@ Requirements: Rust (tested on 1.99), Node 22, ALSA (present on this machine). Th Open **http://127.0.0.1:5173**. The daemon listens on `ws://127.0.0.1:4317` with the development token `dev`. -Or run the two halves separately: +Or the two halves separately: ```bash cargo run -p zhila-daemon -- --port 4317 npm --prefix ui run dev ``` -Controls: `space` play/stop · `s` strike the selected vein · `1`–`5` select a vein · click a lane label to select; double-click to strike · tempo `−` / `+`. +### Try the material loop -The daemon prints the audio device, rate, buffer, sample format and session id. Use `--no-audio` for UI/protocol tests, `--port` to change the port, and `ZHILA_TOKEN` or `--token` to change the dev token. The surface reads `VITE_ZHILA_WS` and `VITE_ZHILA_TOKEN` at build time; both default to the values above. +1. Open **quarry** in the top rail. +2. `take a core` and choose a WAV (a short field recording works; the fixture `fixtures/clicks-01.wav` is one). +3. The file is decoded, the seams are found and sixteen faces are cut. Click a face, then `lay on bass`. +4. `space` plays the loop; the bass row now triggers the sample while the other veins keep their procedural voices. Edit steps in the pattern; each edit goes to the engine and the journal. +5. Go back to **nave** to see the pattern on the road and the levels in the room. + +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 `−` / `+`. + +The daemon prints the audio device, rate, buffer and session id. Use `--no-audio` for UI/protocol tests, `--port` to change the port, and `ZHILA_TOKEN` or `--token` to change the dev token. The surface reads `VITE_ZHILA_WS` and `VITE_ZHILA_TOKEN` at build time. ## Layout ``` -crates/zhila-protocol wire types, binary meter frame, tests -crates/zhila-core session, journal, validation, engine trait -crates/zhila-engine cpal stream, voices, phrase, levels -crates/zhila-daemon WebSocket daemon, auth, broadcast -ui Svelte 5 surface (browser-first) -ui/src/lib/descent.ts the reactive canvas -app Tauri 2 shell (scaffolded, system deps missing) -docs plan, protocol, P0 notes +crates/zhila-protocol wire types, binary meter frame, cores/faces, tests +crates/zhila-core session, journal, material snapshot, engine trait +crates/zhila-engine cpal stream, procedural + sample voices, levels +crates/zhila-material decode, onset analysis, sixteen faces, peaks +crates/zhila-daemon WebSocket daemon, auth, binary import +ui Svelte 5 surface: descent + quarry +fixtures a small click track for the smoke tests +app Tauri 2 shell (scaffolded; system deps missing) +docs plan, protocol, P0/P1 notes reference/demo the earlier design demo ``` ## Tests ```bash -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings -npm --prefix ui run check -npm --prefix ui run build -node scripts/wire-smoke.mjs # daemon running -npm --prefix ui run smoke # daemon + vite dev running +./scripts/check.sh # fmt, clippy, cargo tests, svelte-check, build +node scripts/wire-smoke.mjs # daemon running +node scripts/material-smoke.mjs # daemon running: import, face, play +npm --prefix ui run smoke # daemon + Vite: P0 regression, screenshots +npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, screenshots ``` -`npm --prefix ui run smoke` writes `preview-p0.png` and `preview-p0-playing.png` and asserts the wire, the pattern, the cilia and the live levels. +`npm --prefix ui run smoke` writes `preview-p0*.png`; `quarry-smoke` writes `preview-p1-*.png`. -## P0 limits (next, not hidden) +## P1a limits (next, not hidden) -- One fixed phrase; patterns are not editable in the surface yet. -- Five veins are fixed; the engine has per-vein gain, no sends or automation. -- The surface is read-only except transport, strike, tempo and gains. -- No session persistence, project file or archive yet. -- No agent and no MCP in this phase; the command bus and the journal are the ground for them. +- The archive is in memory. There is no save/load yet; a daemon restart loses imported cores and pattern edits. Persistence is the next slice. +- No recording, no waveform trimming, no manual cut/loop points; the sixteen faces are automatic seams. +- No undo/redo for pattern edits yet; the journal records them, the surface does not expose history. +- One fixed two-bar phrase and five veins; pattern rows are veins, one face per row, no per-step face or pitch. +- 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 is not verified; see the blocker. -P1 is the core session and the material room: import/record, analysis, cores and faces, a zoomable waveform and a real pattern model. The plan is in [`docs/PLAN.md`](docs/PLAN.md). +The next slice is P1b: a project file and SQLite journal, an asset archive, autosave and reload, undo for pattern edits, recording, and waveform trimming. Then the arrangement and the agent. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the P1a notes are in [`docs/P1.md`](docs/P1.md). diff --git a/crates/zhila-core/src/lib.rs b/crates/zhila-core/src/lib.rs index f69ac17..f00f067 100644 --- a/crates/zhila-core/src/lib.rs +++ b/crates/zhila-core/src/lib.rs @@ -10,21 +10,57 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use thiserror::Error; use zhila_protocol::{ - Actor, AudioInfo, Command, CommandResult, Event, 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, 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, }; pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66]; +/// A face as the engine needs it: half-open sample range in the core's source +/// rate. +#[derive(Debug, Clone)] +pub struct AudioFace { + pub index: u8, + pub start: u32, + pub end: u32, + pub rms: f32, +} + +/// A core with its samples. The archive keeps this; the engine receives a +/// snapshot of it through the control layer. +#[derive(Clone)] +pub struct Core { + pub summary: CoreSummary, + pub samples: Arc<[f32]>, + pub faces: Vec, +} + +#[derive(Clone)] +pub struct AudioCore { + pub id: String, + pub sample_rate: u32, + pub samples: Arc<[f32]>, + pub faces: Vec, +} + +/// Immutable material view shared with the audio thread. +#[derive(Clone)] +pub struct MaterialSnapshot { + pub pattern: Pattern, + pub cores: Vec, +} + /// The audio engine as seen by the session. Implementations must be real-time /// safe on the audio thread and non-blocking on these methods. pub trait Engine: Send + Sync { fn play(&self); fn stop(&self); fn strike(&self, vein: VeinId, velocity: f32); + fn strike_face(&self, core: &str, face: u8, velocity: f32); fn set_tempo(&self, bpm: f32); fn set_gain(&self, vein: VeinId, gain: f32); + fn set_material(&self, material: Arc); fn info(&self) -> AudioInfo; fn meter(&self) -> MeterFrame; } @@ -37,6 +73,14 @@ pub enum CoreError { InvalidVelocity, #[error("gain must be between 0 and 1.2")] InvalidGain, + #[error("pattern row must be 0..4")] + InvalidRow, + #[error("step must be 0..15")] + InvalidStep, + #[error("no such core or face")] + UnknownFace, + #[error("this command only exists on the binary import path")] + UnsupportedCommand, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -55,6 +99,7 @@ pub struct Session { playing: bool, gains: [f32; VEIN_COUNT], pattern: Pattern, + cores: Vec, journal: Vec, engine: Arc, } @@ -65,16 +110,19 @@ impl Session { } pub fn with_pattern(engine: Arc, pattern: Pattern) -> Self { - Self { + let session = Self { revision: 0, seq: 0, bpm: 128.0, playing: false, gains: INITIAL_GAINS, pattern, + cores: Vec::new(), journal: Vec::new(), engine, - } + }; + session.sync_material(); + session } pub fn revision(&self) -> u64 { @@ -109,6 +157,7 @@ impl Session { }) .collect(), pattern: self.pattern.clone(), + cores: self.cores.iter().map(|core| core.summary.clone()).collect(), audio: self.engine.info(), journal_len: self.journal.len(), } @@ -223,9 +272,187 @@ impl Session { vec![Event::VeinGain { vein, gain }], )) } + Command::StrikeFace { + core, + face, + velocity, + } => { + if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) { + return Err(CoreError::InvalidVelocity); + } + self.find_face(&core, face)?; + self.engine.strike_face(&core, face, velocity); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::FaceStruck { + core, + face, + velocity, + }], + )) + } + Command::SetPatternStep { + row, + step, + velocity, + } => self.set_pattern_step(row, step, velocity, actor), + Command::SetPatternFace { row, core, face } => { + self.set_pattern_face(row, &core, face, actor) + } + Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand), } } + pub fn cores(&self) -> &[Core] { + &self.cores + } + + pub fn material_snapshot(&self) -> MaterialSnapshot { + MaterialSnapshot { + pattern: self.pattern.clone(), + cores: self + .cores + .iter() + .map(|core| AudioCore { + id: core.summary.id.clone(), + sample_rate: core.summary.sample_rate, + samples: core.samples.clone(), + faces: core.faces.clone(), + }) + .collect(), + } + } + + /// Called by the daemon after the binary import and the analysis. + pub fn import_core(&mut self, core: Core, actor: Actor) -> State { + let name = core.summary.name.clone(); + let seal = core.summary.seal; + self.cores.push(core); + self.revision += 1; + self.record(actor, Command::ImportCore { name, seal }); + self.sync_material(); + self.snapshot() + } + + pub fn set_pattern_step( + &mut self, + row: u8, + step: u8, + velocity: u8, + actor: Actor, + ) -> Result<(CommandResult, Vec), CoreError> { + if row as usize >= self.pattern.rows.len() { + return Err(CoreError::InvalidRow); + } + if step as usize >= LOOP_STEPS { + return Err(CoreError::InvalidStep); + } + let velocity = velocity.min(127); + let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else { + return Err(CoreError::InvalidRow); + }; + if row_ref.velocities[step as usize] == velocity { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + row_ref.velocities[step as usize] = velocity; + self.revision += 1; + self.record( + actor, + Command::SetPatternStep { + row, + step, + velocity, + }, + ); + self.sync_material(); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::PatternStep { + row, + step, + velocity, + }], + )) + } + + pub fn set_pattern_face( + &mut self, + row: u8, + core: &str, + face: u8, + actor: Actor, + ) -> Result<(CommandResult, Vec), CoreError> { + if row as usize >= self.pattern.rows.len() { + return Err(CoreError::InvalidRow); + } + self.find_face(core, face)?; + let reference = FaceRef { + core: core.to_string(), + face, + }; + let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else { + return Err(CoreError::InvalidRow); + }; + if row_ref.face.as_ref() == Some(&reference) { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + row_ref.face = Some(reference); + self.revision += 1; + self.record( + actor, + Command::SetPatternFace { + row, + core: core.to_string(), + face, + }, + ); + self.sync_material(); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::PatternFace { + row, + core: core.to_string(), + face, + }], + )) + } + + fn find_face(&self, core_id: &str, face: u8) -> Result<(), CoreError> { + let Some(core) = self + .cores + .iter() + .find(|candidate| candidate.summary.id.as_str() == core_id) + else { + return Err(CoreError::UnknownFace); + }; + if core.faces.iter().any(|candidate| candidate.index == face) { + Ok(()) + } else { + Err(CoreError::UnknownFace) + } + } + + fn sync_material(&self) { + let snapshot = self.material_snapshot(); + self.engine.set_material(Arc::new(snapshot)); + } + fn record(&mut self, actor: Actor, command: Command) { self.seq += 1; self.journal.push(JournalEntry { @@ -278,7 +505,11 @@ pub fn default_pattern() -> Pattern { velocities[13] = 60; } } - rows.push(PatternRow { vein, velocities }); + rows.push(PatternRow { + vein, + face: None, + velocities, + }); } Pattern { steps: LOOP_STEPS as u16, @@ -291,6 +522,7 @@ pub fn default_pattern() -> Pattern { mod tests { use super::*; use std::sync::Mutex; + use zhila_protocol::{CoreSummary, FaceSummary, Seal}; struct TestEngine { calls: Mutex>, @@ -320,6 +552,10 @@ mod tests { fn calls(&self) -> Vec { self.calls.lock().unwrap().clone() } + + fn clear(&self) { + self.calls.lock().unwrap().clear(); + } } impl Engine for TestEngine { @@ -335,6 +571,12 @@ mod tests { .unwrap() .push(format!("strike:{}:{velocity}", vein.name())); } + fn strike_face(&self, core: &str, face: u8, velocity: f32) { + self.calls + .lock() + .unwrap() + .push(format!("face:{core}:{face}:{velocity}")); + } fn set_tempo(&self, bpm: f32) { self.calls.lock().unwrap().push(format!("tempo:{bpm}")); } @@ -344,6 +586,12 @@ mod tests { .unwrap() .push(format!("gain:{}:{gain}", vein.name())); } + fn set_material(&self, material: Arc) { + self.calls + .lock() + .unwrap() + .push(format!("material:{}", material.cores.len())); + } fn info(&self) -> AudioInfo { self.info.clone() } @@ -354,7 +602,43 @@ mod tests { fn session() -> (Session, Arc) { let engine = Arc::new(TestEngine::new()); - (Session::new(engine.clone()), engine) + let session = Session::new(engine.clone()); + engine.clear(); + (session, engine) + } + + fn test_core() -> Core { + let faces: Vec = (0..16) + .map(|index| AudioFace { + index, + start: index as u32 * 3_000, + end: (index as u32 + 1) * 3_000, + rms: 0.5, + }) + .collect(); + let summary = CoreSummary { + id: "core-test".into(), + name: "test material".into(), + seal: Seal::Found, + sample_rate: 48_000, + duration_sec: 1.0, + faces: faces + .iter() + .map(|face| FaceSummary { + index: face.index, + rank: face.index, + start_sec: face.start as f32 / 48_000.0, + end_sec: face.end as f32 / 48_000.0, + rms: face.rms, + }) + .collect(), + peaks: vec![0.0, 0.5, 1.0], + }; + Core { + summary, + samples: Arc::from(vec![0.0f32; 48_000]), + faces, + } } #[test] @@ -467,5 +751,96 @@ mod tests { assert_eq!(state.pattern.rows.len(), VEIN_COUNT); assert_eq!(state.audio.device, "test"); assert_eq!(state.journal_len, 0); + assert!(state.cores.is_empty()); + } + + #[test] + fn import_adds_a_core_and_syncs_material() { + let (mut session, engine) = session(); + let state = session.import_core(test_core(), Actor::Hand); + assert_eq!(state.cores.len(), 1); + assert_eq!(state.cores[0].id, "core-test"); + assert_eq!(session.journal().len(), 1); + assert!(matches!( + session.journal()[0].command, + Command::ImportCore { .. } + )); + assert!(engine.calls().iter().any(|call| call == "material:1")); + } + + #[test] + fn pattern_edits_are_journaled_and_synced() { + let (mut session, engine) = session(); + session.import_core(test_core(), Actor::Hand); + engine.clear(); + let (result, events) = session + .set_pattern_face(0, "core-test", 3, Actor::Hand) + .unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert_eq!(state.pattern.rows[0].face.as_ref().unwrap().face, 3); + assert_eq!( + events, + vec![Event::PatternFace { + row: 0, + core: "core-test".into(), + face: 3 + }] + ); + let (result, events) = session.set_pattern_step(0, 1, 100, Actor::Hand).unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert_eq!(state.pattern.rows[0].velocities[1], 100); + assert_eq!( + events, + vec![Event::PatternStep { + row: 0, + step: 1, + velocity: 100 + }] + ); + assert_eq!(session.journal().len(), 3); + assert!( + engine + .calls() + .iter() + .any(|call| call.starts_with("material:")) + ); + } + + #[test] + fn faces_are_validated_and_can_be_struck() { + let (mut session, engine) = session(); + session.import_core(test_core(), Actor::Hand); + engine.clear(); + let (result, events) = session + .apply( + Command::StrikeFace { + core: "core-test".into(), + face: 5, + velocity: 0.75, + }, + Actor::Hand, + ) + .unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert_eq!(state.cores.len(), 1); + assert_eq!(events.len(), 1); + assert_eq!(engine.calls(), vec!["face:core-test:5:0.75"]); + assert!(matches!( + session.apply( + Command::StrikeFace { + core: "missing".into(), + face: 0, + velocity: 0.5 + }, + Actor::Hand + ), + Err(CoreError::UnknownFace) + )); } } diff --git a/crates/zhila-daemon/Cargo.toml b/crates/zhila-daemon/Cargo.toml index ff8176d..b065fa4 100644 --- a/crates/zhila-daemon/Cargo.toml +++ b/crates/zhila-daemon/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] zhila-core = { path = "../zhila-core" } zhila-engine = { path = "../zhila-engine" } +zhila-material = { path = "../zhila-material" } zhila-protocol = { path = "../zhila-protocol" } tokio.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/zhila-daemon/src/main.rs b/crates/zhila-daemon/src/main.rs index 904d7d2..83ad6d1 100644 --- a/crates/zhila-daemon/src/main.rs +++ b/crates/zhila-daemon/src/main.rs @@ -20,7 +20,7 @@ use tracing::{info, warn}; use zhila_core::{CoreError, Engine, Session}; use zhila_engine::{AudioEngine, NullEngine}; use zhila_protocol::{ - Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, ServerMessage, + Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage, }; const HELLO_TIMEOUT: Duration = Duration::from_secs(5); @@ -216,7 +216,7 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> { protocol, token, } => (client, protocol, token), - ClientMessage::Command { .. } => { + ClientMessage::Command { .. } | ClientMessage::ImportCore { .. } => { let _ = send_out( &mut sink, error_message(None, ErrorCode::Protocol, "expected hello before commands"), @@ -303,9 +303,60 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> { } }); + let mut pending_import: Option<(u64, String, Seal)> = None; while let Some(frame) = source.next().await { let message = match frame { Ok(Message::Text(text)) => text, + Ok(Message::Binary(bytes)) => { + let Some((id, name, seal)) = pending_import.take() else { + let _ = direct_tx.send(error_message( + None, + ErrorCode::BadRequest, + "a binary frame arrived without an import header", + )); + continue; + }; + let import = tokio::task::spawn_blocking(move || { + zhila_material::import_bytes(&name, seal, bytes.to_vec()) + }) + .await; + match import { + Ok(Ok(core)) => { + let summary = core.summary.clone(); + let state = { + let mut session = shared.session.lock().await; + session.import_core(core, Actor::Hand) + }; + let _ = direct_tx.send(Outbound::Text( + serde_json::to_string(&ServerMessage::Imported { id, core: summary }) + .expect("import serializes"), + )); + let snapshot = ServerMessage::Snapshot { + revision: state.revision, + state, + }; + let _ = shared + .out + .send(Outbound::Text(serde_json::to_string(&snapshot).unwrap())); + } + Ok(Err(error)) => { + let _ = direct_tx.send(error_message( + Some(id), + ErrorCode::BadRequest, + error.to_string(), + )); + } + Err(error) => { + warn!("import task failed: {error}"); + let _ = direct_tx.send(error_message( + Some(id), + ErrorCode::Internal, + "the import task failed", + )); + } + } + continue; + } Ok(Message::Close(_)) => break, Ok(_) => continue, Err(error) => { @@ -330,6 +381,17 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> { ClientMessage::Hello { .. } => { let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted")); } + ClientMessage::ImportCore { id, name, seal } => { + if pending_import.is_some() { + let _ = direct_tx.send(error_message( + Some(id), + ErrorCode::BadRequest, + "the previous import has not received its bytes yet", + )); + } else { + pending_import = Some((id, name, seal)); + } + } ClientMessage::Command { id, command } => { let outcome = { let mut session = shared.session.lock().await; @@ -403,9 +465,13 @@ fn origin_allowed(origin: &str) -> bool { fn error_code(error: &CoreError) -> ErrorCode { match error { - CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => { - ErrorCode::BadRequest - } + CoreError::InvalidTempo + | CoreError::InvalidVelocity + | CoreError::InvalidGain + | CoreError::InvalidRow + | CoreError::InvalidStep + | CoreError::UnknownFace + | CoreError::UnsupportedCommand => ErrorCode::BadRequest, } } diff --git a/crates/zhila-engine/src/lib.rs b/crates/zhila-engine/src/lib.rs index 01538c4..025b103 100644 --- a/crates/zhila-engine/src/lib.rs +++ b/crates/zhila-engine/src/lib.rs @@ -12,7 +12,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig}; use rtrb::{Consumer, Producer, RingBuffer}; use thiserror::Error; -use zhila_core::Engine as EngineControl; +use zhila_core::{Engine as EngineControl, MaterialSnapshot}; use zhila_protocol::{ AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, STEPS_PER_BAR, VEIN_COUNT, VeinId, @@ -20,13 +20,27 @@ use zhila_protocol::{ const VOICES: usize = 32; -#[derive(Debug, Clone, Copy)] +/// (vein index, velocity, optional (core index, face index)) for one step. +type StepTrigger = (usize, u8, Option<(usize, u8)>); + enum AudioCommand { Play, Stop, - Strike { vein: u8, velocity: f32 }, + Strike { + vein: u8, + velocity: f32, + }, + StrikeFace { + core: String, + face: u8, + velocity: f32, + }, SetTempo(f32), - SetGain { vein: u8, gain: f32 }, + SetGain { + vein: u8, + gain: f32, + }, + SetMaterial(Arc), } struct Levels { @@ -63,6 +77,14 @@ impl Levels { } } +struct SampleVoice { + core_index: usize, + start: usize, + end: usize, + position: f64, + rate: f64, +} + struct Voice { active: bool, vein: usize, @@ -78,6 +100,7 @@ struct Voice { hp: f32, prev: f32, rng: u32, + sample: Option, } impl Voice { @@ -97,6 +120,7 @@ impl Voice { hp: 0.0, prev: 0.0, rng: 0x9e37_79b9, + sample: None, } } @@ -110,19 +134,52 @@ impl Voice { self.lp = 0.0; self.hp = 0.0; self.prev = 0.0; - let (freq, attack, decay, pan) = match vein { - VeinId::Bass => (55.0, 0.004, 3.2, 0.0), - VeinId::Crack => (0.0, 0.001, 38.0, 0.35), - VeinId::Voice => (220.0, 0.09, 1.1, -0.25), - VeinId::Noise => (0.0, 0.004, 5.0, 0.55), - VeinId::Silt => (1318.5, 0.002, 14.0, -0.45), + self.sample = None; + let (freq, attack, decay) = match vein { + VeinId::Bass => (55.0, 0.004, 3.2), + VeinId::Crack => (0.0, 0.001, 38.0), + VeinId::Voice => (220.0, 0.09, 1.1), + VeinId::Noise => (0.0, 0.004, 5.0), + VeinId::Silt => (1318.5, 0.002, 14.0), }; self.freq = freq; self.attack = attack; self.decay = decay; - let angle = (pan + 1.0) * FRAC_PI_4; - self.gain_l = angle.cos(); - self.gain_r = angle.sin(); + let (gain_l, gain_r) = pan_for(vein); + self.gain_l = gain_l; + self.gain_r = gain_r; + } + + fn trigger_sample( + &mut self, + vein: VeinId, + velocity: f32, + core_index: usize, + start: usize, + end: usize, + rate: f64, + ) { + self.active = true; + self.vein = vein.index(); + self.phase = 0.0; + self.freq = 0.0; + self.t = 0.0; + self.velocity = velocity.clamp(0.0, 1.0); + self.attack = 0.004; + self.decay = 4.0; + self.lp = 0.0; + self.hp = 0.0; + self.prev = 0.0; + let (gain_l, gain_r) = pan_for(vein); + self.gain_l = gain_l; + self.gain_r = gain_r; + self.sample = Some(SampleVoice { + core_index, + start, + end, + position: start as f64, + rate, + }); } fn noise(&mut self) -> f32 { @@ -130,8 +187,35 @@ impl Voice { (self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0 } - fn next(&mut self, sample_rate: f32) -> f32 { + fn next(&mut self, sample_rate: f32, material: Option<&MaterialSnapshot>) -> f32 { self.t += 1.0 / sample_rate; + if let Some(voice) = self.sample.as_mut() { + let Some(core) = material.and_then(|material| material.cores.get(voice.core_index)) + else { + self.active = false; + return 0.0; + }; + if voice.position >= voice.end as f64 { + self.active = false; + return 0.0; + } + let index = voice.position.floor() as usize; + let fraction = (voice.position - index as f64) as f32; + let first = core.samples.get(index).copied().unwrap_or(0.0); + let second = core.samples.get(index + 1).copied().unwrap_or(first); + let raw = first + (second - first) * fraction; + let remaining = + ((voice.end as f64 - voice.position) / voice.rate / sample_rate as f64) as f32; + let fade_in = (self.t / 0.004).min(1.0); + let fade_out = (remaining / 0.004).min(1.0); + voice.position += voice.rate; + if voice.position >= voice.end as f64 { + self.active = false; + } + let _ = voice.start; + return raw * fade_in.min(fade_out) * self.velocity; + } + let attack = (self.t / self.attack).min(1.0); let env = attack * (-self.t * self.decay).exp(); if env < 0.0002 || self.t > 8.0 { @@ -173,6 +257,18 @@ impl Voice { } } +fn pan_for(vein: VeinId) -> (f32, f32) { + let pan = match vein { + VeinId::Bass => 0.0, + VeinId::Crack => 0.35, + VeinId::Voice => -0.25, + VeinId::Noise => 0.55, + VeinId::Silt => -0.45, + }; + let angle = (pan + 1.0) * FRAC_PI_4; + (angle.cos(), angle.sin()) +} + struct AudioState { rx: Consumer, sample_rate: f32, @@ -181,7 +277,8 @@ struct AudioState { playing: bool, step: usize, pending: f64, - pattern: [[u8; LOOP_STEPS]; VEIN_COUNT], + fallback: Pattern, + material: Option>, voices: [Voice; VOICES], gains: [f32; VEIN_COUNT], master_env: f32, @@ -192,13 +289,6 @@ struct AudioState { impl AudioState { fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer) -> Self { - let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT]; - for row in &pattern.rows { - let index = row.vein.index(); - for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() { - grid[index][step] = *velocity; - } - } Self { rx, sample_rate: sample_rate as f32, @@ -207,7 +297,8 @@ impl AudioState { playing: false, step: 0, pending: 0.0, - pattern: grid, + fallback: pattern.clone(), + material: None, voices: std::array::from_fn(|_| Voice::inactive()), gains: zhila_core::INITIAL_GAINS, master_env: 0.0, @@ -246,6 +337,34 @@ impl AudioState { }; self.trigger(vein, velocity); } + AudioCommand::StrikeFace { + core, + face, + velocity, + } => { + let Some(material) = self.material.as_ref().cloned() else { + return; + }; + let Some(core_index) = material + .cores + .iter() + .position(|candidate| candidate.id == core) + else { + return; + }; + let vein_index = material + .pattern + .rows + .iter() + .find(|row| { + row.face.as_ref().is_some_and(|reference| { + reference.core == core && reference.face == face + }) + }) + .map(|row| row.vein.index()) + .unwrap_or(0); + self.trigger_sample(core_index, face, vein_index, velocity); + } AudioCommand::SetTempo(bpm) => { self.bpm = bpm.clamp(MIN_BPM, MAX_BPM); } @@ -254,15 +373,14 @@ impl AudioState { self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN); } } + AudioCommand::SetMaterial(material) => { + self.material = Some(material); + } } } - fn trigger(&mut self, vein: VeinId, velocity: f32) { - if velocity <= 0.0 { - return; - } - let index = self - .voices + fn free_voice(&self) -> usize { + self.voices .iter() .position(|voice| !voice.active) .unwrap_or_else(|| { @@ -272,18 +390,86 @@ impl AudioState { .max_by(|a, b| a.1.t.total_cmp(&b.1.t)) .map(|(index, _)| index) .unwrap_or(0) - }); + }) + } + + fn trigger(&mut self, vein: VeinId, velocity: f32) { + if velocity <= 0.0 { + return; + } + let index = self.free_voice(); self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); self.voices[index].trigger(vein, velocity, self.rng); } + fn trigger_sample( + &mut self, + core_index: usize, + face_index: u8, + vein_index: usize, + velocity: f32, + ) { + if velocity <= 0.0 { + return; + } + let Some(material) = self.material.as_ref().cloned() else { + return; + }; + let Some(core) = material.cores.get(core_index) else { + return; + }; + let Some(face) = core.faces.iter().find(|face| face.index == face_index) else { + return; + }; + let start = face.start as usize; + let end = face.end as usize; + if start >= end || end > core.samples.len() { + return; + } + let Some(vein) = VeinId::from_index(vein_index) else { + return; + }; + let rate = core.sample_rate as f64 / self.sample_rate as f64; + let index = self.free_voice(); + self.voices[index].trigger_sample(vein, velocity, core_index, start, end, rate); + } + fn trigger_step(&mut self) { let step = self.step; - for vein in 0..VEIN_COUNT { - let velocity = self.pattern[vein][step]; - if velocity > 0 - && let Some(vein) = VeinId::from_index(vein) - { + let (plan, count) = { + let material = self.material.as_ref().cloned(); + let pattern = material + .as_ref() + .map(|material| &material.pattern) + .unwrap_or(&self.fallback); + let mut plan: [StepTrigger; 8] = [(0, 0, None); 8]; + let mut count = 0; + for row in &pattern.rows { + if count >= plan.len() { + break; + } + let velocity = row.velocities.get(step).copied().unwrap_or(0); + if velocity == 0 { + continue; + } + let face = row.face.as_ref().and_then(|reference| { + material.as_ref().and_then(|material| { + material + .cores + .iter() + .position(|core| core.id == reference.core) + .map(|core_index| (core_index, reference.face)) + }) + }); + plan[count] = (row.vein.index(), velocity, face); + count += 1; + } + (plan, count) + }; + for (vein_index, velocity, face) in plan.into_iter().take(count) { + if let Some((core_index, face_index)) = face { + self.trigger_sample(core_index, face_index, vein_index, velocity as f32 / 127.0); + } else if let Some(vein) = VeinId::from_index(vein_index) { self.trigger(vein, velocity as f32 / 127.0); } } @@ -299,6 +485,7 @@ impl AudioState { self.handle(command); } + let material = self.material.as_ref().cloned(); for frame in data.chunks_mut(self.channels) { if self.playing { if self.pending <= 0.0 { @@ -315,7 +502,7 @@ impl AudioState { if !voice.active { continue; } - let sample = voice.next(self.sample_rate); + let sample = voice.next(self.sample_rate, material.as_deref()); let index = voice.vein; let amplitude = sample * self.gains[index]; left += amplitude * voice.gain_l; @@ -374,6 +561,14 @@ impl EngineControl for Control { }); } + fn strike_face(&self, core: &str, face: u8, velocity: f32) { + self.send(AudioCommand::StrikeFace { + core: core.to_string(), + face, + velocity, + }); + } + fn set_tempo(&self, bpm: f32) { self.send(AudioCommand::SetTempo(bpm)); } @@ -385,6 +580,10 @@ impl EngineControl for Control { }); } + fn set_material(&self, material: Arc) { + self.send(AudioCommand::SetMaterial(material)); + } + fn info(&self) -> AudioInfo { self.info.clone() } @@ -527,8 +726,10 @@ impl EngineControl for NullEngine { fn play(&self) {} fn stop(&self) {} fn strike(&self, _vein: VeinId, _velocity: f32) {} + fn strike_face(&self, _core: &str, _face: u8, _velocity: f32) {} fn set_tempo(&self, _bpm: f32) {} fn set_gain(&self, _vein: VeinId, _gain: f32) {} + fn set_material(&self, _material: Arc) {} fn info(&self) -> AudioInfo { self.info.clone() } diff --git a/crates/zhila-material/Cargo.toml b/crates/zhila-material/Cargo.toml new file mode 100644 index 0000000..74a59fc --- /dev/null +++ b/crates/zhila-material/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zhila-material" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +zhila-core = { path = "../zhila-core" } +zhila-protocol = { path = "../zhila-protocol" } +symphonia = "0.5" +blake3 = "1" +thiserror.workspace = true + +[dev-dependencies] +hound = "3" diff --git a/crates/zhila-material/src/lib.rs b/crates/zhila-material/src/lib.rs new file mode 100644 index 0000000..cafc910 --- /dev/null +++ b/crates/zhila-material/src/lib.rs @@ -0,0 +1,316 @@ +//! ZHILA material: decode an uploaded file, find its seams, cut sixteen faces. +//! +//! The import path is intentionally CPU-bound and off the audio thread. The +//! archive receives mono f32 samples at the source rate; the engine resamples +//! playback through its rate while the output stays at 48 kHz. + +use std::io::Cursor; +use std::sync::Arc; + +use blake3::Hasher; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use thiserror::Error; +use zhila_core::{AudioFace, Core}; +use zhila_protocol::{CoreSummary, FaceSummary, Seal}; + +const FACE_COUNT: usize = 16; +const PEAK_BINS: usize = 1400; +const ONSET_HOPS_PER_SECOND: f32 = 100.0; // 10 ms energy hops + +#[derive(Debug, Error)] +pub enum MaterialError { + #[error("could not decode the audio: {0}")] + Decode(String), + #[error("the file has no audio track")] + NoTrack, + #[error("the audio is too short to cut")] + TooShort, +} + +/// Decode `bytes`, analyse the seams and build a core. `name` is used both as +/// the display name and as the format hint (its extension). +pub fn import_bytes(name: &str, seal: Seal, bytes: Vec) -> Result { + let mut hasher = Hasher::new(); + hasher.update(&bytes); + let digest = hasher.finalize(); + let id = format!("core-{}", &digest.to_hex()[..10]); + + let (samples, sample_rate) = decode(name, bytes)?; + if samples.len() < 1_000 { + return Err(MaterialError::TooShort); + } + let (faces, face_summaries) = analyse(&samples, sample_rate); + let peaks = waveform_peaks(&samples); + let duration_sec = samples.len() as f32 / sample_rate as f32; + let summary = CoreSummary { + id, + name: display_name(name), + seal, + sample_rate, + duration_sec, + faces: face_summaries, + peaks, + }; + Ok(Core { + summary, + samples: Arc::from(samples), + faces, + }) +} + +fn display_name(name: &str) -> String { + let base = std::path::Path::new(name) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("core") + .trim(); + if base.is_empty() { + "core".into() + } else { + base.chars().take(48).collect() + } +} + +fn decode(name: &str, bytes: Vec) -> Result<(Vec, u32), MaterialError> { + let source = MediaSourceStream::new(Box::new(Cursor::new(bytes)), Default::default()); + let mut hint = Hint::new(); + if let Some(extension) = std::path::Path::new(name) + .extension() + .and_then(|extension| extension.to_str()) + { + hint.with_extension(extension); + } + let probed = symphonia::default::get_probe() + .format( + &hint, + source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|error| MaterialError::Decode(error.to_string()))?; + let mut format = probed.format; + let track = format.default_track().ok_or(MaterialError::NoTrack)?; + let track_id = track.id; + let codec_params = track.codec_params.clone(); + let sample_rate = codec_params + .sample_rate + .ok_or_else(|| MaterialError::Decode("missing sample rate".into()))?; + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|error| MaterialError::Decode(error.to_string()))?; + + let mut samples: Vec = Vec::with_capacity(48_000 * 8); + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(_)) => break, + Err(SymphoniaError::ResetRequired) => break, + Err(error) => return Err(MaterialError::Decode(error.to_string())), + }; + if packet.track_id() != track_id { + continue; + } + match decoder.decode(&packet) { + Ok(decoded) => { + let spec = *decoded.spec(); + let channels = spec.channels.count().max(1); + let mut buffer = SampleBuffer::::new(decoded.capacity() as u64, spec); + buffer.copy_interleaved_ref(decoded); + for frame in buffer.samples().chunks(channels) { + let mono = frame.iter().sum::() / channels as f32; + samples.push(mono.clamp(-1.0, 1.0)); + } + } + Err(SymphoniaError::DecodeError(_)) => continue, + Err(error) => return Err(MaterialError::Decode(error.to_string())), + } + } + if samples.is_empty() { + return Err(MaterialError::Decode("no samples decoded".into())); + } + Ok((samples, sample_rate)) +} + +fn analyse(samples: &[f32], sample_rate: u32) -> (Vec, Vec) { + let hop = (sample_rate as f32 / ONSET_HOPS_PER_SECOND).max(64.0) as usize; + let energy: Vec = samples + .chunks(hop) + .map(|chunk| { + chunk.iter().map(|sample| sample * sample).sum::() / chunk.len().max(1) as f32 + }) + .collect(); + let mut flux = vec![0.0f32; energy.len()]; + for index in 1..energy.len() { + flux[index] = (energy[index] - energy[index - 1]).max(0.0); + } + let mean = flux.iter().sum::() / flux.len().max(1) as f32; + let variance = flux + .iter() + .map(|value| (value - mean) * (value - mean)) + .sum::() + / flux.len().max(1) as f32; + let threshold = mean + variance.sqrt() * 0.55; + let min_gap = (sample_rate as f32 * 0.09 / hop as f32).max(1.0) as usize; + + let mut candidates: Vec<(usize, f32)> = Vec::new(); + for index in 1..flux.len().saturating_sub(1) { + let value = flux[index]; + if value > threshold && value >= flux[index - 1] && value > flux[index + 1] { + candidates.push((index, value)); + } + } + candidates.sort_by(|a, b| b.1.total_cmp(&a.1)); + + let mut onsets: Vec = Vec::new(); + for (index, _) in candidates { + if onsets.len() >= FACE_COUNT - 1 { + break; + } + if onsets + .iter() + .all(|existing| existing.abs_diff(index) >= min_gap) + { + onsets.push(index); + } + } + + let mut boundaries: Vec = onsets.into_iter().map(|index| index * hop).collect(); + boundaries.push(0); + boundaries.push(samples.len()); + boundaries.sort_unstable(); + boundaries.dedup(); + while boundaries.len() < FACE_COUNT + 1 { + let Some((split_at, _)) = boundaries + .windows(2) + .enumerate() + .max_by_key(|(_, pair)| pair[1] - pair[0]) + else { + break; + }; + let pair = &boundaries[split_at..split_at + 2]; + let midpoint = pair[0] + (pair[1] - pair[0]) / 2; + if midpoint == pair[0] || midpoint == pair[1] { + break; + } + boundaries.insert(split_at + 1, midpoint); + } + if boundaries.len() > FACE_COUNT + 1 { + boundaries.truncate(FACE_COUNT + 1); + let last = boundaries.len() - 1; + boundaries[last] = samples.len(); + } + + let mut faces = Vec::with_capacity(FACE_COUNT); + let mut summaries = Vec::with_capacity(FACE_COUNT); + for index in 0..FACE_COUNT { + let start = boundaries[index]; + let end = boundaries[index + 1].max(start + 1).min(samples.len()); + let segment = &samples[start..end]; + let rms = (segment.iter().map(|sample| sample * sample).sum::() + / segment.len().max(1) 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 as f32, + end_sec: end as f32 / sample_rate as f32, + rms, + }); + } + + let mut ranked: Vec = (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; + } + + (faces, summaries) +} + +fn waveform_peaks(samples: &[f32]) -> Vec { + let mut peaks = vec![0.0f32; PEAK_BINS]; + let chunk = (samples.len() as f32 / PEAK_BINS as f32).ceil().max(1.0) as usize; + for (index, frame) in samples.chunks(chunk).take(PEAK_BINS).enumerate() { + peaks[index] = frame + .iter() + .fold(0.0f32, |peak, sample| peak.max(sample.abs())); + } + let maximum = peaks.iter().fold(0.0f32, |peak, value| peak.max(*value)); + if maximum > 1e-6 { + for peak in &mut peaks { + *peak = (*peak / maximum).min(1.0); + } + } + peaks +} + +#[cfg(test)] +mod tests { + use super::*; + + fn click_track(seconds: f32, sample_rate: u32) -> Vec { + let frames = (seconds * sample_rate as f32) as usize; + let mut samples = vec![0.0f32; frames]; + let mut position = 0; + while position < frames { + for offset in 0..960 { + if position + offset < frames { + let envelope = 1.0 - offset as f32 / 960.0; + samples[position + offset] = (offset as f32 * 0.12).sin() * envelope * 0.7; + } + } + position += (sample_rate / 6) as usize; + } + let mut bytes = Vec::new(); + let spec = hound::WavSpec { + channels: 1, + sample_rate, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::new(Cursor::new(&mut bytes), spec).unwrap(); + for sample in samples { + writer + .write_sample((sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16) + .unwrap(); + } + writer.finalize().unwrap(); + bytes + } + + #[test] + fn imports_a_click_track_into_sixteen_faces() { + let bytes = click_track(2.0, 48_000); + let core = import_bytes("clicks.wav", Seal::Found, bytes).unwrap(); + assert_eq!(core.summary.sample_rate, 48_000); + assert_eq!(core.faces.len(), FACE_COUNT); + assert_eq!(core.summary.faces.len(), FACE_COUNT); + assert_eq!(core.summary.peaks.len(), PEAK_BINS); + assert!(core.summary.duration_sec > 1.9); + assert_eq!(core.summary.name, "clicks"); + assert_eq!(core.summary.seal, Seal::Found); + assert!(core.faces.iter().all(|face| face.start < face.end)); + assert!(core.summary.faces.iter().all(|face| face.rank < 16)); + } + + #[test] + fn short_audio_is_rejected() { + let bytes = click_track(0.01, 48_000); + assert!(matches!( + import_bytes("short.wav", Seal::Found, bytes), + Err(MaterialError::TooShort) + )); + } +} diff --git a/crates/zhila-protocol/src/lib.rs b/crates/zhila-protocol/src/lib.rs index a8b0ec0..fb65541 100644 --- a/crates/zhila-protocol/src/lib.rs +++ b/crates/zhila-protocol/src/lib.rs @@ -79,6 +79,46 @@ pub enum Actor { System, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Seal { + Found, + Taken, + Gift, + Loan, + Unknown, +} + +/// A face of a core, as the wire sees it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FaceSummary { + pub index: u8, + /// 0 is the loudest face. + pub rank: u8, + pub start_sec: f32, + pub end_sec: f32, + pub rms: f32, +} + +/// A core in the archive, without the samples themselves. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CoreSummary { + pub id: String, + pub name: String, + pub seal: Seal, + pub sample_rate: u32, + pub duration_sec: f32, + pub faces: Vec, + /// Normalised waveform envelope for the quarry, roughly 1400 bins. + pub peaks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FaceRef { + pub core: String, + pub face: u8, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { @@ -91,6 +131,12 @@ pub enum ClientMessage { id: u64, command: Command, }, + /// The next binary frame is the encoded audio for this import. + ImportCore { + id: u64, + name: String, + seal: Seal, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -113,6 +159,10 @@ pub enum ServerMessage { id: u64, result: CommandResult, }, + Imported { + id: u64, + core: CoreSummary, + }, Error { id: Option, code: ErrorCode, @@ -126,9 +176,37 @@ pub enum Command { GetSnapshot, Play, Stop, - Strike { vein: VeinId, velocity: f32 }, - SetTempo { bpm: f32 }, - SetVeinGain { vein: VeinId, gain: f32 }, + Strike { + vein: VeinId, + velocity: f32, + }, + StrikeFace { + core: String, + face: u8, + velocity: f32, + }, + SetTempo { + bpm: f32, + }, + SetVeinGain { + vein: VeinId, + gain: f32, + }, + SetPatternStep { + row: u8, + step: u8, + velocity: u8, + }, + SetPatternFace { + row: u8, + core: String, + face: u8, + }, + /// Journal-only: a core was imported through the binary path. + ImportCore { + name: String, + seal: Seal, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -141,10 +219,35 @@ pub enum CommandResult { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Event { - Transport { playing: bool }, - Tempo { bpm: f32 }, - VeinGain { vein: VeinId, gain: f32 }, - Struck { vein: VeinId, velocity: f32 }, + Transport { + playing: bool, + }, + Tempo { + bpm: f32, + }, + VeinGain { + vein: VeinId, + gain: f32, + }, + Struck { + vein: VeinId, + velocity: f32, + }, + PatternStep { + row: u8, + step: u8, + velocity: u8, + }, + PatternFace { + row: u8, + core: String, + face: u8, + }, + FaceStruck { + core: String, + face: u8, + velocity: f32, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -184,6 +287,7 @@ pub struct State { pub transport: TransportState, pub veins: Vec, pub pattern: Pattern, + pub cores: Vec, pub audio: AudioInfo, pub journal_len: usize, } @@ -219,6 +323,8 @@ impl Pattern { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PatternRow { pub vein: VeinId, + /// None keeps the procedural voice for this vein; Some plays a face. + pub face: Option, /// One velocity per step, 0..=127. Zero means the step is empty. pub velocities: Vec, } diff --git a/docs/P1.md b/docs/P1.md new file mode 100644 index 0000000..25c5b42 --- /dev/null +++ b/docs/P1.md @@ -0,0 +1,55 @@ +# 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 is still blocked on the system development packages (see `P0.md`). + +## 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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 39eebba..d2d2dd9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **P0 выполнен** (см. `P0.md`). Демо `reference/demo/` — референс, не кодовая база. +Статус: **P0 и P1a выполнены** (см. `P0.md`, `P1.md`). Демо `reference/demo/` — референс, не кодовая база. **Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3f93fa8..865c801 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -91,3 +91,58 @@ The browser sets `binaryType = "arraybuffer"` and reads a `DataView`. The frame - Project/asset commands. - Agent tools and MCP. - Per-client cursors or collaboration. + + +## P1a additions + +The protocol remains version 1; the additions are backward-compatible for the P0 surface (unknown fields are ignored). + +### Cores + +```json +{ + "type": "import_core", + "id": 100, + "name": "clicks-01.wav", + "seal": "found" +} +``` + +The next binary frame from the client is the encoded audio. The daemon answers with: + +```json +{"type":"imported","id":100,"core":{ + "id":"core-f298c4956d","name":"clicks-01","seal":"found", + "sample_rate":48000,"duration_sec":2.6, + "faces":[{"index":0,"rank":9,"start_sec":0.0,"end_sec":0.14,"rms":0.14}], + "peaks":[0.0,0.32,...] +}} +``` + +A snapshot follows and is broadcast to every client. `state.cores` carries the same summaries. + +### Pattern rows + +```json +{"vein":"bass","face":{"core":"core-f298c4956d","face":10},"velocities":[112,0,...]} +``` + +`face: null` means the procedural voice for that vein. + +### Commands + +| command | args | journal | +|---|---|---| +| `strike_face` | `core`, `face`, `velocity` 0..1 | no | +| `set_pattern_step` | `row` 0..4, `step` 0..15, `velocity` 0..127 | yes | +| `set_pattern_face` | `row`, `core`, `face` | yes | + +Events: `pattern_step`, `pattern_face`, `face_struck`. + +### Import semantics + +- The import header must arrive immediately before the binary payload on the same connection. +- The daemon decodes in a blocking task; the meter stream and other clients continue. +- The archive id is a content hash prefix (`core-` + 10 hex); the same bytes get the same id. +- The import is journaled on the session as `import_core` (journal-only; clients cannot send it as a command). +- If the file cannot be decoded or is too short, the requester receives `error` with the import id. diff --git a/fixtures/clicks-01.wav b/fixtures/clicks-01.wav new file mode 100644 index 0000000..6a431bc Binary files /dev/null and b/fixtures/clicks-01.wav differ diff --git a/preview-p0-playing.png b/preview-p0-playing.png index 6b59733..508bc9b 100644 Binary files a/preview-p0-playing.png and b/preview-p0-playing.png differ diff --git a/preview-p0.png b/preview-p0.png index 8f0c1d5..2a8ef36 100644 Binary files a/preview-p0.png and b/preview-p0.png differ diff --git a/preview-p1-nave.png b/preview-p1-nave.png new file mode 100644 index 0000000..9d9d4c1 Binary files /dev/null and b/preview-p1-nave.png differ diff --git a/preview-p1-quarry-empty.png b/preview-p1-quarry-empty.png new file mode 100644 index 0000000..6f71a5f Binary files /dev/null and b/preview-p1-quarry-empty.png differ diff --git a/preview-p1-quarry.png b/preview-p1-quarry.png new file mode 100644 index 0000000..730343b Binary files /dev/null and b/preview-p1-quarry.png differ diff --git a/scripts/material-smoke.mjs b/scripts/material-smoke.mjs new file mode 100644 index 0000000..3560462 --- /dev/null +++ b/scripts/material-smoke.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Import a WAV, assign a face to a pattern row, hear it in the loop. +import { readFileSync } from 'node:fs'; + +const WIRE = process.env.ZHILA_WS ?? 'ws://127.0.0.1:4317'; +const TOKEN = process.env.ZHILA_TOKEN ?? 'dev'; +const fixture = new URL('../fixtures/clicks-01.wav', import.meta.url); +const bytes = readFileSync(fixture); + +const ws = new WebSocket(WIRE); +ws.binaryType = 'arraybuffer'; +const pending = new Map(); +let snapshot = null; +let imported = null; +let frames = 0; +let maxMaster = 0; +const events = []; +let failure = null; +const timeout = setTimeout(() => fail(new Error('material smoke timed out')), 30_000); + +function fail(error) { + if (failure) return; + failure = error; + clearTimeout(timeout); + try { ws.close(); } catch {} + console.error('FAIL', error.message); + process.exit(1); +} + +function send(id, command) { + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ type: 'command', id, command })); + }); +} + +function finish() { + clearTimeout(timeout); + try { ws.close(); } catch {} + console.log(JSON.stringify({ + core: imported?.core?.name, + id: imported?.core?.id, + faces: imported?.core?.faces?.length, + peaks: imported?.core?.peaks?.length, + duration: imported?.core?.duration_sec, + topFace: imported?.core?.faces?.find(f => f.rank === 0)?.index, + patternFace: snapshot?.pattern?.rows?.[0]?.face, + frames, + maxMaster: +maxMaster.toFixed(3), + events: events.map(e => e.kind) + }, null, 2)); + process.exit(0); +} + +ws.onopen = () => { + ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: TOKEN, client: 'material-smoke' })); +}; +ws.onerror = () => fail(new Error('websocket error')); + +ws.onmessage = async (message) => { + if (failure) return; + if (message.data instanceof ArrayBuffer) { + const view = new DataView(message.data); + if (view.byteLength === 31 && view.getUint8(0) === 1) { + frames++; + maxMaster = Math.max(maxMaster, view.getFloat32(7, true)); + } + return; + } + let parsed; + try { parsed = JSON.parse(message.data); } catch { return; } + if (parsed.type === 'hello') return; + if (parsed.type === 'snapshot') { + snapshot = parsed.state; + if (!imported) { + // first snapshot: begin the import + ws.send(JSON.stringify({ + type: 'import_core', id: 100, name: 'clicks-01.wav', seal: 'found' + })); + ws.send(bytes); + } + return; + } + if (parsed.type === 'imported') { + imported = parsed; + try { + const core = imported.core; + if (!core || core.faces.length !== 16) throw new Error('the core did not get 16 faces'); + if (!core.peaks.length) throw new Error('the core has no waveform peaks'); + const top = core.faces.find(f => f.rank === 0); + if (!top) throw new Error('no ranked face'); + await send(101, { type: 'set_pattern_face', row: 0, core: core.id, face: top.index }); + await send(102, { type: 'play' }); + await new Promise(r => setTimeout(r, 2200)); + await send(103, { type: 'strike_face', core: core.id, face: top.index, velocity: 0.9 }); + await new Promise(r => setTimeout(r, 500)); + if (!snapshot) throw new Error('no snapshot after import'); + if (!snapshot.cores?.length) throw new Error('the snapshot has no cores'); + if (!snapshot.pattern?.rows?.[0]?.face) throw new Error('the pattern row has no face'); + if (!(maxMaster > 0.001)) throw new Error('the engine never made a level'); + if (!events.some(e => e.kind === 'face_struck')) throw new Error('no face_struck event'); + finish(); + } catch (error) { + fail(error); + } + return; + } + if (parsed.type === 'result') { + const entry = pending.get(parsed.id); + if (!entry) return; + pending.delete(parsed.id); + if (parsed.result?.state) snapshot = parsed.result.state; + entry.resolve(parsed.result); + return; + } + if (parsed.type === 'event') { + events.push(parsed.event); + return; + } + if (parsed.type === 'error') { + const entry = parsed.id != null ? pending.get(parsed.id) : null; + if (entry) { pending.delete(parsed.id); entry.reject(new Error(parsed.message)); } + else fail(new Error(parsed.message)); + } +}; diff --git a/ui/package.json b/ui/package.json index 44d143e..422bf47 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview --port 5173 --strictPort", "check": "svelte-check --tsconfig ./tsconfig.json", - "smoke": "node scripts/smoke.mjs" + "smoke": "node scripts/smoke.mjs", + "quarry-smoke": "node scripts/quarry-smoke.mjs" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0", diff --git a/ui/scripts/quarry-smoke.mjs b/ui/scripts/quarry-smoke.mjs new file mode 100644 index 0000000..eb186da --- /dev/null +++ b/ui/scripts/quarry-smoke.mjs @@ -0,0 +1,64 @@ +import { chromium } from 'playwright-core'; + +const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome'; +const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 }); +const errors = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); }); + +await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' }); +await page.waitForTimeout(2400); +await page.locator('.room', { hasText: 'quarry' }).click(); +await page.waitForTimeout(500); +await page.screenshot({ path: '../preview-p1-quarry-empty.png' }); + +await page.locator('input[type=file]').setInputFiles('../fixtures/clicks-01.wav'); +await page.waitForFunction(() => document.querySelector('.core-name')?.textContent?.includes('clicks-01'), null, { timeout: 20000 }); +await page.waitForTimeout(700); +await page.locator('.face').nth(10).click(); +await page.waitForTimeout(250); +await page.locator('.assign').click(); +await page.waitForTimeout(500); +if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click(); +await page.waitForTimeout(1900); +await page.screenshot({ path: '../preview-p1-quarry.png' }); + +const report = await page.evaluate(() => ({ + net: document.querySelector('.net b')?.textContent, + cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent), + faces: document.querySelectorAll('.face').length, + selectedFace: document.querySelector('.face.is-on .face-index')?.textContent, + patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(), + steps: document.querySelectorAll('.step').length, + stepsOn: document.querySelectorAll('.step.is-on').length, + wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-8), + master: document.querySelector('.master-db')?.textContent, + cilia: [...document.querySelectorAll('.cilium')].map((el) => Number(getComputedStyle(el).getPropertyValue('--level'))), + canvas: { w: document.querySelector('canvas.wave')?.width, h: document.querySelector('canvas.wave')?.height } +})); +console.log(JSON.stringify(report, null, 2)); + +await page.locator('.room', { hasText: 'nave' }).click(); +await page.waitForTimeout(1600); +await page.screenshot({ path: '../preview-p1-nave.png' }); +const nave = await page.evaluate(() => ({ + canvas: { w: document.querySelector('.descent canvas')?.width, h: document.querySelector('.descent canvas')?.height }, + lanes: document.querySelectorAll('.lane').length, + faceRows: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-4) +})); +console.log(JSON.stringify(nave, null, 2)); + +const failures = []; +if (!report.cores.length) failures.push('no core imported'); +if (report.faces !== 16) failures.push('the face wall is not 16'); +if (!report.patternFace || report.patternFace.startsWith('procedural')) failures.push('the pattern face was not assigned'); +if (report.canvas.w < 200) failures.push('the waveform canvas did not size'); +if (!report.cilia.some((level) => level > 0.001)) failures.push('no level moved'); +if (errors.length) failures.push('console errors'); +await browser.close(); +if (failures.length) { + console.error('QUARRY SMOKE FAILED: ' + failures.join('; ')); + process.exit(1); +} +console.log('quarry smoke passed'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 5ad9047..5d9f878 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -4,12 +4,14 @@ import { onMount } from 'svelte'; import Descent from './components/Descent.svelte'; import EnginePanel from './components/EnginePanel.svelte'; + import Quarry from './components/Quarry.svelte'; import Transport from './components/Transport.svelte'; import Wire from './components/Wire.svelte'; import { Bridge } from './lib/bridge'; import { commandLabel } from './lib/format'; import type { Command, + CoreSummary, Event, HelloMessage, MeterFrame, @@ -30,6 +32,11 @@ let selected: VeinId = $state('bass'); let entries: WireEntry[] = $state([]); let hello: HelloMessage | null = $state(null); + let view: 'nave' | 'quarry' = $state('nave'); + let selectedCoreId: string | null = $state(null); + let selectedFace: number = $state(-1); + let selectedRow: number = $state(0); + let importing: boolean = $state(false); let bridge: Bridge | null = null; let entryId = 0; let snapshotLogged = false; @@ -57,12 +64,23 @@ function onState(next: State) { project = next; + const cores = next.cores ?? []; + if (!selectedCoreId || !cores.some((core) => core.id === selectedCoreId)) { + const first = cores[0]; + selectedCoreId = first?.id ?? null; + selectedFace = first ? (first.faces.find((face) => face.rank === 0)?.index ?? 0) : -1; + } if (!snapshotLogged) { snapshotLogged = true; - addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · rev ${next.revision}`); + addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`); } } + function onImported(core: CoreSummary) { + selectedCoreId = core.id; + selectedFace = core.faces.find((face) => face.rank === 0)?.index ?? 0; + } + function onMeter(next: MeterFrame) { meter = next; } @@ -74,6 +92,12 @@ else if (event.kind === 'vein_gain') { const vein = project.veins.find((entry) => entry.id === event.vein); if (vein) vein.gain = event.gain; + } else if (event.kind === 'pattern_step') { + const row = project.pattern.rows[event.row]; + if (row) row.velocities[event.step] = event.velocity; + } else if (event.kind === 'pattern_face') { + const row = project.pattern.rows[event.row]; + if (row) row.face = { core: event.core, face: event.face }; } } @@ -116,6 +140,55 @@ void run({ type: 'set_vein_gain', vein, gain }); } + async function importCore(file: File) { + if (!bridge || importing) return; + importing = true; + addEntry('send', `import ${file.name}`); + try { + const data = await file.arrayBuffer(); + const core = await bridge.importCore(file.name, 'found', data); + selectedCoreId = core.id; + selectedFace = core.faces.find((face) => face.rank === 0)?.index ?? 0; + addEntry('recv', 'core', `${core.name} · ${core.faces.length} faces · ${core.duration_sec.toFixed(1)} s`); + } catch (error) { + addEntry('error', 'import', error instanceof Error ? error.message : String(error)); + } finally { + importing = false; + } + } + + function selectCore(id: string) { + selectedCoreId = id; + const core = project?.cores.find((item) => item.id === id); + selectedFace = core ? (core.faces.find((face) => face.rank === 0)?.index ?? 0) : -1; + } + + function strikeFace(core: string, face: number) { + void run({ type: 'strike_face', core, face, velocity: 0.85 }); + } + + function strikeSelected() { + if (view === 'quarry' && selectedCoreId && selectedFace >= 0) { + strikeFace(selectedCoreId, selectedFace); + } else { + strike(); + } + } + + function assignFace() { + if (!selectedCoreId || selectedFace < 0) return; + void run({ + type: 'set_pattern_face', + row: selectedRow, + core: selectedCoreId, + face: selectedFace + }); + } + + function toggleStep(row: number, step: number, on: boolean) { + void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 }); + } + function select(vein: VeinId) { selected = vein; } @@ -138,12 +211,17 @@ } if (event.key === 's' || event.key === 'S') { event.preventDefault(); - strike(); + strikeSelected(); return; } if (event.key >= '1' && event.key <= '5') { - const vein = VEINS[Number(event.key) - 1]; - if (vein) select(vein); + const index = Number(event.key) - 1; + if (view === 'quarry') { + selectedRow = index; + } else { + const vein = VEINS[index]; + if (vein) select(vein); + } } } @@ -162,7 +240,8 @@ onState, onMeter, onEvent, - onError + onError, + onImported }); bridge.connect(); window.addEventListener('keydown', onKey); @@ -180,10 +259,22 @@ 0.1
- nave + + + hand - {project ? `${project.veins.length} veins` : 'five veins'} + + {view === 'quarry' + ? `${project?.cores.length ?? 0} core${(project?.cores.length ?? 0) === 1 ? '' : 's'}` + : project + ? `${project.veins.length} veins` + : 'five veins'} +
@@ -193,7 +284,25 @@
- + {#if view === 'nave'} + + {:else} + (selectedFace = face)} + onselectrow={(row) => (selectedRow = row)} + onimport={importCore} + onassign={assignFace} + onstrikeface={strikeFace} + onstep={toggleStep} + /> + {/if}
diff --git a/ui/src/app.css b/ui/src/app.css index e3b7771..2d3a63d 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1039,6 +1039,507 @@ a:focus-visible, } } + +/* — quarry — */ + +button.room { + border: 0; + background: transparent; + cursor: pointer; +} + +.quarry { + position: absolute; + inset: 0; + z-index: 2; + padding: 22px calc(var(--panel-r) + 20px) 26px calc(var(--panel-l) + 22px); + overflow-y: auto; + background: + radial-gradient(ellipse at 50% 4%, rgba(232, 64, 42, 0.06), transparent 55%), + var(--resin); + animation: fade 0.7s 0.05s ease both; + scrollbar-width: thin; + scrollbar-color: rgba(155, 133, 121, 0.3) transparent; +} + +.quarry-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(155, 133, 121, 0.12); +} + +.quarry-head h1 { + margin: 0; + font-family: var(--font-display); + font-size: 30px; + font-style: italic; + font-weight: 300; + line-height: 1; + color: var(--bone); +} + +.quarry-head p { + margin: 5px 0 0; + font-size: 8.5px; + letter-spacing: 0.14em; + color: var(--ash-2); +} + +.quarry-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.import-state { + font-size: 8.5px; + letter-spacing: 0.08em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.import, +.empty-action { + padding: 7px 11px; + border: 1px solid rgba(232, 64, 42, 0.35); + background: rgba(232, 64, 42, 0.04); + color: var(--bone); + font-size: 9.5px; + letter-spacing: 0.04em; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.import:hover, +.empty-action:hover { + border-color: var(--ember); + background: rgba(232, 64, 42, 0.1); +} + +.import:disabled, +.empty-action:disabled { + opacity: 0.45; + cursor: wait; +} + +.file { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.quarry-empty { + max-width: 460px; + margin: 13vh auto 0; + text-align: center; +} + +.empty-title { + margin: 0; + font-family: var(--font-display); + font-size: 30px; + font-style: italic; + font-weight: 300; + line-height: 1; + color: var(--bone); +} + +.empty-copy { + margin: 14px 0 18px; + font-size: 10px; + line-height: 1.8; + color: var(--ash); +} + +.empty-formats { + margin-top: 14px; + font-size: 8px; + letter-spacing: 0.14em; + color: var(--ash-2); +} + +.core-strip { + display: flex; + gap: 2px; + margin-top: 10px; + overflow-x: auto; + border-bottom: 1px solid rgba(155, 133, 121, 0.1); + scrollbar-width: none; +} + +.core-strip::-webkit-scrollbar { + display: none; +} + +.core-tab { + flex: 0 0 auto; + padding: 7px 10px 6px; + border-top: 1px solid rgba(155, 133, 121, 0.16); + text-align: left; + color: var(--ash); + transition: color 0.2s ease, border-color 0.2s ease; +} + +.core-tab:hover, +.core-tab.is-on { + color: var(--bone); +} + +.core-tab.is-on { + border-top-color: var(--ember); +} + +.core-name { + display: block; + font-size: 10px; + letter-spacing: 0.02em; + line-height: 1.4; +} + +.core-meta { + display: block; + font-size: 8px; + letter-spacing: 0.04em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.quarry-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 196px; + gap: 18px; + align-items: start; + margin-top: 12px; +} + +.quarry-left { + min-width: 0; +} + +.material-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; +} + +.material-head h2 { + margin: 0; + font-family: var(--font-display); + font-size: 25px; + font-weight: 300; + line-height: 1.1; + color: var(--bone); +} + +.material-head p { + margin: 4px 0 0; + font-size: 8.5px; + letter-spacing: 0.06em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.material-rank { + font-size: 8.5px; + letter-spacing: 0.06em; + color: var(--ash); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.wave { + display: block; + width: 100%; + height: 154px; + margin-top: 7px; + border-top: 1px solid rgba(155, 133, 121, 0.12); + border-bottom: 1px solid rgba(155, 133, 121, 0.12); +} + +.wave-axis { + display: flex; + justify-content: space-between; + padding: 4px 0 9px; + font-size: 8px; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.face-wall { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 3px; +} + +.face { + position: relative; + height: 34px; + overflow: hidden; + padding: 4px 5px; + border: 1px solid rgba(155, 133, 121, 0.14); + background: rgba(12, 9, 8, 0.62); + text-align: left; + color: var(--ash); + transition: border-color 0.2s ease, color 0.2s ease; +} + +.face:hover { + border-color: rgba(232, 64, 42, 0.5); + color: var(--bone); +} + +.face.is-on { + border-color: var(--ember); + color: var(--bone); + box-shadow: inset 0 0 0 1px rgba(232, 64, 42, 0.35); +} + +.face-index { + position: relative; + z-index: 1; + display: block; + font-size: 8.5px; + line-height: 1.3; + color: var(--bone); + font-variant-numeric: tabular-nums; +} + +.face-rank { + position: relative; + z-index: 1; + display: block; + font-size: 7.5px; + line-height: 1.3; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.face-energy { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: calc(var(--energy) * 100%); + background: linear-gradient(to top, rgba(232, 64, 42, 0.28), rgba(232, 64, 42, 0.02)); + pointer-events: none; +} + +.material-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + margin-top: 10px; +} + +.face-strike, +.assign { + padding: 7px 10px; + border: 1px solid rgba(155, 133, 121, 0.25); + color: var(--bone); + font-size: 9.5px; + letter-spacing: 0.03em; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.face-strike:hover, +.assign:hover { + border-color: var(--ember); + background: rgba(232, 64, 42, 0.08); +} + +.assign { + border-color: rgba(232, 64, 42, 0.42); + color: var(--ember); +} + +.face-strike:disabled, +.assign:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.assign-note { + font-size: 8px; + color: var(--ash-2); +} + +.pattern-head { + display: flex; + justify-content: space-between; + padding: 14px 0 7px; + margin-top: 9px; + border-top: 1px solid rgba(155, 133, 121, 0.12); + font-size: 8px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.pattern { + display: grid; + grid-template-columns: 94px minmax(0, 1fr); + gap: 1px 8px; + align-items: center; +} + +.pattern-row { + display: flex; + flex-direction: column; + min-width: 0; + padding: 4px 0; + border-top: 1px solid transparent; + text-align: left; + color: var(--ash); + transition: color 0.2s ease, border-color 0.2s ease; +} + +.pattern-row:hover, +.pattern-row.is-on { + color: var(--bone); +} + +.pattern-row.is-on { + border-top-color: var(--ember); +} + +.pattern-name { + font-size: 9.5px; + line-height: 1.4; +} + +.pattern-face { + overflow: hidden; + font-size: 7.5px; + line-height: 1.4; + color: var(--ash-2); + white-space: nowrap; + text-overflow: ellipsis; +} + +.pattern-cells { + display: grid; + grid-template-columns: repeat(16, minmax(0, 1fr)); + gap: 2px; +} + +.step { + position: relative; + height: 22px; + overflow: hidden; + border-top: 1px solid rgba(155, 133, 121, 0.14); + background: rgba(155, 133, 121, 0.03); +} + +.step.is-downbeat { + border-top-color: rgba(155, 133, 121, 0.34); +} + +.step.is-on { + background: rgba(232, 64, 42, 0.07); +} + +.step:hover { + background: rgba(232, 64, 42, 0.16); +} + +.step-fill { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: calc(var(--velocity) * 100%); + background: linear-gradient(to top, rgba(232, 64, 42, 0.55), rgba(232, 64, 42, 0.14)); + pointer-events: none; +} + +.quarry-side { + min-width: 0; + padding-left: 16px; + border-left: 1px solid rgba(155, 133, 121, 0.12); +} + +.side-head { + display: flex; + justify-content: space-between; + font-size: 8px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.side-note { + margin: 7px 0 11px; + font-size: 9px; + line-height: 1.75; + color: var(--ash); +} + +.face-stats { + display: flex; + flex-direction: column; +} + +.face-stats div { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid rgba(155, 133, 121, 0.07); + font-size: 9px; +} + +.face-stats span { + color: var(--ash-2); +} + +.face-stats b { + overflow: hidden; + color: var(--bone); + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.side-rule { + height: 1px; + margin: 14px 0; + background: rgba(155, 133, 121, 0.1); +} + +.side-legend { + display: flex; + gap: 7px; + padding: 3px 0; + font-size: 8px; + color: var(--ash-2); +} + +.legend-key { + min-width: 64px; + color: var(--ash); +} + +.side-footer { + display: flex; + justify-content: space-between; + gap: 6px; + margin-top: 20px; + padding-top: 10px; + border-top: 1px solid rgba(155, 133, 121, 0.1); + font-size: 8px; + color: var(--ash-2); +} + +.side-footer b { + color: var(--bone); + font-weight: 400; +} + /* — responsive — */ @media (max-width: 1240px) { @@ -1082,6 +1583,24 @@ a:focus-visible, min-height: 420px; width: 100%; } + .quarry { + position: relative; + inset: auto; + width: 100%; + padding: 20px 18px 24px; + } + .quarry-grid { + grid-template-columns: 1fr; + } + .quarry-side { + padding-top: 14px; + padding-left: 0; + border-top: 1px solid rgba(155, 133, 121, 0.12); + border-left: 0; + } + .wave { + height: 130px; + } .scene { top: 27%; width: 86%; @@ -1215,6 +1734,33 @@ a:focus-visible, margin-left: 0; justify-content: flex-end; } + .quarry { + padding: 18px 14px 22px; + } + .quarry-head h1 { + font-size: 26px; + } + .quarry-actions { + gap: 6px; + } + .import-state { + display: none; + } + .face-wall { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .pattern { + grid-template-columns: 66px minmax(0, 1fr); + } + .pattern-cells { + gap: 1px; + } + .step { + height: 19px; + } + .pattern-face { + display: none; + } .pool { padding: 18px 16px 14px; } diff --git a/ui/src/components/Quarry.svelte b/ui/src/components/Quarry.svelte new file mode 100644 index 0000000..928619e --- /dev/null +++ b/ui/src/components/Quarry.svelte @@ -0,0 +1,295 @@ + + + + +
event.preventDefault()} ondrop={dropped}> +
+
+

quarry

+

material · archive · sixteen faces

+
+
+ + {importing ? 'cutting…' : `${cores.length} core${cores.length === 1 ? '' : 's'}`} + + + +
+
+ + {#if !cores.length} +
+

the archive is empty.

+

+ Import a field recording. The quarry finds the seams and cuts sixteen faces. They can be + struck on their own or laid on a vein. +

+ +

WAV · FLAC · MP3 · OGG

+
+ {:else if core} +
+ {#each cores as item} + + {/each} +
+ +
+
+
+
+

{core.name}

+

{core.sample_rate} Hz · {core.duration_sec.toFixed(1)} s · seal {core.seal}

+
+
+ face {face ? String(face.index + 1).padStart(2, '0') : '—'} · rank {face ? String(face.rank + 1).padStart(2, '0') : '—'} +
+
+ + +
+ 0.0 s + {core.duration_sec.toFixed(1)} s +
+ +
+ {#each core.faces as item} + + {/each} +
+ +
+ + + select a face, then lay it on the chosen vein +
+ +
+ pattern + {pattern?.steps ?? 16} steps · two bars +
+
+ {#each pattern?.rows ?? [] as row, rowIndex} + +
+ {#each row.velocities as velocity, step} + + {/each} +
+ {/each} +
+
+ + +
+ {/if} +
diff --git a/ui/src/lib/bridge.ts b/ui/src/lib/bridge.ts index 5007ce5..75f1796 100644 --- a/ui/src/lib/bridge.ts +++ b/ui/src/lib/bridge.ts @@ -1,9 +1,11 @@ import type { Command, CommandResult, + CoreSummary, Event, HelloMessage, MeterFrame, + Seal, ServerMessage, State, Status @@ -17,6 +19,7 @@ export interface BridgeHandlers { onMeter: (meter: MeterFrame) => void; onEvent: (event: Event) => void; onError: (message: string) => void; + onImported?: (core: CoreSummary) => void; } interface Pending { @@ -25,11 +28,19 @@ interface Pending { timer: number; } +interface PendingImport { + resolve: (core: CoreSummary) => void; + reject: (error: Error) => void; + timer: number; +} + const COMMAND_TIMEOUT_MS = 5000; +const IMPORT_TIMEOUT_MS = 60_000; export class Bridge { private ws: WebSocket | null = null; private pending = new Map(); + private imports = new Map(); private nextId = 0; private closed = false; private reconnectTimer: number | null = null; @@ -81,6 +92,7 @@ export class Bridge { ws.onclose = () => { if (this.ws === ws) this.ws = null; this.rejectAll(new Error('the wire closed')); + this.rejectImports(new Error('the wire closed')); this.setStatus('offline'); if (!this.closed) this.scheduleReconnect(); }; @@ -102,6 +114,23 @@ export class Bridge { }); } + importCore(name: string, seal: Seal, data: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('the engine is not on the wire')); + return; + } + const id = ++this.nextId; + const timer = window.setTimeout(() => { + this.imports.delete(id); + reject(new Error('the quarry did not answer in time')); + }, IMPORT_TIMEOUT_MS); + this.imports.set(id, { resolve, reject, timer }); + this.ws.send(JSON.stringify({ type: 'import_core', id, name, seal })); + this.ws.send(data); + }); + } + destroy(): void { this.closed = true; if (this.reconnectTimer !== null) { @@ -111,6 +140,7 @@ export class Bridge { this.ws?.close(); this.ws = null; this.rejectAll(new Error('the wire was closed by the surface')); + this.rejectImports(new Error('the wire was closed by the surface')); this.setStatus('offline'); } @@ -160,6 +190,15 @@ export class Bridge { case 'event': this.handlers.onEvent(message.event); break; + case 'imported': { + const pending = this.imports.get(message.id); + if (!pending) break; + this.imports.delete(message.id); + window.clearTimeout(pending.timer); + this.handlers.onImported?.(message.core); + pending.resolve(message.core); + break; + } case 'error': { if (message.id !== null) { const pending = this.pending.get(message.id); @@ -169,6 +208,13 @@ export class Bridge { pending.reject(new Error(message.message)); break; } + const importing = this.imports.get(message.id); + if (importing) { + this.imports.delete(message.id); + window.clearTimeout(importing.timer); + importing.reject(new Error(message.message)); + break; + } } this.handlers.onError(message.message); break; @@ -196,6 +242,14 @@ export class Bridge { this.pending.clear(); } + private rejectImports(error: Error): void { + for (const [, pending] of this.imports) { + window.clearTimeout(pending.timer); + pending.reject(error); + } + this.imports.clear(); + } + private setStatus(status: Status): void { if (this.status === status) return; this.status = status; diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts index 417cf4d..fb27f48 100644 --- a/ui/src/lib/format.ts +++ b/ui/src/lib/format.ts @@ -30,6 +30,9 @@ export function commandLabel(command: Command): string { case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`; case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`; case 'set_vein_gain': return `${command.vein} gain ${command.gain.toFixed(2)}`; + 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 'get_snapshot': return 'snapshot'; } } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index a46eabc..614f285 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -1,6 +1,30 @@ // Wire types for protocol v1. Kept in step with crates/zhila-protocol. export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt'; export type Status = 'connecting' | 'live' | 'offline'; +export type Seal = 'found' | 'taken' | 'gift' | 'loan' | 'unknown'; + +export interface FaceSummary { + index: number; + rank: number; + start_sec: number; + end_sec: number; + rms: number; +} + +export interface CoreSummary { + id: string; + name: string; + seal: Seal; + sample_rate: number; + duration_sec: number; + faces: FaceSummary[]; + peaks: number[]; +} + +export interface FaceRef { + core: string; + face: number; +} export interface AudioInfo { running: boolean; @@ -25,6 +49,7 @@ export interface VeinState { export interface PatternRow { vein: VeinId; + face: FaceRef | null; velocities: number[]; } @@ -39,6 +64,7 @@ export interface State { transport: TransportState; veins: VeinState[]; pattern: Pattern; + cores: CoreSummary[]; audio: AudioInfo; journal_len: number; } @@ -47,7 +73,10 @@ export type Event = | { kind: 'transport'; playing: boolean } | { kind: 'tempo'; bpm: number } | { kind: 'vein_gain'; vein: VeinId; gain: number } - | { kind: 'struck'; vein: VeinId; velocity: number }; + | { kind: 'struck'; vein: VeinId; velocity: number } + | { kind: 'pattern_step'; row: number; step: number; velocity: number } + | { kind: 'pattern_face'; row: number; core: string; face: number } + | { kind: 'face_struck'; core: string; face: number; velocity: number }; export type Command = | { type: 'get_snapshot' } @@ -55,7 +84,10 @@ export type Command = | { type: 'stop' } | { type: 'strike'; vein: VeinId; velocity: number } | { type: 'set_tempo'; bpm: number } - | { type: 'set_vein_gain'; vein: VeinId; gain: number }; + | { type: 'set_vein_gain'; vein: VeinId; gain: number } + | { type: 'strike_face'; core: string; face: number; velocity: number } + | { type: 'set_pattern_step'; row: number; step: number; velocity: number } + | { type: 'set_pattern_face'; row: number; core: string; face: number }; export type CommandResult = | { kind: 'snapshot'; state: State } @@ -93,7 +125,13 @@ export interface ErrorMessage { message: string; } -export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage; +export interface ImportedMessage { + type: 'imported'; + id: number; + core: CoreSummary; +} + +export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage | ImportedMessage; export interface MeterFrame { bar: number;