// 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 { AVA_HOOKS, resetEelBuffers } from './expression' 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 = 8192 const POINT_STRIDE = 8 // x, y, red, green, blue, skip, size, alpha /** Read a script bg* control value. Non-finite (NaN / Infinity from a bad * script computation) and missing values fall back to the neutral value -- * a NaN must never reach a canvas transform (it poisons the matrix and every * later draw becomes a silent no-op: a black screen with no error). */ function bgNum(v: number | undefined, neutral: number): number { const x = typeof v === 'number' ? v : neutral return Number.isFinite(x) ? x : neutral } 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 = {} /** +1 native (y up) / -1 AVS-ported presets (y down). Applied when reading points. */ private ysign = 1 /** v7.2 preset-change transition: snapshot of the outgoing frame — the * loop dissolves it into the new preset through a progressive blur so * preset swaps never hard-cut (no eye-jerking). Null when idle. */ private transition: { snap: HTMLCanvasElement; start: number; duration: number } | null = null /** ctx.filter support (older Safari lacks it → plain crossfade fallback). */ private canFilter = false /** v7.12 background-feedback buffer. Same pixel size as the live canvas; * recreated automatically on resize. Null until a script first asks for a * background transform (zero cost for every preset that doesn't). */ private bgBuffer: HTMLCanvasElement | null = null private bgBufCtx: CanvasRenderingContext2D | null = null constructor() { // AVS-eel compatibility hooks — the expression engine reads audio/time // through these (getspec/getosc/gettime). Headless tests can override. AVA_HOOKS.spectrumAt = (pos) => this.audio.getSpectrum(pos) AVA_HOOKS.waveAt = (pos) => this.audio.getWave(pos) AVA_HOOKS.now = () => this.env.time ?? 0 } 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.canFilter = typeof ctx.filter === 'string' this.transition = null 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 this.transition = null 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. * v7.2: when a script is already rendering on a live canvas, the * outgoing frame is snapshotted first so the loop can blur-dissolve it * into the new preset (preset changes never hard-cut). */ setScript(script: AvaScript): void { if (this.script && this.canvas && this.ctx && this.running) { try { const snap = document.createElement('canvas') snap.width = this.canvas.width snap.height = this.canvas.height snap.getContext('2d')?.drawImage(this.canvas, 0, 0) this.transition = { snap, start: performance.now(), duration: 700 } } catch { this.transition = null // canvas readback failed → hard cut, as before } } this.script = script this.scriptError = null this.ysign = script.meta.ysign === -1 ? -1 : 1 // beatage starts "long ago" so exp(-beatage*k) envelopes read as 0 until // the first real beat arrives. this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0, beatage: 99 } resetEelBuffers() 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 ?? '--' } /** * v7.12 — background feedback transform (the AVS movement family). * * The whole framebuffer is moved BEFORE this frame's points are drawn: * 1. snapshot the live canvas into the offscreen buffer; * 2. wipe the canvas black; * 3. draw the snapshot back through the frame's transform. * * bgrot radians per frame — + rotates the picture CLOCKWISE on screen * (canvas y is down, so ctx.rotate(+θ) spins cw); - = ccw. * bgx/bgy drift per frame in the -1..1 screen convention (y is UP, * matching script x/y) — slide up, down, left, right. * bgzoom per-frame scale — <1 draw-in (shrinks toward center), * >1 blow-out (expands outward); 1 = neutral. * * Uncovered regions (rotated corners, slid-off edges) read black, and the * persistence fade (persist header / env.persist) blends the trails — * which is exactly how the classic AVS plugins made "speed accelerates to * the beat, a hard hit flips the spin, fade to black stays slow" work: * the rate is just a script variable driven by beatage envelopes, and a * [beat] section flips the sign on a hard hit (bass vs bass_att). */ private applyBackground(w: number, h: number, rot: number, tx: number, ty: number, zoom: number): void { const canvas = this.canvas const ctx = this.ctx if (!canvas || !ctx) return if (!this.bgBuffer || this.bgBuffer.width !== w || this.bgBuffer.height !== h) { const buf = document.createElement('canvas') buf.width = w buf.height = h this.bgBuffer = buf this.bgBufCtx = buf.getContext('2d') } const bctx = this.bgBufCtx if (!bctx || !this.bgBuffer) return // 1. snapshot the live canvas ('copy' replaces the buffer wholesale) bctx.globalCompositeOperation = 'copy' bctx.drawImage(canvas, 0, 0) // 2. wipe — the transformed snapshot must REPLACE the old content, not // double-expose over it ctx.globalCompositeOperation = 'source-over' ctx.fillStyle = '#000' ctx.fillRect(0, 0, w, h) // 3. draw the snapshot back through this frame's transform. Screen y is // down; script y is up — bgy > 0 moves the picture UP. ctx.save() ctx.imageSmoothingEnabled = true ctx.translate(w / 2 + tx * (w / 2), h / 2 - ty * (h / 2)) if (rot !== 0) ctx.rotate(rot) if (zoom !== 1) ctx.scale(zoom, zoom) ctx.drawImage(this.bgBuffer, -w / 2, -h / 2) ctx.restore() } 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) // Beat detection (and the predictor grid) only runs while a source is // playing/loaded. With no audio, the tracker goes to standby: no beats // fire, the BPM resets to 0 and learned tempo is dropped — so nothing // pulses to ghost beats when the music is stopped (v6.4). // v7.1: the detector input is the ONSET (spectral-flux) envelope, not // the smoothed bass level — see lib/ava/beat.ts and lib/ava/types.ts. const beatFrame = this.audio.kind === 'idle' ? this.beat.standby() : this.beat.update(levels.flux, 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) // seconds since the last beat — 0 on the beat frame itself. Paired with // exp(-beatage * k) it is the classic kick envelope (no per-script // bookkeeping needed). env.beatage = beatFrame.beat ? 0 : (env.beatage ?? 99) + dt 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)}` } // ---- background feedback transform (v7.12) -------------------------- // AVS-style whole-framebuffer movement, read AFTER [frame]/[beat] ran // so this frame's values apply this frame. Set from [init] (persists // forever), [frame] or [beat] (every frame / on the kick). Values live // in env like any variable: a script that sets them only on beats gets // on-beat jumps. Neutral readings skip the pass entirely. const bgRot = Math.max(-0.6, Math.min(0.6, bgNum(env.bgrot, 0))) const bgX = Math.max(-0.25, Math.min(0.25, bgNum(env.bgx, 0))) const bgY = Math.max(-0.25, Math.min(0.25, bgNum(env.bgy, 0))) const bgZoom = Math.max(0.5, Math.min(2, bgNum(env.bgzoom, 1))) if (bgRot !== 0 || bgX !== 0 || bgY !== 0 || bgZoom !== 1) { this.applyBackground(w, h, bgRot, bgX, bgY, bgZoom) } } // ---- 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 * this.ysign 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 gain = pass === 0 ? 0.22 : 0.9 let pen = false let pr = 0, pg = 0, pb = 0, pa = 0, pxp = 0, pyp = 0, plw = 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])) // per-point line width (AVS linesize) — falls back to meta.linewidth const lw = Math.max(0.3, pts[o + 6] > 0 ? pts[o + 6] : meta.linewidth) * (pass === 0 ? 3.2 : 1) * scale if (skip) { pen = false pr = r; pg = g; pb = b; pa = a; pxp = x; pyp = y; plw = lw 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.lineWidth = (plw + lw) / 2 ctx.beginPath() ctx.moveTo(pxp, pyp) ctx.lineTo(x, y) ctx.stroke() } pr = r; pg = g; pb = b; pa = a; pxp = x; pyp = y; plw = lw 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() } } } // ---- preset-change blur transition (v7.2) ----------------------------- // Dissolve the outgoing snapshot into the freshly rendered frame: the // old image blurs progressively and fades out (easeOutCubic) while a // slight scale-up keeps its softening edges from opening a gap at the // canvas border. Covers every swap path — tree Load, random club mix, // the Demo showcase — since they all funnel through setScript(). if (this.transition) { const t = (now - this.transition.start) / this.transition.duration if (t >= 1) { this.transition = null } else { const e = 1 - (1 - t) * (1 - t) * (1 - t) ctx.save() ctx.globalCompositeOperation = 'source-over' ctx.globalAlpha = Math.max(0, 1 - e) if (this.canFilter) ctx.filter = `blur(${(e * e * 12 * (h / 480)).toFixed(1)}px)` const grow = 1 + 0.06 * e ctx.translate(w / 2, h / 2) ctx.scale(grow, grow) ctx.drawImage(this.transition.snap, -w / 2, -h / 2, w, h) ctx.restore() } } // ---- 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 }