P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -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>
|
||||
Generated
+1576
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
+1237
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
Vendored
+10
@@ -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' }
|
||||
});
|
||||
Reference in New Issue
Block a user