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"
|
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa-sys",
|
"alsa-sys",
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -89,18 +89,43 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -125,12 +150,28 @@ version = "3.20.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
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]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -210,13 +251,25 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
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]]
|
[[package]]
|
||||||
name = "coreaudio-rs"
|
name = "coreaudio-rs"
|
||||||
version = "0.14.2"
|
version = "0.14.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-audio-toolbox",
|
"objc2-audio-toolbox",
|
||||||
"objc2-core-audio",
|
"objc2-core-audio",
|
||||||
@@ -301,10 +354,24 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"objc2",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -321,6 +388,18 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -388,6 +467,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hound"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -554,13 +639,19 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
@@ -649,7 +740,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-audio",
|
"objc2-core-audio",
|
||||||
@@ -664,7 +755,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
@@ -688,7 +779,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -698,7 +789,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -717,7 +808,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -845,6 +936,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@@ -914,6 +1011,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -968,6 +1071,139 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1439,6 +1675,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"zhila-core",
|
"zhila-core",
|
||||||
"zhila-engine",
|
"zhila-engine",
|
||||||
|
"zhila-material",
|
||||||
"zhila-protocol",
|
"zhila-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1453,6 +1690,18 @@ dependencies = [
|
|||||||
"zhila-protocol",
|
"zhila-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zhila-material"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
|
"hound",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"zhila-core",
|
||||||
|
"zhila-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zhila-protocol"
|
name = "zhila-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/zhila-core",
|
"crates/zhila-core",
|
||||||
"crates/zhila-engine",
|
"crates/zhila-engine",
|
||||||
"crates/zhila-daemon",
|
"crates/zhila-daemon",
|
||||||
|
"crates/zhila-material",
|
||||||
]
|
]
|
||||||
exclude = ["app"]
|
exclude = ["app"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
# ZHILA
|
# 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.
|
- **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.
|
||||||
- **Protocol v1.** JSON control frames and a 31-byte binary meter frame over a local WebSocket.
|
- **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.
|
||||||
- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains), performance events that are not history.
|
- **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.
|
||||||
- **Daemon.** Loopback-only, token in the hello, origin guard, snapshot on connect, results and events, binary meters at 30 Hz.
|
- **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.
|
||||||
- **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.
|
- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains, pattern faces and steps); play, stop and strikes are performance, not history.
|
||||||
- **Verification.** Rust unit tests, a wire smoke test against live audio, a browser smoke test with screenshots, `svelte-check`, a production build.
|
- **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
|
## 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
|
```bash
|
||||||
./scripts/dev.sh
|
./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`.
|
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
|
```bash
|
||||||
cargo run -p zhila-daemon -- --port 4317
|
cargo run -p zhila-daemon -- --port 4317
|
||||||
npm --prefix ui run dev
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/zhila-protocol wire types, binary meter frame, tests
|
crates/zhila-protocol wire types, binary meter frame, cores/faces, tests
|
||||||
crates/zhila-core session, journal, validation, engine trait
|
crates/zhila-core session, journal, material snapshot, engine trait
|
||||||
crates/zhila-engine cpal stream, voices, phrase, levels
|
crates/zhila-engine cpal stream, procedural + sample voices, levels
|
||||||
crates/zhila-daemon WebSocket daemon, auth, broadcast
|
crates/zhila-material decode, onset analysis, sixteen faces, peaks
|
||||||
ui Svelte 5 surface (browser-first)
|
crates/zhila-daemon WebSocket daemon, auth, binary import
|
||||||
ui/src/lib/descent.ts the reactive canvas
|
ui Svelte 5 surface: descent + quarry
|
||||||
app Tauri 2 shell (scaffolded, system deps missing)
|
fixtures a small click track for the smoke tests
|
||||||
docs plan, protocol, P0 notes
|
app Tauri 2 shell (scaffolded; system deps missing)
|
||||||
|
docs plan, protocol, P0/P1 notes
|
||||||
reference/demo the earlier design demo
|
reference/demo the earlier design demo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
./scripts/check.sh # fmt, clippy, cargo tests, svelte-check, build
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
node scripts/wire-smoke.mjs # daemon running
|
||||||
npm --prefix ui run check
|
node scripts/material-smoke.mjs # daemon running: import, face, play
|
||||||
npm --prefix ui run build
|
npm --prefix ui run smoke # daemon + Vite: P0 regression, screenshots
|
||||||
node scripts/wire-smoke.mjs # daemon running
|
npm --prefix ui run quarry-smoke # daemon + Vite: import, assign, play, screenshots
|
||||||
npm --prefix ui run smoke # daemon + vite dev running
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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.
|
- 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.
|
||||||
- Five veins are fixed; the engine has per-vein gain, no sends or automation.
|
- No recording, no waveform trimming, no manual cut/loop points; the sixteen faces are automatic seams.
|
||||||
- The surface is read-only except transport, strike, tempo and gains.
|
- No undo/redo for pattern edits yet; the journal records them, the surface does not expose history.
|
||||||
- No session persistence, project file or archive yet.
|
- One fixed two-bar phrase and five veins; pattern rows are veins, one face per row, no per-step face or pitch.
|
||||||
- No agent and no MCP in this phase; the command bus and the journal are the ground for them.
|
- 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.
|
- 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 serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use zhila_protocol::{
|
use zhila_protocol::{
|
||||||
Actor, AudioInfo, Command, CommandResult, Event, LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN,
|
Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FaceRef, LOOP_BARS, LOOP_STEPS,
|
||||||
MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, TransportState,
|
MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State,
|
||||||
VEIN_COUNT, VeinId, VeinState,
|
TransportState, VEIN_COUNT, VeinId, VeinState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
|
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
|
/// The audio engine as seen by the session. Implementations must be real-time
|
||||||
/// safe on the audio thread and non-blocking on these methods.
|
/// safe on the audio thread and non-blocking on these methods.
|
||||||
pub trait Engine: Send + Sync {
|
pub trait Engine: Send + Sync {
|
||||||
fn play(&self);
|
fn play(&self);
|
||||||
fn stop(&self);
|
fn stop(&self);
|
||||||
fn strike(&self, vein: VeinId, velocity: f32);
|
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_tempo(&self, bpm: f32);
|
||||||
fn set_gain(&self, vein: VeinId, gain: f32);
|
fn set_gain(&self, vein: VeinId, gain: f32);
|
||||||
|
fn set_material(&self, material: Arc<MaterialSnapshot>);
|
||||||
fn info(&self) -> AudioInfo;
|
fn info(&self) -> AudioInfo;
|
||||||
fn meter(&self) -> MeterFrame;
|
fn meter(&self) -> MeterFrame;
|
||||||
}
|
}
|
||||||
@@ -37,6 +73,14 @@ pub enum CoreError {
|
|||||||
InvalidVelocity,
|
InvalidVelocity,
|
||||||
#[error("gain must be between 0 and 1.2")]
|
#[error("gain must be between 0 and 1.2")]
|
||||||
InvalidGain,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -55,6 +99,7 @@ pub struct Session {
|
|||||||
playing: bool,
|
playing: bool,
|
||||||
gains: [f32; VEIN_COUNT],
|
gains: [f32; VEIN_COUNT],
|
||||||
pattern: Pattern,
|
pattern: Pattern,
|
||||||
|
cores: Vec<Core>,
|
||||||
journal: Vec<JournalEntry>,
|
journal: Vec<JournalEntry>,
|
||||||
engine: Arc<dyn Engine>,
|
engine: Arc<dyn Engine>,
|
||||||
}
|
}
|
||||||
@@ -65,16 +110,19 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
|
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
|
||||||
Self {
|
let session = Self {
|
||||||
revision: 0,
|
revision: 0,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
bpm: 128.0,
|
bpm: 128.0,
|
||||||
playing: false,
|
playing: false,
|
||||||
gains: INITIAL_GAINS,
|
gains: INITIAL_GAINS,
|
||||||
pattern,
|
pattern,
|
||||||
|
cores: Vec::new(),
|
||||||
journal: Vec::new(),
|
journal: Vec::new(),
|
||||||
engine,
|
engine,
|
||||||
}
|
};
|
||||||
|
session.sync_material();
|
||||||
|
session
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revision(&self) -> u64 {
|
pub fn revision(&self) -> u64 {
|
||||||
@@ -109,6 +157,7 @@ impl Session {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
pattern: self.pattern.clone(),
|
pattern: self.pattern.clone(),
|
||||||
|
cores: self.cores.iter().map(|core| core.summary.clone()).collect(),
|
||||||
audio: self.engine.info(),
|
audio: self.engine.info(),
|
||||||
journal_len: self.journal.len(),
|
journal_len: self.journal.len(),
|
||||||
}
|
}
|
||||||
@@ -223,9 +272,187 @@ impl Session {
|
|||||||
vec![Event::VeinGain { vein, gain }],
|
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) {
|
fn record(&mut self, actor: Actor, command: Command) {
|
||||||
self.seq += 1;
|
self.seq += 1;
|
||||||
self.journal.push(JournalEntry {
|
self.journal.push(JournalEntry {
|
||||||
@@ -278,7 +505,11 @@ pub fn default_pattern() -> Pattern {
|
|||||||
velocities[13] = 60;
|
velocities[13] = 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.push(PatternRow { vein, velocities });
|
rows.push(PatternRow {
|
||||||
|
vein,
|
||||||
|
face: None,
|
||||||
|
velocities,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Pattern {
|
Pattern {
|
||||||
steps: LOOP_STEPS as u16,
|
steps: LOOP_STEPS as u16,
|
||||||
@@ -291,6 +522,7 @@ pub fn default_pattern() -> Pattern {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use zhila_protocol::{CoreSummary, FaceSummary, Seal};
|
||||||
|
|
||||||
struct TestEngine {
|
struct TestEngine {
|
||||||
calls: Mutex<Vec<String>>,
|
calls: Mutex<Vec<String>>,
|
||||||
@@ -320,6 +552,10 @@ mod tests {
|
|||||||
fn calls(&self) -> Vec<String> {
|
fn calls(&self) -> Vec<String> {
|
||||||
self.calls.lock().unwrap().clone()
|
self.calls.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear(&self) {
|
||||||
|
self.calls.lock().unwrap().clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine for TestEngine {
|
impl Engine for TestEngine {
|
||||||
@@ -335,6 +571,12 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push(format!("strike:{}:{velocity}", vein.name()));
|
.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) {
|
fn set_tempo(&self, bpm: f32) {
|
||||||
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
|
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
|
||||||
}
|
}
|
||||||
@@ -344,6 +586,12 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push(format!("gain:{}:{gain}", vein.name()));
|
.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 {
|
fn info(&self) -> AudioInfo {
|
||||||
self.info.clone()
|
self.info.clone()
|
||||||
}
|
}
|
||||||
@@ -354,7 +602,43 @@ mod tests {
|
|||||||
|
|
||||||
fn session() -> (Session, Arc<TestEngine>) {
|
fn session() -> (Session, Arc<TestEngine>) {
|
||||||
let engine = Arc::new(TestEngine::new());
|
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]
|
#[test]
|
||||||
@@ -467,5 +751,96 @@ mod tests {
|
|||||||
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
||||||
assert_eq!(state.audio.device, "test");
|
assert_eq!(state.audio.device, "test");
|
||||||
assert_eq!(state.journal_len, 0);
|
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]
|
[dependencies]
|
||||||
zhila-core = { path = "../zhila-core" }
|
zhila-core = { path = "../zhila-core" }
|
||||||
zhila-engine = { path = "../zhila-engine" }
|
zhila-engine = { path = "../zhila-engine" }
|
||||||
|
zhila-material = { path = "../zhila-material" }
|
||||||
zhila-protocol = { path = "../zhila-protocol" }
|
zhila-protocol = { path = "../zhila-protocol" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use tracing::{info, warn};
|
|||||||
use zhila_core::{CoreError, Engine, Session};
|
use zhila_core::{CoreError, Engine, Session};
|
||||||
use zhila_engine::{AudioEngine, NullEngine};
|
use zhila_engine::{AudioEngine, NullEngine};
|
||||||
use zhila_protocol::{
|
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);
|
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
@@ -216,7 +216,7 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
|||||||
protocol,
|
protocol,
|
||||||
token,
|
token,
|
||||||
} => (client, protocol, token),
|
} => (client, protocol, token),
|
||||||
ClientMessage::Command { .. } => {
|
ClientMessage::Command { .. } | ClientMessage::ImportCore { .. } => {
|
||||||
let _ = send_out(
|
let _ = send_out(
|
||||||
&mut sink,
|
&mut sink,
|
||||||
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
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 {
|
while let Some(frame) = source.next().await {
|
||||||
let message = match frame {
|
let message = match frame {
|
||||||
Ok(Message::Text(text)) => text,
|
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(Message::Close(_)) => break,
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -330,6 +381,17 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
|||||||
ClientMessage::Hello { .. } => {
|
ClientMessage::Hello { .. } => {
|
||||||
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
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 } => {
|
ClientMessage::Command { id, command } => {
|
||||||
let outcome = {
|
let outcome = {
|
||||||
let mut session = shared.session.lock().await;
|
let mut session = shared.session.lock().await;
|
||||||
@@ -403,9 +465,13 @@ fn origin_allowed(origin: &str) -> bool {
|
|||||||
|
|
||||||
fn error_code(error: &CoreError) -> ErrorCode {
|
fn error_code(error: &CoreError) -> ErrorCode {
|
||||||
match error {
|
match error {
|
||||||
CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => {
|
CoreError::InvalidTempo
|
||||||
ErrorCode::BadRequest
|
| 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 cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig};
|
||||||
use rtrb::{Consumer, Producer, RingBuffer};
|
use rtrb::{Consumer, Producer, RingBuffer};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use zhila_core::Engine as EngineControl;
|
use zhila_core::{Engine as EngineControl, MaterialSnapshot};
|
||||||
use zhila_protocol::{
|
use zhila_protocol::{
|
||||||
AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern,
|
AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern,
|
||||||
STEPS_PER_BAR, VEIN_COUNT, VeinId,
|
STEPS_PER_BAR, VEIN_COUNT, VeinId,
|
||||||
@@ -20,13 +20,27 @@ use zhila_protocol::{
|
|||||||
|
|
||||||
const VOICES: usize = 32;
|
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 {
|
enum AudioCommand {
|
||||||
Play,
|
Play,
|
||||||
Stop,
|
Stop,
|
||||||
Strike { vein: u8, velocity: f32 },
|
Strike {
|
||||||
|
vein: u8,
|
||||||
|
velocity: f32,
|
||||||
|
},
|
||||||
|
StrikeFace {
|
||||||
|
core: String,
|
||||||
|
face: u8,
|
||||||
|
velocity: f32,
|
||||||
|
},
|
||||||
SetTempo(f32),
|
SetTempo(f32),
|
||||||
SetGain { vein: u8, gain: f32 },
|
SetGain {
|
||||||
|
vein: u8,
|
||||||
|
gain: f32,
|
||||||
|
},
|
||||||
|
SetMaterial(Arc<MaterialSnapshot>),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Levels {
|
struct Levels {
|
||||||
@@ -63,6 +77,14 @@ impl Levels {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SampleVoice {
|
||||||
|
core_index: usize,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
position: f64,
|
||||||
|
rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
struct Voice {
|
struct Voice {
|
||||||
active: bool,
|
active: bool,
|
||||||
vein: usize,
|
vein: usize,
|
||||||
@@ -78,6 +100,7 @@ struct Voice {
|
|||||||
hp: f32,
|
hp: f32,
|
||||||
prev: f32,
|
prev: f32,
|
||||||
rng: u32,
|
rng: u32,
|
||||||
|
sample: Option<SampleVoice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Voice {
|
impl Voice {
|
||||||
@@ -97,6 +120,7 @@ impl Voice {
|
|||||||
hp: 0.0,
|
hp: 0.0,
|
||||||
prev: 0.0,
|
prev: 0.0,
|
||||||
rng: 0x9e37_79b9,
|
rng: 0x9e37_79b9,
|
||||||
|
sample: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,19 +134,52 @@ impl Voice {
|
|||||||
self.lp = 0.0;
|
self.lp = 0.0;
|
||||||
self.hp = 0.0;
|
self.hp = 0.0;
|
||||||
self.prev = 0.0;
|
self.prev = 0.0;
|
||||||
let (freq, attack, decay, pan) = match vein {
|
self.sample = None;
|
||||||
VeinId::Bass => (55.0, 0.004, 3.2, 0.0),
|
let (freq, attack, decay) = match vein {
|
||||||
VeinId::Crack => (0.0, 0.001, 38.0, 0.35),
|
VeinId::Bass => (55.0, 0.004, 3.2),
|
||||||
VeinId::Voice => (220.0, 0.09, 1.1, -0.25),
|
VeinId::Crack => (0.0, 0.001, 38.0),
|
||||||
VeinId::Noise => (0.0, 0.004, 5.0, 0.55),
|
VeinId::Voice => (220.0, 0.09, 1.1),
|
||||||
VeinId::Silt => (1318.5, 0.002, 14.0, -0.45),
|
VeinId::Noise => (0.0, 0.004, 5.0),
|
||||||
|
VeinId::Silt => (1318.5, 0.002, 14.0),
|
||||||
};
|
};
|
||||||
self.freq = freq;
|
self.freq = freq;
|
||||||
self.attack = attack;
|
self.attack = attack;
|
||||||
self.decay = decay;
|
self.decay = decay;
|
||||||
let angle = (pan + 1.0) * FRAC_PI_4;
|
let (gain_l, gain_r) = pan_for(vein);
|
||||||
self.gain_l = angle.cos();
|
self.gain_l = gain_l;
|
||||||
self.gain_r = angle.sin();
|
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 {
|
fn noise(&mut self) -> f32 {
|
||||||
@@ -130,8 +187,35 @@ impl Voice {
|
|||||||
(self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
(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;
|
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 attack = (self.t / self.attack).min(1.0);
|
||||||
let env = attack * (-self.t * self.decay).exp();
|
let env = attack * (-self.t * self.decay).exp();
|
||||||
if env < 0.0002 || self.t > 8.0 {
|
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 {
|
struct AudioState {
|
||||||
rx: Consumer<AudioCommand>,
|
rx: Consumer<AudioCommand>,
|
||||||
sample_rate: f32,
|
sample_rate: f32,
|
||||||
@@ -181,7 +277,8 @@ struct AudioState {
|
|||||||
playing: bool,
|
playing: bool,
|
||||||
step: usize,
|
step: usize,
|
||||||
pending: f64,
|
pending: f64,
|
||||||
pattern: [[u8; LOOP_STEPS]; VEIN_COUNT],
|
fallback: Pattern,
|
||||||
|
material: Option<Arc<MaterialSnapshot>>,
|
||||||
voices: [Voice; VOICES],
|
voices: [Voice; VOICES],
|
||||||
gains: [f32; VEIN_COUNT],
|
gains: [f32; VEIN_COUNT],
|
||||||
master_env: f32,
|
master_env: f32,
|
||||||
@@ -192,13 +289,6 @@ struct AudioState {
|
|||||||
|
|
||||||
impl AudioState {
|
impl AudioState {
|
||||||
fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer<AudioCommand>) -> Self {
|
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 {
|
Self {
|
||||||
rx,
|
rx,
|
||||||
sample_rate: sample_rate as f32,
|
sample_rate: sample_rate as f32,
|
||||||
@@ -207,7 +297,8 @@ impl AudioState {
|
|||||||
playing: false,
|
playing: false,
|
||||||
step: 0,
|
step: 0,
|
||||||
pending: 0.0,
|
pending: 0.0,
|
||||||
pattern: grid,
|
fallback: pattern.clone(),
|
||||||
|
material: None,
|
||||||
voices: std::array::from_fn(|_| Voice::inactive()),
|
voices: std::array::from_fn(|_| Voice::inactive()),
|
||||||
gains: zhila_core::INITIAL_GAINS,
|
gains: zhila_core::INITIAL_GAINS,
|
||||||
master_env: 0.0,
|
master_env: 0.0,
|
||||||
@@ -246,6 +337,34 @@ impl AudioState {
|
|||||||
};
|
};
|
||||||
self.trigger(vein, velocity);
|
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) => {
|
AudioCommand::SetTempo(bpm) => {
|
||||||
self.bpm = bpm.clamp(MIN_BPM, MAX_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);
|
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) {
|
fn free_voice(&self) -> usize {
|
||||||
if velocity <= 0.0 {
|
self.voices
|
||||||
return;
|
|
||||||
}
|
|
||||||
let index = self
|
|
||||||
.voices
|
|
||||||
.iter()
|
.iter()
|
||||||
.position(|voice| !voice.active)
|
.position(|voice| !voice.active)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
@@ -272,18 +390,86 @@ impl AudioState {
|
|||||||
.max_by(|a, b| a.1.t.total_cmp(&b.1.t))
|
.max_by(|a, b| a.1.t.total_cmp(&b.1.t))
|
||||||
.map(|(index, _)| index)
|
.map(|(index, _)| index)
|
||||||
.unwrap_or(0)
|
.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.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
self.voices[index].trigger(vein, velocity, self.rng);
|
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) {
|
fn trigger_step(&mut self) {
|
||||||
let step = self.step;
|
let step = self.step;
|
||||||
for vein in 0..VEIN_COUNT {
|
let (plan, count) = {
|
||||||
let velocity = self.pattern[vein][step];
|
let material = self.material.as_ref().cloned();
|
||||||
if velocity > 0
|
let pattern = material
|
||||||
&& let Some(vein) = VeinId::from_index(vein)
|
.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);
|
self.trigger(vein, velocity as f32 / 127.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +485,7 @@ impl AudioState {
|
|||||||
self.handle(command);
|
self.handle(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let material = self.material.as_ref().cloned();
|
||||||
for frame in data.chunks_mut(self.channels) {
|
for frame in data.chunks_mut(self.channels) {
|
||||||
if self.playing {
|
if self.playing {
|
||||||
if self.pending <= 0.0 {
|
if self.pending <= 0.0 {
|
||||||
@@ -315,7 +502,7 @@ impl AudioState {
|
|||||||
if !voice.active {
|
if !voice.active {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sample = voice.next(self.sample_rate);
|
let sample = voice.next(self.sample_rate, material.as_deref());
|
||||||
let index = voice.vein;
|
let index = voice.vein;
|
||||||
let amplitude = sample * self.gains[index];
|
let amplitude = sample * self.gains[index];
|
||||||
left += amplitude * voice.gain_l;
|
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) {
|
fn set_tempo(&self, bpm: f32) {
|
||||||
self.send(AudioCommand::SetTempo(bpm));
|
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 {
|
fn info(&self) -> AudioInfo {
|
||||||
self.info.clone()
|
self.info.clone()
|
||||||
}
|
}
|
||||||
@@ -527,8 +726,10 @@ impl EngineControl for NullEngine {
|
|||||||
fn play(&self) {}
|
fn play(&self) {}
|
||||||
fn stop(&self) {}
|
fn stop(&self) {}
|
||||||
fn strike(&self, _vein: VeinId, _velocity: f32) {}
|
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_tempo(&self, _bpm: f32) {}
|
||||||
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
|
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
|
||||||
|
fn set_material(&self, _material: Arc<MaterialSnapshot>) {}
|
||||||
fn info(&self) -> AudioInfo {
|
fn info(&self) -> AudioInfo {
|
||||||
self.info.clone()
|
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,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
@@ -91,6 +131,12 @@ pub enum ClientMessage {
|
|||||||
id: u64,
|
id: u64,
|
||||||
command: Command,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -113,6 +159,10 @@ pub enum ServerMessage {
|
|||||||
id: u64,
|
id: u64,
|
||||||
result: CommandResult,
|
result: CommandResult,
|
||||||
},
|
},
|
||||||
|
Imported {
|
||||||
|
id: u64,
|
||||||
|
core: CoreSummary,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
id: Option<u64>,
|
id: Option<u64>,
|
||||||
code: ErrorCode,
|
code: ErrorCode,
|
||||||
@@ -126,9 +176,37 @@ pub enum Command {
|
|||||||
GetSnapshot,
|
GetSnapshot,
|
||||||
Play,
|
Play,
|
||||||
Stop,
|
Stop,
|
||||||
Strike { vein: VeinId, velocity: f32 },
|
Strike {
|
||||||
SetTempo { bpm: f32 },
|
vein: VeinId,
|
||||||
SetVeinGain { vein: VeinId, gain: f32 },
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -141,10 +219,35 @@ pub enum CommandResult {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
Transport { playing: bool },
|
Transport {
|
||||||
Tempo { bpm: f32 },
|
playing: bool,
|
||||||
VeinGain { vein: VeinId, gain: f32 },
|
},
|
||||||
Struck { vein: VeinId, velocity: f32 },
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -184,6 +287,7 @@ pub struct State {
|
|||||||
pub transport: TransportState,
|
pub transport: TransportState,
|
||||||
pub veins: Vec<VeinState>,
|
pub veins: Vec<VeinState>,
|
||||||
pub pattern: Pattern,
|
pub pattern: Pattern,
|
||||||
|
pub cores: Vec<CoreSummary>,
|
||||||
pub audio: AudioInfo,
|
pub audio: AudioInfo,
|
||||||
pub journal_len: usize,
|
pub journal_len: usize,
|
||||||
}
|
}
|
||||||
@@ -219,6 +323,8 @@ impl Pattern {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct PatternRow {
|
pub struct PatternRow {
|
||||||
pub vein: VeinId,
|
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.
|
/// One velocity per step, 0..=127. Zero means the step is empty.
|
||||||
pub velocities: Vec<u8>,
|
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 — план проекта
|
# ZHILA — план проекта
|
||||||
|
|
||||||
Черновик v1.1 · 1 ноября 2026
|
Черновик 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.
|
**Зафиксировано 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.
|
- Project/asset commands.
|
||||||
- Agent tools and MCP.
|
- Agent tools and MCP.
|
||||||
- Per-client cursors or collaboration.
|
- 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",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5173 --strictPort",
|
"preview": "vite preview --port 5173 --strictPort",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
"smoke": "node scripts/smoke.mjs"
|
"smoke": "node scripts/smoke.mjs",
|
||||||
|
"quarry-smoke": "node scripts/quarry-smoke.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@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 { onMount } from 'svelte';
|
||||||
import Descent from './components/Descent.svelte';
|
import Descent from './components/Descent.svelte';
|
||||||
import EnginePanel from './components/EnginePanel.svelte';
|
import EnginePanel from './components/EnginePanel.svelte';
|
||||||
|
import Quarry from './components/Quarry.svelte';
|
||||||
import Transport from './components/Transport.svelte';
|
import Transport from './components/Transport.svelte';
|
||||||
import Wire from './components/Wire.svelte';
|
import Wire from './components/Wire.svelte';
|
||||||
import { Bridge } from './lib/bridge';
|
import { Bridge } from './lib/bridge';
|
||||||
import { commandLabel } from './lib/format';
|
import { commandLabel } from './lib/format';
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
|
CoreSummary,
|
||||||
Event,
|
Event,
|
||||||
HelloMessage,
|
HelloMessage,
|
||||||
MeterFrame,
|
MeterFrame,
|
||||||
@@ -30,6 +32,11 @@
|
|||||||
let selected: VeinId = $state('bass');
|
let selected: VeinId = $state('bass');
|
||||||
let entries: WireEntry[] = $state([]);
|
let entries: WireEntry[] = $state([]);
|
||||||
let hello: HelloMessage | null = $state(null);
|
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 bridge: Bridge | null = null;
|
||||||
let entryId = 0;
|
let entryId = 0;
|
||||||
let snapshotLogged = false;
|
let snapshotLogged = false;
|
||||||
@@ -57,12 +64,23 @@
|
|||||||
|
|
||||||
function onState(next: State) {
|
function onState(next: State) {
|
||||||
project = next;
|
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) {
|
if (!snapshotLogged) {
|
||||||
snapshotLogged = true;
|
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) {
|
function onMeter(next: MeterFrame) {
|
||||||
meter = next;
|
meter = next;
|
||||||
}
|
}
|
||||||
@@ -74,6 +92,12 @@
|
|||||||
else if (event.kind === 'vein_gain') {
|
else if (event.kind === 'vein_gain') {
|
||||||
const vein = project.veins.find((entry) => entry.id === event.vein);
|
const vein = project.veins.find((entry) => entry.id === event.vein);
|
||||||
if (vein) vein.gain = event.gain;
|
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 });
|
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) {
|
function select(vein: VeinId) {
|
||||||
selected = vein;
|
selected = vein;
|
||||||
}
|
}
|
||||||
@@ -138,12 +211,17 @@
|
|||||||
}
|
}
|
||||||
if (event.key === 's' || event.key === 'S') {
|
if (event.key === 's' || event.key === 'S') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
strike();
|
strikeSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key >= '1' && event.key <= '5') {
|
if (event.key >= '1' && event.key <= '5') {
|
||||||
const vein = VEINS[Number(event.key) - 1];
|
const index = Number(event.key) - 1;
|
||||||
if (vein) select(vein);
|
if (view === 'quarry') {
|
||||||
|
selectedRow = index;
|
||||||
|
} else {
|
||||||
|
const vein = VEINS[index];
|
||||||
|
if (vein) select(vein);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +240,8 @@
|
|||||||
onState,
|
onState,
|
||||||
onMeter,
|
onMeter,
|
||||||
onEvent,
|
onEvent,
|
||||||
onError
|
onError,
|
||||||
|
onImported
|
||||||
});
|
});
|
||||||
bridge.connect();
|
bridge.connect();
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
@@ -180,10 +259,22 @@
|
|||||||
<span class="version">0.1</span>
|
<span class="version">0.1</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rail-center">
|
<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="rail-rule" aria-hidden="true"></span>
|
||||||
<span class="room is-on"><span class="mode-dot" aria-hidden="true"></span>hand</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>
|
||||||
<div class="net" class:is-live={status === 'live'}>
|
<div class="net" class:is-live={status === 'live'}>
|
||||||
<span class="dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
@@ -193,7 +284,25 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="hero">
|
<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} />
|
<Wire {entries} {status} onreconnect={reconnect} />
|
||||||
<EnginePanel state={project} {meter} {status} session={hello?.session ?? '—'} ongain={setGain} />
|
<EnginePanel state={project} {meter} {status} session={hello?.session ?? '—'} ongain={setGain} />
|
||||||
</main>
|
</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 — */
|
/* — responsive — */
|
||||||
|
|
||||||
@media (max-width: 1240px) {
|
@media (max-width: 1240px) {
|
||||||
@@ -1082,6 +1583,24 @@ a:focus-visible,
|
|||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
width: 100%;
|
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 {
|
.scene {
|
||||||
top: 27%;
|
top: 27%;
|
||||||
width: 86%;
|
width: 86%;
|
||||||
@@ -1215,6 +1734,33 @@ a:focus-visible,
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
justify-content: flex-end;
|
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 {
|
.pool {
|
||||||
padding: 18px 16px 14px;
|
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 {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
|
CoreSummary,
|
||||||
Event,
|
Event,
|
||||||
HelloMessage,
|
HelloMessage,
|
||||||
MeterFrame,
|
MeterFrame,
|
||||||
|
Seal,
|
||||||
ServerMessage,
|
ServerMessage,
|
||||||
State,
|
State,
|
||||||
Status
|
Status
|
||||||
@@ -17,6 +19,7 @@ export interface BridgeHandlers {
|
|||||||
onMeter: (meter: MeterFrame) => void;
|
onMeter: (meter: MeterFrame) => void;
|
||||||
onEvent: (event: Event) => void;
|
onEvent: (event: Event) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
|
onImported?: (core: CoreSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pending {
|
interface Pending {
|
||||||
@@ -25,11 +28,19 @@ interface Pending {
|
|||||||
timer: number;
|
timer: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingImport {
|
||||||
|
resolve: (core: CoreSummary) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: number;
|
||||||
|
}
|
||||||
|
|
||||||
const COMMAND_TIMEOUT_MS = 5000;
|
const COMMAND_TIMEOUT_MS = 5000;
|
||||||
|
const IMPORT_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export class Bridge {
|
export class Bridge {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private pending = new Map<number, Pending>();
|
private pending = new Map<number, Pending>();
|
||||||
|
private imports = new Map<number, PendingImport>();
|
||||||
private nextId = 0;
|
private nextId = 0;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private reconnectTimer: number | null = null;
|
private reconnectTimer: number | null = null;
|
||||||
@@ -81,6 +92,7 @@ export class Bridge {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (this.ws === ws) this.ws = null;
|
if (this.ws === ws) this.ws = null;
|
||||||
this.rejectAll(new Error('the wire closed'));
|
this.rejectAll(new Error('the wire closed'));
|
||||||
|
this.rejectImports(new Error('the wire closed'));
|
||||||
this.setStatus('offline');
|
this.setStatus('offline');
|
||||||
if (!this.closed) this.scheduleReconnect();
|
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 {
|
destroy(): void {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
if (this.reconnectTimer !== null) {
|
if (this.reconnectTimer !== null) {
|
||||||
@@ -111,6 +140,7 @@ export class Bridge {
|
|||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.rejectAll(new Error('the wire was closed by the surface'));
|
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');
|
this.setStatus('offline');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +190,15 @@ export class Bridge {
|
|||||||
case 'event':
|
case 'event':
|
||||||
this.handlers.onEvent(message.event);
|
this.handlers.onEvent(message.event);
|
||||||
break;
|
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': {
|
case 'error': {
|
||||||
if (message.id !== null) {
|
if (message.id !== null) {
|
||||||
const pending = this.pending.get(message.id);
|
const pending = this.pending.get(message.id);
|
||||||
@@ -169,6 +208,13 @@ export class Bridge {
|
|||||||
pending.reject(new Error(message.message));
|
pending.reject(new Error(message.message));
|
||||||
break;
|
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);
|
this.handlers.onError(message.message);
|
||||||
break;
|
break;
|
||||||
@@ -196,6 +242,14 @@ export class Bridge {
|
|||||||
this.pending.clear();
|
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 {
|
private setStatus(status: Status): void {
|
||||||
if (this.status === status) return;
|
if (this.status === status) return;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export function commandLabel(command: Command): string {
|
|||||||
case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`;
|
case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`;
|
||||||
case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`;
|
case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`;
|
||||||
case 'set_vein_gain': return `${command.vein} gain ${command.gain.toFixed(2)}`;
|
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';
|
case 'get_snapshot': return 'snapshot';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
|
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
|
||||||
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
|
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
|
||||||
export type Status = 'connecting' | 'live' | 'offline';
|
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 {
|
export interface AudioInfo {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -25,6 +49,7 @@ export interface VeinState {
|
|||||||
|
|
||||||
export interface PatternRow {
|
export interface PatternRow {
|
||||||
vein: VeinId;
|
vein: VeinId;
|
||||||
|
face: FaceRef | null;
|
||||||
velocities: number[];
|
velocities: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +64,7 @@ export interface State {
|
|||||||
transport: TransportState;
|
transport: TransportState;
|
||||||
veins: VeinState[];
|
veins: VeinState[];
|
||||||
pattern: Pattern;
|
pattern: Pattern;
|
||||||
|
cores: CoreSummary[];
|
||||||
audio: AudioInfo;
|
audio: AudioInfo;
|
||||||
journal_len: number;
|
journal_len: number;
|
||||||
}
|
}
|
||||||
@@ -47,7 +73,10 @@ export type Event =
|
|||||||
| { kind: 'transport'; playing: boolean }
|
| { kind: 'transport'; playing: boolean }
|
||||||
| { kind: 'tempo'; bpm: number }
|
| { kind: 'tempo'; bpm: number }
|
||||||
| { kind: 'vein_gain'; vein: VeinId; gain: 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 =
|
export type Command =
|
||||||
| { type: 'get_snapshot' }
|
| { type: 'get_snapshot' }
|
||||||
@@ -55,7 +84,10 @@ export type Command =
|
|||||||
| { type: 'stop' }
|
| { type: 'stop' }
|
||||||
| { type: 'strike'; vein: VeinId; velocity: number }
|
| { type: 'strike'; vein: VeinId; velocity: number }
|
||||||
| { type: 'set_tempo'; bpm: 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 =
|
export type CommandResult =
|
||||||
| { kind: 'snapshot'; state: State }
|
| { kind: 'snapshot'; state: State }
|
||||||
@@ -93,7 +125,13 @@ export interface ErrorMessage {
|
|||||||
message: string;
|
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 {
|
export interface MeterFrame {
|
||||||
bar: number;
|
bar: number;
|
||||||
|
|||||||