P1a: material import, sixteen faces, sample engine and quarry
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/zhila-core",
|
||||
"crates/zhila-engine",
|
||||
"crates/zhila-daemon",
|
||||
"crates/zhila-material",
|
||||
]
|
||||
exclude = ["app"]
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 569 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 328 KiB |
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||