P1a: material import, sixteen faces, sample engine and quarry

This commit is contained in:
Emil
2026-11-01 00:56:51 +03:00
parent db4368efae
commit ebdeca3b92
28 changed files with 2799 additions and 114 deletions
Generated
+258 -9
View File
@@ -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"
+1
View File
@@ -5,6 +5,7 @@ members = [
"crates/zhila-core",
"crates/zhila-engine",
"crates/zhila-daemon",
"crates/zhila-material",
]
exclude = ["app"]
+47 -37
View File
@@ -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).
+382 -7
View File
@@ -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<AudioFace>,
}
#[derive(Clone)]
pub struct AudioCore {
pub id: String,
pub sample_rate: u32,
pub samples: Arc<[f32]>,
pub faces: Vec<AudioFace>,
}
/// Immutable material view shared with the audio thread.
#[derive(Clone)]
pub struct MaterialSnapshot {
pub pattern: Pattern,
pub cores: Vec<AudioCore>,
}
/// 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<MaterialSnapshot>);
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<Core>,
journal: Vec<JournalEntry>,
engine: Arc<dyn Engine>,
}
@@ -65,16 +110,19 @@ impl Session {
}
pub fn with_pattern(engine: Arc<dyn Engine>, 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<Event>), 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<Event>), 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<Vec<String>>,
@@ -320,6 +552,10 @@ mod tests {
fn calls(&self) -> Vec<String> {
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<MaterialSnapshot>) {
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<TestEngine>) {
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<AudioFace> = (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)
));
}
}
+1
View File
@@ -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
+71 -5
View File
@@ -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,
}
}
+237 -36
View File
@@ -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<MaterialSnapshot>),
}
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<SampleVoice>,
}
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<AudioCommand>,
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<Arc<MaterialSnapshot>>,
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<AudioCommand>) -> 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<MaterialSnapshot>) {
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<MaterialSnapshot>) {}
fn info(&self) -> AudioInfo {
self.info.clone()
}
+15
View File
@@ -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"
+316
View File
@@ -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<u8>) -> Result<Core, MaterialError> {
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<u8>) -> Result<(Vec<f32>, 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<f32> = 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::<f32>::new(decoded.capacity() as u64, spec);
buffer.copy_interleaved_ref(decoded);
for frame in buffer.samples().chunks(channels) {
let mono = frame.iter().sum::<f32>() / 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<AudioFace>, Vec<FaceSummary>) {
let hop = (sample_rate as f32 / ONSET_HOPS_PER_SECOND).max(64.0) as usize;
let energy: Vec<f32> = samples
.chunks(hop)
.map(|chunk| {
chunk.iter().map(|sample| sample * sample).sum::<f32>() / 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::<f32>() / flux.len().max(1) as f32;
let variance = flux
.iter()
.map(|value| (value - mean) * (value - mean))
.sum::<f32>()
/ 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<usize> = 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<usize> = 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::<f32>()
/ 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<usize> = (0..summaries.len()).collect();
ranked.sort_by(|a, b| summaries[*b].rms.total_cmp(&summaries[*a].rms));
for (rank, index) in ranked.into_iter().enumerate() {
summaries[index].rank = rank as u8;
}
(faces, summaries)
}
fn waveform_peaks(samples: &[f32]) -> Vec<f32> {
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<u8> {
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)
));
}
}
+113 -7
View File
@@ -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<FaceSummary>,
/// Normalised waveform envelope for the quarry, roughly 1400 bins.
pub peaks: Vec<f32>,
}
#[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<u64>,
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<VeinState>,
pub pattern: Pattern,
pub cores: Vec<CoreSummary>,
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<FaceRef>,
/// One velocity per step, 0..=127. Zero means the step is empty.
pub velocities: Vec<u8>,
}
+55
View File
@@ -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<MaterialSnapshot> 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.
+1 -1
View File
@@ -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.
+55
View File
@@ -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.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 569 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

