P0: Rust engine, wire daemon and audio-reactive descent surface
@@ -0,0 +1,33 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/zhila-protocol",
|
||||||
|
"crates/zhila-core",
|
||||||
|
"crates/zhila-engine",
|
||||||
|
"crates/zhila-daemon",
|
||||||
|
]
|
||||||
|
exclude = ["app"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["ZHILA"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros", "net", "sync", "signal", "time"] }
|
||||||
|
tokio-tungstenite = "0.30"
|
||||||
|
futures-util = { version = "0.3", features = ["sink"] }
|
||||||
|
cpal = "0.18"
|
||||||
|
rtrb = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
@@ -1,5 +1,77 @@
|
|||||||
# ZHILA
|
# ZHILA
|
||||||
|
|
||||||
Sound is terrain. A sampling instrument with an agent on the same verbs as the hand.
|
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.
|
||||||
|
|
||||||
P0 is in progress: the Rust core, the local protocol and the browser surface. See `docs/PLAN.md`.
|
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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What is real in P0
|
||||||
|
|
||||||
|
- **Rust engine.** `cpal` output stream, 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein gains and levels, a bar clock. No Web Audio.
|
||||||
|
- **Protocol v1.** JSON control frames and a 31-byte binary meter frame over a local WebSocket.
|
||||||
|
- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains), performance events that are not history.
|
||||||
|
- **Daemon.** Loopback-only, token in the hello, origin guard, snapshot on connect, results and events, binary meters at 30 Hz.
|
||||||
|
- **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five vein levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gain sliders; transport; keyboard control.
|
||||||
|
- **Verification.** Rust unit tests, a wire smoke test against live audio, a browser smoke test with screenshots, `svelte-check`, a production build.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p zhila-daemon -- --port 4317
|
||||||
|
npm --prefix ui run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Controls: `space` play/stop · `s` strike the selected vein · `1`–`5` select a vein · click a lane label to select; double-click to strike · tempo `−` / `+`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/zhila-protocol wire types, binary meter frame, tests
|
||||||
|
crates/zhila-core session, journal, validation, engine trait
|
||||||
|
crates/zhila-engine cpal stream, voices, phrase, levels
|
||||||
|
crates/zhila-daemon WebSocket daemon, auth, broadcast
|
||||||
|
ui Svelte 5 surface (browser-first)
|
||||||
|
ui/src/lib/descent.ts the reactive canvas
|
||||||
|
app Tauri 2 shell (scaffolded, system deps missing)
|
||||||
|
docs plan, protocol, P0 notes
|
||||||
|
reference/demo the earlier design demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
npm --prefix ui run check
|
||||||
|
npm --prefix ui run build
|
||||||
|
node scripts/wire-smoke.mjs # daemon running
|
||||||
|
npm --prefix ui run smoke # daemon + vite dev running
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## P0 limits (next, not hidden)
|
||||||
|
|
||||||
|
- One fixed phrase; patterns are not editable in the surface yet.
|
||||||
|
- Five veins are fixed; the engine has per-vein gain, no sends or automation.
|
||||||
|
- The surface is read-only except transport, strike, tempo and gains.
|
||||||
|
- No session persistence, project file or archive yet.
|
||||||
|
- No agent and no MCP in this phase; the command bus and the journal are the ground for them.
|
||||||
|
- The 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).
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ZHILA · Tauri shell
|
||||||
|
|
||||||
|
This is the desktop shell for the P0 surface. It is scaffolded and **not yet built** on the development machine: the GTK/WebKit/dbus development packages are missing and `sudo` requires a password.
|
||||||
|
|
||||||
|
The shell expects the Vite surface at `http://127.0.0.1:5173` (the `devUrl` in `src-tauri/tauri.conf.json`) and the Rust daemon at `ws://127.0.0.1:4317`. The P0 window does not embed the engine yet.
|
||||||
|
|
||||||
|
## Install the system packages (Fedora)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install dbus-devel webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3-devel \
|
||||||
|
librsvg2-devel openssl-devel libsoup3-devel javascriptcoregtk4.1-devel pkgconf-pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1
|
||||||
|
cd ..
|
||||||
|
cargo run -p zhila-daemon -- --port 4317
|
||||||
|
|
||||||
|
# terminal 2
|
||||||
|
cd ..
|
||||||
|
npm --prefix ui run dev
|
||||||
|
|
||||||
|
# terminal 3
|
||||||
|
npm --prefix app install
|
||||||
|
npm --prefix app run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix ui run build
|
||||||
|
npm --prefix app run tauri build -- --bundles deb
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundle targets are `deb` and `appimage`. The icon at `src-tauri/icons/icon.png` is a P0 placeholder.
|
||||||
|
|
||||||
|
## Observed failure before the system packages
|
||||||
|
|
||||||
|
```
|
||||||
|
Package 'dbus-1' was not found in the pkg-config search path.
|
||||||
|
The system library `dbus-1` required by crate `libdbus-sys` was not found.
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkg-config` also reports missing `webkit2gtk-4.1` and `gtk+-3.0`. The exact `cargo check` output is in `docs/P0.md`.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"name": "zhila-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "zhila-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"bin": {
|
||||||
|
"tauri": "tauri.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "2.12.4",
|
||||||
|
"@tauri-apps/cli-darwin-x64": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": "2.12.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": "2.12.4",
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": "2.12.4",
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": "2.12.4",
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": "2.12.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz",
|
||||||
|
"integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "zhila-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "zhila-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "ZHILA · first descent"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the ZHILA window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("ZHILA could not open its window");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "ZHILA",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "studio.zhila.app",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "",
|
||||||
|
"devUrl": "http://127.0.0.1:5173",
|
||||||
|
"beforeBuildCommand": "",
|
||||||
|
"frontendDist": "../../ui/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "ZHILA · first descent",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["deb", "appimage"],
|
||||||
|
"icon": ["icons/icon.png"],
|
||||||
|
"category": "Music",
|
||||||
|
"shortDescription": "ZHILA",
|
||||||
|
"longDescription": "A sampling instrument where sound is terrain."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "zhila-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zhila-protocol = { path = "../zhila-protocol" }
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
//! ZHILA session core: state, journal, validation, and the commands that the
|
||||||
|
//! hand, the API and MCP will all share.
|
||||||
|
//!
|
||||||
|
//! The engine is a trait here so the core can be tested without an audio
|
||||||
|
//! device. The real engine lives in `zhila-engine`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
use zhila_protocol::{
|
||||||
|
Actor, AudioInfo, Command, CommandResult, Event, LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN,
|
||||||
|
MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, TransportState,
|
||||||
|
VEIN_COUNT, VeinId, VeinState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
|
||||||
|
|
||||||
|
/// The audio engine as seen by the session. Implementations must be real-time
|
||||||
|
/// safe on the audio thread and non-blocking on these methods.
|
||||||
|
pub trait Engine: Send + Sync {
|
||||||
|
fn play(&self);
|
||||||
|
fn stop(&self);
|
||||||
|
fn strike(&self, vein: VeinId, velocity: f32);
|
||||||
|
fn set_tempo(&self, bpm: f32);
|
||||||
|
fn set_gain(&self, vein: VeinId, gain: f32);
|
||||||
|
fn info(&self) -> AudioInfo;
|
||||||
|
fn meter(&self) -> MeterFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CoreError {
|
||||||
|
#[error("tempo must be between 40 and 220 bpm")]
|
||||||
|
InvalidTempo,
|
||||||
|
#[error("velocity must be between 0 and 1")]
|
||||||
|
InvalidVelocity,
|
||||||
|
#[error("gain must be between 0 and 1.2")]
|
||||||
|
InvalidGain,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct JournalEntry {
|
||||||
|
pub seq: u64,
|
||||||
|
pub revision: u64,
|
||||||
|
pub actor: Actor,
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Session {
|
||||||
|
revision: u64,
|
||||||
|
seq: u64,
|
||||||
|
bpm: f32,
|
||||||
|
playing: bool,
|
||||||
|
gains: [f32; VEIN_COUNT],
|
||||||
|
pattern: Pattern,
|
||||||
|
journal: Vec<JournalEntry>,
|
||||||
|
engine: Arc<dyn Engine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(engine: Arc<dyn Engine>) -> Self {
|
||||||
|
Self::with_pattern(engine, default_pattern())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
|
||||||
|
Self {
|
||||||
|
revision: 0,
|
||||||
|
seq: 0,
|
||||||
|
bpm: 128.0,
|
||||||
|
playing: false,
|
||||||
|
gains: INITIAL_GAINS,
|
||||||
|
pattern,
|
||||||
|
journal: Vec::new(),
|
||||||
|
engine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revision(&self) -> u64 {
|
||||||
|
self.revision
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pattern(&self) -> &Pattern {
|
||||||
|
&self.pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn journal(&self) -> &[JournalEntry] {
|
||||||
|
&self.journal
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> State {
|
||||||
|
let meter = self.engine.meter();
|
||||||
|
State {
|
||||||
|
revision: self.revision,
|
||||||
|
transport: TransportState {
|
||||||
|
playing: self.playing,
|
||||||
|
bpm: self.bpm,
|
||||||
|
bar: meter.bar,
|
||||||
|
loop_bars: LOOP_BARS,
|
||||||
|
},
|
||||||
|
veins: VeinId::ALL
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, vein)| VeinState {
|
||||||
|
id: *vein,
|
||||||
|
name: vein.name().to_string(),
|
||||||
|
gain: self.gains[index],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
pattern: self.pattern.clone(),
|
||||||
|
audio: self.engine.info(),
|
||||||
|
journal_len: self.journal.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(
|
||||||
|
&mut self,
|
||||||
|
command: Command,
|
||||||
|
actor: Actor,
|
||||||
|
) -> Result<(CommandResult, Vec<Event>), CoreError> {
|
||||||
|
match command {
|
||||||
|
Command::GetSnapshot => Ok((
|
||||||
|
CommandResult::Snapshot {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)),
|
||||||
|
Command::Play => {
|
||||||
|
if self.playing {
|
||||||
|
return Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.playing = true;
|
||||||
|
self.engine.play();
|
||||||
|
self.revision += 1;
|
||||||
|
Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
vec![Event::Transport { playing: true }],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Command::Stop => {
|
||||||
|
if !self.playing {
|
||||||
|
return Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.playing = false;
|
||||||
|
self.engine.stop();
|
||||||
|
self.revision += 1;
|
||||||
|
Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
vec![Event::Transport { playing: false }],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Command::Strike { vein, velocity } => {
|
||||||
|
if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) {
|
||||||
|
return Err(CoreError::InvalidVelocity);
|
||||||
|
}
|
||||||
|
self.engine.strike(vein, velocity);
|
||||||
|
Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
vec![Event::Struck { vein, velocity }],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Command::SetTempo { bpm } => {
|
||||||
|
if !bpm.is_finite() || !(MIN_BPM..=MAX_BPM).contains(&bpm) {
|
||||||
|
return Err(CoreError::InvalidTempo);
|
||||||
|
}
|
||||||
|
if (self.bpm - bpm).abs() < 1e-3 {
|
||||||
|
return Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.bpm = bpm;
|
||||||
|
self.engine.set_tempo(bpm);
|
||||||
|
self.revision += 1;
|
||||||
|
self.record(actor, Command::SetTempo { bpm });
|
||||||
|
Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
vec![Event::Tempo { bpm }],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Command::SetVeinGain { vein, gain } => {
|
||||||
|
if !gain.is_finite() || !(MIN_GAIN..=MAX_GAIN).contains(&gain) {
|
||||||
|
return Err(CoreError::InvalidGain);
|
||||||
|
}
|
||||||
|
let index = vein.index();
|
||||||
|
if (self.gains[index] - gain).abs() < 1e-3 {
|
||||||
|
return Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.gains[index] = gain;
|
||||||
|
self.engine.set_gain(vein, gain);
|
||||||
|
self.revision += 1;
|
||||||
|
self.record(actor, Command::SetVeinGain { vein, gain });
|
||||||
|
Ok((
|
||||||
|
CommandResult::State {
|
||||||
|
state: self.snapshot(),
|
||||||
|
},
|
||||||
|
vec![Event::VeinGain { vein, gain }],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, actor: Actor, command: Command) {
|
||||||
|
self.seq += 1;
|
||||||
|
self.journal.push(JournalEntry {
|
||||||
|
seq: self.seq,
|
||||||
|
revision: self.revision,
|
||||||
|
actor,
|
||||||
|
at_ms: now_ms(),
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first descent: a two-bar phrase in five veins.
|
||||||
|
pub fn default_pattern() -> Pattern {
|
||||||
|
let mut rows = Vec::with_capacity(VEIN_COUNT);
|
||||||
|
for vein in VeinId::ALL {
|
||||||
|
let mut velocities = vec![0u8; LOOP_STEPS];
|
||||||
|
match vein {
|
||||||
|
VeinId::Bass => {
|
||||||
|
velocities[0] = 112;
|
||||||
|
velocities[3] = 86;
|
||||||
|
velocities[6] = 102;
|
||||||
|
velocities[10] = 94;
|
||||||
|
velocities[14] = 68;
|
||||||
|
}
|
||||||
|
VeinId::Crack => {
|
||||||
|
velocities[2] = 72;
|
||||||
|
velocities[7] = 108;
|
||||||
|
velocities[9] = 58;
|
||||||
|
velocities[15] = 92;
|
||||||
|
}
|
||||||
|
VeinId::Voice => {
|
||||||
|
velocities[0] = 64;
|
||||||
|
velocities[8] = 84;
|
||||||
|
}
|
||||||
|
VeinId::Noise => {
|
||||||
|
velocities[4] = 54;
|
||||||
|
velocities[11] = 62;
|
||||||
|
velocities[13] = 42;
|
||||||
|
}
|
||||||
|
VeinId::Silt => {
|
||||||
|
velocities[3] = 76;
|
||||||
|
velocities[13] = 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push(PatternRow { vein, velocities });
|
||||||
|
}
|
||||||
|
Pattern {
|
||||||
|
steps: LOOP_STEPS as u16,
|
||||||
|
steps_per_bar: STEPS_PER_BAR as u16,
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
struct TestEngine {
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
info: AudioInfo,
|
||||||
|
meter: MeterFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestEngine {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
info: AudioInfo {
|
||||||
|
running: true,
|
||||||
|
device: "test".into(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
buffer_frames: 256,
|
||||||
|
channels: 2,
|
||||||
|
},
|
||||||
|
meter: MeterFrame {
|
||||||
|
bar: 2.25,
|
||||||
|
master: 0.3,
|
||||||
|
veins: [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine for TestEngine {
|
||||||
|
fn play(&self) {
|
||||||
|
self.calls.lock().unwrap().push("play".into());
|
||||||
|
}
|
||||||
|
fn stop(&self) {
|
||||||
|
self.calls.lock().unwrap().push("stop".into());
|
||||||
|
}
|
||||||
|
fn strike(&self, vein: VeinId, velocity: f32) {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("strike:{}:{velocity}", vein.name()));
|
||||||
|
}
|
||||||
|
fn set_tempo(&self, bpm: f32) {
|
||||||
|
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
|
||||||
|
}
|
||||||
|
fn set_gain(&self, vein: VeinId, gain: f32) {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("gain:{}:{gain}", vein.name()));
|
||||||
|
}
|
||||||
|
fn info(&self) -> AudioInfo {
|
||||||
|
self.info.clone()
|
||||||
|
}
|
||||||
|
fn meter(&self) -> MeterFrame {
|
||||||
|
self.meter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session() -> (Session, Arc<TestEngine>) {
|
||||||
|
let engine = Arc::new(TestEngine::new());
|
||||||
|
(Session::new(engine.clone()), engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_pattern_is_complete() {
|
||||||
|
let pattern = default_pattern();
|
||||||
|
assert_eq!(pattern.steps as usize, LOOP_STEPS);
|
||||||
|
assert_eq!(pattern.rows.len(), VEIN_COUNT);
|
||||||
|
for row in &pattern.rows {
|
||||||
|
assert_eq!(row.velocities.len(), LOOP_STEPS);
|
||||||
|
assert!(row.velocities.iter().any(|v| *v > 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn play_updates_state_without_journaling() {
|
||||||
|
let (mut session, engine) = session();
|
||||||
|
let (result, events) = session.apply(Command::Play, Actor::Hand).unwrap();
|
||||||
|
let CommandResult::State { state } = result else {
|
||||||
|
panic!("expected state");
|
||||||
|
};
|
||||||
|
assert!(state.transport.playing);
|
||||||
|
assert_eq!(state.revision, 1);
|
||||||
|
assert_eq!(session.journal().len(), 0);
|
||||||
|
assert_eq!(engine.calls(), vec!["play"]);
|
||||||
|
assert_eq!(events, vec![Event::Transport { playing: true }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tempo_is_validated_and_journaled() {
|
||||||
|
let (mut session, engine) = session();
|
||||||
|
let (result, events) = session
|
||||||
|
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
|
||||||
|
.unwrap();
|
||||||
|
let CommandResult::State { state } = result else {
|
||||||
|
panic!("expected state");
|
||||||
|
};
|
||||||
|
assert_eq!(state.transport.bpm, 132.0);
|
||||||
|
assert_eq!(session.journal().len(), 1);
|
||||||
|
assert_eq!(session.journal()[0].actor, Actor::Hand);
|
||||||
|
assert_eq!(session.journal()[0].revision, 1);
|
||||||
|
assert_eq!(events, vec![Event::Tempo { bpm: 132.0 }]);
|
||||||
|
assert_eq!(engine.calls(), vec!["tempo:132"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tempo_out_of_range_is_rejected() {
|
||||||
|
let (mut session, engine) = session();
|
||||||
|
assert!(matches!(
|
||||||
|
session.apply(Command::SetTempo { bpm: 12.0 }, Actor::Hand),
|
||||||
|
Err(CoreError::InvalidTempo)
|
||||||
|
));
|
||||||
|
assert!(engine.calls().is_empty());
|
||||||
|
assert_eq!(session.journal().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strikes_are_performance_not_history() {
|
||||||
|
let (mut session, engine) = session();
|
||||||
|
let (result, events) = session
|
||||||
|
.apply(
|
||||||
|
Command::Strike {
|
||||||
|
vein: VeinId::Bass,
|
||||||
|
velocity: 0.8,
|
||||||
|
},
|
||||||
|
Actor::Hand,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let CommandResult::State { state } = result else {
|
||||||
|
panic!("expected state");
|
||||||
|
};
|
||||||
|
assert_eq!(state.revision, 0);
|
||||||
|
assert_eq!(session.journal().len(), 0);
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(engine.calls()[0], "strike:bass:0.8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gain_is_validated() {
|
||||||
|
let (mut session, _) = session();
|
||||||
|
assert!(matches!(
|
||||||
|
session.apply(
|
||||||
|
Command::SetVeinGain {
|
||||||
|
vein: VeinId::Noise,
|
||||||
|
gain: 2.0
|
||||||
|
},
|
||||||
|
Actor::Api
|
||||||
|
),
|
||||||
|
Err(CoreError::InvalidGain)
|
||||||
|
));
|
||||||
|
session
|
||||||
|
.apply(
|
||||||
|
Command::SetVeinGain {
|
||||||
|
vein: VeinId::Noise,
|
||||||
|
gain: 0.55,
|
||||||
|
},
|
||||||
|
Actor::Api,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.journal().len(), 1);
|
||||||
|
assert_eq!(session.journal()[0].actor, Actor::Api);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_carries_meter_and_pattern() {
|
||||||
|
let (session, _) = session();
|
||||||
|
let state = session.snapshot();
|
||||||
|
assert_eq!(state.transport.bar, 2.25);
|
||||||
|
assert_eq!(state.transport.loop_bars, LOOP_BARS);
|
||||||
|
assert_eq!(state.veins.len(), VEIN_COUNT);
|
||||||
|
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
|
||||||
|
assert_eq!(state.audio.device, "test");
|
||||||
|
assert_eq!(state.journal_len, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "zhila-daemon"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "zhila-daemon"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zhila-core = { path = "../zhila-core" }
|
||||||
|
zhila-engine = { path = "../zhila-engine" }
|
||||||
|
zhila-protocol = { path = "../zhila-protocol" }
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-tungstenite.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
//! ZHILA local daemon: one WebSocket endpoint, one session, one engine.
|
||||||
|
//!
|
||||||
|
//! Text frames are JSON control messages; meter frames are binary. The daemon
|
||||||
|
//! binds to loopback only and requires a token in the hello.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use clap::Parser;
|
||||||
|
use futures_util::stream::SplitSink;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||||
|
use tokio::time::{interval, timeout};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||||
|
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use zhila_core::{CoreError, Engine, Session};
|
||||||
|
use zhila_engine::{AudioEngine, NullEngine};
|
||||||
|
use zhila_protocol::{
|
||||||
|
Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, ServerMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const METER_INTERVAL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
|
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "zhila-daemon",
|
||||||
|
version,
|
||||||
|
about = "ZHILA local engine daemon (P0)"
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// WebSocket port on loopback.
|
||||||
|
#[arg(long, default_value_t = 4317)]
|
||||||
|
port: u16,
|
||||||
|
/// Shared token for the hello. Dev default; change it for anything real.
|
||||||
|
#[arg(long, default_value = "dev")]
|
||||||
|
token: String,
|
||||||
|
/// Run without an output device; useful for UI tests.
|
||||||
|
#[arg(long)]
|
||||||
|
no_audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Outbound {
|
||||||
|
Text(String),
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Shared {
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
out: broadcast::Sender<Outbound>,
|
||||||
|
token: Arc<String>,
|
||||||
|
session_id: Arc<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "zhila_daemon=info,zhila_engine=info".into());
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token);
|
||||||
|
|
||||||
|
let pattern = zhila_core::default_pattern();
|
||||||
|
let audio_holder = if args.no_audio {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match AudioEngine::start(&pattern) {
|
||||||
|
Ok(engine) => Some(engine),
|
||||||
|
Err(error) => {
|
||||||
|
warn!("audio offline: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let control: Arc<dyn Engine> = match &audio_holder {
|
||||||
|
Some(engine) => engine.control(),
|
||||||
|
None => Arc::new(NullEngine::new()),
|
||||||
|
};
|
||||||
|
let shutdown = control.clone();
|
||||||
|
let info = control.info();
|
||||||
|
let session = Arc::new(Mutex::new(Session::with_pattern(control.clone(), pattern)));
|
||||||
|
let (out, _) = broadcast::channel::<Outbound>(128);
|
||||||
|
let session_id = format!("zhila-{:x}", now_ms());
|
||||||
|
let shared = Shared {
|
||||||
|
session,
|
||||||
|
out: out.clone(),
|
||||||
|
token: Arc::new(token.clone()),
|
||||||
|
session_id: Arc::new(session_id.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let meter_engine = control.clone();
|
||||||
|
let meter_out = out.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = interval(METER_INTERVAL);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
let frame = meter_engine.meter();
|
||||||
|
let _ = meter_out.send(Outbound::Binary(frame.encode()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", args.port))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?;
|
||||||
|
|
||||||
|
print_banner(args.port, &token, &info, &session_id);
|
||||||
|
if token == "dev" {
|
||||||
|
warn!("using the development token; pass --token or ZHILA_TOKEN to change it");
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let (stream, peer) = accepted.context("accept failed")?;
|
||||||
|
let shared = shared.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(error) = handle(stream, shared).await {
|
||||||
|
warn!("connection {peer}: {error:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
info!("shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown.stop();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, session_id: &str) {
|
||||||
|
println!();
|
||||||
|
println!(" ZHILA · P0 engine");
|
||||||
|
println!(" ws://127.0.0.1:{port} · token {token}");
|
||||||
|
if info.running {
|
||||||
|
let buffer = if info.buffer_frames == 0 {
|
||||||
|
"default".to_string()
|
||||||
|
} else {
|
||||||
|
info.buffer_frames.to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" audio {} · {} Hz · {} ch · buffer {}",
|
||||||
|
info.device, info.sample_rate, info.channels, buffer
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(" audio offline · the surface will connect, but nothing will sound");
|
||||||
|
}
|
||||||
|
println!(" session {session_id}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||||
|
let peer = stream.peer_addr().ok();
|
||||||
|
let ws = accept_hdr_async(stream, handshake)
|
||||||
|
.await
|
||||||
|
.context("websocket handshake failed")?;
|
||||||
|
|
||||||
|
let (mut sink, mut source) = ws.split();
|
||||||
|
|
||||||
|
let first = match timeout(HELLO_TIMEOUT, source.next()).await {
|
||||||
|
Ok(Some(Ok(message))) => message,
|
||||||
|
Ok(Some(Err(error))) => return Err(error.into()),
|
||||||
|
Ok(None) => return Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(
|
||||||
|
None,
|
||||||
|
ErrorCode::Protocol,
|
||||||
|
"expected a hello within 5 seconds",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Message::Text(text) = first else {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(None, ErrorCode::Protocol, "the first frame must be a hello"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let hello: ClientMessage = match serde_json::from_str(text.as_str()) {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(None, ErrorCode::BadRequest, "malformed hello"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (client, protocol, token) = match hello {
|
||||||
|
ClientMessage::Hello {
|
||||||
|
client,
|
||||||
|
protocol,
|
||||||
|
token,
|
||||||
|
} => (client, protocol, token),
|
||||||
|
ClientMessage::Command { .. } => {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(None, ErrorCode::Protocol, "expected hello before commands"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if protocol != PROTOCOL_VERSION {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(
|
||||||
|
None,
|
||||||
|
ErrorCode::Protocol,
|
||||||
|
format!(
|
||||||
|
"protocol mismatch: this daemon speaks {PROTOCOL_VERSION}, the client sent {protocol}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if token.as_str() != shared.token.as_str() {
|
||||||
|
let _ = send_out(
|
||||||
|
&mut sink,
|
||||||
|
error_message(None, ErrorCode::Unauthorized, "the token was not accepted"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = shared.session.lock().await.snapshot();
|
||||||
|
info!(?peer, client = %client, "wire opened");
|
||||||
|
send_out(
|
||||||
|
&mut sink,
|
||||||
|
Outbound::Text(
|
||||||
|
serde_json::to_string(&ServerMessage::Hello {
|
||||||
|
protocol: PROTOCOL_VERSION,
|
||||||
|
session: shared.session_id.as_ref().clone(),
|
||||||
|
engine: state.audio.clone(),
|
||||||
|
})
|
||||||
|
.expect("hello serializes"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let revision = state.revision;
|
||||||
|
send_out(
|
||||||
|
&mut sink,
|
||||||
|
Outbound::Text(
|
||||||
|
serde_json::to_string(&ServerMessage::Snapshot { revision, state })
|
||||||
|
.expect("snapshot serializes"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::<Outbound>();
|
||||||
|
let mut events = shared.out.subscribe();
|
||||||
|
let writer = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
direct = direct_rx.recv() => {
|
||||||
|
match direct {
|
||||||
|
Some(outbound) => {
|
||||||
|
if send_out(&mut sink, outbound).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcast = events.recv() => {
|
||||||
|
match broadcast {
|
||||||
|
Ok(outbound) => {
|
||||||
|
if send_out(&mut sink, outbound).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while let Some(frame) = source.next().await {
|
||||||
|
let message = match frame {
|
||||||
|
Ok(Message::Text(text)) => text,
|
||||||
|
Ok(Message::Close(_)) => break,
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(error) => {
|
||||||
|
warn!("wire read error: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let message: ClientMessage = match serde_json::from_str(message.as_str()) {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = direct_tx.send(error_message(
|
||||||
|
None,
|
||||||
|
ErrorCode::BadRequest,
|
||||||
|
"malformed message",
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
ClientMessage::Hello { .. } => {
|
||||||
|
let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted"));
|
||||||
|
}
|
||||||
|
ClientMessage::Command { id, command } => {
|
||||||
|
let outcome = {
|
||||||
|
let mut session = shared.session.lock().await;
|
||||||
|
session.apply(command, Actor::Hand)
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
Ok((result, emitted)) => {
|
||||||
|
let revision = match &result {
|
||||||
|
CommandResult::Snapshot { state } | CommandResult::State { state } => {
|
||||||
|
state.revision
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = direct_tx.send(Outbound::Text(
|
||||||
|
serde_json::to_string(&ServerMessage::Result { id, result })
|
||||||
|
.expect("result serializes"),
|
||||||
|
));
|
||||||
|
for event in emitted {
|
||||||
|
let message = ServerMessage::Event { revision, event };
|
||||||
|
let _ = shared
|
||||||
|
.out
|
||||||
|
.send(Outbound::Text(serde_json::to_string(&message).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = direct_tx.send(error_message(
|
||||||
|
Some(id),
|
||||||
|
error_code(&error),
|
||||||
|
error.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(direct_tx);
|
||||||
|
let _ = writer.await;
|
||||||
|
info!(?peer, "wire closed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
fn handshake(request: &Request, response: Response) -> Result<Response, ErrorResponse> {
|
||||||
|
if let Some(origin) = request
|
||||||
|
.headers()
|
||||||
|
.get("origin")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
&& !origin_allowed(origin)
|
||||||
|
{
|
||||||
|
return Err(ErrorResponse::new(Some(
|
||||||
|
"origin is not allowed to talk to ZHILA".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn origin_allowed(origin: &str) -> bool {
|
||||||
|
if origin == "null" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some((scheme, rest)) = origin.split_once("://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(scheme, "http" | "https" | "tauri") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let authority = rest.split('/').next().unwrap_or_default();
|
||||||
|
let host = authority.split(':').next().unwrap_or_default();
|
||||||
|
matches!(host, "localhost" | "127.0.0.1" | "tauri.localhost")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_code(error: &CoreError) -> ErrorCode {
|
||||||
|
match error {
|
||||||
|
CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => {
|
||||||
|
ErrorCode::BadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_message(id: Option<u64>, code: ErrorCode, message: impl Into<String>) -> Outbound {
|
||||||
|
Outbound::Text(
|
||||||
|
serde_json::to_string(&ServerMessage::Error {
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
})
|
||||||
|
.expect("error serializes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_out(sink: &mut WsSink, outbound: Outbound) -> anyhow::Result<()> {
|
||||||
|
let message = match outbound {
|
||||||
|
Outbound::Text(text) => Message::Text(text.into()),
|
||||||
|
Outbound::Binary(bytes) => Message::Binary(bytes.into()),
|
||||||
|
};
|
||||||
|
sink.send(message).await.context("websocket send failed")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "zhila-engine"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
zhila-core = { path = "../zhila-core" }
|
||||||
|
zhila-protocol = { path = "../zhila-protocol" }
|
||||||
|
cpal.workspace = true
|
||||||
|
rtrb.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
//! ZHILA audio engine, P0.
|
||||||
|
//!
|
||||||
|
//! A real cpal stream with a fixed voice pool, a two-bar phrase, per-vein
|
||||||
|
//! levels and a bar position. Everything that runs in the callback is
|
||||||
|
//! allocation-free. The control surface is lock-free on the audio side.
|
||||||
|
|
||||||
|
use std::f32::consts::{FRAC_PI_4, TAU};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig};
|
||||||
|
use rtrb::{Consumer, Producer, RingBuffer};
|
||||||
|
use thiserror::Error;
|
||||||
|
use zhila_core::Engine as EngineControl;
|
||||||
|
use zhila_protocol::{
|
||||||
|
AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern,
|
||||||
|
STEPS_PER_BAR, VEIN_COUNT, VeinId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOICES: usize = 32;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum AudioCommand {
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
|
Strike { vein: u8, velocity: f32 },
|
||||||
|
SetTempo(f32),
|
||||||
|
SetGain { vein: u8, gain: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Levels {
|
||||||
|
master: AtomicU32,
|
||||||
|
veins: [AtomicU32; VEIN_COUNT],
|
||||||
|
bar: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Levels {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
master: AtomicU32::new(0.0f32.to_bits()),
|
||||||
|
veins: std::array::from_fn(|_| AtomicU32::new(0.0f32.to_bits())),
|
||||||
|
bar: AtomicU32::new(1.0f32.to_bits()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self, frame: &MeterFrame) {
|
||||||
|
self.master.store(frame.master.to_bits(), Ordering::Relaxed);
|
||||||
|
self.bar.store(frame.bar.to_bits(), Ordering::Relaxed);
|
||||||
|
for (level, slot) in frame.veins.iter().zip(self.veins.iter()) {
|
||||||
|
slot.store(level.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&self) -> MeterFrame {
|
||||||
|
let master = f32::from_bits(self.master.load(Ordering::Relaxed));
|
||||||
|
let bar = f32::from_bits(self.bar.load(Ordering::Relaxed));
|
||||||
|
let mut veins = [0.0; VEIN_COUNT];
|
||||||
|
for (level, slot) in veins.iter_mut().zip(self.veins.iter()) {
|
||||||
|
*level = f32::from_bits(slot.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
MeterFrame { bar, master, veins }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Voice {
|
||||||
|
active: bool,
|
||||||
|
vein: usize,
|
||||||
|
phase: f32,
|
||||||
|
freq: f32,
|
||||||
|
t: f32,
|
||||||
|
attack: f32,
|
||||||
|
decay: f32,
|
||||||
|
velocity: f32,
|
||||||
|
gain_l: f32,
|
||||||
|
gain_r: f32,
|
||||||
|
lp: f32,
|
||||||
|
hp: f32,
|
||||||
|
prev: f32,
|
||||||
|
rng: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Voice {
|
||||||
|
fn inactive() -> Self {
|
||||||
|
Self {
|
||||||
|
active: false,
|
||||||
|
vein: 0,
|
||||||
|
phase: 0.0,
|
||||||
|
freq: 0.0,
|
||||||
|
t: 0.0,
|
||||||
|
attack: 0.01,
|
||||||
|
decay: 4.0,
|
||||||
|
velocity: 0.0,
|
||||||
|
gain_l: 0.707,
|
||||||
|
gain_r: 0.707,
|
||||||
|
lp: 0.0,
|
||||||
|
hp: 0.0,
|
||||||
|
prev: 0.0,
|
||||||
|
rng: 0x9e37_79b9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger(&mut self, vein: VeinId, velocity: f32, seed: u32) {
|
||||||
|
self.active = true;
|
||||||
|
self.vein = vein.index();
|
||||||
|
self.phase = 0.0;
|
||||||
|
self.t = 0.0;
|
||||||
|
self.velocity = velocity.clamp(0.0, 1.0);
|
||||||
|
self.rng = seed | 1;
|
||||||
|
self.lp = 0.0;
|
||||||
|
self.hp = 0.0;
|
||||||
|
self.prev = 0.0;
|
||||||
|
let (freq, attack, decay, pan) = match vein {
|
||||||
|
VeinId::Bass => (55.0, 0.004, 3.2, 0.0),
|
||||||
|
VeinId::Crack => (0.0, 0.001, 38.0, 0.35),
|
||||||
|
VeinId::Voice => (220.0, 0.09, 1.1, -0.25),
|
||||||
|
VeinId::Noise => (0.0, 0.004, 5.0, 0.55),
|
||||||
|
VeinId::Silt => (1318.5, 0.002, 14.0, -0.45),
|
||||||
|
};
|
||||||
|
self.freq = freq;
|
||||||
|
self.attack = attack;
|
||||||
|
self.decay = decay;
|
||||||
|
let angle = (pan + 1.0) * FRAC_PI_4;
|
||||||
|
self.gain_l = angle.cos();
|
||||||
|
self.gain_r = angle.sin();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn noise(&mut self) -> f32 {
|
||||||
|
self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
(self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&mut self, sample_rate: f32) -> f32 {
|
||||||
|
self.t += 1.0 / sample_rate;
|
||||||
|
let attack = (self.t / self.attack).min(1.0);
|
||||||
|
let env = attack * (-self.t * self.decay).exp();
|
||||||
|
if env < 0.0002 || self.t > 8.0 {
|
||||||
|
self.active = false;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let sample = match self.vein {
|
||||||
|
0 => {
|
||||||
|
let fundamental = (TAU * self.phase).sin();
|
||||||
|
let sub = (TAU * self.phase * 0.5).sin();
|
||||||
|
fundamental + sub * 0.45
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let white = self.noise();
|
||||||
|
let hp = white - self.prev + 0.94 * self.hp;
|
||||||
|
self.prev = white;
|
||||||
|
self.hp = hp;
|
||||||
|
hp * 0.7
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let fundamental = (TAU * self.phase).sin();
|
||||||
|
let overtone = (TAU * self.phase * 2.0).sin();
|
||||||
|
fundamental + overtone * 0.18
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
let white = self.noise();
|
||||||
|
self.lp += (white - self.lp) * 0.06;
|
||||||
|
self.lp
|
||||||
|
}
|
||||||
|
_ => (TAU * self.phase).sin(),
|
||||||
|
};
|
||||||
|
if self.freq > 0.0 {
|
||||||
|
self.phase += self.freq / sample_rate;
|
||||||
|
if self.phase >= 1.0 {
|
||||||
|
self.phase -= 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sample * env * self.velocity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioState {
|
||||||
|
rx: Consumer<AudioCommand>,
|
||||||
|
sample_rate: f32,
|
||||||
|
channels: usize,
|
||||||
|
bpm: f32,
|
||||||
|
playing: bool,
|
||||||
|
step: usize,
|
||||||
|
pending: f64,
|
||||||
|
pattern: [[u8; LOOP_STEPS]; VEIN_COUNT],
|
||||||
|
voices: [Voice; VOICES],
|
||||||
|
gains: [f32; VEIN_COUNT],
|
||||||
|
master_env: f32,
|
||||||
|
vein_env: [f32; VEIN_COUNT],
|
||||||
|
bar: f32,
|
||||||
|
rng: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioState {
|
||||||
|
fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer<AudioCommand>) -> Self {
|
||||||
|
let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT];
|
||||||
|
for row in &pattern.rows {
|
||||||
|
let index = row.vein.index();
|
||||||
|
for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() {
|
||||||
|
grid[index][step] = *velocity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
rx,
|
||||||
|
sample_rate: sample_rate as f32,
|
||||||
|
channels: channels as usize,
|
||||||
|
bpm: 128.0,
|
||||||
|
playing: false,
|
||||||
|
step: 0,
|
||||||
|
pending: 0.0,
|
||||||
|
pattern: grid,
|
||||||
|
voices: std::array::from_fn(|_| Voice::inactive()),
|
||||||
|
gains: zhila_core::INITIAL_GAINS,
|
||||||
|
master_env: 0.0,
|
||||||
|
vein_env: [0.0; VEIN_COUNT],
|
||||||
|
bar: 1.0,
|
||||||
|
rng: 0x51ed_2701,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_samples(&self) -> f64 {
|
||||||
|
self.sample_rate as f64 * 60.0 / self.bpm as f64 / 2.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, command: AudioCommand) {
|
||||||
|
match command {
|
||||||
|
AudioCommand::Play => {
|
||||||
|
self.playing = true;
|
||||||
|
self.step = 0;
|
||||||
|
self.pending = 0.0;
|
||||||
|
self.bar = 1.0;
|
||||||
|
}
|
||||||
|
AudioCommand::Stop => {
|
||||||
|
self.playing = false;
|
||||||
|
self.step = 0;
|
||||||
|
self.pending = 0.0;
|
||||||
|
self.bar = 1.0;
|
||||||
|
self.master_env = 0.0;
|
||||||
|
self.vein_env = [0.0; VEIN_COUNT];
|
||||||
|
for voice in &mut self.voices {
|
||||||
|
voice.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::Strike { vein, velocity } => {
|
||||||
|
let Some(vein) = VeinId::from_index(vein as usize) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.trigger(vein, velocity);
|
||||||
|
}
|
||||||
|
AudioCommand::SetTempo(bpm) => {
|
||||||
|
self.bpm = bpm.clamp(MIN_BPM, MAX_BPM);
|
||||||
|
}
|
||||||
|
AudioCommand::SetGain { vein, gain } => {
|
||||||
|
if let Some(index) = VeinId::from_index(vein as usize) {
|
||||||
|
self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger(&mut self, vein: VeinId, velocity: f32) {
|
||||||
|
if velocity <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = self
|
||||||
|
.voices
|
||||||
|
.iter()
|
||||||
|
.position(|voice| !voice.active)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
self.voices
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|a, b| a.1.t.total_cmp(&b.1.t))
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.unwrap_or(0)
|
||||||
|
});
|
||||||
|
self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
self.voices[index].trigger(vein, velocity, self.rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger_step(&mut self) {
|
||||||
|
let step = self.step;
|
||||||
|
for vein in 0..VEIN_COUNT {
|
||||||
|
let velocity = self.pattern[vein][step];
|
||||||
|
if velocity > 0
|
||||||
|
&& let Some(vein) = VeinId::from_index(vein)
|
||||||
|
{
|
||||||
|
self.trigger(vein, velocity as f32 / 127.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bar = step as f32 / STEPS_PER_BAR as f32 + 1.0;
|
||||||
|
self.step = (step + 1) % LOOP_STEPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render<T>(&mut self, data: &mut [T], levels: &Levels)
|
||||||
|
where
|
||||||
|
T: SizedSample + FromSample<f32>,
|
||||||
|
{
|
||||||
|
while let Ok(command) = self.rx.pop() {
|
||||||
|
self.handle(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
for frame in data.chunks_mut(self.channels) {
|
||||||
|
if self.playing {
|
||||||
|
if self.pending <= 0.0 {
|
||||||
|
self.trigger_step();
|
||||||
|
self.pending += self.step_samples();
|
||||||
|
}
|
||||||
|
self.pending -= 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut left = 0.0f32;
|
||||||
|
let mut right = 0.0f32;
|
||||||
|
let mut sums = [0.0f32; VEIN_COUNT];
|
||||||
|
for voice in &mut self.voices {
|
||||||
|
if !voice.active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sample = voice.next(self.sample_rate);
|
||||||
|
let index = voice.vein;
|
||||||
|
let amplitude = sample * self.gains[index];
|
||||||
|
left += amplitude * voice.gain_l;
|
||||||
|
right += amplitude * voice.gain_r;
|
||||||
|
sums[index] += amplitude.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
let peak = (left.abs() + right.abs()) * 0.5;
|
||||||
|
self.master_env += (peak - self.master_env) * 0.0015;
|
||||||
|
for (env, sum) in self.vein_env.iter_mut().zip(sums.iter()) {
|
||||||
|
*env += (*sum - *env) * 0.002;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out_l = (left * 0.9).tanh() * 0.8;
|
||||||
|
let out_r = (right * 0.9).tanh() * 0.8;
|
||||||
|
for (channel, sample) in frame.iter_mut().enumerate() {
|
||||||
|
let value = match (self.channels, channel) {
|
||||||
|
(1, _) => (out_l + out_r) * 0.5,
|
||||||
|
(_, 0) => out_l,
|
||||||
|
(_, 1) => out_r,
|
||||||
|
_ => (out_l + out_r) * 0.5,
|
||||||
|
};
|
||||||
|
*sample = T::from_sample(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
levels.store(&MeterFrame {
|
||||||
|
bar: self.bar,
|
||||||
|
master: self.master_env,
|
||||||
|
veins: self.vein_env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The send side of the engine. Cheap, lock-free for the audio thread and
|
||||||
|
/// safe to share with the session and the WebSocket daemon.
|
||||||
|
pub struct Control {
|
||||||
|
tx: Mutex<Producer<AudioCommand>>,
|
||||||
|
levels: Arc<Levels>,
|
||||||
|
info: AudioInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineControl for Control {
|
||||||
|
fn play(&self) {
|
||||||
|
self.send(AudioCommand::Play);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) {
|
||||||
|
self.send(AudioCommand::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strike(&self, vein: VeinId, velocity: f32) {
|
||||||
|
self.send(AudioCommand::Strike {
|
||||||
|
vein: vein.index() as u8,
|
||||||
|
velocity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_tempo(&self, bpm: f32) {
|
||||||
|
self.send(AudioCommand::SetTempo(bpm));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_gain(&self, vein: VeinId, gain: f32) {
|
||||||
|
self.send(AudioCommand::SetGain {
|
||||||
|
vein: vein.index() as u8,
|
||||||
|
gain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> AudioInfo {
|
||||||
|
self.info.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meter(&self) -> MeterFrame {
|
||||||
|
self.levels.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Control {
|
||||||
|
fn send(&self, command: AudioCommand) {
|
||||||
|
if let Ok(mut tx) = self.tx.lock() {
|
||||||
|
let _ = tx.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum EngineError {
|
||||||
|
#[error("no default audio output device")]
|
||||||
|
NoDevice,
|
||||||
|
#[error("audio backend: {0}")]
|
||||||
|
Backend(#[from] cpal::Error),
|
||||||
|
#[error("unsupported sample format: {0:?}")]
|
||||||
|
UnsupportedFormat(SampleFormat),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Started {
|
||||||
|
stream: Stream,
|
||||||
|
control: Arc<Control>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the cpal stream alive. The control handle can be shared freely; the
|
||||||
|
/// stream itself must stay on the thread that created it.
|
||||||
|
pub struct AudioEngine {
|
||||||
|
_stream: Stream,
|
||||||
|
control: Arc<Control>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioEngine {
|
||||||
|
pub fn start(pattern: &Pattern) -> Result<Self, EngineError> {
|
||||||
|
let started = match Self::try_start(pattern, BufferSize::Fixed(256)) {
|
||||||
|
Ok(started) => started,
|
||||||
|
Err(EngineError::Backend(_)) => Self::try_start(pattern, BufferSize::Default)?,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
_stream: started.stream,
|
||||||
|
control: started.control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control(&self) -> Arc<Control> {
|
||||||
|
self.control.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> AudioInfo {
|
||||||
|
self.control.info.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_start(pattern: &Pattern, buffer: BufferSize) -> Result<Started, EngineError> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = host.default_output_device().ok_or(EngineError::NoDevice)?;
|
||||||
|
let supported = device.default_output_config()?;
|
||||||
|
let format = supported.sample_format();
|
||||||
|
let mut config: StreamConfig = supported.into();
|
||||||
|
config.buffer_size = buffer;
|
||||||
|
let sample_rate = config.sample_rate;
|
||||||
|
let channels = config.channels;
|
||||||
|
let device_name = device.to_string();
|
||||||
|
let (producer, consumer) = RingBuffer::new(256);
|
||||||
|
let levels = Arc::new(Levels::new());
|
||||||
|
let state = AudioState::new(sample_rate, channels, pattern, consumer);
|
||||||
|
let stream = match format {
|
||||||
|
SampleFormat::F32 => build_stream::<f32>(&device, config, state, levels.clone())?,
|
||||||
|
SampleFormat::I16 => build_stream::<i16>(&device, config, state, levels.clone())?,
|
||||||
|
other => return Err(EngineError::UnsupportedFormat(other)),
|
||||||
|
};
|
||||||
|
let info = AudioInfo {
|
||||||
|
running: true,
|
||||||
|
device: device_name,
|
||||||
|
sample_rate,
|
||||||
|
buffer_frames: match buffer {
|
||||||
|
BufferSize::Fixed(frames) => frames,
|
||||||
|
BufferSize::Default => 0,
|
||||||
|
},
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
let control = Arc::new(Control {
|
||||||
|
tx: Mutex::new(producer),
|
||||||
|
levels,
|
||||||
|
info,
|
||||||
|
});
|
||||||
|
Ok(Started { stream, control })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: StreamConfig,
|
||||||
|
mut state: AudioState,
|
||||||
|
levels: Arc<Levels>,
|
||||||
|
) -> Result<Stream, EngineError>
|
||||||
|
where
|
||||||
|
T: SizedSample + FromSample<f32> + 'static,
|
||||||
|
{
|
||||||
|
let stream = device.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
state.render(data, &levels);
|
||||||
|
},
|
||||||
|
|error| eprintln!("zhila audio: {error}"),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
stream.play()?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A silent engine for headless protocol tests and machines without an output
|
||||||
|
/// device. It reports itself as offline so the surface never pretends to play.
|
||||||
|
pub struct NullEngine {
|
||||||
|
info: AudioInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NullEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NullEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
info: AudioInfo::offline(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineControl for NullEngine {
|
||||||
|
fn play(&self) {}
|
||||||
|
fn stop(&self) {}
|
||||||
|
fn strike(&self, _vein: VeinId, _velocity: f32) {}
|
||||||
|
fn set_tempo(&self, _bpm: f32) {}
|
||||||
|
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
|
||||||
|
fn info(&self) -> AudioInfo {
|
||||||
|
self.info.clone()
|
||||||
|
}
|
||||||
|
fn meter(&self) -> MeterFrame {
|
||||||
|
MeterFrame::silent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "zhila-protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! ZHILA wire protocol, version 1.
|
||||||
|
//!
|
||||||
|
//! Text frames carry JSON control messages. Audio meters travel in a compact
|
||||||
|
//! binary frame (see [`MeterFrame`]) at ~30 Hz, never inside JSON.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
pub const VEIN_COUNT: usize = 5;
|
||||||
|
pub const LOOP_STEPS: usize = 16;
|
||||||
|
pub const STEPS_PER_BAR: usize = 8;
|
||||||
|
pub const LOOP_BARS: f32 = 2.0;
|
||||||
|
pub const MIN_BPM: f32 = 40.0;
|
||||||
|
pub const MAX_BPM: f32 = 220.0;
|
||||||
|
pub const MIN_GAIN: f32 = 0.0;
|
||||||
|
pub const MAX_GAIN: f32 = 1.2;
|
||||||
|
|
||||||
|
pub const METER_FRAME_KIND: u8 = 1;
|
||||||
|
/// `kind (1) | count (2) | bar (4) | master (4) | levels (5 * 4)`
|
||||||
|
pub const METER_FRAME_LEN: usize = 3 + 4 * (2 + VEIN_COUNT);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VeinId {
|
||||||
|
Bass,
|
||||||
|
Crack,
|
||||||
|
Voice,
|
||||||
|
Noise,
|
||||||
|
Silt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VeinId {
|
||||||
|
pub const ALL: [VeinId; VEIN_COUNT] = [
|
||||||
|
VeinId::Bass,
|
||||||
|
VeinId::Crack,
|
||||||
|
VeinId::Voice,
|
||||||
|
VeinId::Noise,
|
||||||
|
VeinId::Silt,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
VeinId::Bass => 0,
|
||||||
|
VeinId::Crack => 1,
|
||||||
|
VeinId::Voice => 2,
|
||||||
|
VeinId::Noise => 3,
|
||||||
|
VeinId::Silt => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
VeinId::Bass => "bass",
|
||||||
|
VeinId::Crack => "crack",
|
||||||
|
VeinId::Voice => "voice",
|
||||||
|
VeinId::Noise => "noise",
|
||||||
|
VeinId::Silt => "silt",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_index(index: usize) -> Option<Self> {
|
||||||
|
match index {
|
||||||
|
0 => Some(VeinId::Bass),
|
||||||
|
1 => Some(VeinId::Crack),
|
||||||
|
2 => Some(VeinId::Voice),
|
||||||
|
3 => Some(VeinId::Noise),
|
||||||
|
4 => Some(VeinId::Silt),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Actor {
|
||||||
|
Hand,
|
||||||
|
Api,
|
||||||
|
Mcp,
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
Hello {
|
||||||
|
protocol: u32,
|
||||||
|
token: String,
|
||||||
|
client: String,
|
||||||
|
},
|
||||||
|
Command {
|
||||||
|
id: u64,
|
||||||
|
command: Command,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
Hello {
|
||||||
|
protocol: u32,
|
||||||
|
session: String,
|
||||||
|
engine: AudioInfo,
|
||||||
|
},
|
||||||
|
Snapshot {
|
||||||
|
revision: u64,
|
||||||
|
state: State,
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
revision: u64,
|
||||||
|
event: Event,
|
||||||
|
},
|
||||||
|
Result {
|
||||||
|
id: u64,
|
||||||
|
result: CommandResult,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
id: Option<u64>,
|
||||||
|
code: ErrorCode,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Command {
|
||||||
|
GetSnapshot,
|
||||||
|
Play,
|
||||||
|
Stop,
|
||||||
|
Strike { vein: VeinId, velocity: f32 },
|
||||||
|
SetTempo { bpm: f32 },
|
||||||
|
SetVeinGain { vein: VeinId, gain: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum CommandResult {
|
||||||
|
Snapshot { state: State },
|
||||||
|
State { state: State },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
Transport { playing: bool },
|
||||||
|
Tempo { bpm: f32 },
|
||||||
|
VeinGain { vein: VeinId, gain: f32 },
|
||||||
|
Struck { vein: VeinId, velocity: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
BadRequest,
|
||||||
|
Unauthorized,
|
||||||
|
Protocol,
|
||||||
|
Audio,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AudioInfo {
|
||||||
|
pub running: bool,
|
||||||
|
pub device: String,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub buffer_frames: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioInfo {
|
||||||
|
pub fn offline() -> Self {
|
||||||
|
Self {
|
||||||
|
running: false,
|
||||||
|
device: "none".into(),
|
||||||
|
sample_rate: 48_000,
|
||||||
|
buffer_frames: 0,
|
||||||
|
channels: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct State {
|
||||||
|
pub revision: u64,
|
||||||
|
pub transport: TransportState,
|
||||||
|
pub veins: Vec<VeinState>,
|
||||||
|
pub pattern: Pattern,
|
||||||
|
pub audio: AudioInfo,
|
||||||
|
pub journal_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TransportState {
|
||||||
|
pub playing: bool,
|
||||||
|
pub bpm: f32,
|
||||||
|
pub bar: f32,
|
||||||
|
pub loop_bars: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct VeinState {
|
||||||
|
pub id: VeinId,
|
||||||
|
pub name: String,
|
||||||
|
pub gain: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Pattern {
|
||||||
|
pub steps: u16,
|
||||||
|
pub steps_per_bar: u16,
|
||||||
|
pub rows: Vec<PatternRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
pub fn row(&self, vein: VeinId) -> Option<&PatternRow> {
|
||||||
|
self.rows.iter().find(|row| row.vein == vein)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PatternRow {
|
||||||
|
pub vein: VeinId,
|
||||||
|
/// One velocity per step, 0..=127. Zero means the step is empty.
|
||||||
|
pub velocities: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single binary meter frame. Text frames never carry audio-rate data.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct MeterFrame {
|
||||||
|
pub bar: f32,
|
||||||
|
pub master: f32,
|
||||||
|
pub veins: [f32; VEIN_COUNT],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeterFrame {
|
||||||
|
pub fn silent() -> Self {
|
||||||
|
Self {
|
||||||
|
bar: 1.0,
|
||||||
|
master: 0.0,
|
||||||
|
veins: [0.0; VEIN_COUNT],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(METER_FRAME_LEN);
|
||||||
|
out.push(METER_FRAME_KIND);
|
||||||
|
out.extend_from_slice(&(VEIN_COUNT as u16).to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.bar.to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.master.to_le_bytes());
|
||||||
|
for level in self.veins {
|
||||||
|
out.extend_from_slice(&level.to_le_bytes());
|
||||||
|
}
|
||||||
|
debug_assert_eq!(out.len(), METER_FRAME_LEN);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(data: &[u8]) -> Option<Self> {
|
||||||
|
if data.len() != METER_FRAME_LEN || data[0] != METER_FRAME_KIND {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = u16::from_le_bytes([data[1], data[2]]) as usize;
|
||||||
|
if count != VEIN_COUNT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bar = read_f32(data, 3)?;
|
||||||
|
let master = read_f32(data, 7)?;
|
||||||
|
let mut veins = [0.0; VEIN_COUNT];
|
||||||
|
for (index, level) in veins.iter_mut().enumerate() {
|
||||||
|
*level = read_f32(data, 11 + index * 4)?;
|
||||||
|
}
|
||||||
|
Some(Self { bar, master, veins })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_f32(data: &[u8], offset: usize) -> Option<f32> {
|
||||||
|
let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?;
|
||||||
|
Some(f32::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_roundtrip() {
|
||||||
|
let message = ClientMessage::Hello {
|
||||||
|
protocol: PROTOCOL_VERSION,
|
||||||
|
token: "dev".into(),
|
||||||
|
client: "ui".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&message).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"hello\""));
|
||||||
|
let back: ClientMessage = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_roundtrip() {
|
||||||
|
let message = ClientMessage::Command {
|
||||||
|
id: 7,
|
||||||
|
command: Command::Strike {
|
||||||
|
vein: VeinId::Bass,
|
||||||
|
velocity: 0.8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&message).unwrap();
|
||||||
|
let back: ClientMessage = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meter_roundtrip_is_exact() {
|
||||||
|
let frame = MeterFrame {
|
||||||
|
bar: 2.75,
|
||||||
|
master: 0.42,
|
||||||
|
veins: [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||||
|
};
|
||||||
|
let bytes = frame.encode();
|
||||||
|
assert_eq!(bytes.len(), METER_FRAME_LEN);
|
||||||
|
let back = MeterFrame::decode(&bytes).expect("valid frame");
|
||||||
|
assert_eq!(back, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meter_rejects_garbage() {
|
||||||
|
assert!(MeterFrame::decode(&[]).is_none());
|
||||||
|
assert!(MeterFrame::decode(&[METER_FRAME_KIND; 4]).is_none());
|
||||||
|
let mut bytes = MeterFrame::silent().encode();
|
||||||
|
bytes[0] = 9;
|
||||||
|
assert!(MeterFrame::decode(&bytes).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vein_indices_are_stable() {
|
||||||
|
for (index, vein) in VeinId::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(vein.index(), index);
|
||||||
|
assert_eq!(VeinId::from_index(index), Some(*vein));
|
||||||
|
}
|
||||||
|
assert_eq!(VeinId::from_index(VEIN_COUNT), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# P0 · hello engine
|
||||||
|
|
||||||
|
The first phase proves the spine: Rust audio, a session with a journal, a protocol with two transports, and a surface that reacts to the sound. The demo in `reference/demo` is the visual reference; none of its code is used here.
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
| Area | Deliverable |
|
||||||
|
|---|---|
|
||||||
|
| Protocol | `zhila-protocol`: JSON messages, five veins, pattern, state, binary meter frame; unit tests |
|
||||||
|
| Session | `zhila-core`: engine trait, validation, revisions, journal for tempo/gain, default sixteen-step phrase; tests |
|
||||||
|
| Engine | `zhila-engine`: cpal 0.18, 48 kHz stereo, fixed 256-frame buffer where available, 32-voice pool, five vein timbres, phrase clock, per-vein levels, master level; no allocation on the audio path |
|
||||||
|
| Daemon | `zhila-daemon`: WebSocket, hello/token/protocol/origin checks, snapshot, results, broadcast events, 30 Hz binary meters, current-thread Tokio, graceful Ctrl-C |
|
||||||
|
| Surface | Svelte 5 + Vite: audio-reactive descent, wire log, engine facts, cilia, signal trace, per-vein gain sliders, transport, keyboard |
|
||||||
|
| Verification | `cargo test`, `cargo clippy`, `svelte-check`, production build, live wire test, browser smoke + screenshots |
|
||||||
|
| Shell | Tauri 2 scaffold in `app`; build blocked by missing system development packages (below) |
|
||||||
|
|
||||||
|
## Decisions taken in P0
|
||||||
|
|
||||||
|
- **cpal, not Web Audio.** The browser is a surface; sound is native Rust. The cpal 0.18 API uses a unified `cpal::Error`, `SampleRate = u32`, `Display` for the device name and a by-value `StreamConfig`.
|
||||||
|
- **JSON control + binary meters.** Control metadata is small and debuggable; meters are 31-byte frames at 30 Hz.
|
||||||
|
- **One daemon, one session.** Loopback + token + origin guard are the P0 security boundary. No production auth yet.
|
||||||
|
- **The journal is for project edits.** Play/stop/strike are performance and do not pollute undo.
|
||||||
|
- **The surface is a projection.** The server owns state; the UI applies snapshots, results and events. The canvas owns only its visual phase.
|
||||||
|
- **Every layer reacts to sound.** The terrain alpha/offset, eye size/glow/rings, city lights, dust, road bands, stone glow, playhead, lane fills, chrome hairlines, cilia, signal trace and master line all read the five vein levels and the master. No decorative animation is independent of the audio.
|
||||||
|
|
||||||
|
## Tauri blocker
|
||||||
|
|
||||||
|
`cargo check` in `app/src-tauri` failed on this machine because the GTK/WebKit/dbus development packages are absent and `sudo` needs a password:
|
||||||
|
|
||||||
|
```
|
||||||
|
Package 'dbus-1' was not found
|
||||||
|
The system library `dbus-1` required by crate `libdbus-sys` was not found.
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkg-config` also reports missing `webkit2gtk-4.1` and `gtk+-3.0`. The shell is scaffolded, not verified.
|
||||||
|
|
||||||
|
Install the Fedora development packages (one command, needs your password):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install dbus-devel webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3-devel \
|
||||||
|
librsvg2-devel openssl-devel libsoup3-devel javascriptcoregtk4.1-devel pkgconf-pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --manifest-path app/src-tauri/Cargo.toml
|
||||||
|
npm --prefix ui run build
|
||||||
|
npm --prefix app run tauri dev # expects the daemon and Vite to be running
|
||||||
|
```
|
||||||
|
|
||||||
|
The Tauri window loads `http://127.0.0.1:5173`; the daemon remains a separate process in P0. Embedding the engine in the Tauri backend is P1 work, after the engine's lifecycle is settled.
|
||||||
|
|
||||||
|
## P0 exit criteria
|
||||||
|
|
||||||
|
- A browser surface talks to a Rust daemon over a local protocol: **passed**.
|
||||||
|
- Real cpal output with five veins and non-zero meters: **passed** (Default Audio Device, 48 kHz, 256-frame buffer, master peak ~0.45 in the wire test).
|
||||||
|
- A session that validates and journals project edits: **passed** (Rust tests).
|
||||||
|
- A surface that visibly reacts to the five levels: **passed** (canvas, cilia, trace, gains; screenshots).
|
||||||
|
- A Tauri window on Linux: **blocked** by system development packages; the exact command is above.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
P1: core session and material. Import/record, analysis, cores and faces, a zoomable waveform, a real pattern model, a flat precision view while the descent remains the primary arrangement, and the first journal-backed undo. The plan and cut lines are in `PLAN.md`.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ZHILA — план проекта
|
# ZHILA — план проекта
|
||||||
|
|
||||||
Черновик v1.1 · 1 ноября 2026
|
Черновик v1.1 · 1 ноября 2026
|
||||||
Статус: **план**. Код проекта не начат. Демо `zhila/` — референс, не кодовая база.
|
Статус: **P0 выполнен** (см. `P0.md`). Демо `reference/demo/` — референс, не кодовая база.
|
||||||
|
|
||||||
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
|
**Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ZHILA wire protocol · v1
|
||||||
|
|
||||||
|
Loopback only, one daemon per session. Text frames are JSON; one binary frame type carries meters. The protocol is deliberately small in P0; the revision model and the command bus are the parts meant to survive into P1.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- WebSocket at `ws://127.0.0.1:4317` by default.
|
||||||
|
- The daemon binds `127.0.0.1` only.
|
||||||
|
- The first client frame must be `hello` within 5 seconds.
|
||||||
|
- If the client sends an `Origin`, it must be `http://localhost[:port]`, `http://127.0.0.1[:port]`, or `https://tauri.localhost`. Native clients may omit it.
|
||||||
|
- The dev token is `dev`. Set `ZHILA_TOKEN` or `--token` to change it. Tokens are compared in the hello, never put in the URL.
|
||||||
|
|
||||||
|
## Client → daemon
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"hello","protocol":1,"token":"dev","client":"zhila-ui"}
|
||||||
|
{"type":"command","id":7,"command":{"type":"play"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
| command | args | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `get_snapshot` | — | returns a snapshot, no revision change |
|
||||||
|
| `play` | — | transport, idempotent |
|
||||||
|
| `stop` | — | transport, idempotent |
|
||||||
|
| `strike` | `vein` (bass/crack/voice/noise/silt), `velocity` 0..1 | performance; not journaled |
|
||||||
|
| `set_tempo` | `bpm` 40..220 | journaled |
|
||||||
|
| `set_vein_gain` | `vein`, `gain` 0..1.2 | journaled |
|
||||||
|
|
||||||
|
## Daemon → client
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"hello","protocol":1,"session":"zhila-1a193dfd50c","engine":{"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2}}
|
||||||
|
{"type":"snapshot","revision":0,"state":{...}}
|
||||||
|
{"type":"result","id":7,"result":{"kind":"state","state":{...}}}
|
||||||
|
{"type":"event","revision":1,"event":{"kind":"transport","playing":true}}
|
||||||
|
{"type":"error","id":7,"code":"bad_request","message":"tempo must be between 40 and 220 bpm"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`state`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"revision": 1,
|
||||||
|
"transport": {"playing": true, "bpm": 128.0, "bar": 1.0, "loop_bars": 2.0},
|
||||||
|
"veins": [{"id":"bass","name":"bass","gain":0.82}, "..."],
|
||||||
|
"pattern": {"steps":16,"steps_per_bar":8,"rows":[{"vein":"bass","velocities":[112,0,0,86,...]}]},
|
||||||
|
"audio": {"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2},
|
||||||
|
"journal_len": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`result.kind` is `snapshot` or `state`; both carry the full state.
|
||||||
|
|
||||||
|
Events:
|
||||||
|
|
||||||
|
- `{"kind":"transport","playing":bool}`
|
||||||
|
- `{"kind":"tempo","bpm":number}`
|
||||||
|
- `{"kind":"vein_gain","vein":VeinId,"gain":number}`
|
||||||
|
- `{"kind":"struck","vein":VeinId,"velocity":number}`
|
||||||
|
|
||||||
|
Errors: `bad_request` (validation), `unauthorized`, `protocol` (version/hello), `internal`.
|
||||||
|
|
||||||
|
## Binary meter frame
|
||||||
|
|
||||||
|
Sent at ~30 Hz, 31 bytes, little-endian:
|
||||||
|
|
||||||
|
```
|
||||||
|
offset size field
|
||||||
|
0 1 kind = 1
|
||||||
|
1 2 count = 5
|
||||||
|
3 4 bar (f32)
|
||||||
|
7 4 master level (f32)
|
||||||
|
11 20 five vein levels (f32 each)
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser sets `binaryType = "arraybuffer"` and reads a `DataView`. The frame is deliberately separate from JSON; no meter ever goes through text frames.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
- `revision` increments on every state-changing command: transport, tempo, gains. It is a state version, not the journal length.
|
||||||
|
- `journal_len` counts project edits only: tempo and gains in P0. Play, stop and strike are performance.
|
||||||
|
- The daemon broadcasts events to every client. A command's `result` goes only to the requester.
|
||||||
|
- The server is authoritative. The surface holds a projection and updates it from snapshots, results and events.
|
||||||
|
- P0 has no persistence; a daemon restart starts a fresh session at 128 bpm, not playing, journal empty.
|
||||||
|
|
||||||
|
## Not in v1 yet
|
||||||
|
|
||||||
|
- Binary snapshots / CRDTs.
|
||||||
|
- Project/asset commands.
|
||||||
|
- Agent tools and MCP.
|
||||||
|
- Per-client cursors or collaboration.
|
||||||
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 561 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
# ZHILA
|
||||||
|
|
||||||
|
DAW, в котором звук лежит рельефом. Две комнаты: **nave** — собор, где камни укладываются и играются, и **quarry** — карьер под ним, где материал берут и режут. Сэмплирование здесь не «библиотека сэмплов», а карьер и кладка: у каждой вещи есть место, шрам, погода и клеймо.
|
||||||
|
|
||||||
|
Демка играет и слушается агентом: 32-тактовый луп из настоящих срезов, живой транспорт на аудиочасах, правка камней, undo, сохранение, импорт/запись, рендер в WAV.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть **http://127.0.0.1:4173**. Скрипт берёт ключ из `DEEPSEEK_API_KEY` или из уже сохранённого ключа harness (`~/.dsh/.credentials.yaml`); ключ остаётся на сервере и в браузер не попадает. Порт можно поменять: `PORT=5000 ./start.sh`.
|
||||||
|
|
||||||
|
Без сервера `index.html` тоже открывается, но хор работает в локальном режиме (по правилам), без модели.
|
||||||
|
|
||||||
|
## Агент
|
||||||
|
|
||||||
|
Встроенный агент — это не чат сбоку, а тот же набор команд, что у руки. Модель (`deepseek-chat`) получает 14 инструментов и действует ими в живом проекте:
|
||||||
|
|
||||||
|
`get_state`, `list_cores`, `extract_core`, `fracture_core`, `strike_face`, `place_stone`, `move_stone`, `delete_stone`, `set_weather`, `set_rings`, `set_tempo`, `transport`, `open_core`, `set_macros`.
|
||||||
|
|
||||||
|
- Консоль `choir` показывает настоящий трейс: инструмент, аргументы, результат, время. Ответ — одна короткая реплика на языке пользователя.
|
||||||
|
- Агент видит состояние (камни, керны, погоду, позицию), проверяет, где уже занято, и не кладёт камень поверх того же диапазона без просьбы.
|
||||||
|
- `api live` в шапке — модель на связи; `api local` — сервера нет, работает локальный дирижёр по правилам (тоже через инструменты).
|
||||||
|
- Ключ и модель живут только на сервере; сервер слушает 127.0.0.1 и принимает CORS только с localhost.
|
||||||
|
|
||||||
|
Примеры: «сделай темнее и добавь редкий шум на восемь тактов, потом включи», «возьми керн из лестничной клетки и нарежь его заново», «убери один камень из голоса, стало тесно», «покажи мне керн, из которого сделан бас».
|
||||||
|
|
||||||
|
MCP для внешних агентов ещё не поднят: те же 14 команд — это и есть будущая поверхность. Локальный API уже отделён от UI, поэтому сервер MCP — следующий шаг.
|
||||||
|
|
||||||
|
## nave
|
||||||
|
|
||||||
|
- `space` — пуск/пауза.
|
||||||
|
- Камни: тащи вверх-вниз — время с привязкой к четверти такта; клик — выбрать; `Backspace` — закопать; `⌘/Ctrl+D` — дублировать; `⌘/Ctrl+Z` / `⇧⌘Z` — undo/redo.
|
||||||
|
- `crystal` — живая партитура событий вокруг playhead (только просмотр).
|
||||||
|
- `cilia` — уровни жил; красные точки — живой сигнал. `warmth / dust / threshold` правят мастер.
|
||||||
|
- `render` — офлайн-рендер 32 тактов в `zhila-descent-04.wav` (~62 c, 48 кГц, стерео).
|
||||||
|
- Поле «say what you want to hear» зовёт агента. Ползунки `shape` — предпочтения для следующего слоя; агент видит их в состоянии.
|
||||||
|
|
||||||
|
## quarry
|
||||||
|
|
||||||
|
- `core tray` — выбранный керн: настоящая волна, слои, шов, трещины. Линза читает глубину.
|
||||||
|
- `fracture` реально перерезает керн: 16 граней получают новые границы, и камни из этого керна начинают звучать иначе.
|
||||||
|
- `the face` — 16 граней. Удар звучит; `place` кладёт одиночный камень, `loop` — повторяющийся каждые 2 такта.
|
||||||
|
- `weather` — `age / pressure / air` меняют климат всего микса, `heat` расстраивает голоса, `ghost` добавляет хвост источника. `grow ring` усиливает эрозию петель.
|
||||||
|
- `extract`: `core` — новый процедурный керн, `file` — импорт, `room` — запись комнаты (`rec` тоже).
|
||||||
|
- `ledger` — кто, откуда и с каким клеймом взял.
|
||||||
|
|
||||||
|
## Что работает по-настоящему
|
||||||
|
|
||||||
|
- **Звук.** Web Audio: срезы кернов как сэмплы, 16 граней, кольца с эрозией, климат, лимитер, панорама, анализ мастера.
|
||||||
|
- **Транспорт.** Аудиочасы — мастер, lookahead-планировщик, луп, play/pause/stop/seek, темп на лету.
|
||||||
|
- **Правка.** Выбор, перенос, удаление, дублирование, undo/redo, автосейв.
|
||||||
|
- **Материал.** Процедурные керны, импорт, запись; поиск швов по огибающей; повторный `fracture`.
|
||||||
|
- **Агент.** Реальная модель, 14 инструментов, трейс, проверка состояния, локальный фолбэк.
|
||||||
|
- **Экспорт.** Офлайн-рендер в WAV с погодой, макросами и мастером.
|
||||||
|
|
||||||
|
## Что всё ещё макет
|
||||||
|
|
||||||
|
- Внешний MCP-сервер не поднят; `mcp`-чип — заявка на следующий шаг.
|
||||||
|
- Клеймо и опись — журнал, не права и не согласие. Модель `draft → seal` описана, не реализована.
|
||||||
|
- `crystal` не редактирует ноты; нет MIDI, редактора маппинга граней, trim/loop-точек, сендов/групп, дублей записи.
|
||||||
|
- Импортированные и записанные керны живут только в памяти; после перезагрузки их камни молчат. Пять жил и панорама фиксированы.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- `index.html`, `style.css` — разметка и стиль
|
||||||
|
- `engine.js` — аудиоядро: керны, срезы, голоса, климат, транспорт, рендер
|
||||||
|
- `app.js` — nave: партитура, правка, история, анализ, кристалл, командный слой
|
||||||
|
- `quarry.js` — карьер: лоток, грани, погода, кольца, опись
|
||||||
|
- `agent.js` — хор в браузере: трейс, инструменты, локальный дирижёр
|
||||||
|
- `server.js`, `agent-tools.js`, `start.sh` — локальный сервер и модель
|
||||||
|
- `terrain.js` — рельеф и город
|
||||||
|
- `preview.png`, `preview-playing.png`, `preview-agent.png`, `preview-quarry.png`, `preview-mobile.png`, `preview-quarry-mobile.png` — скриншоты
|
||||||
|
- `preview.mp3` — 62 секунды базового рендера
|
||||||
|
|
||||||
|
Сброс проекта: очистить site data (localStorage `zhila.project.v3`).
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/* ZHILA · agent — tools + the choir's brief. Shared by the local server. */
|
||||||
|
|
||||||
|
const SYSTEM = `You are the Choir, the agent embedded in ZHILA — a DAW where sound is terrain and samples are stones cut from cores.
|
||||||
|
|
||||||
|
The instrument has two rooms. "nave" is the arrangement: five veins (bass, crack, voice, noise, silt) laid as a masonry of stones over a 32-bar loop. "quarry" is the material: cores are recordings, 16 faces are cut slices of a core, rings are loops that erode per cycle, weather shapes the whole mix.
|
||||||
|
|
||||||
|
You act only through the tools. Never claim a change that a tool did not return. Never invent ids: call get_state or list_cores when unsure.
|
||||||
|
|
||||||
|
A good layer is material, not wallpaper:
|
||||||
|
- a low pulse or bed on bass or voice, a mid texture on crack or noise, sparse accents on silt;
|
||||||
|
- rings repeat (ring_bars > 0); 0 places a one-shot;
|
||||||
|
- use faces that list_cores ranks as loud, and vary pitch by -12..+12 semitones;
|
||||||
|
- leave space: 1–3 new stones is usually enough; do not fill every bar;
|
||||||
|
- weather changes the whole piece: age darkens, pressure saturates, air opens the room, heat detunes;
|
||||||
|
- if the user asks to hear something, use strike_face; if they ask to see material, use open_core.
|
||||||
|
|
||||||
|
Place stones with explicit bars and lengths. start_bar is 1..32, length_bars 1..16, ring_bars 0..8. Place in the vein whose role fits the sound.
|
||||||
|
|
||||||
|
Do not narrate tool use and do not announce what you are about to do. Call the tools, then give exactly one short final report. Answer in the user's language, terse and concrete, one or two sentences about what you took or changed. When speaking Russian, keep the instrument's field names in English (core, face, vein, ring, stone, weather, seal) instead of translating them. No markdown lists, no headings, no emoji. Do not explain the interface unless asked.
|
||||||
|
|
||||||
|
Before placing, look at the existing stones in get_state. Do not cover the exact same vein and bar range as an existing stone unless the user asked to double or layer it. If the requested range is taken, shift, shorten, or choose another vein and say so.`;
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_state',
|
||||||
|
description: 'Current project state: tempo, transport, weather, rings, cores, every stone with its id/vein/bars/ring/core/face, and the current selection.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'list_cores',
|
||||||
|
description: 'List cores in the archive with duration, seal, and the indices of the loudest faces.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'extract_core',
|
||||||
|
description: 'Take a new procedural core into the archive (the choir goes out for material). Returns the new core.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'A short name for the place or material.' },
|
||||||
|
seal: { type: 'string', enum: ['found', 'taken', 'gift', 'loan', 'unknown'] }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'fracture_core',
|
||||||
|
description: 'Re-cut a core into sixteen new faces. Every stone cut from this core changes its slice.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { core_id: { type: 'string' } },
|
||||||
|
required: ['core_id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'strike_face',
|
||||||
|
description: 'Audition one face through the weather. Use this to let the user hear material.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
core_id: { type: 'string' },
|
||||||
|
face: { type: 'integer', minimum: 0, maximum: 15 },
|
||||||
|
velocity: { type: 'number', minimum: 0, maximum: 1 }
|
||||||
|
},
|
||||||
|
required: ['core_id', 'face'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'place_stone',
|
||||||
|
description: 'Place a stone in the arrangement. A stone is a face of a core, cut into a vein over bars; ring_bars 0 plays once, ring_bars 2 repeats every two bars.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
vein: { type: 'string', enum: ['bass', 'crack', 'voice', 'noise', 'silt'] },
|
||||||
|
core_id: { type: 'string' },
|
||||||
|
face: { type: 'integer', minimum: 0, maximum: 15 },
|
||||||
|
start_bar: { type: 'number', minimum: 1, maximum: 32 },
|
||||||
|
length_bars: { type: 'number', minimum: 1, maximum: 16 },
|
||||||
|
ring_bars: { type: 'number', minimum: 0, maximum: 8 },
|
||||||
|
pitch: { type: 'number', minimum: -12, maximum: 12 },
|
||||||
|
gain: { type: 'number', minimum: 0.1, maximum: 1.2 },
|
||||||
|
name: { type: 'string' }
|
||||||
|
},
|
||||||
|
required: ['vein', 'core_id', 'face', 'start_bar', 'length_bars'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'move_stone',
|
||||||
|
description: 'Move an existing stone in time, keeping its length. Get ids from get_state.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
stone_id: { type: 'integer' },
|
||||||
|
start_bar: { type: 'number', minimum: 1, maximum: 32 }
|
||||||
|
},
|
||||||
|
required: ['stone_id', 'start_bar'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'delete_stone',
|
||||||
|
description: 'Bury a stone (remove it from the arrangement).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { stone_id: { type: 'integer' } },
|
||||||
|
required: ['stone_id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'set_weather',
|
||||||
|
description: 'Shape the climate of the whole piece: age (dark/fossil), pressure (saturation), air (room), heat (detune). Omitted values stay unchanged.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
age: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
pressure: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
air: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
heat: { type: 'number', minimum: 0, maximum: 1 }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'set_rings',
|
||||||
|
description: 'Set how many growth rings the loops carry; more rings mean stronger erosion on every cycle.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { count: { type: 'integer', minimum: 0, maximum: 12 } },
|
||||||
|
required: ['count'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'set_tempo',
|
||||||
|
description: 'Set the tempo in BPM (40..220).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { bpm: { type: 'number', minimum: 40, maximum: 220 } },
|
||||||
|
required: ['bpm'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'transport',
|
||||||
|
description: 'Play, pause, stop or seek the transport. seek needs a bar.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: { type: 'string', enum: ['play', 'pause', 'stop', 'seek'] },
|
||||||
|
bar: { type: 'number', minimum: 1, maximum: 32 }
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'open_core',
|
||||||
|
description: 'Descend to the quarry and show a core in the tray.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { core_id: { type: 'string' } },
|
||||||
|
required: ['core_id'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'set_macros',
|
||||||
|
description: 'Master macros: warmth (tilt), dust (hiss), threshold (limiter). 0..1.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
warmth: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
dust: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
threshold: { type: 'number', minimum: 0, maximum: 1 }
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = { SYSTEM, TOOLS };
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
/* ZHILA · agent
|
||||||
|
the choir: a model on the local server, acting through the same verbs as the hand.
|
||||||
|
without a server or a key it falls back to a local conductor — still using the tools. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
|
||||||
|
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const API = location.protocol === 'file:' ? 'http://127.0.0.1:4173' : '';
|
||||||
|
|
||||||
|
const E = window.ZHILA_ENGINE;
|
||||||
|
const Z = window.ZHILA;
|
||||||
|
const Q = window.ZHILA_QUARRY;
|
||||||
|
|
||||||
|
const logEl = $('#log');
|
||||||
|
const stateEl = $('#agent-state');
|
||||||
|
const stateText = $('#agent-state-text');
|
||||||
|
const netText = $('#net-text');
|
||||||
|
|
||||||
|
const TOOL_NAMES = ['get_state', 'list_cores', 'extract_core', 'fracture_core', 'strike_face',
|
||||||
|
'place_stone', 'move_stone', 'delete_stone', 'set_weather', 'set_rings',
|
||||||
|
'set_tempo', 'transport', 'open_core', 'set_macros'];
|
||||||
|
|
||||||
|
let live = false;
|
||||||
|
let busy = false;
|
||||||
|
let history = [];
|
||||||
|
let welcomed = false;
|
||||||
|
|
||||||
|
function timeNow() {
|
||||||
|
const d = new Date();
|
||||||
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollLog() {
|
||||||
|
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, thinking) {
|
||||||
|
if (!stateText) return;
|
||||||
|
stateText.textContent = text;
|
||||||
|
if (stateEl) stateEl.classList.toggle('is-thinking', !!thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSay(text) {
|
||||||
|
if (!logEl || !text) return;
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'call call--say';
|
||||||
|
li.innerHTML = '<span class="call-t"></span><p></p>';
|
||||||
|
li.querySelector('.call-t').textContent = timeNow();
|
||||||
|
li.querySelector('p').textContent = text;
|
||||||
|
logEl.appendChild(li);
|
||||||
|
scrollLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addYou(text) {
|
||||||
|
if (!logEl) return;
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'call call--you';
|
||||||
|
li.innerHTML = '<span class="call-t"></span><p></p>';
|
||||||
|
li.querySelector('.call-t').textContent = timeNow();
|
||||||
|
li.querySelector('p').textContent = '\u201C' + text + '\u201D';
|
||||||
|
logEl.appendChild(li);
|
||||||
|
scrollLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function argsHTML(args) {
|
||||||
|
const keys = Object.keys(args || {});
|
||||||
|
if (!keys.length) return '<span class="p">{ }</span>';
|
||||||
|
return '<span class="p">{ </span>' + keys.map(function (k) {
|
||||||
|
let v = args[k];
|
||||||
|
let val;
|
||||||
|
if (typeof v === 'string') val = '"' + esc(v) + '"';
|
||||||
|
else if (typeof v === 'object' && v !== null) val = esc(JSON.stringify(v));
|
||||||
|
else val = esc(String(v));
|
||||||
|
return '<span class="k">"' + esc(k) + '"</span><span class="p">: </span><span class="v">' + val + '</span>';
|
||||||
|
}).join('<span class="p">, </span>') + '<span class="p"> }</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function argLine(name, a) {
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const pitch = n => n ? (n > 0 ? '+' : '') + n + 'st' : '';
|
||||||
|
switch (name) {
|
||||||
|
case 'place_stone':
|
||||||
|
return [a.vein, a.core_id + ' f' + pad(a.face), a.start_bar + '–' + (+a.start_bar + +a.length_bars),
|
||||||
|
a.ring_bars ? 'ring ' + a.ring_bars : 'once', pitch(a.pitch), a.gain ? 'gain ' + a.gain : ''].filter(Boolean).join(' · ');
|
||||||
|
case 'strike_face':
|
||||||
|
return a.core_id + ' f' + pad(a.face);
|
||||||
|
case 'extract_core':
|
||||||
|
return a.name || 'new core';
|
||||||
|
case 'fracture_core':
|
||||||
|
case 'open_core':
|
||||||
|
return a.core_id;
|
||||||
|
case 'move_stone':
|
||||||
|
return 'stone ' + a.stone_id + ' → bar ' + a.start_bar;
|
||||||
|
case 'delete_stone':
|
||||||
|
return 'stone ' + a.stone_id;
|
||||||
|
case 'set_weather':
|
||||||
|
return ['age', 'pressure', 'air', 'heat'].filter(function (k) { return a[k] != null; })
|
||||||
|
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
|
||||||
|
case 'set_rings':
|
||||||
|
return a.count + ' rings';
|
||||||
|
case 'set_tempo':
|
||||||
|
return a.bpm + ' bpm';
|
||||||
|
case 'transport':
|
||||||
|
return a.action + (a.bar ? ' · bar ' + a.bar : '');
|
||||||
|
case 'set_macros':
|
||||||
|
return ['warmth', 'dust', 'threshold'].filter(function (k) { return a[k] != null; })
|
||||||
|
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTool(name, args, source) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'call call--tool';
|
||||||
|
li.innerHTML =
|
||||||
|
'<div class="call-head">' +
|
||||||
|
'<span class="call-t">' + timeNow() + '</span>' +
|
||||||
|
'<span class="call-name"></span>' +
|
||||||
|
'<span class="call-src">' + (source || 'api') + '</span>' +
|
||||||
|
'<span class="call-ms">…</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="call-args call-args--line">' + esc(argLine(name, args)) + '</div>';
|
||||||
|
li.querySelector('.call-name').textContent = name;
|
||||||
|
li.title = JSON.stringify(args);
|
||||||
|
logEl.appendChild(li);
|
||||||
|
scrollLog();
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(name, res) {
|
||||||
|
if (!res) return '';
|
||||||
|
if (res.error) return 'error: ' + res.error;
|
||||||
|
switch (name) {
|
||||||
|
case 'get_state':
|
||||||
|
return (res.stones ? res.stones.length : 0) + ' stones · bar ' + res.transport.bar + ' · ' + res.cores.length + ' cores · ' + res.view;
|
||||||
|
case 'list_cores':
|
||||||
|
return res.cores.length + ' cores: ' + res.cores.map(function (c) { return c.id; }).join(', ');
|
||||||
|
case 'extract_core':
|
||||||
|
return res.name + ' · ' + res.dur + 's · ' + res.seal;
|
||||||
|
case 'fracture_core':
|
||||||
|
return res.id + ' · re-cut · ' + res.loud.slice(0, 3).join(', ');
|
||||||
|
case 'strike_face':
|
||||||
|
return 'struck ' + res.core + ' f' + String(res.face + 1).padStart(2, '0');
|
||||||
|
case 'place_stone':
|
||||||
|
return res.vein + ' · bars ' + res.bars[0] + '–' + res.bars[1] + (res.ring ? ' · ring ' + res.ring : ' · once');
|
||||||
|
case 'move_stone':
|
||||||
|
return 'bars ' + res.bars[0] + '–' + res.bars[1];
|
||||||
|
case 'delete_stone':
|
||||||
|
return 'buried';
|
||||||
|
case 'set_weather':
|
||||||
|
return Object.keys(res.weather).map(function (k) { return k + ' ' + (+res.weather[k]).toFixed(2); }).join(' · ');
|
||||||
|
case 'set_rings':
|
||||||
|
return 'rings ' + res.rings;
|
||||||
|
case 'set_tempo':
|
||||||
|
return res.bpm + ' bpm';
|
||||||
|
case 'transport':
|
||||||
|
return (res.playing ? 'playing' : 'paused') + ' · bar ' + res.bar;
|
||||||
|
case 'open_core':
|
||||||
|
return 'quarry · ' + res.core;
|
||||||
|
case 'set_macros':
|
||||||
|
return Object.keys(res).map(function (k) { return k + ' ' + (+res[k]).toFixed(2); }).join(' · ');
|
||||||
|
default:
|
||||||
|
return JSON.stringify(res).slice(0, 140);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTool(li, ms, res) {
|
||||||
|
if (!li) return;
|
||||||
|
const t = li.querySelector('.call-ms');
|
||||||
|
if (t) t.textContent = ms;
|
||||||
|
const name = li.querySelector('.call-name').textContent;
|
||||||
|
const read = name === 'get_state' || name === 'list_cores';
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'call-result';
|
||||||
|
line.textContent = (res && res.error ? 'error · ' : (read ? '' : 'ok · ')) + summarize(name, res);
|
||||||
|
if (res && res.error) line.style.color = 'rgba(232,64,42,.8)';
|
||||||
|
li.appendChild(line);
|
||||||
|
scrollLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— the verbs ————— */
|
||||||
|
|
||||||
|
async function exec(name, a) {
|
||||||
|
a = a || {};
|
||||||
|
if (!E) throw new Error('no engine');
|
||||||
|
switch (name) {
|
||||||
|
case 'get_state': {
|
||||||
|
return {
|
||||||
|
transport: { playing: E.isPlaying(), bar: +E.getPos().toFixed(2), bpm: E.getBpm() },
|
||||||
|
weather: Q ? Q.weather() : E.weather,
|
||||||
|
rings: Q ? Q.rings() : E.rings,
|
||||||
|
cores: E.cores.map(function (c) {
|
||||||
|
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
|
||||||
|
}),
|
||||||
|
stones: Z.stones(),
|
||||||
|
shape: Z.shape(),
|
||||||
|
view: document.body.dataset.view,
|
||||||
|
selected_core: Q && Q.selectedCore() ? Q.selectedCore().id : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'list_cores': {
|
||||||
|
return {
|
||||||
|
cores: E.cores.map(function (c) {
|
||||||
|
return {
|
||||||
|
id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, faces: 16,
|
||||||
|
loud: c.ranked.slice(0, 5),
|
||||||
|
at: c.ranked.slice(0, 5).map(function (i) { return +c.faces[i].start.toFixed(2); })
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'extract_core': {
|
||||||
|
const c = E.newCore({ name: (a.name || 'a core from the quarry').slice(0, 40), seal: a.seal || 'found' });
|
||||||
|
E.addLog('cores.extract', c.name, c.seal, 'api');
|
||||||
|
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
|
||||||
|
}
|
||||||
|
case 'fracture_core': {
|
||||||
|
const c = E.refracture(a.core_id);
|
||||||
|
if (!c) throw new Error('no such core: ' + a.core_id);
|
||||||
|
if (Q) Q.refresh();
|
||||||
|
return { id: c.id, loud: c.ranked.slice(0, 4), faces: c.faces.length };
|
||||||
|
}
|
||||||
|
case 'strike_face': {
|
||||||
|
const core = E.byId(a.core_id);
|
||||||
|
if (!core) throw new Error('no such core: ' + a.core_id);
|
||||||
|
const face = clamp(Math.round(+a.face || 0), 0, 15);
|
||||||
|
E.strike(core.id, face, { pitch: 0, gain: 0.55 + 0.4 * (a.velocity == null ? 1 : a.velocity), ghost: true });
|
||||||
|
return { core: core.id, face: face, struck: true };
|
||||||
|
}
|
||||||
|
case 'place_stone': {
|
||||||
|
const r = Z.addStoneAt({
|
||||||
|
vein: a.vein, core: a.core_id, face: a.face,
|
||||||
|
start: a.start_bar, length: a.length_bars, ring: a.ring_bars,
|
||||||
|
pitch: a.pitch, gain: a.gain, name: a.name, actor: 'api'
|
||||||
|
});
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
case 'move_stone': {
|
||||||
|
const r = Z.moveStoneAt(a.stone_id, a.start_bar);
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
case 'delete_stone': {
|
||||||
|
const r = Z.deleteStoneAt(a.stone_id);
|
||||||
|
if (r.error) throw new Error(r.error);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
case 'set_weather': {
|
||||||
|
const w = Q ? Q.setWeatherUI({ age: a.age, pressure: a.pressure, air: a.air, heat: a.heat }) : E.weather;
|
||||||
|
return { weather: w };
|
||||||
|
}
|
||||||
|
case 'set_rings': {
|
||||||
|
return { rings: Q ? Q.setRingsUI(a.count) : E.rings };
|
||||||
|
}
|
||||||
|
case 'set_tempo':
|
||||||
|
return { bpm: Z.setTempo(a.bpm) };
|
||||||
|
case 'transport':
|
||||||
|
return Z.transport(a.action, a.bar);
|
||||||
|
case 'open_core': {
|
||||||
|
if (Q) { Q.setView('quarry'); Q.focusCore(a.core_id); }
|
||||||
|
return { core: a.core_id, view: 'quarry' };
|
||||||
|
}
|
||||||
|
case 'set_macros': {
|
||||||
|
const out = {};
|
||||||
|
['warmth', 'dust', 'threshold'].forEach(function (k) {
|
||||||
|
if (a[k] != null) out[k] = Z.setMacroUI(k, a[k]);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('unknown tool: ' + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeTool(name, args, source) {
|
||||||
|
const li = addTool(name, args, source);
|
||||||
|
const t0 = performance.now();
|
||||||
|
let res;
|
||||||
|
try { res = await exec(name, args); }
|
||||||
|
catch (e) { res = { error: String(e && e.message || e) }; }
|
||||||
|
finishTool(li, ((performance.now() - t0) / 1000).toFixed(1) + 's', res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— the model loop ————— */
|
||||||
|
|
||||||
|
function trimHistory() {
|
||||||
|
const h = history.slice();
|
||||||
|
while (h.length > 28) {
|
||||||
|
const i = h.findIndex(function (m) { return m.role === 'user'; });
|
||||||
|
if (i < 0) break;
|
||||||
|
h.splice(0, i + 1);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function modelSay(text) {
|
||||||
|
history.push({ role: 'user', content: text });
|
||||||
|
setStatus('thinking', true);
|
||||||
|
let ran = 0;
|
||||||
|
let lastError = null;
|
||||||
|
let wireDown = false;
|
||||||
|
for (let step = 0; step < 6; step++) {
|
||||||
|
let r, j;
|
||||||
|
try {
|
||||||
|
r = await fetch(API + '/api/agent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: trimHistory() }),
|
||||||
|
signal: AbortSignal.timeout(95000)
|
||||||
|
});
|
||||||
|
j = await r.json().catch(function () { return null; });
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
wireDown = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
lastError = new Error((j && j.error) || ('agent ' + r.status));
|
||||||
|
if (j && j.mode === 'local') { wireDown = true; live = false; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const m = j && j.message;
|
||||||
|
if (!m) { lastError = new Error('empty reply'); break; }
|
||||||
|
history.push(m);
|
||||||
|
if (m.content && m.content.trim()) addSay(m.content.trim());
|
||||||
|
if (!m.tool_calls || !m.tool_calls.length) { lastError = null; break; }
|
||||||
|
for (let k = 0; k < m.tool_calls.length; k++) {
|
||||||
|
const tc = m.tool_calls[k];
|
||||||
|
const name = tc.function && tc.function.name;
|
||||||
|
let args = {};
|
||||||
|
try { args = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (e) {}
|
||||||
|
setStatus('calling ' + name, true);
|
||||||
|
const res = await invokeTool(name, args, 'api');
|
||||||
|
ran++;
|
||||||
|
history.push({ role: 'tool', tool_call_id: tc.id, name: name, content: JSON.stringify(res) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) {
|
||||||
|
if (wireDown) {
|
||||||
|
live = false;
|
||||||
|
document.body.classList.add('agent-offline');
|
||||||
|
if (netText) netText.textContent = 'local';
|
||||||
|
}
|
||||||
|
addSay('the choir lost the wire: ' + lastError.message + '.');
|
||||||
|
if (!ran) localSay(text, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— local conductor (no model) ————— */
|
||||||
|
|
||||||
|
async function localSay(text, afterError) {
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
const core = (Q && Q.selectedCore()) || E.cores[0];
|
||||||
|
if (!core) {
|
||||||
|
addSay('there is no material in the archive yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done = [];
|
||||||
|
const wantsStop = /(stop|стоп|пауза|pause)/.test(t);
|
||||||
|
const wantsPlay = /(play|играй|пуск|запусти|start)/.test(t);
|
||||||
|
if (wantsStop) { await invokeTool('transport', { action: 'pause' }, 'local'); done.push('paused'); }
|
||||||
|
else if (wantsPlay) { await invokeTool('transport', { action: 'play' }, 'local'); done.push('the wheels are turning'); }
|
||||||
|
|
||||||
|
if (/(fracture|cut|реж|трещин|разрез)/.test(t)) {
|
||||||
|
await invokeTool('fracture_core', { core_id: core.id }, 'local');
|
||||||
|
done.push('re-cut ' + core.id);
|
||||||
|
} else if (/(take|bring|extract|найд|принес|достань|новый|room|поле|запись)/.test(t)) {
|
||||||
|
const c = await invokeTool('extract_core', { name: text.slice(0, 42), seal: 'found' }, 'local');
|
||||||
|
done.push('the choir brought ' + c.name);
|
||||||
|
} else if (/(dark|темн|стар|old|fossil|истощ)/.test(t)) {
|
||||||
|
const w = await invokeTool('set_weather', { age: 0.72, air: 0.3 }, 'local');
|
||||||
|
done.push('weather: age 0.72, air 0.30');
|
||||||
|
} else if (/(wet|мокр|room|воздух|air|простор)/.test(t)) {
|
||||||
|
await invokeTool('set_weather', { air: 0.74, pressure: 0.3 }, 'local');
|
||||||
|
done.push('weather: air 0.74, pressure 0.30');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantsPlace = /(place|add|layer|слой|добавь|постав|loop|петл|пульс|bass|низ|голос|шум|звук|дыш)/.test(t);
|
||||||
|
if (wantsPlace || !done.length) {
|
||||||
|
let vein = 'silt';
|
||||||
|
if (/(bass|низ|бас|пол)/.test(t)) vein = 'bass';
|
||||||
|
else if (/(voice|голос|хор|петь)/.test(t)) vein = 'voice';
|
||||||
|
else if (/(noise|шум|воздух|ветер)/.test(t)) vein = 'noise';
|
||||||
|
else if (/(crack|трещ|грит)/.test(t)) vein = 'crack';
|
||||||
|
const face = E.rankFace(core.id, vein === 'bass' ? 0 : 1);
|
||||||
|
const start = Math.min(30, Math.ceil(E.getPos()) + 1);
|
||||||
|
const ring = /(once|один|раз|one)/.test(t) ? 0 : 2;
|
||||||
|
const pitch = vein === 'bass' ? -5 : 0;
|
||||||
|
const res = await invokeTool('place_stone', {
|
||||||
|
vein: vein, core_id: core.id, face: face,
|
||||||
|
start_bar: start, length_bars: 8, ring_bars: ring, pitch: pitch, gain: 0.8
|
||||||
|
}, 'local');
|
||||||
|
if (!res.error) done.push('laid ' + res.name + ' in ' + res.vein + ', bars ' + res.bars[0] + '–' + res.bars[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSay((afterError ? 'the wire is down, so i worked by hand. ' : '') + (done.length ? done.join('. ') + '.' : 'i listened, but found nothing to change.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function say(text) {
|
||||||
|
text = String(text || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (busy) {
|
||||||
|
addSay('one at a time — the choir is still working.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
addYou(text);
|
||||||
|
try {
|
||||||
|
if (live) {
|
||||||
|
await modelSay(text);
|
||||||
|
} else {
|
||||||
|
setStatus('local', false);
|
||||||
|
await localSay(text, false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addSay('the choir failed: ' + (e && e.message || e));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
setStatus(live ? 'listening' : 'local', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— boot ————— */
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const meta = $('.state-meta');
|
||||||
|
if (meta) meta.textContent = TOOL_NAMES.length + ' tools';
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/api/agent', { signal: AbortSignal.timeout(3000) });
|
||||||
|
const j = await r.json();
|
||||||
|
live = !!j.live;
|
||||||
|
} catch (e) {
|
||||||
|
live = false;
|
||||||
|
}
|
||||||
|
document.body.classList.toggle('agent-offline', !live);
|
||||||
|
if (netText) netText.textContent = live ? 'live' : 'local';
|
||||||
|
const chip = $('.chip');
|
||||||
|
if (chip) {
|
||||||
|
chip.classList.toggle('is-live', live);
|
||||||
|
chip.title = live ? 'Embedded agent: live model on the local server' : 'Embedded agent: local conductor (no model on the wire)';
|
||||||
|
}
|
||||||
|
setStatus(live ? 'listening' : 'local', false);
|
||||||
|
if (!welcomed) {
|
||||||
|
welcomed = true;
|
||||||
|
addSay(live
|
||||||
|
? 'the choir is listening. say what should be heard — i can take cores, cut faces, lay stones, or change the weather.'
|
||||||
|
: 'the choir is local: no model on the wire. i can still take, cut, and lay by rule.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ZHILA_AGENT = {
|
||||||
|
say: say,
|
||||||
|
isLive: function () { return live; },
|
||||||
|
isBusy: function () { return busy; },
|
||||||
|
tools: TOOL_NAMES.length
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
window.setTimeout(init, 250);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { window.setTimeout(init, 250); });
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
/* ZHILA · engine
|
||||||
|
audio clock, sample slices, voices, climate, transport, render.
|
||||||
|
one graph for listening and for offline bounce. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||||
|
const mulberry = function (seed) {
|
||||||
|
let a = seed | 0;
|
||||||
|
return function () {
|
||||||
|
a = a + 0x6D2B79F5 | 0;
|
||||||
|
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||||
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const sr = ctx.sampleRate;
|
||||||
|
|
||||||
|
const E = {
|
||||||
|
ctx: ctx,
|
||||||
|
sr: sr,
|
||||||
|
cores: [],
|
||||||
|
song: { bpm: 128, bars: 32, stones: [] },
|
||||||
|
weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 },
|
||||||
|
rings: 4,
|
||||||
|
muted: false,
|
||||||
|
playing: false,
|
||||||
|
log: [],
|
||||||
|
listeners: { cores: [], log: [], render: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ————— graph ————— */
|
||||||
|
|
||||||
|
function impulse(c, seconds) {
|
||||||
|
const n = Math.floor(c.sampleRate * seconds);
|
||||||
|
const buf = c.createBuffer(2, n, c.sampleRate);
|
||||||
|
for (let ch = 0; ch < 2; ch++) {
|
||||||
|
const d = buf.getChannelData(ch);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function graph(c) {
|
||||||
|
const master = c.createGain();
|
||||||
|
master.gain.value = E.muted ? 0 : 0.85;
|
||||||
|
|
||||||
|
const limiter = c.createDynamicsCompressor();
|
||||||
|
limiter.threshold.value = -7;
|
||||||
|
limiter.knee.value = 8;
|
||||||
|
limiter.ratio.value = 10;
|
||||||
|
limiter.attack.value = 0.004;
|
||||||
|
limiter.release.value = 0.22;
|
||||||
|
|
||||||
|
const analyser = c.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
analyser.smoothingTimeConstant = 0.72;
|
||||||
|
|
||||||
|
const climateIn = c.createGain();
|
||||||
|
const age = c.createBiquadFilter();
|
||||||
|
age.type = 'lowpass';
|
||||||
|
age.Q.value = 0.6;
|
||||||
|
const tilt = c.createBiquadFilter();
|
||||||
|
tilt.type = 'highshelf';
|
||||||
|
tilt.frequency.value = 1800;
|
||||||
|
tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||||
|
const drive = c.createGain();
|
||||||
|
const shaper = c.createWaveShaper();
|
||||||
|
const curve = new Float32Array(1024);
|
||||||
|
for (let i = 0; i < 1024; i++) {
|
||||||
|
const x = (i * 2) / 1024 - 1;
|
||||||
|
curve[i] = Math.tanh(x * 2.1);
|
||||||
|
}
|
||||||
|
shaper.curve = curve;
|
||||||
|
shaper.oversample = '2x';
|
||||||
|
const post = c.createGain();
|
||||||
|
const dry = c.createGain();
|
||||||
|
const verb = c.createConvolver();
|
||||||
|
verb.buffer = impulse(c, 2.4);
|
||||||
|
const wet = c.createGain();
|
||||||
|
|
||||||
|
climateIn.connect(age);
|
||||||
|
age.connect(tilt);
|
||||||
|
tilt.connect(drive);
|
||||||
|
drive.connect(shaper);
|
||||||
|
shaper.connect(post);
|
||||||
|
post.connect(dry);
|
||||||
|
dry.connect(limiter);
|
||||||
|
post.connect(verb);
|
||||||
|
verb.connect(wet);
|
||||||
|
wet.connect(limiter);
|
||||||
|
limiter.connect(master);
|
||||||
|
master.connect(analyser);
|
||||||
|
analyser.connect(c.destination);
|
||||||
|
|
||||||
|
const veins = [];
|
||||||
|
const pans = [-0.34, -0.16, 0, 0.16, 0.34];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.value = 0.6;
|
||||||
|
const p = c.createStereoPanner ? c.createStereoPanner() : null;
|
||||||
|
if (p) {
|
||||||
|
p.pan.value = pans[i];
|
||||||
|
g.connect(p);
|
||||||
|
p.connect(climateIn);
|
||||||
|
} else {
|
||||||
|
g.connect(climateIn);
|
||||||
|
}
|
||||||
|
veins.push(g);
|
||||||
|
}
|
||||||
|
const strikeBus = c.createGain();
|
||||||
|
strikeBus.gain.value = 0.8;
|
||||||
|
strikeBus.connect(climateIn);
|
||||||
|
|
||||||
|
// dust: a permanent, quiet hiss in the climate
|
||||||
|
const hissBuf = c.createBuffer(1, Math.floor(c.sampleRate * 2), c.sampleRate);
|
||||||
|
const hd = hissBuf.getChannelData(0);
|
||||||
|
for (let i = 0; i < hd.length; i++) hd[i] = Math.random() * 2 - 1;
|
||||||
|
const hiss = c.createBufferSource();
|
||||||
|
hiss.buffer = hissBuf;
|
||||||
|
hiss.loop = true;
|
||||||
|
const hissF = c.createBiquadFilter();
|
||||||
|
hissF.type = 'highpass';
|
||||||
|
hissF.frequency.value = 1200;
|
||||||
|
const hissGain = c.createGain();
|
||||||
|
hissGain.gain.value = macros.dust * 0.03;
|
||||||
|
hiss.connect(hissF); hissF.connect(hissGain); hissGain.connect(tilt);
|
||||||
|
hiss.start();
|
||||||
|
|
||||||
|
limiter.threshold.value = -20 + macros.threshold * 20;
|
||||||
|
|
||||||
|
return {
|
||||||
|
master: master, limiter: limiter, analyser: analyser, climateIn: climateIn,
|
||||||
|
age: age, tilt: tilt, drive: drive, post: post, dry: dry, wet: wet, verb: verb,
|
||||||
|
veins: veins, strikeBus: strikeBus, hiss: hissGain
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const macros = { warmth: 0.58, dust: 0.34, threshold: 0.72 };
|
||||||
|
const G = graph(ctx);
|
||||||
|
const freqData = new Uint8Array(G.analyser.frequencyBinCount);
|
||||||
|
const timeData = new Uint8Array(G.analyser.fftSize);
|
||||||
|
|
||||||
|
function applyClimate(target, w) {
|
||||||
|
const t = target.ctx ? target.ctx.currentTime : 0;
|
||||||
|
target.age.frequency.setTargetAtTime(650 + (1 - w.age) * 11500, t, 0.08);
|
||||||
|
target.drive.gain.setTargetAtTime(1 + w.pressure * 4.2, t, 0.08);
|
||||||
|
target.post.gain.setTargetAtTime(0.72 / (1 + w.pressure * 1.1), t, 0.08);
|
||||||
|
target.wet.gain.setTargetAtTime(0.035 + w.air * 0.52, t, 0.08);
|
||||||
|
target.dry.gain.setTargetAtTime(1 - w.air * 0.28, t, 0.08);
|
||||||
|
}
|
||||||
|
G.ctx = ctx;
|
||||||
|
applyClimate(G, E.weather);
|
||||||
|
|
||||||
|
E.setMacro = function (name, value) {
|
||||||
|
macros[name] = clamp(+value, 0, 1);
|
||||||
|
if (name === 'warmth') G.tilt.gain.setTargetAtTime((macros.warmth - 0.5) * 10, ctx.currentTime, 0.06);
|
||||||
|
if (name === 'dust') G.hiss.gain.setTargetAtTime(macros.dust * 0.03, ctx.currentTime, 0.1);
|
||||||
|
if (name === 'threshold') G.limiter.threshold.setTargetAtTime(-20 + macros.threshold * 20, ctx.currentTime, 0.1);
|
||||||
|
};
|
||||||
|
E.getMacro = function (name) { return macros[name]; };
|
||||||
|
|
||||||
|
/* ————— sound of the material ————— */
|
||||||
|
|
||||||
|
function synth(kind, seed) {
|
||||||
|
const rnd = mulberry(seed * 131 + 7);
|
||||||
|
const dur = kind === 'room' ? 15 : kind === 'rail' ? 12 : kind === 'rain' ? 7.6 : 9.6;
|
||||||
|
const n = Math.floor(dur * sr);
|
||||||
|
const buf = ctx.createBuffer(1, n, sr);
|
||||||
|
const d = buf.getChannelData(0);
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
|
if (kind === 'stair') {
|
||||||
|
let lp = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const w = Math.random() * 2 - 1;
|
||||||
|
lp += (w - lp) * 0.012;
|
||||||
|
d[i] = lp * 0.9;
|
||||||
|
peak = Math.max(peak, Math.abs(d[i]));
|
||||||
|
}
|
||||||
|
const hits = 11 + Math.floor(rnd() * 5);
|
||||||
|
for (let k = 0; k < hits; k++) {
|
||||||
|
const at = Math.floor((0.04 + rnd() * 0.88) * n);
|
||||||
|
const f = 46 + rnd() * 46;
|
||||||
|
const amp = 0.26 + rnd() * 0.34;
|
||||||
|
const dec = 0.28 + rnd() * 0.75;
|
||||||
|
const max = Math.min(n - at, Math.floor(dec * 3 * sr));
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
const e = Math.exp(-i / (dec * sr));
|
||||||
|
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * e;
|
||||||
|
}
|
||||||
|
const tn = Math.min(n - at, Math.floor(0.05 * sr));
|
||||||
|
let nlp = 0;
|
||||||
|
for (let i = 0; i < tn; i++) {
|
||||||
|
const w = Math.random() * 2 - 1;
|
||||||
|
nlp += (w - nlp) * 0.2;
|
||||||
|
d[at + i] += nlp * 0.32 * Math.exp(-i / (0.018 * sr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'rain') {
|
||||||
|
let lp = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const w = Math.random() * 2 - 1;
|
||||||
|
lp += (w - lp) * 0.52;
|
||||||
|
d[i] = (w - lp) * 0.018;
|
||||||
|
}
|
||||||
|
const ticks = 640;
|
||||||
|
for (let k = 0; k < ticks; k++) {
|
||||||
|
const at = Math.floor(rnd() * (n - 800));
|
||||||
|
const amp = 0.04 + rnd() * 0.2;
|
||||||
|
const len = 40 + Math.floor(rnd() * 260);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
d[at + i] += (Math.random() * 2 - 1) * amp * Math.exp(-i / (0.0022 * sr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 14; k++) {
|
||||||
|
const at = Math.floor(rnd() * (n - sr));
|
||||||
|
const f = 900 + rnd() * 2800;
|
||||||
|
const amp = 0.025 + rnd() * 0.045;
|
||||||
|
const dec = 0.16 + rnd() * 0.4;
|
||||||
|
for (let i = 0; i < Math.min(n - at, Math.floor(dec * 3 * sr)); i++) {
|
||||||
|
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * Math.exp(-i / (dec * sr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'room') {
|
||||||
|
let brown = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const w = Math.random() * 2 - 1;
|
||||||
|
brown = (brown + w * 0.02) * 0.999;
|
||||||
|
const swell = 0.55 + 0.45 * Math.sin(TAU() * 0.055 * i / sr + seed);
|
||||||
|
d[i] = brown * 5.2 * swell + Math.sin(TAU() * 41 * i / sr) * 0.012;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let lp = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const w = Math.random() * 2 - 1;
|
||||||
|
lp += (w - lp) * 0.006;
|
||||||
|
d[i] = lp * 1.7;
|
||||||
|
}
|
||||||
|
const squeals = 3 + Math.floor(rnd() * 3);
|
||||||
|
for (let k = 0; k < squeals; k++) {
|
||||||
|
const at = Math.floor((0.1 + rnd() * 0.75) * n);
|
||||||
|
const f0 = 320 + rnd() * 280;
|
||||||
|
const f1 = f0 * (1.15 + rnd() * 0.5);
|
||||||
|
const amp = 0.03 + rnd() * 0.035;
|
||||||
|
const len = Math.min(n - at, Math.floor((0.9 + rnd() * 1.4) * sr));
|
||||||
|
let ph = 0;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const u = i / len;
|
||||||
|
const f = f0 + (f1 - f0) * u;
|
||||||
|
ph += TAU() * f / sr;
|
||||||
|
d[at + i] += Math.sin(ph) * amp * Math.sin(Math.PI * Math.pow(u, 0.6)) * Math.exp(-u * 1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 4; k++) {
|
||||||
|
const at = Math.floor(rnd() * 0.9 * n);
|
||||||
|
const f = 38 + rnd() * 30;
|
||||||
|
for (let i = 0; i < Math.min(n - at, Math.floor(1.2 * sr)); i++) {
|
||||||
|
d[at + i] += Math.sin(TAU() * f * i / sr) * 0.16 * Math.exp(-i / (0.4 * sr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(d[i]));
|
||||||
|
const norm = 0.58 / (peak || 1);
|
||||||
|
const fade = Math.floor(0.03 * sr);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let g = norm;
|
||||||
|
if (i < fade) g *= i / fade;
|
||||||
|
if (i > n - fade) g *= (n - i) / fade;
|
||||||
|
d[i] *= g;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TAU() { return Math.PI * 2; }
|
||||||
|
|
||||||
|
function envelope(buffer, buckets) {
|
||||||
|
const ch = buffer.getChannelData(0);
|
||||||
|
const n = ch.length;
|
||||||
|
const env = new Float32Array(buckets);
|
||||||
|
const step = n / buckets;
|
||||||
|
for (let i = 0; i < buckets; i++) {
|
||||||
|
let m = 0;
|
||||||
|
const from = Math.floor(i * step);
|
||||||
|
const to = Math.min(n, Math.floor((i + 1) * step));
|
||||||
|
for (let k = from; k < to; k += 2) {
|
||||||
|
const v = Math.abs(ch[k]);
|
||||||
|
if (v > m) m = v;
|
||||||
|
}
|
||||||
|
env[i] = m;
|
||||||
|
}
|
||||||
|
const mx = env.reduce(function (a, b) { return Math.max(a, b); }, 0.0001);
|
||||||
|
for (let i = 0; i < buckets; i++) env[i] = Math.min(1, env[i] / mx);
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rmsOf(buffer, startSec, lenSec) {
|
||||||
|
const ch = buffer.getChannelData(0);
|
||||||
|
const a = Math.floor(startSec * buffer.sampleRate);
|
||||||
|
const b = Math.min(ch.length, Math.floor((startSec + lenSec) * buffer.sampleRate));
|
||||||
|
let s = 0, c = 0;
|
||||||
|
for (let i = a; i < b; i += 3) { s += ch[i] * ch[i]; c++; }
|
||||||
|
return c ? Math.sqrt(s / c) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFaces(buffer, variant) {
|
||||||
|
const total = buffer.duration;
|
||||||
|
const env = envelope(buffer, 400);
|
||||||
|
const bounds = [0];
|
||||||
|
for (let i = 1; i < 16; i++) {
|
||||||
|
const base = Math.floor((i / 16) * 400);
|
||||||
|
const span = 10;
|
||||||
|
let best = base, bestV = Infinity;
|
||||||
|
for (let k = Math.max(1, base - span); k < Math.min(399, base + span); k++) {
|
||||||
|
if (env[k] < bestV) { bestV = env[k]; best = k; }
|
||||||
|
}
|
||||||
|
bounds.push(best / 400);
|
||||||
|
}
|
||||||
|
if (variant) {
|
||||||
|
const rnd = mulberry(variant * 997 + 13);
|
||||||
|
for (let i = 1; i < 16; i++) {
|
||||||
|
const shift = (rnd() - 0.5) * 0.045;
|
||||||
|
bounds[i] = clamp(bounds[i] + shift, bounds[i - 1] + 0.004, 0.995);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounds.push(1);
|
||||||
|
const faces = [];
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const s = bounds[i] * total;
|
||||||
|
const e = bounds[i + 1] * total;
|
||||||
|
faces.push({ start: s, len: Math.max(0.03, e - s), rms: rmsOf(buffer, s, Math.max(0.03, e - s)) });
|
||||||
|
}
|
||||||
|
const ranked = faces.map(function (f, i) { return i; }).sort(function (a, b) { return faces[b].rms - faces[a].rms; });
|
||||||
|
const cuts = [faces[5].start, faces[10].start, faces[14].start];
|
||||||
|
return { faces: faces, ranked: ranked, cuts: cuts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCore(opts) {
|
||||||
|
const buffer = opts.buffer || synth(opts.kind || 'stair', opts.seed || 1);
|
||||||
|
const met = makeFaces(buffer, opts.variant || 0);
|
||||||
|
const core = {
|
||||||
|
id: opts.id || 'core-' + (E.cores.length + 1),
|
||||||
|
name: opts.name || 'untitled core',
|
||||||
|
seal: opts.seal || 'taken',
|
||||||
|
kind: opts.kind || 'stair',
|
||||||
|
seed: opts.seed || 1,
|
||||||
|
dur: buffer.duration,
|
||||||
|
buffer: buffer,
|
||||||
|
env: envelope(buffer, 220),
|
||||||
|
faces: met.faces,
|
||||||
|
ranked: met.ranked,
|
||||||
|
cuts: met.cuts
|
||||||
|
};
|
||||||
|
E.cores.push(core);
|
||||||
|
E.listeners.cores.forEach(function (fn) { fn(core); });
|
||||||
|
return core;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankFace(coreId, rank) {
|
||||||
|
const c = byId(coreId);
|
||||||
|
if (!c) return 0;
|
||||||
|
return c.ranked[clamp(rank | 0, 0, 15)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function byId(id) {
|
||||||
|
for (let i = 0; i < E.cores.length; i++) if (E.cores[i].id === id) return E.cores[i];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— voices ————— */
|
||||||
|
|
||||||
|
const voices = [];
|
||||||
|
|
||||||
|
function playSlice(core, face, opts) {
|
||||||
|
if (!core || !face) return;
|
||||||
|
const when = opts.when || (ctx.currentTime + 0.01);
|
||||||
|
const pitch = opts.pitch || 0;
|
||||||
|
const rate = Math.pow(2, pitch / 12) * (1 + E.weather.heat * 0.03);
|
||||||
|
const vol = clamp(opts.gain == null ? 0.8 : opts.gain, 0, 2);
|
||||||
|
const dur = Math.max(0.04, face.len / rate);
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = core.buffer;
|
||||||
|
src.playbackRate.value = rate;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
const atk = 0.004;
|
||||||
|
const rel = Math.min(0.03, dur * 0.3);
|
||||||
|
g.gain.setValueAtTime(0, when);
|
||||||
|
g.gain.linearRampToValueAtTime(vol, when + atk);
|
||||||
|
g.gain.setValueAtTime(vol, Math.max(when + atk, when + dur - rel));
|
||||||
|
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||||
|
src.connect(g);
|
||||||
|
const target = opts.bus || 0;
|
||||||
|
if (opts.bus) g.connect(opts.bus);
|
||||||
|
else g.connect(G.veins[clamp(target, 0, 4)] || G.veins[0]);
|
||||||
|
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||||
|
src.stop(when + dur + 0.01);
|
||||||
|
const voice = { src: src, g: g, end: when + dur };
|
||||||
|
voices.push(voice);
|
||||||
|
src.onended = function () {
|
||||||
|
const i = voices.indexOf(voice);
|
||||||
|
if (i >= 0) voices.splice(i, 1);
|
||||||
|
};
|
||||||
|
if (voices.length > 30) {
|
||||||
|
const old = voices.shift();
|
||||||
|
try {
|
||||||
|
old.g.gain.cancelScheduledValues(ctx.currentTime);
|
||||||
|
old.g.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||||
|
old.src.stop(ctx.currentTime + 0.05);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strike(coreId, faceIdx, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
resume();
|
||||||
|
const core = byId(coreId);
|
||||||
|
if (!core) return;
|
||||||
|
const face = core.faces[faceIdx];
|
||||||
|
if (!face) return;
|
||||||
|
playSlice(core, face, {
|
||||||
|
pitch: opts.pitch || 0,
|
||||||
|
gain: opts.gain || 0.9,
|
||||||
|
bus: G.strikeBus
|
||||||
|
});
|
||||||
|
if (opts.ghost) {
|
||||||
|
const when = ctx.currentTime + 0.02;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = core.buffer;
|
||||||
|
src.playbackRate.value = Math.pow(2, (opts.pitch || 0) / 12) * 0.5;
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = 'lowpass';
|
||||||
|
f.frequency.value = 900;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
const dur = Math.min(1.2, face.len * 2.4);
|
||||||
|
g.gain.setValueAtTime(0, when);
|
||||||
|
g.gain.linearRampToValueAtTime(0.09, when + 0.05);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
|
||||||
|
src.connect(f); f.connect(g); g.connect(G.strikeBus);
|
||||||
|
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||||
|
src.stop(when + dur + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVoices() {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
voices.splice(0).forEach(function (v) {
|
||||||
|
try {
|
||||||
|
v.g.gain.cancelScheduledValues(now);
|
||||||
|
v.g.gain.setTargetAtTime(0, now, 0.015);
|
||||||
|
v.src.stop(now + 0.08);
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— song & scheduler ————— */
|
||||||
|
|
||||||
|
let events = [];
|
||||||
|
let pass = 0, cursor = 0, nextTime = Infinity, timer = null;
|
||||||
|
let transportStart = 0;
|
||||||
|
let pausedPos = 7.5;
|
||||||
|
|
||||||
|
function barSec() { return 60 / E.song.bpm * 4; }
|
||||||
|
function bps() { return E.song.bpm / 60 / 4; }
|
||||||
|
function loopDur() { return E.song.bars * barSec(); }
|
||||||
|
|
||||||
|
function compile() {
|
||||||
|
events = [];
|
||||||
|
E.song.stones.forEach(function (s) {
|
||||||
|
const core = byId(s.coreId);
|
||||||
|
if (!core) return;
|
||||||
|
const face = core.faces[s.face | 0];
|
||||||
|
if (!face) return;
|
||||||
|
const a = clamp(+s.a || 1, 1, E.song.bars);
|
||||||
|
const b = clamp(+s.b || a + 1, a + 0.01, E.song.bars);
|
||||||
|
const ring = Math.max(0, +s.ring || 0);
|
||||||
|
const pitch = +s.pitch || 0;
|
||||||
|
const gain = s.gain == null ? 0.8 : +s.gain;
|
||||||
|
const vein = clamp(+s.vein || 0, 0, 4);
|
||||||
|
if (ring > 0) {
|
||||||
|
let cycle = 0;
|
||||||
|
const endBar = b >= E.song.bars - 1e-6 ? b + 1e-6 : b - 1e-6;
|
||||||
|
for (let bar = a; bar < endBar; bar += ring) {
|
||||||
|
events.push({ bar: bar, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: cycle++ });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
events.push({ bar: a, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
events.sort(function (p, q) { return p.bar - q.bar; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScheduler() {
|
||||||
|
pass = 0; cursor = 0;
|
||||||
|
nextTime = events.length
|
||||||
|
? transportStart + (events[0].bar - 1) * barSec()
|
||||||
|
: Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!E.playing || !events.length) return;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const until = now + 0.18;
|
||||||
|
let guard = 0;
|
||||||
|
while (nextTime < until && guard++ < 800) {
|
||||||
|
if (nextTime >= now - 0.02) {
|
||||||
|
const ev = events[cursor];
|
||||||
|
const growth = 1 + ev.cycle * 0.0006 * E.rings;
|
||||||
|
playSlice(ev.core, ev.face, {
|
||||||
|
when: nextTime,
|
||||||
|
pitch: ev.pitch + ev.cycle * 0.01 * E.rings,
|
||||||
|
gain: ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)) * growth,
|
||||||
|
vein: ev.vein
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor++;
|
||||||
|
if (cursor >= events.length) { cursor = 0; pass++; }
|
||||||
|
nextTime = transportStart + pass * loopDur() + (events[cursor].bar - 1) * barSec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume() {
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
E.play = function () {
|
||||||
|
resume();
|
||||||
|
if (E.playing) return;
|
||||||
|
E.playing = true;
|
||||||
|
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.06;
|
||||||
|
resetScheduler();
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = setInterval(tick, 25);
|
||||||
|
tick();
|
||||||
|
};
|
||||||
|
|
||||||
|
E.pause = function () {
|
||||||
|
if (!E.playing) return;
|
||||||
|
pausedPos = E.getPos();
|
||||||
|
E.playing = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
stopVoices();
|
||||||
|
};
|
||||||
|
|
||||||
|
E.stop = function () {
|
||||||
|
E.playing = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
pausedPos = 1;
|
||||||
|
stopVoices();
|
||||||
|
};
|
||||||
|
|
||||||
|
E.seek = function (pos) {
|
||||||
|
pausedPos = clamp(pos, 1, E.song.bars);
|
||||||
|
if (E.playing) {
|
||||||
|
stopVoices();
|
||||||
|
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.04;
|
||||||
|
resetScheduler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
E.getPos = function () {
|
||||||
|
if (!E.playing) return pausedPos;
|
||||||
|
const p = 1 + (ctx.currentTime - transportStart) * bps();
|
||||||
|
const loop = E.song.bars;
|
||||||
|
return ((p - 1) % loop + loop) % loop + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
E.setBpm = function (bpm) {
|
||||||
|
const pos = E.getPos();
|
||||||
|
E.song.bpm = clamp(bpm, 40, 220);
|
||||||
|
if (E.playing) {
|
||||||
|
transportStart = ctx.currentTime - (pos - 1) / bps();
|
||||||
|
resetScheduler();
|
||||||
|
}
|
||||||
|
pausedPos = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
E.setSong = function (stones) {
|
||||||
|
E.song.stones = (stones || []).slice();
|
||||||
|
compile();
|
||||||
|
if (E.playing) {
|
||||||
|
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||||
|
resetScheduler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
E.setWeather = function (w) {
|
||||||
|
Object.assign(E.weather, w || {});
|
||||||
|
applyClimate(G, E.weather);
|
||||||
|
};
|
||||||
|
|
||||||
|
E.setRings = function (n) { E.rings = clamp(n, 0, 24); };
|
||||||
|
|
||||||
|
E.setVeinGain = function (i, v) {
|
||||||
|
const g = G.veins[clamp(i, 0, 4)];
|
||||||
|
if (g) g.gain.setTargetAtTime(clamp(v, 0, 1.4), ctx.currentTime, 0.03);
|
||||||
|
};
|
||||||
|
|
||||||
|
E.setMuted = function (muted) {
|
||||||
|
E.muted = !!muted;
|
||||||
|
G.master.gain.setTargetAtTime(E.muted ? 0 : 0.85, ctx.currentTime, 0.08);
|
||||||
|
};
|
||||||
|
|
||||||
|
E.getMuted = function () { return E.muted; };
|
||||||
|
E.isPlaying = function () { return E.playing; };
|
||||||
|
E.getBpm = function () { return E.song.bpm; };
|
||||||
|
E.getEvents = function () { return events; };
|
||||||
|
E.pausedPos = function () { return pausedPos; };
|
||||||
|
|
||||||
|
/* ————— analysis ————— */
|
||||||
|
|
||||||
|
E.strike = strike;
|
||||||
|
|
||||||
|
E.read = function () {
|
||||||
|
G.analyser.getByteFrequencyData(freqData);
|
||||||
|
G.analyser.getByteTimeDomainData(timeData);
|
||||||
|
return { freq: freqData, time: timeData, sr: sr };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ————— cores ————— */
|
||||||
|
|
||||||
|
E.byId = byId;
|
||||||
|
E.rankFace = rankFace;
|
||||||
|
E.refracture = function (coreId) {
|
||||||
|
const core = byId(coreId);
|
||||||
|
if (!core) return null;
|
||||||
|
core.variant = (core.variant || 0) + 1;
|
||||||
|
const met = makeFaces(core.buffer, core.variant);
|
||||||
|
core.faces = met.faces;
|
||||||
|
core.ranked = met.ranked;
|
||||||
|
core.cuts = met.cuts;
|
||||||
|
compile();
|
||||||
|
if (E.playing) {
|
||||||
|
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||||
|
resetScheduler();
|
||||||
|
}
|
||||||
|
return core;
|
||||||
|
};
|
||||||
|
|
||||||
|
E.newCore = function (opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const kinds = ['stair', 'rain', 'room', 'rail'];
|
||||||
|
const kind = opts.kind || kinds[E.cores.length % kinds.length];
|
||||||
|
return addCore({
|
||||||
|
name: opts.name || kind + ' core ' + (E.cores.length + 1),
|
||||||
|
seal: opts.seal || 'taken',
|
||||||
|
kind: kind,
|
||||||
|
seed: opts.seed || (Math.floor(Math.random() * 900) + 17),
|
||||||
|
id: opts.id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
E.addBufferCore = function (opts) {
|
||||||
|
return addCore({
|
||||||
|
name: opts.name,
|
||||||
|
seal: opts.seal || 'taken',
|
||||||
|
kind: 'import',
|
||||||
|
buffer: opts.buffer,
|
||||||
|
id: opts.id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
E.onCores = function (fn) { E.listeners.cores.push(fn); };
|
||||||
|
E.onLog = function (fn) { E.listeners.log.push(fn); };
|
||||||
|
|
||||||
|
E.addLog = function (tool, detail, seal, by) {
|
||||||
|
E.log.unshift({ t: nowTime(), tool: tool, detail: detail, seal: seal || '', by: by || 'hand' });
|
||||||
|
if (E.log.length > 40) E.log.pop();
|
||||||
|
E.listeners.log.forEach(function (fn) { fn(E.log); });
|
||||||
|
};
|
||||||
|
|
||||||
|
function nowTime() {
|
||||||
|
const d = new Date();
|
||||||
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— import & record ————— */
|
||||||
|
|
||||||
|
E.importFile = function (file) {
|
||||||
|
return file.arrayBuffer().then(function (ab) {
|
||||||
|
return ctx.decodeAudioData(ab);
|
||||||
|
}).then(function (buffer) {
|
||||||
|
const name = file.name.replace(/\.[a-z0-9]+$/i, '').slice(0, 34) || 'imported';
|
||||||
|
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||||
|
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||||
|
return core;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
E.recordRoom = function (seconds) {
|
||||||
|
seconds = seconds || 6;
|
||||||
|
return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
const types = ['audio/webm;codecs=opus', 'audio/webm', ''];
|
||||||
|
let rec = null;
|
||||||
|
for (let i = 0; i < types.length; i++) {
|
||||||
|
try { rec = new MediaRecorder(stream, types[i] ? { mimeType: types[i] } : undefined); break; } catch (e) {}
|
||||||
|
}
|
||||||
|
if (!rec) { stream.getTracks().forEach(function (t) { t.stop(); }); reject(new Error('no recorder')); return; }
|
||||||
|
const chunks = [];
|
||||||
|
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
||||||
|
rec.onstop = function () {
|
||||||
|
stream.getTracks().forEach(function (t) { t.stop(); });
|
||||||
|
const blob = new Blob(chunks, { type: chunks[0] ? chunks[0].type : 'audio/webm' });
|
||||||
|
blob.arrayBuffer().then(function (ab) { return ctx.decodeAudioData(ab); }).then(function (buffer) {
|
||||||
|
const name = 'room, ' + nowTime();
|
||||||
|
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||||
|
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||||
|
resolve(core);
|
||||||
|
}).catch(reject);
|
||||||
|
};
|
||||||
|
rec.start();
|
||||||
|
setTimeout(function () { if (rec.state !== 'inactive') rec.stop(); }, seconds * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ————— render ————— */
|
||||||
|
|
||||||
|
function encodeWav(buffer) {
|
||||||
|
const channels = Math.min(2, buffer.numberOfChannels);
|
||||||
|
const len = buffer.length;
|
||||||
|
const bytes = 44 + len * channels * 2;
|
||||||
|
const ab = new ArrayBuffer(bytes);
|
||||||
|
const view = new DataView(ab);
|
||||||
|
function str(off, s) { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }
|
||||||
|
str(0, 'RIFF');
|
||||||
|
view.setUint32(4, bytes - 8, true);
|
||||||
|
str(8, 'WAVE');
|
||||||
|
str(12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, channels, true);
|
||||||
|
view.setUint32(24, buffer.sampleRate, true);
|
||||||
|
view.setUint32(28, buffer.sampleRate * channels * 2, true);
|
||||||
|
view.setUint16(32, channels * 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
str(36, 'data');
|
||||||
|
view.setUint32(40, len * channels * 2, true);
|
||||||
|
const chans = [];
|
||||||
|
for (let c = 0; c < channels; c++) chans.push(buffer.getChannelData(c));
|
||||||
|
let off = 44;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
for (let c = 0; c < channels; c++) {
|
||||||
|
const s = clamp(chans[c][i], -1, 1);
|
||||||
|
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
|
off += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Blob([ab], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
E.render = function () {
|
||||||
|
const duration = loopDur() + 2.4;
|
||||||
|
const off = new OfflineAudioContext(2, Math.ceil(duration * sr), sr);
|
||||||
|
const go = graph(off);
|
||||||
|
go.ctx = off;
|
||||||
|
Object.assign(go, { ctx: off });
|
||||||
|
applyClimate(go, E.weather);
|
||||||
|
go.tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||||
|
go.hiss.gain.value = macros.dust * 0.03;
|
||||||
|
go.limiter.threshold.value = -20 + macros.threshold * 20;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
go.veins[i].gain.value = G.veins[i].gain.value;
|
||||||
|
}
|
||||||
|
off.destination.channelCount = 2;
|
||||||
|
const startAt = off.currentTime;
|
||||||
|
events.forEach(function (ev) {
|
||||||
|
const when = startAt + (ev.bar - 1) * barSec() + 0.01;
|
||||||
|
const rate = Math.pow(2, (ev.pitch + ev.cycle * 0.01 * E.rings) / 12) * (1 + E.weather.heat * 0.03);
|
||||||
|
const dur = Math.max(0.04, ev.face.len / rate);
|
||||||
|
const src = off.createBufferSource();
|
||||||
|
src.buffer = ev.core.buffer;
|
||||||
|
src.playbackRate.value = rate;
|
||||||
|
const g = off.createGain();
|
||||||
|
const vol = clamp(ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)), 0, 2);
|
||||||
|
g.gain.setValueAtTime(0, when);
|
||||||
|
g.gain.linearRampToValueAtTime(vol, when + 0.004);
|
||||||
|
g.gain.setValueAtTime(vol, Math.max(when + 0.004, when + dur - 0.03));
|
||||||
|
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||||
|
src.connect(g);
|
||||||
|
g.connect(go.veins[ev.vein] || go.veins[0]);
|
||||||
|
src.start(when, clamp(ev.face.start, 0, ev.core.dur - 0.01));
|
||||||
|
src.stop(when + dur + 0.01);
|
||||||
|
});
|
||||||
|
return off.startRendering().then(function (buf) {
|
||||||
|
return encodeWav(buf);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
E.download = function () {
|
||||||
|
return E.render().then(function (blob) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'zhila-descent-04.wav';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 20000);
|
||||||
|
return blob;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ————— default cores ————— */
|
||||||
|
|
||||||
|
addCore({ id: 'stairwell-03', name: 'stairwell, 3rd floor', seal: 'taken', kind: 'stair', seed: 3 });
|
||||||
|
addCore({ id: 'rain-metal', name: 'rain on metal', seal: 'found', kind: 'rain', seed: 7 });
|
||||||
|
addCore({ id: 'room-04', name: 'room tone 04', seal: 'gift', kind: 'room', seed: 11 });
|
||||||
|
addCore({ id: 'rail-02', name: 'rail, night', seal: 'loan', kind: 'rail', seed: 5 });
|
||||||
|
|
||||||
|
E.log = [
|
||||||
|
{ t: '21:52', tool: 'cores.extract', detail: 'stairwell, 3rd floor', seal: 'taken', by: 'hand' },
|
||||||
|
{ t: '21:53', tool: 'faces.fracture', detail: 'stairwell-03 · 16 faces', seal: '', by: 'hand' },
|
||||||
|
{ t: '21:55', tool: 'rings.grow', detail: 'ring 04 · young', seal: '', by: 'mcp' }
|
||||||
|
];
|
||||||
|
|
||||||
|
window.ZHILA_ENGINE = E;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Zhila · descent 04</title>
|
||||||
|
<meta name="description" content="Zhila — a DAW where sound is terrain. The choir above, your hands below, agents in the protocol.">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300;0,400;1,300;1,400&family=JetBrains+Mono:wght@200;300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230c0908'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23e8432a' stroke-width='.8'/%3E%3Ccircle cx='16' cy='16' r='5.5' fill='%23000'/%3E%3C/svg%3E">
|
||||||
|
</head>
|
||||||
|
<body data-mode="choir" data-view="nave">
|
||||||
|
|
||||||
|
<div class="vignette" aria-hidden="true"></div>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
<canvas id="terrain" aria-hidden="true"></canvas>
|
||||||
|
<canvas id="breath" aria-hidden="true"></canvas>
|
||||||
|
<div class="quarry-dim" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
|
||||||
|
<header class="rail rail--top">
|
||||||
|
<a class="brand" href="#" aria-label="Zhila, version 0.4">ZHILA<span class="brand-v">0.4</span></a>
|
||||||
|
<div class="doc">
|
||||||
|
<span class="doc-name">descent 04</span>
|
||||||
|
<span class="doc-rule" aria-hidden="true"></span>
|
||||||
|
<span class="doc-meta">A minor<i class="doc-dot"></i>4/4<i class="doc-dot"></i>5 veins</span>
|
||||||
|
</div>
|
||||||
|
<div class="views" role="group" aria-label="Room">
|
||||||
|
<button class="view-btn is-on" type="button" data-view-btn="nave" aria-pressed="true">nave</button>
|
||||||
|
<span class="mode-slash" aria-hidden="true">/</span>
|
||||||
|
<button class="view-btn" type="button" data-view-btn="quarry" aria-pressed="false">quarry</button>
|
||||||
|
</div>
|
||||||
|
<div class="mode" role="group" aria-label="Who is playing">
|
||||||
|
<button class="mode-btn is-on" type="button" data-mode-btn="choir" aria-pressed="true">choir</button>
|
||||||
|
<span class="mode-slash" aria-hidden="true">/</span>
|
||||||
|
<button class="mode-btn" type="button" data-mode-btn="hand" aria-pressed="false">hand</button>
|
||||||
|
</div>
|
||||||
|
<div class="net" id="net">
|
||||||
|
<span class="led" aria-hidden="true"></span>
|
||||||
|
<span class="net-tag">api</span>
|
||||||
|
<i class="net-rule" aria-hidden="true"></i>
|
||||||
|
<span id="net-text">connecting</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="sky" aria-label="The eye and the voice">
|
||||||
|
<div class="sky-inner">
|
||||||
|
<div class="void-wrap">
|
||||||
|
<canvas id="halo" aria-hidden="true"></canvas>
|
||||||
|
<button class="void" id="void" type="button" aria-label="Call the choir: make a layer from your words">
|
||||||
|
<span class="void-word" id="void-word" aria-hidden="true">entry</span>
|
||||||
|
<span class="void-label" aria-hidden="true">scene</span>
|
||||||
|
<span class="void-hint" aria-hidden="true">call</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form class="invocation" id="invocation">
|
||||||
|
<label class="sr" for="prompt">What should sound</label>
|
||||||
|
<input id="prompt" name="prompt" type="text" placeholder="say what you want to hear" autocomplete="off" spellcheck="false">
|
||||||
|
<button class="invoke" type="submit">call</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint hint--choir"><kbd>⏎</kbd> call <span class="hint-rule" aria-hidden="true"></span> <kbd>space</kbd> play</p>
|
||||||
|
<p class="hint hint--hand">drag cilia <span class="hint-rule" aria-hidden="true"></span> click the nave</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="deck">
|
||||||
|
|
||||||
|
<aside class="agent rail" data-pool="0.95" aria-label="Choir — agent console">
|
||||||
|
<div class="head">
|
||||||
|
<h2 class="title">choir</h2>
|
||||||
|
<span class="chips">
|
||||||
|
<span class="chip" title="Embedded agent: a live model when the local server runs">api</span>
|
||||||
|
<span class="chip" title="Model Context Protocol — the same tools for external agents (not wired yet)">mcp</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="state" id="agent-state">
|
||||||
|
<span class="led" aria-hidden="true"></span>
|
||||||
|
<span id="agent-state-text">listening</span>
|
||||||
|
<span class="state-meta">tools</span>
|
||||||
|
</div>
|
||||||
|
<ol class="log" id="log" aria-live="polite"></ol>
|
||||||
|
<div class="shape" aria-label="Shape of the next layer">
|
||||||
|
<div class="shape-head"><span>shape</span><span>next</span></div>
|
||||||
|
<label class="slider"><span class="slider-name">density</span><input type="range" min="0" max="100" value="62" id="s-density"><output for="s-density">0.62</output></label>
|
||||||
|
<label class="slider"><span class="slider-name">depth</span><input type="range" min="0" max="100" value="78" id="s-depth"><output for="s-depth">0.78</output></label>
|
||||||
|
<label class="slider"><span class="slider-name">grain</span><input type="range" min="0" max="100" value="31" id="s-grain"><output for="s-grain">0.31</output></label>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="corridor" data-pool="0.42" aria-label="Nave — the arrangement">
|
||||||
|
<div class="head corridor-head">
|
||||||
|
<h2 class="title">nave</h2>
|
||||||
|
<span class="sub corridor-sub"><span>5 veins</span><i class="sub-rule"></i><span id="corridor-read">bar 7.3</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
<canvas id="spires" aria-hidden="true"></canvas>
|
||||||
|
<svg id="nave" role="img" aria-label="Five veins running toward the city at the end of the road"></svg>
|
||||||
|
<span class="now-label" aria-hidden="true">now</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side" aria-label="Hands">
|
||||||
|
<section class="crystal rail" data-pool="0.95" aria-label="Crystal — notes">
|
||||||
|
<div class="head">
|
||||||
|
<h2 class="title">crystal</h2>
|
||||||
|
<span class="sub">notes</span>
|
||||||
|
</div>
|
||||||
|
<div class="crystal-meta"><span id="crystal-bars">bars 5–8</span><span>A minor</span></div>
|
||||||
|
<div class="lattice" id="lattice"></div>
|
||||||
|
</section>
|
||||||
|
<section class="cilia rail" data-pool="0.95" aria-label="Cilia — levels">
|
||||||
|
<div class="head">
|
||||||
|
<h2 class="title">cilia</h2>
|
||||||
|
<span class="sub">levels</span>
|
||||||
|
</div>
|
||||||
|
<div class="faders" id="faders"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="quarry-tray" id="quarry-tray" aria-label="Core tray">
|
||||||
|
<div class="head tray-head">
|
||||||
|
<div class="tray-id">
|
||||||
|
<h2 class="title">core tray</h2>
|
||||||
|
<span class="sub" id="tray-name">stairwell, 3rd floor</span>
|
||||||
|
</div>
|
||||||
|
<div class="tray-meta">
|
||||||
|
<span class="seal" id="tray-seal"><i aria-hidden="true"></i><span>taken</span></span>
|
||||||
|
<i class="sub-rule" aria-hidden="true"></i>
|
||||||
|
<span class="tray-num" id="tray-dur">12.4 s</span>
|
||||||
|
<i class="sub-rule" aria-hidden="true"></i>
|
||||||
|
<span class="tray-num" id="tray-pos">0.84 s</span>
|
||||||
|
<i class="sub-rule" aria-hidden="true"></i>
|
||||||
|
<button class="ghost ghost--small" id="fracture" type="button" title="Cut this core into sixteen faces">fracture</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tray-stage" id="tray-stage">
|
||||||
|
<canvas id="tray" aria-hidden="true"></canvas>
|
||||||
|
<svg id="tray-overlay" aria-hidden="true"></svg>
|
||||||
|
<p class="tray-hint" aria-hidden="true">drag the lens to read the core</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="quarry-deck" id="quarry-deck" aria-label="Quarry">
|
||||||
|
|
||||||
|
<aside class="store rail" aria-label="Core store">
|
||||||
|
<div class="head">
|
||||||
|
<h2 class="title">store</h2>
|
||||||
|
<span class="sub">cores</span>
|
||||||
|
</div>
|
||||||
|
<div class="store-list" id="store"></div>
|
||||||
|
<form class="choir-call" id="choir-call">
|
||||||
|
<input id="choir-input" type="text" placeholder="send the choir for…" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="submit">call</button>
|
||||||
|
</form>
|
||||||
|
<button class="ghost ghost--small extract-btn" id="extract" type="button">extract by hand</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="faces" aria-label="The face">
|
||||||
|
<div class="head faces-head">
|
||||||
|
<h2 class="title">the face</h2>
|
||||||
|
<span class="sub" id="faces-read">16 faces</span>
|
||||||
|
</div>
|
||||||
|
<div class="face-wall" id="face-wall"></div>
|
||||||
|
<div class="face-foot">
|
||||||
|
<span class="face-status" id="face-status">strike a stone</span>
|
||||||
|
<span class="place-group">
|
||||||
|
<button class="ghost ghost--small" id="place" type="button" disabled>place</button>
|
||||||
|
<button class="ghost ghost--small" id="place-ring" type="button" disabled>loop</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="weather rail" aria-label="Weather">
|
||||||
|
<div class="head">
|
||||||
|
<h2 class="title">weather</h2>
|
||||||
|
<span class="sub">climate</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-list">
|
||||||
|
<label class="slider"><span class="slider-name">age</span><input type="range" min="0" max="100" value="34" id="w-age"><output for="w-age">0.34</output></label>
|
||||||
|
<label class="slider"><span class="slider-name">pressure</span><input type="range" min="0" max="100" value="52" id="w-pressure"><output for="w-pressure">0.52</output></label>
|
||||||
|
<label class="slider"><span class="slider-name">air</span><input type="range" min="0" max="100" value="41" id="w-air"><output for="w-air">0.41</output></label>
|
||||||
|
<label class="slider"><span class="slider-name">heat</span><input type="range" min="0" max="100" value="28" id="w-heat"><output for="w-heat">0.28</output></label>
|
||||||
|
</div>
|
||||||
|
<div class="climate" id="climate">young, damp, cold</div>
|
||||||
|
|
||||||
|
<div class="ring-block">
|
||||||
|
<div class="ring-head"><span class="tp-label">ring</span><strong id="ring-count">04</strong></div>
|
||||||
|
<div class="ring-visual" id="ring-visual"></div>
|
||||||
|
<button class="ghost ghost--small" id="grow" type="button">grow ring</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ghost-toggle">
|
||||||
|
<span class="tp-label">ghost</span>
|
||||||
|
<button class="toggle" id="ghost" type="button" aria-pressed="true">on</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="ledger" aria-label="Ledger">
|
||||||
|
<div class="ledger-head">
|
||||||
|
<span>ledger</span>
|
||||||
|
<span class="ledger-sub">the memory of taking</span>
|
||||||
|
</div>
|
||||||
|
<ol class="ledger-list" id="ledger"></ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="rail rail--transport" aria-label="Pulse">
|
||||||
|
<input type="file" id="file-in" accept="audio/*" hidden>
|
||||||
|
<div class="tp tp--go">
|
||||||
|
<button class="play" id="play" type="button" aria-label="Play" aria-pressed="false">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="i-play" d="M8.5 5.2v13.6L19 12z"/><g class="i-pause"><path d="M9 5.5v13"/><path d="M15 5.5v13"/></g></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon" id="stop" type="button" aria-label="Stop">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon icon--rec" id="rec" type="button" aria-label="Record" aria-pressed="false">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--clock">
|
||||||
|
<span class="tp-label">bar</span>
|
||||||
|
<span class="tp-bars" id="bars">7.3</span>
|
||||||
|
<span class="tp-time" id="time">00:12.66</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--tempo">
|
||||||
|
<span class="tp-label">tempo</span>
|
||||||
|
<div class="tempo-stack">
|
||||||
|
<button class="tp-num" id="bpm" type="button" aria-label="Tempo 128 — drag up or down">128.00</button>
|
||||||
|
<span class="tp-sub">4/4<i class="tp-rule" aria-hidden="true"></i>A minor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--knobs" aria-label="Nodes">
|
||||||
|
<div class="knob" data-knob="warmth" data-value="0.58"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">warmth</span></div>
|
||||||
|
<div class="knob" data-knob="dust" data-value="0.34"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">dust</span></div>
|
||||||
|
<div class="knob" data-knob="threshold" data-value="0.72"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">threshold</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--in" role="group" aria-label="What the instrument listens to">
|
||||||
|
<span class="tp-label">extract</span>
|
||||||
|
<div class="srcs">
|
||||||
|
<button class="src is-on" type="button" data-src="drone" aria-pressed="true" title="Generate a core from the instrument">core</button>
|
||||||
|
<button class="src" type="button" data-src="file" aria-pressed="false" title="Import an audio file as a core">file</button>
|
||||||
|
<button class="src" type="button" data-src="room" aria-pressed="false" title="Record the room into a core">room</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--scope">
|
||||||
|
<span class="tp-label">signal</span>
|
||||||
|
<canvas id="scope" aria-hidden="true"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--master">
|
||||||
|
<div class="master-top">
|
||||||
|
<span class="tp-label">master</span>
|
||||||
|
<span class="tp-db" id="master-db">−∞ dB</span>
|
||||||
|
<button class="snd" id="snd" type="button" aria-pressed="true" aria-label="Audio on">audio</button>
|
||||||
|
</div>
|
||||||
|
<div class="meter" aria-hidden="true"><i class="meter-fill" id="meter-fill"></i><b class="meter-peak" id="meter-peak"></b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp tp--end">
|
||||||
|
<button class="ghost" type="button">layer</button>
|
||||||
|
<button class="ghost ghost--ember" type="button">render</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="terrain.js"></script>
|
||||||
|
<script src="engine.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script src="quarry.js"></script>
|
||||||
|
<script src="agent.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,956 @@
|
|||||||
|
/* ZHILA · the quarry
|
||||||
|
a core is a place, a face is a cut, a ring is a year.
|
||||||
|
the choir goes out and takes; the hand cuts and sets. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
|
||||||
|
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
|
||||||
|
const tray = $('#tray');
|
||||||
|
const tctx = tray.getContext('2d');
|
||||||
|
const overlay = $('#tray-overlay');
|
||||||
|
const stage = $('#tray-stage');
|
||||||
|
const wall = $('#face-wall');
|
||||||
|
const storeEl = $('#store');
|
||||||
|
const ledgerEl = $('#ledger');
|
||||||
|
|
||||||
|
const E = window.ZHILA_ENGINE;
|
||||||
|
const cores = E ? E.cores : [];
|
||||||
|
const SEMIS = [-12, -9, -7, -5, 0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
sel: 0,
|
||||||
|
lens: 0.84,
|
||||||
|
fracture: 0,
|
||||||
|
faceSeed: 7,
|
||||||
|
selFace: -1,
|
||||||
|
rings: 4,
|
||||||
|
ghost: true,
|
||||||
|
weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const HAND_BANK = ['underpass, 2am', 'wet brick', 'empty pool', 'ventilation shaft', 'far room', 'snow on tin', 'lift cable'];
|
||||||
|
let handBank = HAND_BANK.slice();
|
||||||
|
let lensLine = null, lensHandle = null, lensText = null;
|
||||||
|
|
||||||
|
/* ————— cores ————— */
|
||||||
|
|
||||||
|
function mulberry(seed) {
|
||||||
|
let a = seed | 0;
|
||||||
|
return function () {
|
||||||
|
a = a + 0x6D2B79F5 | 0;
|
||||||
|
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||||
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— ledger ————— */
|
||||||
|
|
||||||
|
let entries = E ? E.log : [];
|
||||||
|
|
||||||
|
function sealHTML(kind, small) {
|
||||||
|
if (!kind) return '';
|
||||||
|
return '<span class="seal seal--' + kind + '"><i aria-hidden="true"></i><span>' + kind + '</span></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLedger() {
|
||||||
|
entries = E ? E.log : entries;
|
||||||
|
ledgerEl.innerHTML = entries.slice(0, 3).map(function (e) {
|
||||||
|
return '<li class="ledger-item">' +
|
||||||
|
'<div class="ledger-top"><span class="ledger-t">' + e.t + '</span>' +
|
||||||
|
'<span class="ledger-tool">' + esc(e.tool) + '</span>' +
|
||||||
|
'<span class="ledger-by' + (e.by === 'mcp' ? ' is-mcp' : '') + '">' + e.by + '</span></div>' +
|
||||||
|
'<div class="ledger-detail">' + esc(e.detail) + ' ' + sealHTML(e.seal) + '</div>' +
|
||||||
|
'</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowTime() {
|
||||||
|
const d = new Date();
|
||||||
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(tool, detail, seal, by) {
|
||||||
|
if (E) E.addLog(tool, detail, seal, by);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— core store ————— */
|
||||||
|
|
||||||
|
function drawThumb(canvas, core) {
|
||||||
|
const g = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
g.clearRect(0, 0, W, H);
|
||||||
|
const x = W * 0.18, y = H * 0.04, w = W * 0.64, h = H * 0.92, r = w / 2;
|
||||||
|
const p = new Path2D();
|
||||||
|
p.moveTo(x + r, y);
|
||||||
|
p.lineTo(x + w - r, y);
|
||||||
|
p.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2);
|
||||||
|
p.lineTo(x + r, y + h);
|
||||||
|
p.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI * 1.5);
|
||||||
|
p.closePath();
|
||||||
|
|
||||||
|
g.save();
|
||||||
|
g.clip(p);
|
||||||
|
const grd = g.createLinearGradient(x, y, x + w, y);
|
||||||
|
grd.addColorStop(0, '#1a0d0a');
|
||||||
|
grd.addColorStop(0.45, '#2b1611');
|
||||||
|
grd.addColorStop(1, '#0b0605');
|
||||||
|
g.fillStyle = grd;
|
||||||
|
g.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
const rnd = mulberry(core.seed * 31 + 3);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const yy = y + h * ((i + 0.6) / 8.6);
|
||||||
|
const amp = 1 + rnd() * 3;
|
||||||
|
g.beginPath();
|
||||||
|
for (let k = 0; k <= 10; k++) {
|
||||||
|
const u = k / 10;
|
||||||
|
const px = x + u * w;
|
||||||
|
const py = yy + Math.sin(u * 6 + i) * amp;
|
||||||
|
if (k === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
||||||
|
}
|
||||||
|
g.strokeStyle = i % 2 ? 'rgba(190,130,105,.16)' : 'rgba(80,45,35,.5)';
|
||||||
|
g.lineWidth = 1;
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
// the seam of sound
|
||||||
|
g.beginPath();
|
||||||
|
for (let i = 0; i < core.env.length; i++) {
|
||||||
|
const px = x + (i / (core.env.length - 1)) * w;
|
||||||
|
const py = y + h / 2 - core.env[i] * h * 0.3;
|
||||||
|
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
||||||
|
}
|
||||||
|
g.strokeStyle = 'rgba(255,105,75,.55)';
|
||||||
|
g.lineWidth = 1;
|
||||||
|
g.stroke();
|
||||||
|
g.restore();
|
||||||
|
|
||||||
|
g.strokeStyle = 'rgba(239,228,212,.16)';
|
||||||
|
g.lineWidth = 1;
|
||||||
|
g.stroke(p);
|
||||||
|
g.beginPath();
|
||||||
|
g.ellipse(x + r, y + h / 2, r * 0.55, h * 0.3, 0, 0, TAU);
|
||||||
|
g.strokeStyle = 'rgba(239,228,212,.1)';
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStore() {
|
||||||
|
storeEl.innerHTML = '';
|
||||||
|
cores.forEach(function (core, i) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'core-item' + (i === state.sel ? ' is-on' : '');
|
||||||
|
item.dataset.core = i;
|
||||||
|
item.innerHTML =
|
||||||
|
'<canvas class="core-thumb" width="96" height="116" aria-hidden="true"></canvas>' +
|
||||||
|
'<span class="core-body">' +
|
||||||
|
'<span class="core-name">' + esc(core.name) + '</span>' +
|
||||||
|
'<span class="core-meta">' + core.dur.toFixed(1) + ' s ' + sealHTML(core.seal) + '</span>' +
|
||||||
|
'</span>';
|
||||||
|
storeEl.appendChild(item);
|
||||||
|
drawThumb(item.querySelector('canvas'), core);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEl.addEventListener('click', function (e) {
|
||||||
|
const item = e.target.closest('.core-item');
|
||||||
|
if (!item) return;
|
||||||
|
selectCore(+item.dataset.core);
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlightStore() {
|
||||||
|
$$('.core-item', storeEl).forEach(function (el, i) {
|
||||||
|
const on = i === state.sel;
|
||||||
|
el.classList.toggle('is-on', on);
|
||||||
|
if (on && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— tray ————— */
|
||||||
|
|
||||||
|
function trayGeom() {
|
||||||
|
const W = stage.clientWidth, H = stage.clientHeight;
|
||||||
|
const coreH = clamp(H * 0.55, 96, 172);
|
||||||
|
const top = Math.max(36, (H - coreH) / 2 - 10);
|
||||||
|
const pad = 24;
|
||||||
|
return { W: W, H: H, x0: pad + 10, x1: W - pad - 10, top: top, bottom: top + coreH, coreH: coreH };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capsule(g, x0, top, x1, bottom) {
|
||||||
|
const p = new Path2D();
|
||||||
|
if (p.roundRect) p.roundRect(x0, top, x1 - x0, bottom - top, 2.5);
|
||||||
|
else p.rect(x0, top, x1 - x0, bottom - top);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTray() {
|
||||||
|
const g0 = trayGeom();
|
||||||
|
if (!g0.W || !g0.H) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
tray.width = Math.round(g0.W * dpr);
|
||||||
|
tray.height = Math.round(g0.H * dpr);
|
||||||
|
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
tctx.clearRect(0, 0, g0.W, g0.H);
|
||||||
|
|
||||||
|
const core = cores[state.sel];
|
||||||
|
const { x0, x1, top, bottom, coreH } = g0;
|
||||||
|
const mid = (top + bottom) / 2;
|
||||||
|
const path = capsule(tctx, x0, top, x1, bottom);
|
||||||
|
const rnd = mulberry(core.seed * 131 + 7);
|
||||||
|
|
||||||
|
// shadow under the core
|
||||||
|
tctx.save();
|
||||||
|
tctx.filter = 'blur(14px)';
|
||||||
|
tctx.fillStyle = 'rgba(0,0,0,.75)';
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.ellipse((x0 + x1) / 2, bottom + 10, (x1 - x0) * 0.48, coreH * 0.22, 0, 0, TAU);
|
||||||
|
tctx.fill();
|
||||||
|
tctx.restore();
|
||||||
|
|
||||||
|
tctx.save();
|
||||||
|
tctx.clip(path);
|
||||||
|
const grd = tctx.createLinearGradient(0, top, 0, bottom);
|
||||||
|
grd.addColorStop(0, '#26130d');
|
||||||
|
grd.addColorStop(0.16, '#170b08');
|
||||||
|
grd.addColorStop(0.55, '#100705');
|
||||||
|
grd.addColorStop(1, '#040202');
|
||||||
|
tctx.fillStyle = grd;
|
||||||
|
tctx.fillRect(x0, top, x1 - x0, coreH);
|
||||||
|
|
||||||
|
// strata: sediment layers
|
||||||
|
const bands = 10;
|
||||||
|
for (let i = 0; i < bands; i++) {
|
||||||
|
const yy = top + coreH * ((i + 0.55) / (bands + 0.9)) + (rnd() - 0.5) * 4;
|
||||||
|
const amp = 2 + rnd() * 7;
|
||||||
|
const freq = 1.2 + rnd() * 2.4;
|
||||||
|
const phase = rnd() * TAU;
|
||||||
|
const pts = [];
|
||||||
|
for (let k = 0; k <= 32; k++) {
|
||||||
|
const u = k / 32;
|
||||||
|
const px = x0 + u * (x1 - x0);
|
||||||
|
const py = yy + Math.sin(u * TAU * freq + phase) * amp + Math.sin(u * TAU * 5.3 + phase * 2) * 1.4;
|
||||||
|
pts.push([px, py]);
|
||||||
|
}
|
||||||
|
tctx.beginPath();
|
||||||
|
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||||
|
tctx.lineTo(x1, bottom);
|
||||||
|
tctx.lineTo(x0, bottom);
|
||||||
|
tctx.closePath();
|
||||||
|
tctx.fillStyle = i % 2 ? 'rgba(150,60,40,.05)' : 'rgba(0,0,0,.18)';
|
||||||
|
tctx.fill();
|
||||||
|
tctx.beginPath();
|
||||||
|
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||||
|
tctx.strokeStyle = i % 3 === 0 ? 'rgba(232,64,42,.16)' : 'rgba(190,130,105,.1)';
|
||||||
|
tctx.lineWidth = 0.8;
|
||||||
|
tctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the seam of sound, inside the stone
|
||||||
|
const env = core.env;
|
||||||
|
const ampY = coreH * 0.3;
|
||||||
|
if (state.ghost) {
|
||||||
|
tctx.beginPath();
|
||||||
|
for (let i = 0; i < env.length; i++) {
|
||||||
|
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||||
|
const py = mid - clamp(env[i] * 1.45, 0, 1) * ampY * 1.25;
|
||||||
|
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
tctx.strokeStyle = 'rgba(232,64,42,.14)';
|
||||||
|
tctx.lineWidth = 0.9;
|
||||||
|
tctx.stroke();
|
||||||
|
}
|
||||||
|
tctx.beginPath();
|
||||||
|
for (let i = 0; i < env.length; i++) {
|
||||||
|
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||||
|
const py = mid - env[i] * ampY;
|
||||||
|
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
for (let i = env.length - 1; i >= 0; i--) {
|
||||||
|
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||||
|
tctx.lineTo(px, mid + env[i] * ampY * 0.78);
|
||||||
|
}
|
||||||
|
tctx.closePath();
|
||||||
|
tctx.fillStyle = 'rgba(232,64,42,.13)';
|
||||||
|
tctx.fill();
|
||||||
|
tctx.save();
|
||||||
|
tctx.shadowColor = 'rgba(232,64,42,.75)';
|
||||||
|
tctx.shadowBlur = 14;
|
||||||
|
tctx.beginPath();
|
||||||
|
for (let i = 0; i < env.length; i++) {
|
||||||
|
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||||
|
const py = mid - env[i] * ampY;
|
||||||
|
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
tctx.strokeStyle = 'rgba(255,140,105,.95)';
|
||||||
|
tctx.lineWidth = 1.5;
|
||||||
|
tctx.stroke();
|
||||||
|
tctx.restore();
|
||||||
|
|
||||||
|
// pits and hairlines in the stone
|
||||||
|
for (let k = 0; k < 16; k++) {
|
||||||
|
const px = x0 + rnd() * (x1 - x0);
|
||||||
|
const py = top + rnd() * coreH;
|
||||||
|
const rr = 0.7 + rnd() * 2.2;
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.ellipse(px, py, rr * 1.9, rr, 0, 0, TAU);
|
||||||
|
tctx.fillStyle = 'rgba(0,0,0,.4)';
|
||||||
|
tctx.fill();
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.ellipse(px, py + 0.7, rr * 1.5, rr * 0.55, 0, 0, TAU);
|
||||||
|
tctx.strokeStyle = 'rgba(232,64,42,.16)';
|
||||||
|
tctx.lineWidth = 0.6;
|
||||||
|
tctx.stroke();
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
let cx = x0 + (0.12 + rnd() * 0.74) * (x1 - x0);
|
||||||
|
let cy = top + rnd() * coreH * 0.55;
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.moveTo(cx, cy);
|
||||||
|
for (let s2 = 0; s2 < 6; s2++) {
|
||||||
|
cx += (rnd() - 0.5) * 12;
|
||||||
|
cy += 4 + rnd() * 7;
|
||||||
|
tctx.lineTo(cx, cy);
|
||||||
|
}
|
||||||
|
tctx.strokeStyle = 'rgba(0,0,0,.55)';
|
||||||
|
tctx.lineWidth = 1.2;
|
||||||
|
tctx.stroke();
|
||||||
|
tctx.strokeStyle = 'rgba(232,64,42,.15)';
|
||||||
|
tctx.lineWidth = 0.5;
|
||||||
|
tctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// fractures, gashes in the core
|
||||||
|
core.cuts.forEach(function (cut) {
|
||||||
|
const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0);
|
||||||
|
const jag = mulberry(Math.round(cut * 100) + core.seed);
|
||||||
|
const pts = [];
|
||||||
|
for (let y = top; y <= bottom; y += 7) pts.push([x + (jag() - 0.5) * 3.2, y]);
|
||||||
|
tctx.beginPath();
|
||||||
|
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||||
|
tctx.strokeStyle = 'rgba(0,0,0,.8)';
|
||||||
|
tctx.lineWidth = 5;
|
||||||
|
tctx.stroke();
|
||||||
|
tctx.strokeStyle = 'rgba(255,110,80,.62)';
|
||||||
|
tctx.lineWidth = 1.15;
|
||||||
|
tctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// surface light
|
||||||
|
const hi = tctx.createLinearGradient(0, top, 0, top + coreH * 0.3);
|
||||||
|
hi.addColorStop(0, 'rgba(255,205,175,.1)');
|
||||||
|
hi.addColorStop(1, 'rgba(255,205,175,0)');
|
||||||
|
tctx.fillStyle = hi;
|
||||||
|
tctx.fillRect(x0, top, x1 - x0, coreH * 0.3);
|
||||||
|
for (let k = 0; k < 6; k++) {
|
||||||
|
const cx = x0 + 8 + rnd() * (x1 - x0 - 16);
|
||||||
|
const w = 3 + rnd() * 11;
|
||||||
|
const d = 1 + rnd() * 3.4;
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.moveTo(cx - w / 2, top);
|
||||||
|
tctx.lineTo(cx + w / 2, top);
|
||||||
|
tctx.lineTo(cx + (rnd() - 0.5) * 4, top + d);
|
||||||
|
tctx.closePath();
|
||||||
|
tctx.fillStyle = 'rgba(2,1,1,.75)';
|
||||||
|
tctx.fill();
|
||||||
|
}
|
||||||
|
tctx.restore();
|
||||||
|
|
||||||
|
tctx.strokeStyle = 'rgba(239,228,212,.2)';
|
||||||
|
tctx.lineWidth = 1;
|
||||||
|
tctx.stroke(path);
|
||||||
|
|
||||||
|
// the cut ends of the bore
|
||||||
|
[x0 + 6, x1 - 6].forEach(function (cx) {
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.ellipse(cx, mid, 6, coreH / 2 - 1, 0, 0, TAU);
|
||||||
|
tctx.fillStyle = 'rgba(3,2,2,.55)';
|
||||||
|
tctx.fill();
|
||||||
|
tctx.strokeStyle = 'rgba(239,228,212,.2)';
|
||||||
|
tctx.lineWidth = 0.9;
|
||||||
|
tctx.stroke();
|
||||||
|
tctx.beginPath();
|
||||||
|
tctx.ellipse(cx, mid, 2.4, coreH * 0.34, 0, 0, TAU);
|
||||||
|
tctx.strokeStyle = 'rgba(239,228,212,.08)';
|
||||||
|
tctx.lineWidth = 0.7;
|
||||||
|
tctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlay() {
|
||||||
|
const g = trayGeom();
|
||||||
|
if (!g.W || !g.H) return;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
const { x0, x1, top, bottom } = g;
|
||||||
|
let s = '';
|
||||||
|
s += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + (top - 20) + '" y2="' + (top - 20) + '" stroke="rgba(232,64,42,.14)" stroke-width=".7"/>';
|
||||||
|
|
||||||
|
core.cuts.forEach(function (cut, i) {
|
||||||
|
const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0);
|
||||||
|
s += '<line class="tray-cut" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + (top - 14) + '" y2="' + (bottom + 12) + '"/>';
|
||||||
|
s += '<text class="tray-cut-text" x="' + (x + 4).toFixed(1) + '" y="' + (top - 6) + '">0' + (i + 1) + '</text>';
|
||||||
|
});
|
||||||
|
|
||||||
|
const ry = bottom + 30;
|
||||||
|
s += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + ry + '" y2="' + ry + '" stroke="rgba(155,133,121,.22)" stroke-width=".7"/>';
|
||||||
|
const step = core.dur > 16 ? 4 : core.dur > 10 ? 2 : 1;
|
||||||
|
for (let t = 0; t <= core.dur + 0.01; t += step) {
|
||||||
|
const x = x0 + clamp(t / core.dur, 0, 1) * (x1 - x0);
|
||||||
|
const major = t % (step * 2) === 0;
|
||||||
|
s += '<line class="tray-tick' + (major ? ' tray-tick--major' : '') + '" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + ry + '" y2="' + (ry + (major ? 6 : 3)) + '"/>';
|
||||||
|
if (major) {
|
||||||
|
s += '<text class="tray-tick-text" x="' + (x + 3).toFixed(1) + '" y="' + (ry + 15) + '">' + t.toFixed(0) + 's</text>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s += '<g id="tray-lens-g">' +
|
||||||
|
'<line class="tray-lens"/>' +
|
||||||
|
'<rect class="tray-lens-handle" width="4" height="12" rx="0"/>' +
|
||||||
|
'<text class="tray-lens-text"></text>' +
|
||||||
|
'</g>';
|
||||||
|
overlay.setAttribute('viewBox', '0 0 ' + g.W + ' ' + g.H);
|
||||||
|
overlay.innerHTML = s;
|
||||||
|
lensLine = overlay.querySelector('.tray-lens');
|
||||||
|
lensHandle = overlay.querySelector('.tray-lens-handle');
|
||||||
|
lensText = overlay.querySelector('.tray-lens-text');
|
||||||
|
updateLensOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLensOnly() {
|
||||||
|
if (!lensLine || !lensText) return;
|
||||||
|
const g = trayGeom();
|
||||||
|
if (!g.W) return;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
const lx = g.x0 + clamp(state.lens / core.dur, 0, 1) * (g.x1 - g.x0);
|
||||||
|
lensLine.setAttribute('x1', lx.toFixed(1));
|
||||||
|
lensLine.setAttribute('x2', lx.toFixed(1));
|
||||||
|
lensLine.setAttribute('y1', (g.top - 16).toFixed(1));
|
||||||
|
lensLine.setAttribute('y2', (g.bottom + 16).toFixed(1));
|
||||||
|
lensHandle.setAttribute('x', (lx - 2).toFixed(1));
|
||||||
|
lensHandle.setAttribute('y', (g.top - 24).toFixed(1));
|
||||||
|
lensText.setAttribute('x', Math.min(lx + 8, g.x1 - 36).toFixed(1));
|
||||||
|
lensText.setAttribute('y', (g.top - 12).toFixed(1));
|
||||||
|
lensText.textContent = state.lens.toFixed(2) + 's';
|
||||||
|
const pos = $('#tray-pos');
|
||||||
|
if (pos) pos.textContent = state.lens.toFixed(2) + ' s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— faces ————— */
|
||||||
|
|
||||||
|
function stoneShape(cell, rnd) {
|
||||||
|
const inset = 1.4;
|
||||||
|
const x0 = cell.x + inset, y0 = cell.y + inset;
|
||||||
|
const x1 = cell.x + cell.w - inset, y1 = cell.y + cell.h - inset;
|
||||||
|
const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
||||||
|
const pts = [];
|
||||||
|
for (let e = 0; e < 4; e++) {
|
||||||
|
const a = corners[e], b = corners[(e + 1) % 4];
|
||||||
|
pts.push([a[0] + (rnd() - 0.5) * 2.5, a[1] + (rnd() - 0.5) * 2.5]);
|
||||||
|
pts.push([
|
||||||
|
(a[0] + b[0]) / 2 + (rnd() - 0.5) * 9,
|
||||||
|
(a[1] + b[1]) / 2 + (rnd() - 0.5) * 9
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centroid(pts) {
|
||||||
|
const n = pts.length;
|
||||||
|
return [pts.reduce((a, p) => a + p[0], 0) / n, pts.reduce((a, p) => a + p[1], 0) / n];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptsStr(pts) {
|
||||||
|
return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealMark(x, y, kind) {
|
||||||
|
let inner = '';
|
||||||
|
if (kind === 'taken') inner = '<path d="M' + (x - 1.4) + ' ' + (y - 1.4) + 'L' + (x + 1.4) + ' ' + (y + 1.4) + 'M' + (x + 1.4) + ' ' + (y - 1.4) + 'L' + (x - 1.4) + ' ' + (y + 1.4) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8" fill="none"/>';
|
||||||
|
else if (kind === 'found') inner = '<circle cx="' + x + '" cy="' + y + '" r="1.1" class="stone-seal-dot"/>';
|
||||||
|
else if (kind === 'loan') inner = '<path d="M' + (x - 1.3) + ' ' + (y + 1.3) + 'L' + (x + 1.3) + ' ' + (y - 1.3) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8"/>';
|
||||||
|
else if (kind === 'gift') inner = '<circle cx="' + x + '" cy="' + y + '" r=".9" fill="none" stroke="rgba(239,228,212,.7)" stroke-width=".7"/>';
|
||||||
|
return '<circle class="stone-seal" cx="' + x + '" cy="' + y + '" r="3"/>' + inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFaces(seed) {
|
||||||
|
if (!wall || !wall.clientWidth || !wall.clientHeight) return;
|
||||||
|
state.faceSeed = seed;
|
||||||
|
const W = wall.clientWidth, H = wall.clientHeight;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
const rnd = mulberry(seed);
|
||||||
|
const rows = 4, gap = 8;
|
||||||
|
let hs = [0, 0, 0, 0].map(function () { return 0.78 + rnd() * 0.44; });
|
||||||
|
const hsum = hs.reduce(function (a, b) { return a + b; }, 0);
|
||||||
|
const availH = H - gap * (rows - 1) - 2;
|
||||||
|
let y = 1;
|
||||||
|
const rowRects = [];
|
||||||
|
hs.forEach(function (h) {
|
||||||
|
const hh = availH * h / hsum;
|
||||||
|
rowRects.push({ y: y, h: hh });
|
||||||
|
y += hh + gap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const faces = [];
|
||||||
|
let idx = 0;
|
||||||
|
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none"><defs>';
|
||||||
|
['A', 'B', 'C', 'D'].forEach(function (t, ti) {
|
||||||
|
svg += '<linearGradient id="stoneGrad' + t + '" x1="0" y1="0" x2="0" y2="1">' +
|
||||||
|
'<stop offset="0" stop-color="#' + ['2b1811', '22120d', '31201a', '1a0e0a'][ti] + '"/>' +
|
||||||
|
'<stop offset=".3" stop-color="#' + ['160c08', '120906', '180d09', '0e0705'][ti] + '"/>' +
|
||||||
|
'<stop offset="1" stop-color="#040202"/>' +
|
||||||
|
'</linearGradient>';
|
||||||
|
});
|
||||||
|
svg += '</defs>';
|
||||||
|
rowRects.forEach(function (row, r) {
|
||||||
|
let ws = [0, 0, 0, 0].map(function () { return 0.72 + rnd() * 0.56; });
|
||||||
|
const wsum = ws.reduce(function (a, b) { return a + b; }, 0);
|
||||||
|
const availW = W - gap * 3 - 2;
|
||||||
|
let x = 1;
|
||||||
|
ws.forEach(function (w, c) {
|
||||||
|
const ww = availW * w / wsum;
|
||||||
|
const cell = { x: x, y: row.y, w: ww, h: row.h };
|
||||||
|
const pts = stoneShape(cell, rnd);
|
||||||
|
const cen = centroid(pts);
|
||||||
|
const inner = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.09, p[1] + (cen[1] - p[1]) * 0.09]; });
|
||||||
|
const inner2 = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.2, p[1] + (cen[1] - p[1]) * 0.2]; });
|
||||||
|
const tone = ['a', 'b', 'c', 'd'][Math.floor(rnd() * 4)];
|
||||||
|
const lip = [pts[7], pts[0], pts[1], pts[2]];
|
||||||
|
const foot = [pts[3], pts[4], pts[5], pts[6]];
|
||||||
|
const grain = [];
|
||||||
|
const gy = cell.y + cell.h * 0.58;
|
||||||
|
for (let k = 0; k <= 8; k++) {
|
||||||
|
const u = k / 8;
|
||||||
|
grain.push([cell.x + 7 + u * (cell.w - 14), gy + Math.sin(u * 5 + rnd() * 2) * 3.4]);
|
||||||
|
}
|
||||||
|
const grain2 = [];
|
||||||
|
const gy2 = cell.y + cell.h * 0.79;
|
||||||
|
for (let k = 0; k <= 6; k++) {
|
||||||
|
const u = k / 6;
|
||||||
|
grain2.push([cell.x + 11 + u * (cell.w - 22), gy2 + Math.sin(u * 4 + rnd() * 2) * 2.6]);
|
||||||
|
}
|
||||||
|
const crack = [];
|
||||||
|
let cx = cell.x + cell.w * (0.45 + rnd() * 0.3), cy = cell.y + 6;
|
||||||
|
crack.push([cx, cy]);
|
||||||
|
for (let k = 0; k < 4; k++) {
|
||||||
|
cx += (rnd() - 0.5) * 12;
|
||||||
|
cy += 4 + rnd() * 8;
|
||||||
|
crack.push([cx, cy]);
|
||||||
|
}
|
||||||
|
const pits = [0, 1, 2].map(function () {
|
||||||
|
return [cell.x + 12 + rnd() * (cell.w - 24), cell.y + 12 + rnd() * (cell.h - 24), 0.7 + rnd() * 1.3];
|
||||||
|
});
|
||||||
|
const strata = [];
|
||||||
|
for (let k = 0; k <= 6; k++) {
|
||||||
|
const u = k / 6;
|
||||||
|
strata.push([cell.x + 8 + u * (cell.w - 16), cell.y + cell.h * 0.32 + Math.sin(u * 4 + r) * 2.4]);
|
||||||
|
}
|
||||||
|
const num = String(idx + 1).padStart(2, '0');
|
||||||
|
const slice = core.faces[idx];
|
||||||
|
const face = {
|
||||||
|
i: idx,
|
||||||
|
semis: SEMIS[(3 - r) * 4 + c],
|
||||||
|
seal: core.seal,
|
||||||
|
t0: slice ? +slice.start.toFixed(2) : 0,
|
||||||
|
t1: slice ? +(slice.start + slice.len).toFixed(2) : 0
|
||||||
|
};
|
||||||
|
faces.push(face);
|
||||||
|
const isSel = state.selFace === idx;
|
||||||
|
svg += '<g class="stone' + (isSel ? ' is-sel' : '') + '" data-i="' + idx + '">' +
|
||||||
|
'<circle class="stone-flash" cx="' + (cen[0]).toFixed(1) + '" cy="' + (cen[1]).toFixed(1) + '" r="' + (Math.min(cell.w, cell.h) * 0.42).toFixed(1) + '"/>' +
|
||||||
|
'<polygon class="stone-body stone-body--' + tone + '" points="' + ptsStr(pts) + '"/>' +
|
||||||
|
'<polyline class="stone-lip" points="' + ptsStr(lip) + '"/>' +
|
||||||
|
'<polyline class="stone-foot-lip" points="' + ptsStr(foot) + '"/>' +
|
||||||
|
'<polygon class="stone-face" points="' + ptsStr(inner) + '"/>' +
|
||||||
|
'<polygon class="stone-face stone-face--inner" points="' + ptsStr(inner2) + '"/>' +
|
||||||
|
'<polyline class="stone-strata" points="' + ptsStr(strata) + '"/>' +
|
||||||
|
'<polyline class="stone-grain" points="' + ptsStr(grain) + '"/>' +
|
||||||
|
'<polyline class="stone-grain stone-grain--faint" points="' + ptsStr(grain2) + '"/>' +
|
||||||
|
'<polyline class="stone-crack" points="' + ptsStr(crack) + '"/>' +
|
||||||
|
pits.map(function (p) {
|
||||||
|
return '<ellipse class="stone-pit" cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" rx="' + (p[2] * 1.7).toFixed(1) + '" ry="' + p[2].toFixed(1) + '"/>';
|
||||||
|
}).join('') +
|
||||||
|
'<text class="stone-num" x="' + (cell.x + 11).toFixed(1) + '" y="' + (cell.y + 17).toFixed(1) + '">' + num + '</text>' +
|
||||||
|
sealMark(cell.x + cell.w - 14, cell.y + cell.h - 13, core.seal) +
|
||||||
|
'</g>';
|
||||||
|
idx++;
|
||||||
|
x += ww + gap;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
svg += '</svg>';
|
||||||
|
wall.innerHTML = svg;
|
||||||
|
state.faces = faces;
|
||||||
|
// keep the selection valid
|
||||||
|
if (state.selFace >= faces.length) state.selFace = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexDur(i) {
|
||||||
|
const core = cores[state.sel];
|
||||||
|
return core.dur / 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
wall.addEventListener('pointerdown', function (e) {
|
||||||
|
const g = e.target.closest ? e.target.closest('.stone') : null;
|
||||||
|
if (!g) return;
|
||||||
|
const i = +g.dataset.i;
|
||||||
|
if (state.selFace !== i) {
|
||||||
|
state.selFace = i;
|
||||||
|
$$('.stone', wall).forEach(function (el) {
|
||||||
|
el.classList.toggle('is-sel', +el.dataset.i === i);
|
||||||
|
});
|
||||||
|
faceStatus();
|
||||||
|
$('#place').disabled = false;
|
||||||
|
const lb = $('#place-ring'); if (lb) lb.disabled = false;
|
||||||
|
}
|
||||||
|
const face = state.faces[i];
|
||||||
|
strike(face);
|
||||||
|
g.classList.remove('is-struck');
|
||||||
|
requestAnimationFrame(function () { g.classList.add('is-struck'); });
|
||||||
|
window.setTimeout(function () { g.classList.remove('is-struck'); }, 520);
|
||||||
|
});
|
||||||
|
|
||||||
|
function faceStatus() {
|
||||||
|
const el = $('#face-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (state.selFace < 0 || !state.faces[state.selFace]) {
|
||||||
|
el.textContent = 'strike a stone';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = state.faces[state.selFace];
|
||||||
|
el.textContent = 'face ' + String(f.i + 1).padStart(2, '0') + ' · ' +
|
||||||
|
f.t0.toFixed(2) + '–' + f.t1.toFixed(2) + ' s · seal ' + f.seal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— weather ————— */
|
||||||
|
|
||||||
|
function climateText() {
|
||||||
|
const w = state.weather;
|
||||||
|
const age = w.age < 0.4 ? 'young' : w.age < 0.7 ? 'middle' : 'old';
|
||||||
|
const air = w.air < 0.35 ? 'dry' : w.air < 0.7 ? 'damp' : 'wet';
|
||||||
|
const heat = w.heat < 0.35 ? 'cold' : w.heat < 0.7 ? 'warm' : 'hot';
|
||||||
|
return age + ', ' + air + ', ' + heat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClimate() {
|
||||||
|
const el = $('#climate');
|
||||||
|
if (el) el.textContent = climateText();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWeather() {
|
||||||
|
['age', 'pressure', 'air', 'heat'].forEach(function (k) {
|
||||||
|
const el = $('#w-' + k);
|
||||||
|
if (!el) return;
|
||||||
|
state.weather[k] = +el.value / 100;
|
||||||
|
const out = el.parentNode.querySelector('output');
|
||||||
|
out.textContent = state.weather[k].toFixed(2);
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
state.weather[k] = +el.value / 100;
|
||||||
|
out.textContent = state.weather[k].toFixed(2);
|
||||||
|
updateClimate();
|
||||||
|
if (E) E.setWeather(state.weather);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateClimate();
|
||||||
|
if (E) E.setWeather(state.weather);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— rings ————— */
|
||||||
|
|
||||||
|
function drawRings() {
|
||||||
|
const box = $('#ring-visual');
|
||||||
|
if (!box) return;
|
||||||
|
const W = box.clientWidth, H = box.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const cx = W / 2, cy = H / 2;
|
||||||
|
let s = '<svg viewBox="0 0 ' + W + ' ' + H + '">';
|
||||||
|
const n = Math.min(state.rings, 12);
|
||||||
|
const maxR = Math.min(W * 0.46, H * 0.46);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const r = maxR * (i + 1) / Math.max(n, 4);
|
||||||
|
const off = (i % 2 ? 1 : -1) * (1 + i * 0.3);
|
||||||
|
s += '<ellipse class="ring-arc" cx="' + (cx + off).toFixed(1) + '" cy="' + cy + '" rx="' + r.toFixed(1) + '" ry="' + (r * 0.58).toFixed(1) + '" stroke-opacity="' + (0.34 + i * 0.05).toFixed(2) + '"/>';
|
||||||
|
}
|
||||||
|
s += '<circle cx="' + cx + '" cy="' + cy + '" r="1.2" fill="rgba(232,64,42,.9)"/></svg>';
|
||||||
|
box.innerHTML = s;
|
||||||
|
const count = $('#ring-count');
|
||||||
|
if (count) count.textContent = String(state.rings).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— sound ————— */
|
||||||
|
|
||||||
|
function strike(face) {
|
||||||
|
if (!E || !face) return;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
if (!core) return;
|
||||||
|
E.strike(core.id, face.i, { pitch: face.semis, gain: 0.9, ghost: state.ghost });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— view, core select, fracture, place ————— */
|
||||||
|
|
||||||
|
function updateHeader() {
|
||||||
|
const core = cores[state.sel];
|
||||||
|
const name = $('#tray-name');
|
||||||
|
const dur = $('#tray-dur');
|
||||||
|
const sealEl = $('#tray-seal');
|
||||||
|
if (name) name.textContent = core.name;
|
||||||
|
if (dur) dur.textContent = core.dur.toFixed(1) + ' s';
|
||||||
|
if (sealEl) {
|
||||||
|
sealEl.className = 'seal seal--' + core.seal;
|
||||||
|
const t = sealEl.querySelector('span');
|
||||||
|
if (t) t.textContent = core.seal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCore(i, keepSeed) {
|
||||||
|
state.sel = clamp(i, 0, cores.length - 1);
|
||||||
|
state.selFace = -1;
|
||||||
|
$('#place').disabled = true;
|
||||||
|
const lb = $('#place-ring'); if (lb) lb.disabled = true;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
state.lens = core.cuts[0] || 0;
|
||||||
|
updateHeader();
|
||||||
|
highlightStore();
|
||||||
|
if (!keepSeed) state.faceSeed = core.seed * 13 + state.fracture * 7;
|
||||||
|
redraw();
|
||||||
|
faceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw() {
|
||||||
|
drawTray();
|
||||||
|
buildOverlay();
|
||||||
|
buildFaces(state.faceSeed);
|
||||||
|
drawRings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fractureCore() {
|
||||||
|
const core = cores[state.sel];
|
||||||
|
state.fracture++;
|
||||||
|
if (E) E.refracture(core.id);
|
||||||
|
state.faceSeed = core.seed * 13 + state.fracture * 7;
|
||||||
|
state.selFace = -1;
|
||||||
|
state.lens = core.cuts[0];
|
||||||
|
$('#place').disabled = true;
|
||||||
|
const lb2 = $('#place-ring'); if (lb2) lb2.disabled = true;
|
||||||
|
redraw();
|
||||||
|
faceStatus();
|
||||||
|
if (wall.animate) {
|
||||||
|
wall.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 420, easing: 'ease-out' });
|
||||||
|
}
|
||||||
|
log('faces.fracture', core.id + ' · 16 faces', '', 'hand');
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeFace(loop) {
|
||||||
|
if (state.selFace < 0 || !state.faces[state.selFace]) return;
|
||||||
|
const core = cores[state.sel];
|
||||||
|
if (!core || !window.ZHILA || !window.ZHILA.addStoneClip) return;
|
||||||
|
const f = state.faces[state.selFace];
|
||||||
|
const label = 'face ' + String(f.i + 1).padStart(2, '0');
|
||||||
|
const res = window.ZHILA.addStoneClip(label, f.seal, {
|
||||||
|
core: core.id, face: f.i, ring: loop ? 2 : 0, pitch: 0, gain: 0.85
|
||||||
|
});
|
||||||
|
const st = $('#face-status');
|
||||||
|
if (res) {
|
||||||
|
if (st) st.textContent = (loop ? 'looping in ' : 'placed in ') + res.lane + ', bar ' + res.start + '–' + res.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract(by, query) {
|
||||||
|
if (!E) return;
|
||||||
|
const name = by === 'mcp'
|
||||||
|
? (query.length > 26 ? query.slice(0, 26) + '…' : query)
|
||||||
|
: (handBank.length ? handBank.shift() : 'new core');
|
||||||
|
const core = E.newCore({ name: name, seal: by === 'mcp' ? 'found' : 'taken' });
|
||||||
|
log('cores.extract', core.name, core.seal, by);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— view switch ————— */
|
||||||
|
|
||||||
|
function setView(v) {
|
||||||
|
document.body.dataset.view = v;
|
||||||
|
$$('.view-btn').forEach(function (b) {
|
||||||
|
const on = b.dataset.viewBtn === v;
|
||||||
|
b.classList.toggle('is-on', on);
|
||||||
|
b.setAttribute('aria-pressed', String(on));
|
||||||
|
});
|
||||||
|
if (v === 'quarry') {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
selectCore(state.sel, true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
if (window.ZHILA && window.ZHILA.buildNave) window.ZHILA.buildNave();
|
||||||
|
if (window.ZHILA && window.ZHILA.buildLattice) window.ZHILA.buildLattice();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$$('.view-btn').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { setView(b.dataset.viewBtn); });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— tray scrubbing ————— */
|
||||||
|
|
||||||
|
let scrubbing = false;
|
||||||
|
function scrub(e) {
|
||||||
|
const r = stage.getBoundingClientRect();
|
||||||
|
const g = trayGeom();
|
||||||
|
if (!g.W) return;
|
||||||
|
const x = clamp(e.clientX - r.left, g.x0, g.x1);
|
||||||
|
const core = cores[state.sel];
|
||||||
|
state.lens = +(((x - g.x0) / (g.x1 - g.x0)) * core.dur).toFixed(2);
|
||||||
|
updateLensOnly();
|
||||||
|
}
|
||||||
|
stage.addEventListener('pointerdown', function (e) {
|
||||||
|
scrubbing = true;
|
||||||
|
stage.setPointerCapture(e.pointerId);
|
||||||
|
scrub(e);
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
stage.addEventListener('pointermove', function (e) { if (scrubbing) scrub(e); });
|
||||||
|
stage.addEventListener('pointerup', function () { scrubbing = false; });
|
||||||
|
stage.addEventListener('pointercancel', function () { scrubbing = false; });
|
||||||
|
|
||||||
|
/* ————— wiring ————— */
|
||||||
|
|
||||||
|
$('#fracture').addEventListener('click', fractureCore);
|
||||||
|
$('#place').addEventListener('click', function () { placeFace(false); });
|
||||||
|
const loopBtn = $('#place-ring');
|
||||||
|
if (loopBtn) loopBtn.addEventListener('click', function () { placeFace(true); });
|
||||||
|
const updatePlace = function () {
|
||||||
|
const on = state.selFace >= 0;
|
||||||
|
$('#place').disabled = !on;
|
||||||
|
if (loopBtn) loopBtn.disabled = !on;
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#ghost').addEventListener('click', function () {
|
||||||
|
state.ghost = !state.ghost;
|
||||||
|
this.setAttribute('aria-pressed', String(state.ghost));
|
||||||
|
this.textContent = state.ghost ? 'on' : 'off';
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#grow').addEventListener('click', function () {
|
||||||
|
state.rings = Math.min(state.rings + 1, 12);
|
||||||
|
if (E) E.setRings(state.rings);
|
||||||
|
drawRings();
|
||||||
|
log('rings.grow', 'ring ' + String(state.rings).padStart(2, '0') + ' · ' + climateText(), '', 'hand');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#extract').addEventListener('click', function () {
|
||||||
|
extract('hand');
|
||||||
|
});
|
||||||
|
|
||||||
|
const choirForm = $('#choir-call');
|
||||||
|
const choirInput = $('#choir-input');
|
||||||
|
choirForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const q = choirInput.value.trim();
|
||||||
|
if (!q || document.body.dataset.mode === 'hand') return;
|
||||||
|
choirInput.value = '';
|
||||||
|
choirForm.classList.add('is-out');
|
||||||
|
window.setTimeout(function () {
|
||||||
|
extract('mcp', q);
|
||||||
|
choirForm.classList.remove('is-out');
|
||||||
|
}, 950);
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncChoir() {
|
||||||
|
const hand = document.body.dataset.mode === 'hand';
|
||||||
|
choirInput.disabled = hand;
|
||||||
|
choirInput.placeholder = hand ? 'the choir is resting' : 'send the choir for…';
|
||||||
|
}
|
||||||
|
new MutationObserver(syncChoir).observe(document.body, { attributes: true, attributeFilter: ['data-mode'] });
|
||||||
|
choirForm.addEventListener('pointerdown', function () {
|
||||||
|
if (document.body.dataset.mode === 'hand') {
|
||||||
|
const btn = document.querySelector('[data-mode-btn="choir"]');
|
||||||
|
if (btn) btn.click();
|
||||||
|
window.setTimeout(function () { choirInput.focus(); }, 40);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let rt = null;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
window.clearTimeout(rt);
|
||||||
|
rt = window.setTimeout(function () {
|
||||||
|
if (document.body.dataset.view !== 'quarry') return;
|
||||||
|
drawTray();
|
||||||
|
buildOverlay();
|
||||||
|
buildFaces(state.faceSeed);
|
||||||
|
drawRings();
|
||||||
|
}, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ————— boot ————— */
|
||||||
|
|
||||||
|
function setWeatherUI(w) {
|
||||||
|
['age', 'pressure', 'air', 'heat'].forEach(function (k) {
|
||||||
|
if (w[k] == null) return;
|
||||||
|
state.weather[k] = clamp(+w[k], 0, 1);
|
||||||
|
const el = $('#w-' + k);
|
||||||
|
if (el) {
|
||||||
|
el.value = Math.round(state.weather[k] * 100);
|
||||||
|
const out = el.parentNode.querySelector('output');
|
||||||
|
if (out) out.textContent = state.weather[k].toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (E) E.setWeather(state.weather);
|
||||||
|
updateClimate();
|
||||||
|
return state.weather;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRingsUI(n) {
|
||||||
|
state.rings = clamp(Math.round(+n || 0), 0, 12);
|
||||||
|
if (E) E.setRings(state.rings);
|
||||||
|
drawRings();
|
||||||
|
return state.rings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
buildStore();
|
||||||
|
selectCore(state.sel, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCore(id) {
|
||||||
|
const idx = cores.findIndex(function (c) { return c.id === id; });
|
||||||
|
if (idx >= 0) selectCore(idx);
|
||||||
|
else selectCore(state.sel, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
if (E) {
|
||||||
|
E.onCores(function (core) {
|
||||||
|
buildStore();
|
||||||
|
const idx = E.cores.indexOf(core);
|
||||||
|
if (idx >= 0) {
|
||||||
|
state.sel = idx;
|
||||||
|
if (document.body.dataset.view === 'quarry') selectCore(idx);
|
||||||
|
else updateHeader();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
E.onLog(renderLedger);
|
||||||
|
E.setRings(state.rings);
|
||||||
|
}
|
||||||
|
buildStore();
|
||||||
|
initWeather();
|
||||||
|
renderLedger();
|
||||||
|
selectCore(0, false);
|
||||||
|
syncChoir();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
window.setTimeout(boot, 0);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ZHILA_QUARRY = {
|
||||||
|
setView: setView, state: state, cores: cores, focusCore: focusCore,
|
||||||
|
setWeatherUI: setWeatherUI, setRingsUI: setRingsUI, refresh: refresh,
|
||||||
|
weather: function () { return state.weather; },
|
||||||
|
rings: function () { return state.rings; },
|
||||||
|
selectedCore: function () { return cores[state.sel] || null; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/* ZHILA · local server
|
||||||
|
serves the instrument and runs the choir: a model with tools that act in the page. */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { SYSTEM, TOOLS } = require('./agent-tools');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PORT = Number(process.env.PORT || 4173);
|
||||||
|
const HOST = '127.0.0.1';
|
||||||
|
const MODEL = process.env.ZHILA_MODEL || 'deepseek-chat';
|
||||||
|
const BASE = process.env.ZHILA_BASE_URL || 'https://api.deepseek.com';
|
||||||
|
const KEY = process.env.DEEPSEEK_API_KEY || '';
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.md': 'text/plain; charset=utf-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
function send(res, code, type, body) {
|
||||||
|
res.writeHead(code, {
|
||||||
|
'Content-Type': type,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Access-Control-Allow-Headers': 'content-type',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, code, obj) {
|
||||||
|
send(res, code, 'application/json; charset=utf-8', JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(req, res, pathname) {
|
||||||
|
let rel = pathname === '/' ? '/index.html' : pathname;
|
||||||
|
try { rel = decodeURIComponent(rel); } catch (e) { return send(res, 400, 'text/plain', 'bad path'); }
|
||||||
|
const file = path.resolve(ROOT, '.' + rel);
|
||||||
|
if (!file.startsWith(ROOT + path.sep) || file === ROOT) return send(res, 403, 'text/plain', 'forbidden');
|
||||||
|
if (!MIME[path.extname(file).toLowerCase()]) return send(res, 404, 'text/plain', 'not found');
|
||||||
|
fs.readFile(file, function (err, data) {
|
||||||
|
if (err) return send(res, 404, 'text/plain', 'not found');
|
||||||
|
send(res, 200, MIME[path.extname(file).toLowerCase()], data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedKey = KEY;
|
||||||
|
|
||||||
|
/* The key may be exported to the launching environment; otherwise the start script injects it. */
|
||||||
|
if (!cachedKey) {
|
||||||
|
console.warn('[zhila] no DEEPSEEK_API_KEY — the agent will run in local mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseMessages(input) {
|
||||||
|
if (!Array.isArray(input)) throw new Error('messages must be an array');
|
||||||
|
if (input.length > 80) throw new Error('conversation too long');
|
||||||
|
let chars = 0;
|
||||||
|
return input.map(function (m) {
|
||||||
|
if (!m || typeof m !== 'object') throw new Error('bad message');
|
||||||
|
const role = m.role;
|
||||||
|
if (!['user', 'assistant', 'tool'].includes(role)) throw new Error('bad role');
|
||||||
|
const out = { role: role };
|
||||||
|
if (typeof m.content === 'string') {
|
||||||
|
if (m.content.length > 12000) throw new Error('message too long');
|
||||||
|
chars += m.content.length;
|
||||||
|
out.content = m.content;
|
||||||
|
} else if (m.content === null) {
|
||||||
|
out.content = null;
|
||||||
|
} else {
|
||||||
|
out.content = '';
|
||||||
|
}
|
||||||
|
if (role === 'tool') {
|
||||||
|
if (typeof m.tool_call_id !== 'string') throw new Error('tool message without id');
|
||||||
|
out.tool_call_id = m.tool_call_id;
|
||||||
|
if (m.name) out.name = String(m.name).slice(0, 80);
|
||||||
|
}
|
||||||
|
if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
||||||
|
out.tool_calls = m.tool_calls.map(function (tc) {
|
||||||
|
if (!tc || tc.type !== 'function' || !tc.function || typeof tc.function.name !== 'string') {
|
||||||
|
throw new Error('bad tool call');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(tc.id || '').slice(0, 80),
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments.slice(0, 8000) : '{}'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}).filter(function (m) {
|
||||||
|
return m.content && m.content.length || m.tool_calls && m.tool_calls.length || m.role === 'tool';
|
||||||
|
}).map(function (m) {
|
||||||
|
if (chars > 200000) throw new Error('conversation too large');
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callModel(messages) {
|
||||||
|
if (!cachedKey) {
|
||||||
|
const err = new Error('no API key');
|
||||||
|
err.code = 'NO_KEY';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
model: MODEL,
|
||||||
|
messages: [{ role: 'system', content: SYSTEM }].concat(messages),
|
||||||
|
tools: TOOLS,
|
||||||
|
tool_choice: 'auto',
|
||||||
|
temperature: 0.75,
|
||||||
|
max_tokens: 900,
|
||||||
|
stream: false
|
||||||
|
};
|
||||||
|
const res = await fetch(BASE.replace(/\/+$/, '') + '/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + cachedKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(90000)
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { throw new Error('model returned non-JSON (' + res.status + ')'); }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data && data.error && data.error.message ? data.error.message : 'model error ' + res.status;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const choice = data.choices && data.choices[0];
|
||||||
|
if (!choice || !choice.message) throw new Error('model returned no message');
|
||||||
|
const m = choice.message;
|
||||||
|
return {
|
||||||
|
role: 'assistant',
|
||||||
|
content: typeof m.content === 'string' ? m.content : '',
|
||||||
|
tool_calls: Array.isArray(m.tool_calls) && m.tool_calls.length ? m.tool_calls : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
let size = 0;
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', function (c) {
|
||||||
|
size += c.length;
|
||||||
|
if (size > 400000) { reject(new Error('body too large')); req.destroy(); return; }
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
req.on('end', function () {
|
||||||
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
|
||||||
|
catch (e) { reject(new Error('bad JSON')); }
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function originAllowed(req) {
|
||||||
|
const o = req.headers.origin;
|
||||||
|
if (!o) return null;
|
||||||
|
if (o === 'null') return 'null';
|
||||||
|
if (/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(o)) return o;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async function (req, res) {
|
||||||
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
||||||
|
const origin = originAllowed(req);
|
||||||
|
if (origin) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
if (req.method === 'OPTIONS') return send(res, 204, 'text/plain', '');
|
||||||
|
if (url.pathname.startsWith('/api/') && origin === false) {
|
||||||
|
return json(res, 403, { error: 'origin not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/agent' && req.method === 'GET') {
|
||||||
|
return json(res, 200, {
|
||||||
|
live: !!cachedKey,
|
||||||
|
mode: cachedKey ? 'model' : 'local',
|
||||||
|
model: cachedKey ? MODEL : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/agent' && req.method === 'POST') {
|
||||||
|
let body;
|
||||||
|
try { body = await readBody(req); }
|
||||||
|
catch (e) { return json(res, 400, { error: e.message }); }
|
||||||
|
let messages;
|
||||||
|
try { messages = normaliseMessages(body.messages); }
|
||||||
|
catch (e) { return json(res, 400, { error: e.message }); }
|
||||||
|
try {
|
||||||
|
const message = await callModel(messages);
|
||||||
|
return json(res, 200, { message: message, model: MODEL });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'NO_KEY') {
|
||||||
|
return json(res, 503, { error: 'agent offline: no API key on the server', mode: 'local' });
|
||||||
|
}
|
||||||
|
console.error('[zhila] model error:', e.message);
|
||||||
|
return json(res, 502, { error: e.message || 'model error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/')) return json(res, 404, { error: 'no such endpoint' });
|
||||||
|
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'text/plain', 'method not allowed');
|
||||||
|
serveStatic(req, res, url.pathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, function () {
|
||||||
|
console.log('');
|
||||||
|
console.log(' ZHILA · http://' + HOST + ':' + PORT);
|
||||||
|
console.log(' agent · ' + (cachedKey ? MODEL + ' (live)' : 'local conductor (no key)'));
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
|
||||||
|
DEEPSEEK_API_KEY="$(python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
d = yaml.safe_load(open(os.path.expanduser('~/.dsh/.credentials.yaml')))
|
||||||
|
print((d.get('refs') or {}).get('DEEPSEEK_API_KEY', ''))
|
||||||
|
except Exception:
|
||||||
|
print('')
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
export DEEPSEEK_API_KEY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${DEEPSEEK_API_KEY}" ]; then
|
||||||
|
echo "no DEEPSEEK_API_KEY — the choir will run in local mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node server.js
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
/* жила · рельеф
|
||||||
|
A single contour field over the whole surface: domain-warped fbm for the strata,
|
||||||
|
a radial well under the eye, perspective compression below the horizon,
|
||||||
|
and black pools cut where the interface needs to speak. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const P = {
|
||||||
|
scale: 1 / 1.35, // field resolution vs. screen
|
||||||
|
density: 17, // contour levels per unit of the field
|
||||||
|
freq: 4.7, // base frequency of the strata
|
||||||
|
oct: 5,
|
||||||
|
warp: 0.66,
|
||||||
|
well: 2.35,
|
||||||
|
line: 0.68, // half-width of a contour, in pixels
|
||||||
|
bloom: 0.5
|
||||||
|
};
|
||||||
|
const SCALE = P.scale;
|
||||||
|
const canvas = document.getElementById('terrain');
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
const spiresCanvas = document.getElementById('spires');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const anchors = {
|
||||||
|
eye: { x: 0.5, y: 0.3, sig: 0.13 },
|
||||||
|
hor: 0.6
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ————— noise ————— */
|
||||||
|
|
||||||
|
function hash(x, y) {
|
||||||
|
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
return (h >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noise(x, y) {
|
||||||
|
const xi = Math.floor(x), yi = Math.floor(y);
|
||||||
|
const xf = x - xi, yf = y - yi;
|
||||||
|
const u = xf * xf * (3 - 2 * xf);
|
||||||
|
const v = yf * yf * (3 - 2 * yf);
|
||||||
|
const a = hash(xi, yi), b = hash(xi + 1, yi);
|
||||||
|
const c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
|
||||||
|
const ab = a + (b - a) * u;
|
||||||
|
const cd = c + (d - c) * u;
|
||||||
|
return ab + (cd - ab) * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fbm(x, y, oct) {
|
||||||
|
let s = 0, amp = 0.5, f = 1, norm = 0;
|
||||||
|
for (let i = 0; i < oct; i++) {
|
||||||
|
s += amp * noise(x * f, y * f);
|
||||||
|
norm += amp;
|
||||||
|
f *= 2.04;
|
||||||
|
amp *= 0.5;
|
||||||
|
}
|
||||||
|
return s / norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smooth = (a, b, x) => {
|
||||||
|
let t = (x - a) / (b - a);
|
||||||
|
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
|
||||||
|
|
||||||
|
/* ————— anchors: where the eye and the horizon actually are ————— */
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const voidEl = document.querySelector('.void');
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const h = window.innerHeight;
|
||||||
|
if (voidEl) {
|
||||||
|
const r = voidEl.getBoundingClientRect();
|
||||||
|
anchors.eye.x = (r.left + r.width / 2) / h;
|
||||||
|
anchors.eye.y = (r.top + r.height / 2) / h;
|
||||||
|
anchors.eye.sig = (r.width * 0.95) / h;
|
||||||
|
}
|
||||||
|
if (stage) {
|
||||||
|
const sr = stage.getBoundingClientRect();
|
||||||
|
anchors.hor = (sr.top + sr.height * 0.28) / h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— black pools around the interface ————— */
|
||||||
|
|
||||||
|
function blob(g, x, y, w, h, seed) {
|
||||||
|
const pad = 15;
|
||||||
|
x -= pad; y -= pad; w += pad * 2; h += pad * 2;
|
||||||
|
let s = seed * 9301 + 49297;
|
||||||
|
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||||
|
const jit = 9;
|
||||||
|
const step = 34;
|
||||||
|
const nx = Math.max(2, Math.round(w / step));
|
||||||
|
const ny = Math.max(2, Math.round(h / step));
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i <= nx; i++) pts.push([x + (w * i) / nx, y + (rnd() - 0.5) * jit]);
|
||||||
|
for (let i = 1; i <= ny; i++) pts.push([x + w + (rnd() - 0.5) * jit, y + (h * i) / ny]);
|
||||||
|
for (let i = nx - 1; i >= 0; i--) pts.push([x + (w * i) / nx, y + h + (rnd() - 0.5) * jit]);
|
||||||
|
for (let i = ny - 1; i > 0; i--) pts.push([x + (rnd() - 0.5) * jit, y + (h * i) / ny]);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
|
||||||
|
g.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
let maskCache = null;
|
||||||
|
|
||||||
|
function buildMask(mw, mh) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = mw; c.height = mh;
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
g.clearRect(0, 0, mw, mh);
|
||||||
|
g.fillStyle = '#fff';
|
||||||
|
g.filter = 'blur(11px)';
|
||||||
|
const els = Array.prototype.slice.call(document.querySelectorAll('[data-pool]'));
|
||||||
|
els.forEach((el, i) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width < 4 || r.height < 4) return;
|
||||||
|
if (r.bottom < -40 || r.top > window.innerHeight + 40) return;
|
||||||
|
g.globalAlpha = parseFloat(el.dataset.pool) || 0.9;
|
||||||
|
blob(g, r.left * SCALE, r.top * SCALE, r.width * SCALE, r.height * SCALE, i + 3);
|
||||||
|
g.fill();
|
||||||
|
});
|
||||||
|
g.filter = 'none';
|
||||||
|
const data = g.getImageData(0, 0, mw, mh).data;
|
||||||
|
maskCache = data;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— the field ————— */
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
measure();
|
||||||
|
const W = canvas.clientWidth || window.innerWidth;
|
||||||
|
const H = canvas.clientHeight || window.innerHeight;
|
||||||
|
const dpr = Math.min(1.6, window.devicePixelRatio || 1);
|
||||||
|
canvas.width = Math.round(W * dpr);
|
||||||
|
canvas.height = Math.round(H * dpr);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.fillStyle = '#080504';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const mw = Math.max(8, Math.round(W * SCALE));
|
||||||
|
const mh = Math.max(8, Math.round(H * SCALE));
|
||||||
|
const mask = buildMask(mw, mh);
|
||||||
|
const e = anchors.eye;
|
||||||
|
const hor = anchors.hor;
|
||||||
|
const sig2 = 2 * e.sig * e.sig;
|
||||||
|
const field = new Float32Array(mw * mh);
|
||||||
|
|
||||||
|
/* pass 1: the terrain itself */
|
||||||
|
for (let j = 0; j < mh; j++) {
|
||||||
|
const py = (j + 0.5) / mh;
|
||||||
|
const below = smooth(hor - 0.01, hor + 0.12, py);
|
||||||
|
const dd = Math.max(py - hor, 0.006);
|
||||||
|
let persp = 1.55 / dd;
|
||||||
|
if (persp > 2.15) persp = 2.15;
|
||||||
|
const sy = 1 + below * (persp - 1) * 0.8;
|
||||||
|
const sx = 1 + below * 0.3;
|
||||||
|
|
||||||
|
for (let i = 0; i < mw; i++) {
|
||||||
|
const px = (i + 0.5) / mh;
|
||||||
|
const wx = fbm(px * 2.05 + 13.7, py * 2.42 + 4.2, 3);
|
||||||
|
const wy = fbm(px * 2.05 + 7.1, py * 2.42 + 9.6, 3);
|
||||||
|
const qx = px + (wx - 0.5) * P.warp;
|
||||||
|
const qy = py + (wy - 0.5) * P.warp;
|
||||||
|
const dx = px - e.x, dy = py - e.y;
|
||||||
|
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
|
||||||
|
let n = fbm(qx * P.freq * sx + 19.1, qy * P.freq * sy + 2.7, P.oct);
|
||||||
|
n -= P.well * well;
|
||||||
|
field[j * mw + i] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pass 2: contours with a constant ink width */
|
||||||
|
const img = new ImageData(mw, mh);
|
||||||
|
const data = img.data;
|
||||||
|
const px2 = 1 / mh;
|
||||||
|
|
||||||
|
for (let j = 0; j < mh; j++) {
|
||||||
|
const py = (j + 0.5) / mh;
|
||||||
|
const below = smooth(hor - 0.01, hor + 0.12, py);
|
||||||
|
const dim = 1 - 0.4 * below;
|
||||||
|
const j0 = Math.max(0, j - 1), j1 = Math.min(mh - 1, j + 1);
|
||||||
|
for (let i = 0; i < mw; i++) {
|
||||||
|
const idx = j * mw + i;
|
||||||
|
const n = field[idx];
|
||||||
|
const i0 = Math.max(0, i - 1), i1 = Math.min(mw - 1, i + 1);
|
||||||
|
const gx = (field[j * mw + i1] - field[j * mw + i0]) * 0.5;
|
||||||
|
const gy = (field[j1 * mw + i] - field[j0 * mw + i]) * 0.5;
|
||||||
|
let g = Math.sqrt(gx * gx + gy * gy);
|
||||||
|
if (g < 0.00035) g = 0.00035;
|
||||||
|
|
||||||
|
const v = n * P.density;
|
||||||
|
const fr = v - Math.floor(v);
|
||||||
|
const edge = (fr < 0.5 ? fr : 1 - fr) / P.density; // distance to the line, in field units
|
||||||
|
const ink = P.line * g * (0.82 + 0.5 * Math.min(1, Math.abs(n) * 2));
|
||||||
|
let line = 1 - edge / ink;
|
||||||
|
if (line < 0) line = 0;
|
||||||
|
line = Math.pow(line, 0.8);
|
||||||
|
|
||||||
|
const px = (i + 0.5) / mh;
|
||||||
|
const dx = px - e.x, dy = py - e.y;
|
||||||
|
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
|
||||||
|
|
||||||
|
const m = 1 - (mask[idx * 4 + 3] / 255) * 0.975;
|
||||||
|
const vy = 1 - Math.abs(py - 0.12) * 0.9;
|
||||||
|
const a = clamp01(line * m * dim * (1 + 0.4 * well) * (0.82 + 0.3 * vy));
|
||||||
|
const wash = clamp01((0.018 + 0.13 * well) * m * dim * (1 - line) * (0.7 + 0.6 * vy));
|
||||||
|
const hot = clamp01(well * 1.1 + n * 0.42);
|
||||||
|
const p = idx * 4;
|
||||||
|
data[p] = 196 + 52 * hot;
|
||||||
|
data[p + 1] = 40 + 30 * hot + 14 * n;
|
||||||
|
data[p + 2] = 28 + 16 * hot;
|
||||||
|
data[p + 3] = (a + wash * (1 - a)) * 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = document.createElement('canvas');
|
||||||
|
tmp.width = mw; tmp.height = mh;
|
||||||
|
tmp.getContext('2d').putImageData(img, 0, 0);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
ctx.globalAlpha = P.bloom;
|
||||||
|
ctx.filter = 'blur(8px)';
|
||||||
|
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.filter = 'none';
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const g = ctx.createRadialGradient(
|
||||||
|
canvas.width * 0.5, canvas.height * 0.36, 0,
|
||||||
|
canvas.width * 0.5, canvas.height * 0.36, Math.max(canvas.width, canvas.height) * 0.78
|
||||||
|
);
|
||||||
|
g.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
g.addColorStop(1, 'rgba(0,0,0,.38)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ————— the city at the end of the road ————— */
|
||||||
|
|
||||||
|
function mulberry32(a) {
|
||||||
|
return function () {
|
||||||
|
a |= 0; a = a + 0x6D2B79F5 | 0;
|
||||||
|
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||||
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spire(g, x, base, h, w, rnd, alpha) {
|
||||||
|
const tipX = x + (rnd() - 0.5) * w * 0.32;
|
||||||
|
const tipY = base - h;
|
||||||
|
|
||||||
|
const halfAt = function (t) {
|
||||||
|
let k = Math.pow(1 - t, 0.62);
|
||||||
|
k *= 1 + 0.1 * Math.sin(t * 7 + w * 0.5);
|
||||||
|
if (t > 0.34) k *= 1 - 0.15 * (t - 0.34);
|
||||||
|
return Math.max(0.5, w * 0.5 * k);
|
||||||
|
};
|
||||||
|
const cxAt = function (t) { return x + (tipX - x) * Math.pow(t, 1.6); };
|
||||||
|
|
||||||
|
const N = 20;
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i <= N; i++) {
|
||||||
|
const t = i / N;
|
||||||
|
pts.push([cxAt(t) - halfAt(t), base - h * t]);
|
||||||
|
}
|
||||||
|
pts.push([tipX, tipY]);
|
||||||
|
for (let i = N; i >= 0; i--) {
|
||||||
|
const t = i / N;
|
||||||
|
pts.push([cxAt(t) + halfAt(t), base - h * t]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new Path2D();
|
||||||
|
body.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
for (let i = 1; i < pts.length; i++) body.lineTo(pts[i][0], pts[i][1]);
|
||||||
|
body.closePath();
|
||||||
|
|
||||||
|
g.fillStyle = 'rgba(58,42,35,.34)';
|
||||||
|
g.fill(body);
|
||||||
|
g.strokeStyle = 'rgba(220,206,190,' + (0.5 * alpha).toFixed(2) + ')';
|
||||||
|
g.lineWidth = 0.8;
|
||||||
|
g.stroke(body);
|
||||||
|
|
||||||
|
/* ribs: the vertical grain of the crystal */
|
||||||
|
const ribs = 4;
|
||||||
|
g.lineWidth = 0.6;
|
||||||
|
g.strokeStyle = 'rgba(208,194,180,' + (0.26 * alpha).toFixed(2) + ')';
|
||||||
|
for (let k = -ribs; k <= ribs; k++) {
|
||||||
|
const f = k / ribs;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(x + f * w * 0.48, base);
|
||||||
|
g.lineTo(cxAt(1) + f * 1.6, tipY + 1);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* strata, straight: a wireframe, not a vase */
|
||||||
|
const bands = 7 + Math.floor(rnd() * 5);
|
||||||
|
g.strokeStyle = 'rgba(208,194,180,' + (0.16 * alpha).toFixed(2) + ')';
|
||||||
|
for (let i = 1; i <= bands; i++) {
|
||||||
|
const t = Math.pow(i / (bands + 1), 1.1);
|
||||||
|
const cx = cxAt(t);
|
||||||
|
const y = base - h * t;
|
||||||
|
const hw = halfAt(t) * 0.97;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cx - hw, y);
|
||||||
|
g.lineTo(cx + hw, y);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a few braces, to catch the light */
|
||||||
|
g.strokeStyle = 'rgba(208,194,180,' + (0.13 * alpha).toFixed(2) + ')';
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
const t0 = 0.16 + i * 0.22;
|
||||||
|
const t1 = t0 + 0.12;
|
||||||
|
const hw0 = halfAt(t0), hw1 = halfAt(t1);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cxAt(t0) - hw0, base - h * t0);
|
||||||
|
g.lineTo(cxAt(t1) + hw1, base - h * t1);
|
||||||
|
g.moveTo(cxAt(t0) + hw0, base - h * t0);
|
||||||
|
g.lineTo(cxAt(t1) - hw1, base - h * t1);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rnd() > 0.35) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(tipX, tipY + 2);
|
||||||
|
g.lineTo(tipX, tipY - h * 0.14);
|
||||||
|
g.strokeStyle = 'rgba(224,210,194,' + (0.4 * alpha).toFixed(2) + ')';
|
||||||
|
g.lineWidth = 0.7;
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpires() {
|
||||||
|
if (!spiresCanvas) return;
|
||||||
|
const W = spiresCanvas.clientWidth;
|
||||||
|
const H = spiresCanvas.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
spiresCanvas.width = Math.round(W * dpr);
|
||||||
|
spiresCanvas.height = Math.round(H * dpr);
|
||||||
|
const g = spiresCanvas.getContext('2d');
|
||||||
|
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
g.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const horizon = H * 0.7;
|
||||||
|
const dark = g.createLinearGradient(0, 0, 0, horizon);
|
||||||
|
dark.addColorStop(0, 'rgba(5,3,2,.62)');
|
||||||
|
dark.addColorStop(0.72, 'rgba(5,3,2,.2)');
|
||||||
|
dark.addColorStop(1, 'rgba(5,3,2,0)');
|
||||||
|
g.fillStyle = dark;
|
||||||
|
g.fillRect(0, 0, W, horizon + 2);
|
||||||
|
|
||||||
|
const haze = g.createLinearGradient(0, horizon - H * 0.55, 0, horizon + H * 0.14);
|
||||||
|
haze.addColorStop(0, 'rgba(232,64,42,0)');
|
||||||
|
haze.addColorStop(1, 'rgba(232,64,42,.1)');
|
||||||
|
g.fillStyle = haze;
|
||||||
|
g.fillRect(0, horizon - H * 0.55, W, H);
|
||||||
|
|
||||||
|
const layers = [
|
||||||
|
{ hMin: 0.14, hMax: 0.42, alpha: 0.42, wid: 0.8, seed: 41, spread: 0.045 },
|
||||||
|
{ hMin: 0.3, hMax: 1.0, alpha: 1, wid: 1, seed: 94, spread: 0.05 }
|
||||||
|
];
|
||||||
|
const clusters = [0.045, 0.15, 0.27, 0.335, 0.665, 0.73, 0.85, 0.955];
|
||||||
|
layers.forEach(function (L) {
|
||||||
|
const rnd = mulberry32(L.seed);
|
||||||
|
const picked = [];
|
||||||
|
clusters.forEach(function (cx) {
|
||||||
|
const n = 1 + Math.floor(rnd() * 2.6);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = cx + (rnd() - 0.5) * L.spread * 2;
|
||||||
|
if (t < 0.02 || t > 0.98) continue;
|
||||||
|
if (t > 0.39 && t < 0.61) continue;
|
||||||
|
const edge = Math.min(1, Math.abs(t - 0.5) * 2.1);
|
||||||
|
picked.push({
|
||||||
|
x: t * W,
|
||||||
|
h: H * (L.hMin + rnd() * (L.hMax - L.hMin)) * (0.55 + 0.6 * edge),
|
||||||
|
w: (7 + rnd() * 16) * L.wid * (0.75 + 0.5 * edge),
|
||||||
|
a: L.alpha * (0.75 + rnd() * 0.25)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
picked.sort(function (a, b) { return a.h - b.h; });
|
||||||
|
picked.forEach(function (p) { spire(g, p.x, horizon + 2, p.h, p.w, rnd, p.a); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ZHILA_TERRAIN = { render, drawSpires, measure, P };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "· rust fmt"
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
echo "· clippy"
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
echo "· rust tests"
|
||||||
|
cargo test --workspace
|
||||||
|
echo "· svelte check"
|
||||||
|
npm --prefix ui run check
|
||||||
|
echo "· surface build"
|
||||||
|
npm --prefix ui run build
|
||||||
|
echo
|
||||||
|
echo "all checks passed"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
kill 0 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
echo "ZHILA"
|
||||||
|
echo " daemon ws://127.0.0.1:4317 (token: ${ZHILA_TOKEN:-dev})"
|
||||||
|
echo " surface http://127.0.0.1:5173"
|
||||||
|
echo
|
||||||
|
|
||||||
|
cargo run -p zhila-daemon -- --port 4317 &
|
||||||
|
npm --prefix ui run dev &
|
||||||
|
|
||||||
|
wait
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// End-to-end wire check against a running zhila-daemon.
|
||||||
|
// Usage: node scripts/wire-smoke.mjs
|
||||||
|
const URL = process.env.ZHILA_WS ?? 'ws://127.0.0.1:4317';
|
||||||
|
const TOKEN = process.env.ZHILA_TOKEN ?? 'dev';
|
||||||
|
|
||||||
|
const ws = new WebSocket(URL);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
let hello = null;
|
||||||
|
let snapshot = null;
|
||||||
|
let frames = 0;
|
||||||
|
let maxMaster = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const events = [];
|
||||||
|
let failure = null;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => fail(new Error('wire smoke timed out after 12s')), 12_000);
|
||||||
|
|
||||||
|
function fail(error) {
|
||||||
|
if (failure) return;
|
||||||
|
failure = error;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try { ws.close(); } catch {}
|
||||||
|
console.error('FAIL', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try { ws.close(); } catch {}
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
protocol: hello?.protocol,
|
||||||
|
session: hello?.session,
|
||||||
|
audio: snapshot?.audio,
|
||||||
|
patternRows: snapshot?.pattern?.rows?.length,
|
||||||
|
steps: snapshot?.pattern?.steps,
|
||||||
|
frames,
|
||||||
|
maxMaster: +maxMaster.toFixed(3),
|
||||||
|
events: events.map(e => e.kind ?? e.type),
|
||||||
|
tempo: snapshot?.transport?.bpm,
|
||||||
|
revision: snapshot?.revision
|
||||||
|
}, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(id, command) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
ws.send(JSON.stringify({ type: 'command', id, command }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: TOKEN, client: 'wire-smoke' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => fail(new Error('websocket error (is the daemon running?)'));
|
||||||
|
|
||||||
|
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) return;
|
||||||
|
frames++;
|
||||||
|
const master = view.getFloat32(7, true);
|
||||||
|
if (master > maxMaster) maxMaster = master;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(message.data); } catch { return; }
|
||||||
|
if (parsed.type === 'hello') {
|
||||||
|
hello = parsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === 'snapshot') {
|
||||||
|
snapshot = parsed.state;
|
||||||
|
try {
|
||||||
|
await send(1, { type: 'play' });
|
||||||
|
await new Promise(r => setTimeout(r, 1600));
|
||||||
|
await send(2, { type: 'strike', vein: 'bass', velocity: 0.9 });
|
||||||
|
await new Promise(r => setTimeout(r, 700));
|
||||||
|
await send(3, { type: 'set_tempo', bpm: 132 });
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
await send(4, { type: 'stop' });
|
||||||
|
if (!frames) throw new Error('no meter frames received');
|
||||||
|
if (!(maxMaster > 0)) throw new Error('meter never left zero — engine may be silent');
|
||||||
|
if (!snapshot.audio?.running) throw new Error('audio reported offline');
|
||||||
|
if (snapshot.pattern?.rows?.length !== 5) throw new Error('pattern is incomplete');
|
||||||
|
done();
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === 'event') {
|
||||||
|
events.push(parsed.event);
|
||||||
|
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 === '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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="theme-color" content="#0c0908" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>ZHILA · first descent</title>
|
||||||
|
<meta name="description" content="ZHILA — a sampling instrument where sound is terrain. P0: the engine, the wire, the descent." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "zhila-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5173 --strictPort",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 5173 --strictPort",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"smoke": "node scripts/smoke.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"playwright-core": "1.56.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/cormorant-garamond": "^5.0.0",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" fill="#0c0908"/>
|
||||||
|
<circle cx="32" cy="29" r="17" fill="#0c0908" stroke="#e8432a" stroke-width="1.4"/>
|
||||||
|
<path d="M17 45 L32 36 L47 45 M24 49 L32 44 L40 49" fill="none" stroke="#a8271a" stroke-width="1.1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,52 @@
|
|||||||
|
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(2800);
|
||||||
|
await page.screenshot({ path: '../preview-p0.png' });
|
||||||
|
if (await page.locator('.play.is-playing').count()) {
|
||||||
|
await page.click('.play');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
await page.click('.play');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.click('.strike');
|
||||||
|
await page.waitForTimeout(1300);
|
||||||
|
await page.screenshot({ path: '../preview-p0-playing.png' });
|
||||||
|
const report = await page.evaluate(() => ({
|
||||||
|
net: document.querySelector('.net b')?.textContent,
|
||||||
|
status: document.querySelector('.wire-status')?.textContent?.trim(),
|
||||||
|
wire: [...document.querySelectorAll('.wire-label')].slice(-8).map((el) => el.textContent),
|
||||||
|
facts: [...document.querySelectorAll('.fact b')].map((el) => el.textContent),
|
||||||
|
lanes: [...document.querySelectorAll('.lane')].map((el) => el.getAttribute('aria-pressed')),
|
||||||
|
gains: [...document.querySelectorAll('.vein-gain')].map((el) => el.textContent),
|
||||||
|
cilia: [...document.querySelectorAll('.cilium')].map((el) => Number(getComputedStyle(el).getPropertyValue('--level'))),
|
||||||
|
trace: Boolean(document.querySelector('canvas.trace')),
|
||||||
|
time: document.querySelector('.clock-time')?.textContent,
|
||||||
|
bar: document.querySelector('.clock-bar')?.textContent,
|
||||||
|
master: document.querySelector('.master-db')?.textContent,
|
||||||
|
canvas: { w: document.querySelector('canvas')?.width, h: document.querySelector('canvas')?.height }
|
||||||
|
}));
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
console.log(errors.length ? errors.join('\n') : 'no console errors');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (report.net !== 'live') failures.push('the engine did not report live');
|
||||||
|
if (!report.wire.includes('play')) failures.push('the wire did not record play');
|
||||||
|
if (!report.wire.includes('strike bass 0.85')) failures.push('the wire did not record the strike');
|
||||||
|
if (!report.trace) failures.push('the signal trace is missing');
|
||||||
|
if (!report.gains.length) failures.push('the vein gains are missing');
|
||||||
|
if (!report.cilia.some((level) => level > 0.001)) failures.push('no cilia level moved');
|
||||||
|
if (!(report.canvas.w > 1000)) failures.push('the descent canvas did not size to the hero');
|
||||||
|
if (errors.length) failures.push('console errors');
|
||||||
|
await browser.close();
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('SMOKE FAILED: ' + failures.join('; '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('smoke passed');
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Descent from './components/Descent.svelte';
|
||||||
|
import EnginePanel from './components/EnginePanel.svelte';
|
||||||
|
import Transport from './components/Transport.svelte';
|
||||||
|
import Wire from './components/Wire.svelte';
|
||||||
|
import { Bridge } from './lib/bridge';
|
||||||
|
import { commandLabel } from './lib/format';
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
Event,
|
||||||
|
HelloMessage,
|
||||||
|
MeterFrame,
|
||||||
|
State,
|
||||||
|
Status,
|
||||||
|
VeinId,
|
||||||
|
WireDir,
|
||||||
|
WireEntry
|
||||||
|
} from './lib/types';
|
||||||
|
import { VEINS } from './lib/types';
|
||||||
|
|
||||||
|
const WIRE_URL = import.meta.env.VITE_ZHILA_WS ?? 'ws://127.0.0.1:4317';
|
||||||
|
const TOKEN = import.meta.env.VITE_ZHILA_TOKEN ?? 'dev';
|
||||||
|
|
||||||
|
let status: Status = $state('offline');
|
||||||
|
let project: State | null = $state(null);
|
||||||
|
let meter: MeterFrame = $state({ bar: 1, master: 0, veins: [0, 0, 0, 0, 0] });
|
||||||
|
let selected: VeinId = $state('bass');
|
||||||
|
let entries: WireEntry[] = $state([]);
|
||||||
|
let hello: HelloMessage | null = $state(null);
|
||||||
|
let bridge: Bridge | null = null;
|
||||||
|
let entryId = 0;
|
||||||
|
let snapshotLogged = false;
|
||||||
|
|
||||||
|
function addEntry(dir: WireDir, label: string, detail?: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const at = [now.getHours(), now.getMinutes(), now.getSeconds()]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
entries = [...entries.slice(-60), { id: ++entryId, at, dir, label, detail }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatus(next: Status) {
|
||||||
|
if (next === status) return;
|
||||||
|
status = next;
|
||||||
|
if (next === 'connecting') addEntry('info', 'opening wire', WIRE_URL);
|
||||||
|
else if (next === 'live') addEntry('info', 'wire open');
|
||||||
|
else if (next === 'offline') addEntry('error', 'wire closed', 'the engine is off the wire');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHello(message: HelloMessage) {
|
||||||
|
hello = message;
|
||||||
|
addEntry('info', 'hello', `${message.session} · protocol ${message.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onState(next: State) {
|
||||||
|
project = next;
|
||||||
|
if (!snapshotLogged) {
|
||||||
|
snapshotLogged = true;
|
||||||
|
addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · rev ${next.revision}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMeter(next: MeterFrame) {
|
||||||
|
meter = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEvent(event: Event) {
|
||||||
|
if (!project) return;
|
||||||
|
if (event.kind === 'transport') project.transport.playing = event.playing;
|
||||||
|
else if (event.kind === 'tempo') project.transport.bpm = event.bpm;
|
||||||
|
else if (event.kind === 'vein_gain') {
|
||||||
|
const vein = project.veins.find((entry) => entry.id === event.vein);
|
||||||
|
if (vein) vein.gain = event.gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(message: string) {
|
||||||
|
addEntry('error', 'wire', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(command: Command) {
|
||||||
|
if (!bridge) return;
|
||||||
|
addEntry('send', commandLabel(command));
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
const result = await bridge.command(command);
|
||||||
|
const ms = performance.now() - started;
|
||||||
|
addEntry('recv', 'ok', ms < 10 ? `${ms.toFixed(1)} ms` : `${Math.round(ms)} ms`);
|
||||||
|
project = result.state;
|
||||||
|
} catch (error) {
|
||||||
|
addEntry('error', 'error', error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
void run({ type: 'play' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
void run({ type: 'stop' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function strike(vein: VeinId = selected) {
|
||||||
|
selected = vein;
|
||||||
|
void run({ type: 'strike', vein, velocity: 0.85 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTempo(bpm: number) {
|
||||||
|
void run({ type: 'set_tempo', bpm });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGain(vein: VeinId, gain: number) {
|
||||||
|
void run({ type: 'set_vein_gain', vein, gain });
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(vein: VeinId) {
|
||||||
|
selected = vein;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnect() {
|
||||||
|
addEntry('info', 'reconnecting');
|
||||||
|
bridge?.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (project?.transport.playing) stop();
|
||||||
|
else play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 's' || event.key === 'S') {
|
||||||
|
event.preventDefault();
|
||||||
|
strike();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key >= '1' && event.key <= '5') {
|
||||||
|
const vein = VEINS[Number(event.key) - 1];
|
||||||
|
if (vein) select(vein);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--m', meter.master.toFixed(4));
|
||||||
|
VEINS.forEach((vein, index) => {
|
||||||
|
root.style.setProperty(`--${vein}`, (meter.veins[index] ?? 0).toFixed(4));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
bridge = new Bridge(WIRE_URL, TOKEN, {
|
||||||
|
onStatus,
|
||||||
|
onHello,
|
||||||
|
onState,
|
||||||
|
onMeter,
|
||||||
|
onEvent,
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
bridge.connect();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
bridge?.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="rail">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark">ZHILA</span>
|
||||||
|
<span class="version">0.1</span>
|
||||||
|
</div>
|
||||||
|
<div class="rail-center">
|
||||||
|
<span class="room is-on">nave</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="rail-note">{project ? `${project.veins.length} veins` : 'five veins'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="net" class:is-live={status === 'live'}>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<span class="net-label">engine</span>
|
||||||
|
<b>{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}</b>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="hero">
|
||||||
|
<Descent state={project} {meter} {selected} onselect={select} {strike} />
|
||||||
|
<Wire {entries} {status} onreconnect={reconnect} />
|
||||||
|
<EnginePanel state={project} {meter} {status} session={hello?.session ?? '—'} ongain={setGain} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Transport
|
||||||
|
state={project}
|
||||||
|
{meter}
|
||||||
|
{selected}
|
||||||
|
{status}
|
||||||
|
onplay={play}
|
||||||
|
onstop={stop}
|
||||||
|
ontempo={setTempo}
|
||||||
|
onstrike={strike}
|
||||||
|
onreconnect={reconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Descent } from '../lib/descent';
|
||||||
|
import type { MeterFrame, State, VeinId } from '../lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
state,
|
||||||
|
meter,
|
||||||
|
selected,
|
||||||
|
onselect,
|
||||||
|
strike
|
||||||
|
}: {
|
||||||
|
state: State | null;
|
||||||
|
meter: MeterFrame;
|
||||||
|
selected: VeinId;
|
||||||
|
onselect: (vein: VeinId) => void;
|
||||||
|
strike: (vein: VeinId) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let host: HTMLDivElement | null = null;
|
||||||
|
let canvas: HTMLCanvasElement | null = null;
|
||||||
|
let descent: Descent | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!canvas || !host) return;
|
||||||
|
descent = new Descent(canvas, host);
|
||||||
|
descent.update(state, meter, selected);
|
||||||
|
descent.start();
|
||||||
|
return () => descent?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
descent?.update(state, meter, selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(event: MouseEvent) {
|
||||||
|
if (!descent) return;
|
||||||
|
const vein = descent.pick(event.clientX, event.clientY);
|
||||||
|
if (vein) onselect(vein);
|
||||||
|
}
|
||||||
|
|
||||||
|
const veins = $derived(state?.veins ?? []);
|
||||||
|
const meta = $derived(
|
||||||
|
`two bars · sixteen steps · ${state ? Math.round(state.transport.bpm) : 128} bpm`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="descent" bind:this={host}>
|
||||||
|
<canvas bind:this={canvas} aria-hidden="true" onclick={pick}></canvas>
|
||||||
|
|
||||||
|
<div class="scene" aria-hidden="true">
|
||||||
|
<h1>first descent</h1>
|
||||||
|
<p>{meta}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lanes">
|
||||||
|
{#each veins as vein, index}
|
||||||
|
<button
|
||||||
|
class="lane"
|
||||||
|
class:is-on={selected === vein.id}
|
||||||
|
style={`--level:${meter.veins[index] ?? 0}`}
|
||||||
|
aria-pressed={selected === vein.id}
|
||||||
|
aria-label={`select ${vein.name}`}
|
||||||
|
title={`select ${vein.name}; double-click to strike`}
|
||||||
|
onclick={() => onselect(vein.id)}
|
||||||
|
ondblclick={() => strike(vein.id)}
|
||||||
|
>
|
||||||
|
<span class="lane-fill" aria-hidden="true"></span>
|
||||||
|
<span class="lane-name">{vein.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { fmtDb } from '../lib/format';
|
||||||
|
import type { MeterFrame, State, Status, VeinId } from '../lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
state,
|
||||||
|
meter,
|
||||||
|
status,
|
||||||
|
session,
|
||||||
|
ongain
|
||||||
|
}: {
|
||||||
|
state: State | null;
|
||||||
|
meter: MeterFrame;
|
||||||
|
status: Status;
|
||||||
|
session: string;
|
||||||
|
ongain: (vein: VeinId, gain: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let traceCanvas: HTMLCanvasElement | null = null;
|
||||||
|
const history: number[] = [];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
history.push(Math.min(1, Math.max(0, meter.master)));
|
||||||
|
while (history.length > 150) history.shift();
|
||||||
|
const canvas = traceCanvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const cssWidth = Math.max(80, canvas.clientWidth || 220);
|
||||||
|
const cssHeight = 46;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const width = Math.floor(cssWidth * dpr);
|
||||||
|
const height = Math.floor(cssHeight * dpr);
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
context.clearRect(0, 0, cssWidth, cssHeight);
|
||||||
|
context.strokeStyle = 'rgba(155, 133, 121, 0.12)';
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, cssHeight - 0.5);
|
||||||
|
context.lineTo(cssWidth, cssHeight - 0.5);
|
||||||
|
context.stroke();
|
||||||
|
if (history.length < 2) return;
|
||||||
|
const peak = Math.max(0.12, ...history);
|
||||||
|
const step = cssWidth / (history.length - 1);
|
||||||
|
const yAt = (value: number) => cssHeight - 4 - (value / peak) * (cssHeight - 10);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, cssHeight);
|
||||||
|
history.forEach((value, index) => {
|
||||||
|
context.lineTo(index * step, yAt(value));
|
||||||
|
});
|
||||||
|
context.lineTo(cssWidth, cssHeight);
|
||||||
|
context.closePath();
|
||||||
|
const fill = context.createLinearGradient(0, 0, 0, cssHeight);
|
||||||
|
fill.addColorStop(0, 'rgba(232, 64, 42, 0.18)');
|
||||||
|
fill.addColorStop(1, 'rgba(232, 64, 42, 0)');
|
||||||
|
context.fillStyle = fill;
|
||||||
|
context.fill();
|
||||||
|
context.beginPath();
|
||||||
|
history.forEach((value, index) => {
|
||||||
|
if (index === 0) context.moveTo(0, yAt(value));
|
||||||
|
else context.lineTo(index * step, yAt(value));
|
||||||
|
});
|
||||||
|
context.strokeStyle = 'rgba(232, 64, 42, 0.85)';
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.stroke();
|
||||||
|
const last = history[history.length - 1];
|
||||||
|
context.fillStyle = 'rgba(239, 228, 212, 0.95)';
|
||||||
|
context.fillRect(cssWidth - 2, yAt(last) - 1, 2, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const audio = $derived(state?.audio ?? null);
|
||||||
|
const running = $derived(audio?.running ?? false);
|
||||||
|
const buffer = $derived(
|
||||||
|
audio ? (audio.buffer_frames > 0 ? `${audio.buffer_frames} fr` : 'default') : '—'
|
||||||
|
);
|
||||||
|
const veins = $derived(state?.veins ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="pool pool--engine" aria-label="engine">
|
||||||
|
<header class="pool-head">
|
||||||
|
<div>
|
||||||
|
<h2>engine</h2>
|
||||||
|
<p class="pool-sub">rust · cpal · p0</p>
|
||||||
|
</div>
|
||||||
|
<div class="pool-state" class:is-live={running && status === 'live'}>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
{running ? 'audio' : 'silent'}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span>device</span><b title={audio?.device ?? '—'}>{audio?.device ?? '—'}</b></div>
|
||||||
|
<div class="fact"><span>rate</span><b>{audio ? `${audio.sample_rate} Hz` : '—'}</b></div>
|
||||||
|
<div class="fact"><span>buffer</span><b>{buffer}</b></div>
|
||||||
|
<div class="fact"><span>channels</span><b>{audio?.channels ?? '—'}</b></div>
|
||||||
|
<div class="fact"><span>revision</span><b>{state?.revision ?? '—'}</b></div>
|
||||||
|
<div class="fact"><span>journal</span><b>{state?.journal_len ?? 0}</b></div>
|
||||||
|
<div class="fact"><span>session</span><b title={session}>{session}</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="veins-head">
|
||||||
|
<span>veins</span>
|
||||||
|
<span>gain</span>
|
||||||
|
</div>
|
||||||
|
<div class="veins">
|
||||||
|
{#each veins as vein}
|
||||||
|
<div class="vein-row">
|
||||||
|
<span class="vein-name">{vein.name}</span>
|
||||||
|
<input
|
||||||
|
class="vein-range"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1.2"
|
||||||
|
step="0.01"
|
||||||
|
value={vein.gain}
|
||||||
|
aria-label={`${vein.name} gain`}
|
||||||
|
onchange={(event) => ongain(vein.id, Number(event.currentTarget.value))}
|
||||||
|
/>
|
||||||
|
<span class="vein-gain">{vein.gain.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cilia-head">
|
||||||
|
<span>cilia</span>
|
||||||
|
<span class="cilia-master">{fmtDb(meter.master)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cilia" role="img" aria-label="per-vein levels">
|
||||||
|
{#each veins as vein, index}
|
||||||
|
<div class="cilium" style={`--level:${meter.veins[index] ?? 0}`}>
|
||||||
|
<div class="cilium-track">
|
||||||
|
<span class="cilium-fill"></span>
|
||||||
|
<span class="cilium-tip"></span>
|
||||||
|
</div>
|
||||||
|
<span class="cilium-name">{vein.name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="master-track" aria-hidden="true">
|
||||||
|
<span style={`width:${Math.min(100, meter.master * 100).toFixed(1)}%`}></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="trace-head">
|
||||||
|
<span>signal</span>
|
||||||
|
<span>last 5 s</span>
|
||||||
|
</div>
|
||||||
|
<canvas class="trace" bind:this={traceCanvas} aria-hidden="true"></canvas>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { fmtBar, fmtDb, fmtTempo, fmtTime } from '../lib/format';
|
||||||
|
import type { MeterFrame, State, Status, VeinId } from '../lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
state,
|
||||||
|
meter,
|
||||||
|
selected,
|
||||||
|
status,
|
||||||
|
onplay,
|
||||||
|
onstop,
|
||||||
|
ontempo,
|
||||||
|
onstrike,
|
||||||
|
onreconnect
|
||||||
|
}: {
|
||||||
|
state: State | null;
|
||||||
|
meter: MeterFrame;
|
||||||
|
selected: VeinId;
|
||||||
|
status: Status;
|
||||||
|
onplay: () => void;
|
||||||
|
onstop: () => void;
|
||||||
|
ontempo: (bpm: number) => void;
|
||||||
|
onstrike: (vein: VeinId) => void;
|
||||||
|
onreconnect: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const playing = $derived(state?.transport.playing ?? false);
|
||||||
|
const bpm = $derived(state?.transport.bpm ?? 128);
|
||||||
|
const bar = $derived(fmtBar(meter.bar));
|
||||||
|
const time = $derived(fmtTime(meter.bar, bpm));
|
||||||
|
const label = $derived(selected.charAt(0).toUpperCase() + selected.slice(1));
|
||||||
|
const loop = $derived(state?.transport.loop_bars ?? 2);
|
||||||
|
|
||||||
|
function adjust(delta: number) {
|
||||||
|
const next = Math.min(220, Math.max(40, Math.round(bpm + delta)));
|
||||||
|
if (next !== bpm) ontempo(next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="transport" style={`--master:${meter.master}`}>
|
||||||
|
<div class="transport-master" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="play"
|
||||||
|
class:is-playing={playing}
|
||||||
|
aria-label={playing ? 'stop' : 'play'}
|
||||||
|
onclick={playing ? onstop : onplay}
|
||||||
|
>
|
||||||
|
{#if playing}<span class="icon-stop" aria-hidden="true"></span>{:else}<span class="icon-play" aria-hidden="true"></span>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="clock">
|
||||||
|
<span class="clock-time">{time}</span>
|
||||||
|
<span class="clock-bar">bar {bar} / {loop}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tempo" aria-label="tempo">
|
||||||
|
<button onclick={() => adjust(-1)} aria-label="one bpm slower">−</button>
|
||||||
|
<span class="tempo-value">{fmtTempo(bpm)}</span>
|
||||||
|
<button onclick={() => adjust(1)} aria-label="one bpm faster">+</button>
|
||||||
|
<span class="tempo-unit">bpm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="master-read">
|
||||||
|
<span class="master-label">master</span>
|
||||||
|
<span class="master-db">{fmtDb(meter.master)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="strike" onclick={() => onstrike(selected)} aria-label={`strike ${selected}`}>
|
||||||
|
<span class="strike-glyph" aria-hidden="true"></span>
|
||||||
|
<span>strike</span>
|
||||||
|
<span class="strike-vein">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="wire-status"
|
||||||
|
class:is-live={status === 'live'}
|
||||||
|
class:is-offline={status === 'offline'}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span>{status === 'live' ? 'engine live' : status === 'connecting' ? 'connecting' : 'engine offline'}</span>
|
||||||
|
{#if status === 'offline'}<button class="reconnect" onclick={onreconnect}>reconnect</button>{/if}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Status, WireEntry } from '../lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
status,
|
||||||
|
onreconnect
|
||||||
|
}: {
|
||||||
|
entries: WireEntry[];
|
||||||
|
status: Status;
|
||||||
|
onreconnect: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let list: HTMLOListElement;
|
||||||
|
let lastCount = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const count = entries.length;
|
||||||
|
if (count !== lastCount) {
|
||||||
|
lastCount = count;
|
||||||
|
if (list) list.scrollTop = list.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="pool pool--wire" aria-label="wire">
|
||||||
|
<header class="pool-head">
|
||||||
|
<div>
|
||||||
|
<h2>wire</h2>
|
||||||
|
<p class="pool-sub">protocol v1 · loopback</p>
|
||||||
|
</div>
|
||||||
|
<div class="pool-state" class:is-live={status === 'live'}>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="wire-list" bind:this={list} aria-live="polite">
|
||||||
|
{#if entries.length === 0}
|
||||||
|
<li class="wire-empty">the wire is quiet.</li>
|
||||||
|
{/if}
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<li class={`wire-entry wire-entry--${entry.dir}`}>
|
||||||
|
<span class="wire-time">{entry.at}</span>
|
||||||
|
<span class="wire-dir" aria-hidden="true">{entry.dir === 'send' ? '→' : entry.dir === 'recv' ? '←' : '·'}</span>
|
||||||
|
<span class="wire-copy">
|
||||||
|
<span class="wire-label">{entry.label}</span>
|
||||||
|
{#if entry.detail}<span class="wire-detail">{entry.detail}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="wire-foot">
|
||||||
|
<span><b>space</b> play</span>
|
||||||
|
<span><b>s</b> strike</span>
|
||||||
|
<span><b>1–5</b> vein</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if status === 'offline'}
|
||||||
|
<div class="wire-offline">
|
||||||
|
<p>the engine is not on the wire.</p>
|
||||||
|
<code>cargo run -p zhila-daemon -- --port 4317</code>
|
||||||
|
<button onclick={onreconnect}>reconnect</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
Event,
|
||||||
|
HelloMessage,
|
||||||
|
MeterFrame,
|
||||||
|
ServerMessage,
|
||||||
|
State,
|
||||||
|
Status
|
||||||
|
} from './types';
|
||||||
|
import { METER_LEN, PROTOCOL_VERSION } from './types';
|
||||||
|
|
||||||
|
export interface BridgeHandlers {
|
||||||
|
onStatus: (status: Status) => void;
|
||||||
|
onHello: (message: HelloMessage) => void;
|
||||||
|
onState: (state: State) => void;
|
||||||
|
onMeter: (meter: MeterFrame) => void;
|
||||||
|
onEvent: (event: Event) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (result: CommandResult) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export class Bridge {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private pending = new Map<number, Pending>();
|
||||||
|
private nextId = 0;
|
||||||
|
private closed = false;
|
||||||
|
private reconnectTimer: number | null = null;
|
||||||
|
private attempts = 0;
|
||||||
|
private status: Status = 'offline';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly url: string,
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly handlers: BridgeHandlers
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.closed || this.ws) return;
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.setStatus('connecting');
|
||||||
|
let ws: WebSocket;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(this.url);
|
||||||
|
} catch (error) {
|
||||||
|
this.handlers.onError(error instanceof Error ? error.message : 'the wire address is invalid');
|
||||||
|
this.scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.attempts = 0;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'hello',
|
||||||
|
protocol: PROTOCOL_VERSION,
|
||||||
|
token: this.token,
|
||||||
|
client: 'zhila-ui'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (message: MessageEvent) => {
|
||||||
|
this.receive(message.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
this.handlers.onError('the wire refused the connection');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (this.ws === ws) this.ws = null;
|
||||||
|
this.rejectAll(new Error('the wire closed'));
|
||||||
|
this.setStatus('offline');
|
||||||
|
if (!this.closed) this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
command(command: Command): Promise<CommandResult> {
|
||||||
|
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.pending.delete(id);
|
||||||
|
reject(new Error('the engine did not answer in time'));
|
||||||
|
}, COMMAND_TIMEOUT_MS);
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify({ type: 'command', id, command }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.closed = true;
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
this.rejectAll(new Error('the wire was closed by the surface'));
|
||||||
|
this.setStatus('offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.closed || this.reconnectTimer !== null) return;
|
||||||
|
const delay = Math.min(5000, 900 + this.attempts * 700);
|
||||||
|
this.attempts += 1;
|
||||||
|
this.reconnectTimer = window.setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private receive(data: unknown): void {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
this.receiveMeter(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof data !== 'string') return;
|
||||||
|
let message: ServerMessage;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(data) as ServerMessage;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (message.type) {
|
||||||
|
case 'hello':
|
||||||
|
this.attempts = 0;
|
||||||
|
this.setStatus('live');
|
||||||
|
this.handlers.onHello(message);
|
||||||
|
break;
|
||||||
|
case 'snapshot':
|
||||||
|
if (this.status !== 'live') this.setStatus('live');
|
||||||
|
this.handlers.onState(message.state);
|
||||||
|
break;
|
||||||
|
case 'result': {
|
||||||
|
const pending = this.pending.get(message.id);
|
||||||
|
if (!pending) break;
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
window.clearTimeout(pending.timer);
|
||||||
|
if (message.result.kind === 'snapshot' || message.result.kind === 'state') {
|
||||||
|
this.handlers.onState(message.result.state);
|
||||||
|
}
|
||||||
|
pending.resolve(message.result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'event':
|
||||||
|
this.handlers.onEvent(message.event);
|
||||||
|
break;
|
||||||
|
case 'error': {
|
||||||
|
if (message.id !== null) {
|
||||||
|
const pending = this.pending.get(message.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
window.clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error(message.message));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.handlers.onError(message.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveMeter(data: ArrayBuffer): void {
|
||||||
|
if (data.byteLength !== METER_LEN) return;
|
||||||
|
const view = new DataView(data);
|
||||||
|
if (view.getUint8(0) !== 1) return;
|
||||||
|
if (view.getUint16(1, true) !== 5) return;
|
||||||
|
const bar = view.getFloat32(3, true);
|
||||||
|
const master = view.getFloat32(7, true);
|
||||||
|
const veins: number[] = [];
|
||||||
|
for (let i = 0; i < 5; i += 1) veins.push(view.getFloat32(11 + i * 4, true));
|
||||||
|
this.handlers.onMeter({ bar, master, veins });
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAll(error: Error): void {
|
||||||
|
for (const [, pending] of this.pending) {
|
||||||
|
window.clearTimeout(pending.timer);
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: Status): void {
|
||||||
|
if (this.status === status) return;
|
||||||
|
this.status = status;
|
||||||
|
this.handlers.onStatus(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
import type { MeterFrame, Pattern, State, VeinId } from './types';
|
||||||
|
|
||||||
|
const VEINS: VeinId[] = ['bass', 'crack', 'voice', 'noise', 'silt'];
|
||||||
|
const STEPS_PER_BAR = 8;
|
||||||
|
const LOOP_STEPS = 16;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const STONE_FILL = [
|
||||||
|
'rgba(232,64,42,',
|
||||||
|
'rgba(168,39,26,',
|
||||||
|
'rgba(239,228,212,',
|
||||||
|
'rgba(155,133,121,',
|
||||||
|
'rgba(109,91,82,'
|
||||||
|
];
|
||||||
|
const STONE_GLOW = [
|
||||||
|
'rgba(232,64,42,',
|
||||||
|
'rgba(168,39,26,',
|
||||||
|
'rgba(239,228,212,',
|
||||||
|
'rgba(155,133,121,',
|
||||||
|
'rgba(109,91,82,'
|
||||||
|
];
|
||||||
|
const CONTOUR_LEVELS = [-0.34, -0.255, -0.17, -0.085, 0, 0.085, 0.17, 0.255, 0.34];
|
||||||
|
|
||||||
|
export class Descent {
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private terrain: HTMLCanvasElement;
|
||||||
|
private glow: HTMLCanvasElement;
|
||||||
|
private w = 0;
|
||||||
|
private h = 0;
|
||||||
|
private dpr = 1;
|
||||||
|
private state: State | null = null;
|
||||||
|
private meter: MeterFrame = { bar: 1, master: 0, veins: [0, 0, 0, 0, 0] };
|
||||||
|
private selected: VeinId = 'bass';
|
||||||
|
private smooth = { master: 0, veins: [0, 0, 0, 0, 0] };
|
||||||
|
private phaseStart = 0;
|
||||||
|
private wasPlaying = false;
|
||||||
|
private displayStep = 0;
|
||||||
|
private bpm = 128;
|
||||||
|
private raf = 0;
|
||||||
|
private last = 0;
|
||||||
|
private resizeTimer: number | null = null;
|
||||||
|
private observer: ResizeObserver | null = null;
|
||||||
|
private reduced = false;
|
||||||
|
private particles: { x: number; y: number; speed: number; phase: number; size: number; alpha: number }[] = [];
|
||||||
|
private lights: { x: number; y: number; size: number; phase: number; intensity: number }[] = [];
|
||||||
|
private road = { cx: 0, bottom: 0, horizon: 0, bhw: 0, thw: 0 };
|
||||||
|
|
||||||
|
constructor(private canvas: HTMLCanvasElement, private host: HTMLElement) {
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('2d canvas is unavailable');
|
||||||
|
this.ctx = context;
|
||||||
|
this.terrain = document.createElement('canvas');
|
||||||
|
this.glow = document.createElement('canvas');
|
||||||
|
this.buildGlow();
|
||||||
|
this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.observer = new ResizeObserver(() => this.onResize());
|
||||||
|
this.observer.observe(this.host);
|
||||||
|
this.resize();
|
||||||
|
this.last = performance.now();
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
cancelAnimationFrame(this.raf);
|
||||||
|
if (this.resizeTimer !== null) window.clearTimeout(this.resizeTimer);
|
||||||
|
this.observer?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(state: State | null, meter: MeterFrame, selected: VeinId): void {
|
||||||
|
if (state) {
|
||||||
|
this.state = state;
|
||||||
|
this.bpm = state.transport.bpm;
|
||||||
|
if (!state.transport.playing) this.wasPlaying = false;
|
||||||
|
}
|
||||||
|
this.meter = meter;
|
||||||
|
this.selected = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(clientX: number, clientY: number): VeinId | null {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const { cx, bottom, horizon, bhw, thw } = this.road;
|
||||||
|
if (y > bottom || y < horizon) return null;
|
||||||
|
const t = (bottom - y) / Math.max(1, bottom - horizon);
|
||||||
|
const half = bhw + (thw - bhw) * t;
|
||||||
|
const relative = (x - cx) / Math.max(1, half);
|
||||||
|
if (relative < -1 || relative > 1) return null;
|
||||||
|
const lane = Math.min(4, Math.max(0, Math.floor(((relative + 1) / 2) * 5)));
|
||||||
|
return VEINS[lane];
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResize(): void {
|
||||||
|
if (this.resizeTimer !== null) window.clearTimeout(this.resizeTimer);
|
||||||
|
this.resizeTimer = window.setTimeout(() => {
|
||||||
|
this.resizeTimer = null;
|
||||||
|
this.resize();
|
||||||
|
}, 140);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const rect = this.host.getBoundingClientRect();
|
||||||
|
const width = Math.max(320, Math.floor(rect.width));
|
||||||
|
const height = Math.max(320, Math.floor(rect.height));
|
||||||
|
this.w = width;
|
||||||
|
this.h = height;
|
||||||
|
this.dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
||||||
|
this.canvas.width = Math.floor(width * this.dpr);
|
||||||
|
this.canvas.height = Math.floor(height * this.dpr);
|
||||||
|
this.canvas.style.width = width + 'px';
|
||||||
|
this.canvas.style.height = height + 'px';
|
||||||
|
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
this.road = {
|
||||||
|
cx: width * 0.5,
|
||||||
|
bottom: height * 0.9,
|
||||||
|
horizon: height * 0.585,
|
||||||
|
bhw: Math.min(width * 0.3, 470),
|
||||||
|
thw: Math.max(56, width * 0.085)
|
||||||
|
};
|
||||||
|
this.seedAtmosphere();
|
||||||
|
this.buildTerrain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedAtmosphere(): void {
|
||||||
|
const random = mulberry(0x5eed1234);
|
||||||
|
this.particles = Array.from({ length: 64 }, () => ({
|
||||||
|
x: random(),
|
||||||
|
y: random(),
|
||||||
|
speed: 0.25 + random() * 1.4,
|
||||||
|
phase: random() * TAU,
|
||||||
|
size: random() > 0.72 ? 1.5 : 1,
|
||||||
|
alpha: 0.18 + random() * 0.5
|
||||||
|
}));
|
||||||
|
this.lights = Array.from({ length: 26 }, () => {
|
||||||
|
const x = this.road.cx + (random() - 0.5) * this.w * 0.62;
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y: this.road.horizon - random() * this.h * 0.085,
|
||||||
|
size: random() > 0.8 ? 2 : 1,
|
||||||
|
phase: random() * TAU,
|
||||||
|
intensity: 0.3 + random() * 0.7
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGlow(): void {
|
||||||
|
const size = 256;
|
||||||
|
this.glow.width = size;
|
||||||
|
this.glow.height = size;
|
||||||
|
const ctx = this.glow.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||||
|
gradient.addColorStop(0, 'rgba(232,64,42,0.55)');
|
||||||
|
gradient.addColorStop(0.32, 'rgba(200,45,30,0.22)');
|
||||||
|
gradient.addColorStop(0.7, 'rgba(120,25,18,0.06)');
|
||||||
|
gradient.addColorStop(1, 'rgba(40,8,6,0)');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTerrain(): void {
|
||||||
|
if (!this.w || !this.h) return;
|
||||||
|
const { w, h, dpr } = this;
|
||||||
|
const canvas = this.terrain;
|
||||||
|
canvas.width = Math.floor(w * dpr);
|
||||||
|
canvas.height = Math.floor(h * dpr);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const base = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
base.addColorStop(0, '#0a0605');
|
||||||
|
base.addColorStop(0.42, '#0d0807');
|
||||||
|
base.addColorStop(0.7, '#110a08');
|
||||||
|
base.addColorStop(1, '#080404');
|
||||||
|
ctx.fillStyle = base;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cell = 9;
|
||||||
|
const cols = Math.ceil(w / cell);
|
||||||
|
const rows = Math.ceil(h / cell);
|
||||||
|
const values = new Float32Array((cols + 1) * (rows + 1));
|
||||||
|
for (let y = 0; y <= rows; y += 1) {
|
||||||
|
for (let x = 0; x <= cols; x += 1) {
|
||||||
|
values[y * (cols + 1) + x] = this.field((x * cell) / w, (y * cell) / h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let levelIndex = 0; levelIndex < CONTOUR_LEVELS.length; levelIndex += 1) {
|
||||||
|
const level = CONTOUR_LEVELS[levelIndex];
|
||||||
|
const t = levelIndex / Math.max(1, CONTOUR_LEVELS.length - 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let y = 0; y < rows; y += 1) {
|
||||||
|
for (let x = 0; x < cols; x += 1) {
|
||||||
|
const v00 = values[y * (cols + 1) + x];
|
||||||
|
const v10 = values[y * (cols + 1) + x + 1];
|
||||||
|
const v11 = values[(y + 1) * (cols + 1) + x + 1];
|
||||||
|
const v01 = values[(y + 1) * (cols + 1) + x];
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
if ((v00 < level) !== (v10 < level)) {
|
||||||
|
const k = (level - v00) / (v10 - v00 || 1);
|
||||||
|
points.push({ x: x * cell + k * cell, y: y * cell });
|
||||||
|
}
|
||||||
|
if ((v10 < level) !== (v11 < level)) {
|
||||||
|
const k = (level - v10) / (v11 - v10 || 1);
|
||||||
|
points.push({ x: (x + 1) * cell, y: y * cell + k * cell });
|
||||||
|
}
|
||||||
|
if ((v11 < level) !== (v01 < level)) {
|
||||||
|
const k = (level - v11) / (v01 - v11 || 1);
|
||||||
|
points.push({ x: (x + 1) * cell - k * cell, y: (y + 1) * cell });
|
||||||
|
}
|
||||||
|
if ((v01 < level) !== (v00 < level)) {
|
||||||
|
const k = (level - v01) / (v00 - v01 || 1);
|
||||||
|
points.push({ x: x * cell, y: (y + 1) * cell - k * cell });
|
||||||
|
}
|
||||||
|
if (points.length === 2) {
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
ctx.lineTo(points[1].x, points[1].y);
|
||||||
|
} else if (points.length === 4) {
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
ctx.lineTo(points[1].x, points[1].y);
|
||||||
|
ctx.moveTo(points[2].x, points[2].y);
|
||||||
|
ctx.lineTo(points[3].x, points[3].y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = `rgba(${Math.round(148 + t * 92)}, ${Math.round(28 + t * 40)}, ${Math.round(20 + t * 26)}, ${0.15 + t * 0.4})`;
|
||||||
|
ctx.lineWidth = 0.7 + t * 0.7;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizonWash = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
horizonWash.addColorStop(0, 'rgba(4,2,2,0.34)');
|
||||||
|
horizonWash.addColorStop(0.35, 'rgba(4,2,2,0)');
|
||||||
|
horizonWash.addColorStop(0.78, 'rgba(8,4,3,0.12)');
|
||||||
|
horizonWash.addColorStop(1, 'rgba(4,2,2,0.55)');
|
||||||
|
ctx.fillStyle = horizonWash;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const vignette = ctx.createRadialGradient(w * 0.5, h * 0.42, Math.min(w, h) * 0.12, w * 0.5, h * 0.46, Math.max(w, h) * 0.74);
|
||||||
|
vignette.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
vignette.addColorStop(0.62, 'rgba(0,0,0,0.12)');
|
||||||
|
vignette.addColorStop(1, 'rgba(0,0,0,0.72)');
|
||||||
|
ctx.fillStyle = vignette;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
this.drawCity(ctx);
|
||||||
|
|
||||||
|
const eyeLight = ctx.createRadialGradient(w * 0.5, h * 0.16, 0, w * 0.5, h * 0.16, Math.min(w, h) * 0.3);
|
||||||
|
eyeLight.addColorStop(0, 'rgba(232,64,42,0.12)');
|
||||||
|
eyeLight.addColorStop(0.55, 'rgba(150,32,22,0.04)');
|
||||||
|
eyeLight.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = eyeLight;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCity(ctx: CanvasRenderingContext2D): void {
|
||||||
|
const { w, h } = this;
|
||||||
|
const horizon = this.road.horizon || h * 0.585;
|
||||||
|
const random = mulberry(0xc17a);
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
const back = ctx.createLinearGradient(0, horizon - h * 0.17, 0, horizon);
|
||||||
|
back.addColorStop(0, 'rgba(232,64,42,0)');
|
||||||
|
back.addColorStop(1, 'rgba(232,64,42,0.06)');
|
||||||
|
ctx.fillStyle = back;
|
||||||
|
ctx.fillRect(w * 0.08, horizon - h * 0.17, w * 0.84, h * 0.17);
|
||||||
|
for (let i = 0; i < 76; i += 1) {
|
||||||
|
const x = w * 0.14 + random() * w * 0.72;
|
||||||
|
const tall = random();
|
||||||
|
const tower = h * (0.012 + tall * tall * 0.135);
|
||||||
|
const half = 1.2 + random() * (5 + tall * 4);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - half, horizon);
|
||||||
|
ctx.lineTo(x + (random() - 0.5) * half, horizon - tower);
|
||||||
|
ctx.lineTo(x + half, horizon);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = `rgba(8,4,3,${0.62 + random() * 0.3})`;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = `rgba(190,160,140,${0.09 + tall * 0.2})`;
|
||||||
|
ctx.lineWidth = 0.7;
|
||||||
|
ctx.stroke();
|
||||||
|
if (tall > 0.8) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, horizon - tower);
|
||||||
|
ctx.lineTo(x, horizon - tower - h * 0.015);
|
||||||
|
ctx.strokeStyle = 'rgba(239,228,212,0.24)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const line = ctx.createLinearGradient(w * 0.08, 0, w * 0.92, 0);
|
||||||
|
line.addColorStop(0, 'rgba(190,160,140,0)');
|
||||||
|
line.addColorStop(0.5, 'rgba(190,160,140,0.32)');
|
||||||
|
line.addColorStop(1, 'rgba(190,160,140,0)');
|
||||||
|
ctx.strokeStyle = line;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(w * 0.08, horizon);
|
||||||
|
ctx.lineTo(w * 0.92, horizon);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private field(u: number, v: number): number {
|
||||||
|
const x = (u - 0.5) * 3.1;
|
||||||
|
const y = (v - 0.18) * 3.8;
|
||||||
|
const warp = fbm(x * 1.05 + 4.2, y * 1.05 + 1.7);
|
||||||
|
let value = fbm(x * 1.65 + warp * 1.55 + 7.3, y * 1.45 + warp * 1.1 + 2.1);
|
||||||
|
value -= 0.5;
|
||||||
|
const dx = (u - 0.5) * 1.7;
|
||||||
|
const dy = (v - 0.16) * 2.35;
|
||||||
|
value -= Math.exp(-(dx * dx + dy * dy) * 10.5) * 0.5;
|
||||||
|
value += Math.exp(-(((u - 0.5) * 2.8) ** 2 + ((v - 0.585) * 9.5) ** 2)) * 0.06;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private frame = (now: number): void => {
|
||||||
|
const dt = Math.min(0.06, Math.max(0, (now - this.last) / 1000));
|
||||||
|
this.last = now;
|
||||||
|
const k = 1 - Math.exp(-dt * 11);
|
||||||
|
this.smooth.master += (this.meter.master - this.smooth.master) * k;
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const target = this.meter.veins[i] ?? 0;
|
||||||
|
this.smooth.veins[i] += (target - this.smooth.veins[i]) * k;
|
||||||
|
}
|
||||||
|
this.computeStep(now);
|
||||||
|
this.draw(now, dt);
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
private computeStep(now: number): void {
|
||||||
|
const playing = this.state?.transport.playing ?? false;
|
||||||
|
const stepsPerSecond = (this.bpm / 60) * 2;
|
||||||
|
if (playing) {
|
||||||
|
if (!this.wasPlaying) {
|
||||||
|
this.phaseStart = now;
|
||||||
|
this.wasPlaying = true;
|
||||||
|
}
|
||||||
|
let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS;
|
||||||
|
const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % LOOP_STEPS + LOOP_STEPS) % LOOP_STEPS;
|
||||||
|
let diff = (step - meterStep + LOOP_STEPS * 1.5) % LOOP_STEPS - LOOP_STEPS / 2;
|
||||||
|
if (Math.abs(diff) > 2.4) {
|
||||||
|
this.phaseStart += (diff / stepsPerSecond) * 1000;
|
||||||
|
step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS;
|
||||||
|
}
|
||||||
|
this.displayStep = step;
|
||||||
|
} else {
|
||||||
|
this.displayStep = 0;
|
||||||
|
this.phaseStart = now;
|
||||||
|
this.wasPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(now: number, dt: number): void {
|
||||||
|
const { ctx, w, h } = this;
|
||||||
|
const level = this.smooth;
|
||||||
|
const bass = level.veins[0];
|
||||||
|
const crack = level.veins[1];
|
||||||
|
const voice = level.veins[2];
|
||||||
|
const noise = level.veins[3];
|
||||||
|
const silt = level.veins[4];
|
||||||
|
const master = level.master;
|
||||||
|
|
||||||
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
ctx.fillStyle = '#0c0908';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.globalAlpha = 0.78 + master * 0.22;
|
||||||
|
ctx.drawImage(this.terrain, 0, -bass * 2.5, w, h + bass * 2.5);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
const eyeX = w * 0.5;
|
||||||
|
const eyeY = h * 0.16;
|
||||||
|
const eyeSize = Math.min(w, h) * (0.64 + bass * 0.34 + master * 0.08);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
ctx.globalAlpha = 0.3 + bass * 0.5 + master * 0.16;
|
||||||
|
ctx.drawImage(this.glow, eyeX - eyeSize / 2, eyeY - eyeSize / 2, eyeSize, eyeSize);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const coreRadius = Math.max(46, w * 0.072);
|
||||||
|
const core = ctx.createRadialGradient(eyeX, eyeY, 0, eyeX, eyeY, coreRadius);
|
||||||
|
core.addColorStop(0, 'rgba(4,2,2,0.99)');
|
||||||
|
core.addColorStop(0.62, 'rgba(6,3,3,0.92)');
|
||||||
|
core.addColorStop(1, 'rgba(6,3,3,0)');
|
||||||
|
ctx.fillStyle = core;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(eyeX, eyeY, coreRadius, 0, TAU);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const ring = Math.min(w, h) * 0.06 + bass * Math.min(w, h) * 0.03;
|
||||||
|
ctx.strokeStyle = `rgba(232,64,42,${0.22 + bass * 0.5 + master * 0.12})`;
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(eyeX, eyeY, ring, 0, TAU);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = `rgba(232,64,42,${0.07 + master * 0.18})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(eyeX, eyeY, ring * 1.42, 0, TAU);
|
||||||
|
ctx.stroke();
|
||||||
|
if (voice > 0.01) {
|
||||||
|
ctx.strokeStyle = `rgba(239,228,212,${voice * 0.2})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(eyeX, eyeY, ring * 1.85, 0, TAU);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const light of this.lights) {
|
||||||
|
const flicker = 0.55 + 0.45 * Math.sin(now * 0.0012 + light.phase);
|
||||||
|
const alpha = light.intensity * flicker * (0.13 + crack * 0.5 + noise * 0.24);
|
||||||
|
if (alpha <= 0.01) continue;
|
||||||
|
ctx.fillStyle = `rgba(232,64,42,${alpha})`;
|
||||||
|
ctx.fillRect(light.x, light.y, light.size, light.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawDust(now, dt, noise, silt);
|
||||||
|
|
||||||
|
this.drawRoad(now, master, bass, crack, voice, noise, silt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawDust(now: number, dt: number, noise: number, silt: number): void {
|
||||||
|
const { ctx, w, h } = this;
|
||||||
|
ctx.save();
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
particle.y -= dt * (0.004 + noise * 0.035) * particle.speed;
|
||||||
|
particle.x += Math.sin(now * 0.00018 + particle.phase) * dt * 0.004;
|
||||||
|
if (particle.y < -0.02) particle.y = 1.02;
|
||||||
|
if (particle.x < -0.02) particle.x = 1.02;
|
||||||
|
if (particle.x > 1.02) particle.x = -0.02;
|
||||||
|
const alpha = particle.alpha * (0.03 + noise * 0.12 + silt * 0.05);
|
||||||
|
if (alpha <= 0.005) continue;
|
||||||
|
ctx.fillStyle = `rgba(239,228,212,${alpha})`;
|
||||||
|
ctx.fillRect(particle.x * w, particle.y * h, particle.size, particle.size);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private roadPoint(z: number): { y: number; half: number } {
|
||||||
|
const { bottom, horizon, bhw, thw } = this.road;
|
||||||
|
const depth = Math.pow(Math.max(0, Math.min(1, z)), 1.16);
|
||||||
|
return {
|
||||||
|
y: bottom - (bottom - horizon) * depth,
|
||||||
|
half: bhw + (thw - bhw) * depth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawRoad(now: number, master: number, bass: number, crack: number, voice: number, noise: number, silt: number): void {
|
||||||
|
const { ctx, w } = this;
|
||||||
|
const { cx, bottom, horizon, bhw, thw } = this.road;
|
||||||
|
const levels = this.smooth.veins;
|
||||||
|
const active = Math.floor(this.displayStep) % LOOP_STEPS;
|
||||||
|
|
||||||
|
const roadFill = ctx.createLinearGradient(0, bottom, 0, horizon);
|
||||||
|
roadFill.addColorStop(0, 'rgba(24,10,7,0.66)');
|
||||||
|
roadFill.addColorStop(0.55, 'rgba(12,6,4,0.46)');
|
||||||
|
roadFill.addColorStop(1, 'rgba(4,2,2,0.2)');
|
||||||
|
ctx.fillStyle = roadFill;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - bhw, bottom);
|
||||||
|
ctx.lineTo(cx + bhw, bottom);
|
||||||
|
ctx.lineTo(cx + thw, horizon);
|
||||||
|
ctx.lineTo(cx - thw, horizon);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
for (let lane = 0; lane < 5; lane += 1) {
|
||||||
|
const leftBottom = cx + ((lane / 5) - 0.5) * 2 * bhw;
|
||||||
|
const rightBottom = cx + (((lane + 1) / 5) - 0.5) * 2 * bhw;
|
||||||
|
const leftTop = cx + ((lane / 5) - 0.5) * 2 * thw;
|
||||||
|
const rightTop = cx + (((lane + 1) / 5) - 0.5) * 2 * thw;
|
||||||
|
const selected = VEINS[lane] === this.selected;
|
||||||
|
const level = levels[lane] ?? 0;
|
||||||
|
ctx.fillStyle = STONE_FILL[lane] + (0.03 + level * 0.18 + (selected ? 0.03 : 0)) + ')';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(leftBottom, bottom);
|
||||||
|
ctx.lineTo(rightBottom, bottom);
|
||||||
|
ctx.lineTo(rightTop, horizon);
|
||||||
|
ctx.lineTo(leftTop, horizon);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
for (let step = 0; step <= LOOP_STEPS; step += 1) {
|
||||||
|
const point = this.roadPoint(step / LOOP_STEPS);
|
||||||
|
const barLine = step % STEPS_PER_BAR === 0;
|
||||||
|
ctx.strokeStyle = barLine
|
||||||
|
? `rgba(190,160,140,${0.18 + master * 0.12})`
|
||||||
|
: `rgba(155,133,121,${0.07 + master * 0.05})`;
|
||||||
|
ctx.lineWidth = barLine ? 1 : 0.65;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - point.half, point.y);
|
||||||
|
ctx.lineTo(cx + point.half, point.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.displayStep > 0) {
|
||||||
|
const z = this.displayStep / LOOP_STEPS;
|
||||||
|
const point = this.roadPoint(z);
|
||||||
|
ctx.fillStyle = `rgba(232,64,42,${0.04 + master * 0.08})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - point.half, point.y);
|
||||||
|
ctx.lineTo(cx + point.half, point.y);
|
||||||
|
ctx.lineTo(cx + thw, horizon);
|
||||||
|
ctx.lineTo(cx - thw, horizon);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let laneEdge = 0; laneEdge <= 5; laneEdge += 1) {
|
||||||
|
const fraction = laneEdge / 5;
|
||||||
|
const left = laneEdge === 0 || laneEdge === 5;
|
||||||
|
const laneA = Math.max(0, laneEdge - 1);
|
||||||
|
const laneB = Math.min(4, laneEdge);
|
||||||
|
const selected = VEINS[laneA] === this.selected || VEINS[laneB] === this.selected;
|
||||||
|
ctx.strokeStyle = left
|
||||||
|
? `rgba(232,64,42,${0.4 + master * 0.25})`
|
||||||
|
: selected
|
||||||
|
? `rgba(232,64,42,${0.24 + levels[laneB] * 0.22})`
|
||||||
|
: `rgba(190,160,140,${0.12 + Math.max(levels[laneA], levels[laneB]) * 0.14})`;
|
||||||
|
if (left) {
|
||||||
|
ctx.lineWidth = 5;
|
||||||
|
ctx.strokeStyle = `rgba(232,64,42,${0.05 + master * 0.12})`;
|
||||||
|
} else {
|
||||||
|
ctx.lineWidth = left ? 1.4 : 0.8;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + (fraction - 0.5) * 2 * bhw, bottom);
|
||||||
|
ctx.lineTo(cx + (fraction - 0.5) * 2 * thw, horizon);
|
||||||
|
ctx.stroke();
|
||||||
|
if (left) {
|
||||||
|
ctx.strokeStyle = `rgba(232,64,42,${0.32 + master * 0.3})`;
|
||||||
|
ctx.lineWidth = 1.1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + (fraction - 0.5) * 2 * bhw, bottom);
|
||||||
|
ctx.lineTo(cx + (fraction - 0.5) * 2 * thw, horizon);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = this.state?.pattern;
|
||||||
|
if (pattern) {
|
||||||
|
this.drawPattern(pattern, active, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const play = this.roadPoint(this.displayStep / LOOP_STEPS);
|
||||||
|
const playing = this.state?.transport.playing ?? false;
|
||||||
|
const playAlpha = playing ? 0.5 + master * 0.45 : 0.18;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
ctx.globalAlpha = 0.26 + master * 0.45;
|
||||||
|
const playGlow = Math.min(w, 560) * 0.24;
|
||||||
|
ctx.drawImage(this.glow, cx - playGlow / 2, play.y - playGlow * 0.4, playGlow, playGlow * 0.8);
|
||||||
|
ctx.restore();
|
||||||
|
ctx.strokeStyle = `rgba(239,228,212,${playAlpha})`;
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - play.half, play.y);
|
||||||
|
ctx.lineTo(cx + play.half, play.y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = `rgba(232,64,42,${0.18 + master * 0.24})`;
|
||||||
|
ctx.lineWidth = 0.65;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - play.half, play.y + 2);
|
||||||
|
ctx.lineTo(cx + play.half, play.y + 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
this.drawBarLabels(ctx, cx, bhw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawPattern(pattern: Pattern, active: number, now: number): void {
|
||||||
|
const { ctx } = this;
|
||||||
|
const cx = this.road.cx;
|
||||||
|
const playing = this.state?.transport.playing ?? false;
|
||||||
|
for (const row of pattern.rows) {
|
||||||
|
const lane = VEINS.indexOf(row.vein);
|
||||||
|
if (lane < 0) continue;
|
||||||
|
for (let step = 0; step < row.velocities.length; step += 1) {
|
||||||
|
const velocity = row.velocities[step];
|
||||||
|
if (!velocity) continue;
|
||||||
|
const z = (step + 0.5) / LOOP_STEPS;
|
||||||
|
const point = this.roadPoint(z);
|
||||||
|
const laneCenter = (lane + 0.5) / 5;
|
||||||
|
const x = cx + (laneCenter - 0.5) * 2 * point.half;
|
||||||
|
const isActive = playing && step === active;
|
||||||
|
const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6);
|
||||||
|
this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawStone(x: number, y: number, size: number, lane: number, velocity: number, active: boolean, z: number, now: number, step: number): void {
|
||||||
|
const { ctx } = this;
|
||||||
|
const glowSize = size * (active ? 10 : 6);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
ctx.globalAlpha = (0.06 + (velocity / 127) * 0.19) * (1 - z * 0.3) * (active ? 2.4 : 1);
|
||||||
|
ctx.drawImage(this.glow, x - glowSize / 2, y - glowSize / 2, glowSize, glowSize);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
const selected = VEINS[lane] === this.selected;
|
||||||
|
const jitter = (a: number, b: number) => {
|
||||||
|
const n = Math.sin(a * 12.9898 + b * 78.233 + lane * 37.719) * 43758.5453;
|
||||||
|
return n - Math.floor(n);
|
||||||
|
};
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.beginPath();
|
||||||
|
const points = 7;
|
||||||
|
for (let i = 0; i < points; i += 1) {
|
||||||
|
const angle = (i / points) * TAU + now * 0.000015 * (active ? 1 : 0.2);
|
||||||
|
const radius = size * (0.55 + jitter(step, i) * 0.45);
|
||||||
|
const px = Math.cos(angle) * radius;
|
||||||
|
const py = Math.sin(angle) * radius * 0.62;
|
||||||
|
if (i === 0) ctx.moveTo(px, py);
|
||||||
|
else ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = active
|
||||||
|
? 'rgba(239,228,212,0.92)'
|
||||||
|
: STONE_FILL[lane] + (0.36 + (velocity / 127) * 0.5) + ')';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = active
|
||||||
|
? 'rgba(232,64,42,0.95)'
|
||||||
|
: selected
|
||||||
|
? 'rgba(232,64,42,0.5)'
|
||||||
|
: 'rgba(239,228,212,0.12)';
|
||||||
|
ctx.lineWidth = active ? 1.1 : 0.6;
|
||||||
|
ctx.stroke();
|
||||||
|
if (active) {
|
||||||
|
ctx.strokeStyle = 'rgba(232,64,42,0.7)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, size * 1.7, 0, TAU);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBarLabels(ctx: CanvasRenderingContext2D, cx: number, bhw: number): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '10px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillStyle = 'rgba(190,160,140,0.78)';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
const first = this.roadPoint(0.0);
|
||||||
|
ctx.fillText('01', cx - bhw - 10, first.y - 2);
|
||||||
|
const second = this.roadPoint(0.5);
|
||||||
|
ctx.fillText('02', cx - second.half - 10, second.y - 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a += 0x6d2b79f5;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash2(x: number, y: number, seed: number): number {
|
||||||
|
let h = x * 374761393 + y * 668265263 + seed * 1442695041;
|
||||||
|
h = (h ^ (h >>> 13)) * 1274126177;
|
||||||
|
h = h ^ (h >>> 16);
|
||||||
|
return (h >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(t: number): number {
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function noise(x: number, y: number): number {
|
||||||
|
const xi = Math.floor(x);
|
||||||
|
const yi = Math.floor(y);
|
||||||
|
const xf = x - xi;
|
||||||
|
const yf = y - yi;
|
||||||
|
const u = smoothstep(xf);
|
||||||
|
const v = smoothstep(yf);
|
||||||
|
const a = hash2(xi, yi, 17);
|
||||||
|
const b = hash2(xi + 1, yi, 17);
|
||||||
|
const c = hash2(xi, yi + 1, 17);
|
||||||
|
const d = hash2(xi + 1, yi + 1, 17);
|
||||||
|
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fbm(x: number, y: number): number {
|
||||||
|
let value = 0;
|
||||||
|
let amplitude = 0.5;
|
||||||
|
let frequency = 1;
|
||||||
|
let total = 0;
|
||||||
|
for (let octave = 0; octave < 4; octave += 1) {
|
||||||
|
value += noise(x * frequency, y * frequency) * amplitude;
|
||||||
|
total += amplitude;
|
||||||
|
amplitude *= 0.5;
|
||||||
|
frequency *= 2.03;
|
||||||
|
}
|
||||||
|
return value / total;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Command } from './types';
|
||||||
|
|
||||||
|
const MINUS = '\u2212';
|
||||||
|
|
||||||
|
export function fmtDb(level: number): string {
|
||||||
|
if (!(level > 0.0001)) return MINUS + '\u221e';
|
||||||
|
const db = 20 * Math.log10(level);
|
||||||
|
return (db >= 0 ? '+' : MINUS) + Math.abs(db).toFixed(1) + ' dB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(bar: number, bpm: number): string {
|
||||||
|
const seconds = Math.max(0, bar - 1) * 4 * (60 / Math.max(40, bpm));
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const rest = seconds - minutes * 60;
|
||||||
|
return String(minutes).padStart(2, '0') + ':' + rest.toFixed(1).padStart(4, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtBar(bar: number): string {
|
||||||
|
return (Math.round(bar * 10) / 10).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTempo(bpm: number): string {
|
||||||
|
return Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandLabel(command: Command): string {
|
||||||
|
switch (command.type) {
|
||||||
|
case 'play': return 'play';
|
||||||
|
case 'stop': return 'stop';
|
||||||
|
case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`;
|
||||||
|
case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`;
|
||||||
|
case 'set_vein_gain': return `${command.vein} gain ${command.gain.toFixed(2)}`;
|
||||||
|
case 'get_snapshot': return 'snapshot';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventLabel(event: { kind: string }): string {
|
||||||
|
return event.kind.replace('_', ' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
|
||||||
|
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
|
||||||
|
export type Status = 'connecting' | 'live' | 'offline';
|
||||||
|
|
||||||
|
export interface AudioInfo {
|
||||||
|
running: boolean;
|
||||||
|
device: string;
|
||||||
|
sample_rate: number;
|
||||||
|
buffer_frames: number;
|
||||||
|
channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportState {
|
||||||
|
playing: boolean;
|
||||||
|
bpm: number;
|
||||||
|
bar: number;
|
||||||
|
loop_bars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VeinState {
|
||||||
|
id: VeinId;
|
||||||
|
name: string;
|
||||||
|
gain: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatternRow {
|
||||||
|
vein: VeinId;
|
||||||
|
velocities: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pattern {
|
||||||
|
steps: number;
|
||||||
|
steps_per_bar: number;
|
||||||
|
rows: PatternRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
revision: number;
|
||||||
|
transport: TransportState;
|
||||||
|
veins: VeinState[];
|
||||||
|
pattern: Pattern;
|
||||||
|
audio: AudioInfo;
|
||||||
|
journal_len: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Event =
|
||||||
|
| { kind: 'transport'; playing: boolean }
|
||||||
|
| { kind: 'tempo'; bpm: number }
|
||||||
|
| { kind: 'vein_gain'; vein: VeinId; gain: number }
|
||||||
|
| { kind: 'struck'; vein: VeinId; velocity: number };
|
||||||
|
|
||||||
|
export type Command =
|
||||||
|
| { type: 'get_snapshot' }
|
||||||
|
| { type: 'play' }
|
||||||
|
| { type: 'stop' }
|
||||||
|
| { type: 'strike'; vein: VeinId; velocity: number }
|
||||||
|
| { type: 'set_tempo'; bpm: number }
|
||||||
|
| { type: 'set_vein_gain'; vein: VeinId; gain: number };
|
||||||
|
|
||||||
|
export type CommandResult =
|
||||||
|
| { kind: 'snapshot'; state: State }
|
||||||
|
| { kind: 'state'; state: State };
|
||||||
|
|
||||||
|
export interface HelloMessage {
|
||||||
|
type: 'hello';
|
||||||
|
protocol: number;
|
||||||
|
session: string;
|
||||||
|
engine: AudioInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotMessage {
|
||||||
|
type: 'snapshot';
|
||||||
|
revision: number;
|
||||||
|
state: State;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventMessage {
|
||||||
|
type: 'event';
|
||||||
|
revision: number;
|
||||||
|
event: Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultMessage {
|
||||||
|
type: 'result';
|
||||||
|
id: number;
|
||||||
|
result: CommandResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
type: 'error';
|
||||||
|
id: number | null;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage;
|
||||||
|
|
||||||
|
export interface MeterFrame {
|
||||||
|
bar: number;
|
||||||
|
master: number;
|
||||||
|
veins: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VEINS: VeinId[] = ['bass', 'crack', 'voice', 'noise', 'silt'];
|
||||||
|
export const METER_LEN = 31;
|
||||||
|
export const PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
export function isVein(value: unknown): value is VeinId {
|
||||||
|
return typeof value === 'string' && (VEINS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WireDir = 'send' | 'recv' | 'info' | 'error';
|
||||||
|
export interface WireEntry {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
dir: WireDir;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '@fontsource/cormorant-garamond/300.css';
|
||||||
|
import '@fontsource/cormorant-garamond/300-italic.css';
|
||||||
|
import '@fontsource/cormorant-garamond/400.css';
|
||||||
|
import '@fontsource/cormorant-garamond/400-italic.css';
|
||||||
|
import '@fontsource/jetbrains-mono/300.css';
|
||||||
|
import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
import '@fontsource/jetbrains-mono/500.css';
|
||||||
|
import './app.css';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const target = document.getElementById('app');
|
||||||
|
if (!target) throw new Error('the surface has no mount point');
|
||||||
|
|
||||||
|
const app = mount(App, { target });
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_ZHILA_WS?: string;
|
||||||
|
readonly VITE_ZHILA_TOKEN?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
compilerOptions: {
|
||||||
|
runes: true
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"checkJs": false,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: { port: 5173, strictPort: true, host: '127.0.0.1' },
|
||||||
|
preview: { port: 5173, strictPort: true, host: '127.0.0.1' },
|
||||||
|
build: { target: 'es2022' }
|
||||||
|
});
|
||||||