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
+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;
}