+125
View File
@@ -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));
}
};
+2 -1
View File
@@ -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",
+64
View File
@@ -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');
+117 -8
View File
@@ -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 @@
<span class="version">0.1</span>
</div>
<div class="rail-center">
<span class="room is-on">nave</span>
<button class="room" class:is-on={view === 'nave'} aria-pressed={view === 'nave'} onclick={() => (view = 'nave')}>
nave
</button>
<span class="rail-rule" aria-hidden="true"></span>
<button class="room" class:is-on={view === 'quarry'} aria-pressed={view === 'quarry'} onclick={() => (view = 'quarry')}>
quarry
</button>
<span class="rail-rule" aria-hidden="true"></span>
<span class="room is-on"><span class="mode-dot" aria-hidden="true"></span>hand</span>
<span class="rail-note">{project ? `${project.veins.length} veins` : 'five veins'}</span>
<span class="rail-note">
{view === 'quarry'
? `${project?.cores.length ?? 0} core${(project?.cores.length ?? 0) === 1 ? '' : 's'}`
: project
? `${project.veins.length} veins`
: 'five veins'}
</span>
</div>
<div class="net" class:is-live={status === 'live'}>
<span class="dot" aria-hidden="true"></span>
@@ -193,7 +284,25 @@
</header>
<main class="hero">
<Descent state={project} {meter} {selected} onselect={select} {strike} />
{#if view === 'nave'}
<Descent state={project} {meter} {selected} onselect={select} {strike} />
{:else}
<Quarry
cores={project?.cores ?? []}
pattern={project?.pattern ?? null}
selectedCore={selectedCoreId}
selectedFace={selectedFace}
selectedRow={selectedRow}
{importing}
onselectcore={selectCore}
onselectface={(face) => (selectedFace = face)}
onselectrow={(row) => (selectedRow = row)}
onimport={importCore}
onassign={assignFace}
onstrikeface={strikeFace}
onstep={toggleStep}
/>
{/if}
<Wire {entries} {status} onreconnect={reconnect} />
<EnginePanel state={project} {meter} {status} session={hello?.session ?? '—'} ongain={setGain} />
</main>
+546
View File
@@ -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;
}
+295
View File
@@ -0,0 +1,295 @@
<svelte:options runes={true} />
<script lang="ts">
import { onMount } from 'svelte';
import type { CoreSummary, Pattern } from '../lib/types';
let {
cores,
pattern,
selectedCore,
selectedFace,
selectedRow,
importing,
onselectcore,
onselectface,
onselectrow,
onimport,
onassign,
onstrikeface,
onstep
}: {
cores: CoreSummary[];
pattern: Pattern | null;
selectedCore: string | null;
selectedFace: number;
selectedRow: number;
importing: boolean;
onselectcore: (id: string) => void;
onselectface: (face: number) => void;
onselectrow: (row: number) => void;
onimport: (file: File) => void;
onassign: () => void;
onstrikeface: (core: string, face: number) => void;
onstep: (row: number, step: number, on: boolean) => void;
} = $props();
let fileInput: HTMLInputElement | null = null;
let wave: HTMLCanvasElement | null = $state(null);
let observer: ResizeObserver | null = null;
const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null);
const face = $derived(
core?.faces.find((item) => item.index === selectedFace) ??
core?.faces.find((item) => item.rank === 0) ??
null
);
onMount(() => {
if (!wave) return;
observer = new ResizeObserver(() => draw());
observer.observe(wave);
draw();
return () => observer?.disconnect();
});
$effect(() => {
core;
face;
draw();
});
function draw() {
const canvas = wave;
if (!canvas || !core) return;
const width = Math.max(200, canvas.clientWidth);
const height = 154;
const dpr = Math.min(2, window.devicePixelRatio || 1);
if (canvas.width !== Math.floor(width * dpr) || canvas.height !== Math.floor(height * dpr)) {
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
}
const context = canvas.getContext('2d');
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const center = height / 2;
const duration = Math.max(0.001, core.duration_sec);
for (const item of core.faces) {
const start = (item.start_sec / duration) * width;
const end = (item.end_sec / duration) * width;
if (face && item.index === face.index) {
context.fillStyle = 'rgba(232,64,42,0.08)';
context.fillRect(start, 0, end - start, height);
}
context.strokeStyle =
item.index % 4 === 0 ? 'rgba(232,64,42,0.34)' : 'rgba(155,133,121,0.14)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(start + 0.5, 0);
context.lineTo(start + 0.5, height);
context.stroke();
}
const step = width / Math.max(1, core.peaks.length - 1);
context.fillStyle = 'rgba(232,64,42,0.58)';
for (let index = 0; index < core.peaks.length; index += 1) {
const amplitude = core.peaks[index] * (height * 0.38);
context.fillRect(index * step, center - amplitude, Math.max(1, step * 0.72), amplitude * 2);
}
context.strokeStyle = 'rgba(239,228,212,0.14)';
context.beginPath();
context.moveTo(0, center + 0.5);
context.lineTo(width, center + 0.5);
context.stroke();
if (face) {
const start = (face.start_sec / duration) * width;
const end = (face.end_sec / duration) * width;
context.strokeStyle = 'rgba(239,228,212,0.72)';
context.lineWidth = 1;
context.strokeRect(start + 0.5, 0.5, Math.max(1, end - start - 1), height - 1);
context.font = '9px "JetBrains Mono", monospace';
context.fillStyle = 'rgba(239,228,212,0.8)';
context.fillText(`f${String(face.index + 1).padStart(2, '0')}`, start + 5, 13);
}
}
function pickFile() {
fileInput?.click();
}
function fileChanged() {
const file = fileInput?.files?.[0];
if (file) onimport(file);
if (fileInput) fileInput.value = '';
}
function dropped(event: DragEvent) {
event.preventDefault();
const file = event.dataTransfer?.files?.[0];
if (file) onimport(file);
}
</script>
<section class="quarry" aria-label="quarry" ondragover={(event) => event.preventDefault()} ondrop={dropped}>
<header class="quarry-head">
<div>
<h1>quarry</h1>
<p>material · archive · sixteen faces</p>
</div>
<div class="quarry-actions">
<span class="import-state">
{importing ? 'cutting…' : `${cores.length} core${cores.length === 1 ? '' : 's'}`}
</span>
<button class="import" onclick={pickFile} disabled={importing}>take a core</button>
<input
class="file"
bind:this={fileInput}
type="file"
accept="audio/*,.wav,.flac,.mp3,.ogg"
onchange={fileChanged}
aria-hidden="true"
tabindex="-1"
/>
</div>
</header>
{#if !cores.length}
<div class="quarry-empty">
<p class="empty-title">the archive is empty.</p>
<p class="empty-copy">
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.
</p>
<button class="empty-action" onclick={pickFile} disabled={importing}>take a core</button>
<p class="empty-formats">WAV · FLAC · MP3 · OGG</p>
</div>
{:else if core}
<div class="core-strip" aria-label="cores">
{#each cores as item}
<button
class="core-tab"
class:is-on={core.id === item.id}
onclick={() => onselectcore(item.id)}
aria-pressed={core.id === item.id}
>
<span class="core-name">{item.name}</span>
<span class="core-meta">{item.duration_sec.toFixed(1)} s · {item.sample_rate / 1000} k</span>
</button>
{/each}
</div>
<div class="quarry-grid">
<div class="quarry-left">
<div class="material-head">
<div>
<h2>{core.name}</h2>
<p>{core.sample_rate} Hz · {core.duration_sec.toFixed(1)} s · seal {core.seal}</p>
</div>
<div class="material-rank">
face {face ? String(face.index + 1).padStart(2, '0') : '—'} · rank {face ? String(face.rank + 1).padStart(2, '0') : '—'}
</div>
</div>
<canvas class="wave" bind:this={wave} aria-hidden="true"></canvas>
<div class="wave-axis">
<span>0.0 s</span>
<span>{core.duration_sec.toFixed(1)} s</span>
</div>
<div class="face-wall" role="group" aria-label="sixteen faces">
{#each core.faces as item}
<button
class="face"
class:is-on={face?.index === item.index}
style={`--energy:${Math.min(1, item.rms * 3.2).toFixed(3)}`}
onclick={() => onselectface(item.index)}
ondblclick={() => onstrikeface(core.id, item.index)}
aria-pressed={face?.index === item.index}
aria-label={`face ${item.index + 1}, rank ${item.rank + 1}`}
title={`face ${item.index + 1} · rank ${item.rank + 1} · double-click to strike`}
>
<span class="face-index">{String(item.index + 1).padStart(2, '0')}</span>
<span class="face-rank">{String(item.rank + 1).padStart(2, '0')}</span>
<span class="face-energy" aria-hidden="true"></span>
</button>
{/each}
</div>
<div class="material-actions">
<button class="face-strike" onclick={() => face && onstrikeface(core.id, face.index)} disabled={!face}>
strike face
</button>
<button class="assign" onclick={onassign} disabled={!face}>
lay on {pattern?.rows[selectedRow]?.vein ?? 'bass'}
</button>
<span class="assign-note">select a face, then lay it on the chosen vein</span>
</div>
<div class="pattern-head">
<span>pattern</span>
<span>{pattern?.steps ?? 16} steps · two bars</span>
</div>
<div class="pattern">
{#each pattern?.rows ?? [] as row, rowIndex}
<button
class="pattern-row"
class:is-on={selectedRow === rowIndex}
onclick={() => onselectrow(rowIndex)}
aria-pressed={selectedRow === rowIndex}
aria-label={`select ${row.vein}`}
>
<span class="pattern-name">{row.vein}</span>
<span class="pattern-face">
{row.face ? `${row.face.core.slice(-6)} f${String(row.face.face + 1).padStart(2, '0')}` : 'procedural'}
</span>
</button>
<div class="pattern-cells" role="group" aria-label={`${row.vein} steps`}>
{#each row.velocities as velocity, step}
<button
class="step"
class:is-on={velocity > 0}
class:is-downbeat={step % 8 === 0}
aria-pressed={velocity > 0}
aria-label={`${row.vein}, step ${step + 1}`}
onclick={() => onstep(rowIndex, step, velocity > 0)}
>
<span class="step-fill" style={`--velocity:${(velocity / 127).toFixed(3)}`}></span>
</button>
{/each}
</div>
{/each}
</div>
</div>
<aside class="quarry-side" aria-label="face details">
<div class="side-head"><span>faces</span><span>energy</span></div>
<p class="side-note">
Sixteen seams from the core. Double-click a face to strike it. The loudest face carries
rank 01.
</p>
<div class="face-stats">
<div><span>rank</span><b>{face ? String(face.rank + 1).padStart(2, '0') : '—'}</b></div>
<div><span>rms</span><b>{face ? face.rms.toFixed(3) : '—'}</b></div>
<div><span>length</span><b>{face ? `${(face.end_sec - face.start_sec).toFixed(2)} s` : '—'}</b></div>
<div><span>seal</span><b>{core.seal}</b></div>
</div>
<div class="side-rule"></div>
<div class="side-head"><span>next</span></div>
<p class="side-note">
Select a face, then lay it on the chosen vein. The steps keep their rhythm; the engine
swaps the procedural voice for the sample.
</p>
<div class="side-legend"><span class="legend-key">double-click</span><span>strike a face</span></div>
<div class="side-legend"><span class="legend-key">click</span><span>select a step</span></div>
<div class="side-legend"><span class="legend-key">space</span><span>play the loop</span></div>
<div class="side-footer">
<span>cores</span><b>{cores.length}</b>
<span>faces</span><b>{cores.length * 16}</b>
</div>
</aside>
</div>
{/if}
</section>
+54
View File
@@ -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<number, Pending>();
private imports = new Map<number, PendingImport>();
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<CoreSummary> {
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;
+3
View File
@@ -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';
}
}
+41 -3
View File
@@ -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;