P0: Rust engine, wire daemon and audio-reactive descent surface

This commit is contained in:
Emil
2026-11-01 00:36:04 +03:00
parent cd967cced4
commit db4368efae
68 changed files with 19507 additions and 3 deletions
+16
View File
@@ -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>
+1576
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+5
View File
@@ -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

+52
View File
@@ -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');
+212
View File
@@ -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>
+1237
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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>
+156
View File
@@ -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>
+87
View File
@@ -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>
+69
View File
@@ -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>
+204
View File
@@ -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);
}
}
+703
View File
@@ -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;
}
+39
View File
@@ -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('_', ' ');
}
+119
View File
@@ -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;
}
+17
View File
@@ -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;
+10
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
compilerOptions: {
runes: true
}
};
+17
View File
@@ -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"]
}
+9
View File
@@ -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' }
});