// AVA — render engine // // Owns the requestAnimationFrame loop: // audio bands -> beat tracker -> env -> [frame] -> [beat] -> [point]xN -> draw // // Drawing supports three modes (lines | dots | solid) with additive blending, // per-point colors, a two-pass glow, and a persistence fade for motion trails. import { AudioEngine } from './audio' import { BeatTracker } from './beat' import type { AvaScript } from './script' import type { BandConfig, BandLevels, BeatConfig, BeatFrame } from './types' import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from './types' const MAX_POINTS = 4096 const POINT_STRIDE = 8 // x, y, red, green, blue, skip, size, alpha export interface EngineStats { fps: number levels: BandLevels beat: BeatFrame source: string points: number scriptName: string error: string | null } export class AvaEngine { readonly audio = new AudioEngine() readonly beat = new BeatTracker({ ...DEFAULT_BEAT_CONFIG }) bandConfig: BandConfig = { ...DEFAULT_BAND_CONFIG } beatConfig: BeatConfig = { ...DEFAULT_BEAT_CONFIG } onStats: ((stats: EngineStats) => void) | null = null private canvas: HTMLCanvasElement | null = null private ctx: CanvasRenderingContext2D | null = null private resizeObserver: ResizeObserver | null = null private raf = 0 private running = false private script: AvaScript | null = null private scriptError: string | null = null private env: Record = {} private points = new Float32Array(MAX_POINTS * POINT_STRIDE) private lastNow = 0 private fpsEma = 60 private lastStatsAt = 0 private clockStart = 0 /** Attach to a canvas and start the loop. */ start(canvas: HTMLCanvasElement): void { this.stop() this.canvas = canvas const ctx = canvas.getContext('2d', { alpha: false }) if (!ctx) return this.ctx = ctx this.ctx.fillStyle = '#000' this.ctx.fillRect(0, 0, canvas.width, canvas.height) this.clockStart = performance.now() this.lastNow = this.clockStart this.running = true this.resizeObserver = new ResizeObserver(() => this.resize()) this.resizeObserver.observe(canvas.parentElement ?? canvas) this.resize() this.raf = requestAnimationFrame(this.loop) } stop(): void { this.running = false if (this.raf) cancelAnimationFrame(this.raf) this.raf = 0 if (this.resizeObserver) { this.resizeObserver.disconnect() this.resizeObserver = null } this.audio.stop() } /** Swap in a new script — re-runs [init] on a fresh environment. */ setScript(script: AvaScript): void { this.script = script this.scriptError = null this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0 } try { script.initProg?.exec(this.env) } catch (err) { this.scriptError = `[init] runtime error: ${String(err)}` } } clearScript(): void { this.script = null this.scriptError = null } getScriptName(): string { return this.script?.meta.name ?? '—' } private resize(): void { if (!this.canvas) return const parent = this.canvas.parentElement const dpr = Math.min(window.devicePixelRatio || 1, 2) const cssW = parent?.clientWidth ?? this.canvas.clientWidth const cssH = parent?.clientHeight ?? this.canvas.clientHeight const w = Math.max(2, Math.round(cssW * dpr)) const h = Math.max(2, Math.round(cssH * dpr)) if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w this.canvas.height = h if (this.ctx) { this.ctx.fillStyle = '#000' this.ctx.fillRect(0, 0, w, h) } } } private loop = (now: number): void => { if (!this.running) return this.raf = requestAnimationFrame(this.loop) if (!this.ctx || !this.canvas) return const dt = Math.min(Math.max((now - this.lastNow) / 1000, 0.0005), 0.1) this.lastNow = now this.fpsEma = this.fpsEma * 0.95 + (1 / dt) * 0.05 const w = this.canvas.width const h = this.canvas.height const ctx = this.ctx // ---- persistence fade ------------------------------------------------- const meta = this.script?.meta const persistBase = meta ? meta.persist : 0.85 const persist = Math.min(Math.max(this.env.persist ?? persistBase, 0), 0.985) ctx.globalCompositeOperation = 'source-over' ctx.fillStyle = `rgba(0,0,0,${(1 - persist).toFixed(4)})` ctx.fillRect(0, 0, w, h) // ---- audio + beat ----------------------------------------------------- const levels = this.audio.getBands(this.bandConfig, dt) const beatFrame = this.beat.update(levels.bassAtt, now, dt, this.beatConfig) // ---- environment ------------------------------------------------------ const env = this.env env.dt = dt env.time = now / 1000 - this.clockStart / 1000 env.frame = (env.frame ?? 0) + 1 env.bass = levels.bass env.mid = levels.mid env.treb = levels.treb env.bass_att = levels.bassAtt env.mid_att = levels.midAtt env.treb_att = levels.trebAtt env.vol = levels.vol env.vol_att = levels.volAtt env.beat = beatFrame.beat ? 1 : 0 env.bpm = beatFrame.bpm env.beatphase = beatFrame.phase env.beatconf = beatFrame.confidence env.beats = (env.beats ?? 0) + (beatFrame.beat ? 1 : 0) env.w = w env.h = h env.aspect = w / Math.max(1, h) env.persist = persistBase // reset so the script can override each frame if (this.script) { try { this.script.frameProg?.exec(env) if (beatFrame.beat) this.script.beatProg?.exec(env) } catch (err) { this.scriptError = `runtime error: ${String(err)}` } } // ---- point evaluation --------------------------------------------------- const pts = this.points let n = 0 if (this.script?.pointProg) { n = Math.min(MAX_POINTS, Math.max(4, Math.round(env.n ?? 400))) try { for (let k = 0; k < n; k++) { const i = k / (n - 1 || 1) env.i = i env.v = this.audio.getWave(i) env.sv = this.audio.getSpectrum(i) // reset output variables (custom variables persist, AVS-style) env.x = 0 env.y = 0 env.red = 1 env.green = 1 env.blue = 1 env.skip = 0 env.size = meta ? meta.linewidth : 2 env.alpha = 1 this.script.pointProg.exec(env) const o = k * POINT_STRIDE pts[o] = env.x pts[o + 1] = env.y pts[o + 2] = env.red pts[o + 3] = env.green pts[o + 4] = env.blue pts[o + 5] = env.skip > 0.5 ? 1 : 0 pts[o + 6] = env.size pts[o + 7] = env.alpha } this.scriptError = null } catch (err) { this.scriptError = `[point] runtime error: ${String(err)}` n = 0 } } // ---- draw --------------------------------------------------------------- const scale = h / 480 // keeps geometry consistent across resolutions const halfW = w / 2 const halfH = h / 2 if (n > 0 && meta) { ctx.globalCompositeOperation = meta.blend === 'additive' ? 'lighter' : 'source-over' if (meta.mode === 'lines') { ctx.lineCap = 'round' ctx.lineJoin = 'round' for (let pass = 0; pass < 2; pass++) { const lw = (pass === 0 ? 3.2 : 1) * meta.linewidth * scale const gain = pass === 0 ? 0.22 : 0.9 ctx.lineWidth = lw let pen = false let pr = 0, pg = 0, pb = 0, pa = 0, pxp = 0, pyp = 0 for (let k = 0; k < n; k++) { const o = k * POINT_STRIDE const skip = pts[o + 5] const x = (pts[o] * 0.5 + 0.5) * w const y = (0.5 - pts[o + 1] * 0.5) * h const r = Math.max(0, Math.min(1, pts[o + 2])) const g = Math.max(0, Math.min(1, pts[o + 3])) const b = Math.max(0, Math.min(1, pts[o + 4])) const a = Math.max(0, Math.min(1, pts[o + 7])) if (skip) { pen = false pr = r; pg = g; pb = b; pa = a; pxp = x; pyp = y continue } if (pen) { ctx.strokeStyle = `rgba(${(((pr + r) / 2) * 255) | 0},${(((pg + g) / 2) * 255) | 0},${(((pb + b) / 2) * 255) | 0},${(((pa + a) / 2) * gain).toFixed(3)})` ctx.beginPath() ctx.moveTo(pxp, pyp) ctx.lineTo(x, y) ctx.stroke() } pr = r; pg = g; pb = b; pa = a; pxp = x; pyp = y pen = true } } } else if (meta.mode === 'dots') { for (let pass = 0; pass < 2; pass++) { const sizeGain = pass === 0 ? 2.6 : 1 const alphaGain = pass === 0 ? 0.18 : 0.85 for (let k = 0; k < n; k++) { const o = k * POINT_STRIDE if (pts[o + 5]) continue const x = (pts[o] * 0.5 + 0.5) * w const y = (0.5 - pts[o + 1] * 0.5) * h const r = Math.max(0, Math.min(1, pts[o + 2])) const g = Math.max(0, Math.min(1, pts[o + 3])) const b = Math.max(0, Math.min(1, pts[o + 4])) const a = Math.max(0, Math.min(1, pts[o + 7])) const rad = Math.max(0.6, pts[o + 6] * scale * sizeGain) ctx.fillStyle = `rgba(${(r * 255) | 0},${(g * 255) | 0},${(b * 255) | 0},${(a * alphaGain).toFixed(3)})` ctx.beginPath() ctx.arc(x, y, rad, 0, 6.28318) ctx.fill() } } } else { // solid: filled polygon through all non-skipped points let sr = 0, sg = 0, sb = 0, sa = 0, count = 0 ctx.beginPath() let pen = false for (let k = 0; k < n; k++) { const o = k * POINT_STRIDE if (pts[o + 5]) { pen = false continue } const x = (pts[o] * 0.5 + 0.5) * w const y = (0.5 - pts[o + 1] * 0.5) * h if (!pen) { ctx.moveTo(x, y) pen = true } else { ctx.lineTo(x, y) } sr += pts[o + 2]; sg += pts[o + 3]; sb += pts[o + 4]; sa += pts[o + 7]; count++ } if (count > 2) { ctx.closePath() const r = Math.max(0, Math.min(1, sr / count)) const g = Math.max(0, Math.min(1, sg / count)) const b = Math.max(0, Math.min(1, sb / count)) const a = Math.max(0, Math.min(1, sa / count)) * 0.65 ctx.fillStyle = `rgba(${(r * 255) | 0},${(g * 255) | 0},${(b * 255) | 0},${a.toFixed(3)})` ctx.fill() } } } // ---- stats to UI (throttled) -------------------------------------------- if (this.onStats && now - this.lastStatsAt > 125) { this.lastStatsAt = now this.onStats({ fps: Math.round(this.fpsEma), levels: { ...levels }, beat: beatFrame, source: this.audio.kind, points: n, scriptName: this.getScriptName(), error: this.scriptError, }) } } } /** Singleton accessor — one engine per browser tab. */ let _engine: AvaEngine | null = null export function getEngine(): AvaEngine { if (!_engine) _engine = new AvaEngine() return _engine }