// AVA — render engine // // Owns the requestAnimationFrame loop: // audio bands -> beat tracker -> env -> [frame] -> [beat] -> [point]xN -> draw // // Drawing supports three render modes (lines | dots | solid). The blend // header (v7.15) selects from the 41-mode registry in blend.ts: native // keywords composite straight onto the live canvas, the rest run through // the WebGL compositor (blend-gpu.ts) after a scratch pass. env.blend can // override the header per frame (reset every frame, like persist). // // The framebuffer is alpha-capable (v7.15) -- behind and clear need real // transparency. The persistence fade is therefore an alpha ERASE // (destination-out) and the bg* wipe a clearRect; over the black stage // both read pixel-identically to the old opaque fills, so existing // presets render exactly as before. persist 1 skips the fade entirely // (v7.14.1: a true zero-fade framebuffer). // // v7.17: the trueblack: header. The fade above multiplies the framebuffer // by persist in 8-bit steps, so any persist >= 0.5 leaves fixed points // (round(v*p) == v for the smallest values -- e.g. at 0.88 everything from // 1..4 is stuck) and long trails settle into a gray wash that never fully // fades; scripts had to keep persist < 0.5, which kills the trail memory a // bg* blow-out needs to be readable. No multiply-only compositing can fix // that in 8-bit (a value of 1 survives every multiplier >= 0.5), so the // flag adds a SUBTRACTIVE tail right after the erase: one fullscreen // 'difference' fill of rgb(1,1,1), an exact -1/255 per pixel per frame. // Bright pixels never notice it (the 0.93 multiply halves them every ~9 // frames anyway); the stuck quantum values are swept to a true 0. Costs // one extra fullscreen fill, only when the flag is on. The tail forces // every pixel opaque (alpha composites to 1 under 'difference'), so it // must not be combined with 'behind'/'clear' art -- opt-in, off by // default, and the Help panel documents the caveat. // // v7.19: trueblack is AUTOMATIC. The subtractive tail now runs by default // in exactly the regime where the multiply sticks (persist >= 0.5), so // every trail -- not just scripts that know the header -- ends in pure // black. The default path is the WebGL compositor's mode-22 sweep // (blend-gpu.ts): premultiplied rgb AND alpha each lose exactly 1/255, // clamped at 0. That is exact in 8-bit (black stays a true 0 -- no // round-trip oscillation like the 'difference' fill's 0<->1 flicker) and // alpha-PRESERVING, so 'behind'/'clear' art keeps its transparent holes // (which heal to truly empty, too -- stuck alpha quanta are swept like // everything else). Because the GPU sweep is safe for alpha art, it runs // unconditionally. The v7.17 Canvas2D 'difference' fill survives only as // the no-WebGL fallback, and there it stands down once the script has // been seen drawing with 'behind'/'clear' (the alphaArt latch; the header // blend pre-latches it, a per-frame env.blend switch latches at draw // time). The header itself is now a tri-state: auto (default -- sweep in // the sticky zone only), on (always, the v7.17 behavior) and off (never, // for authors who want the wash). Scripts at persist < 0.5 pay nothing: // their multiply already decays every quantum value, so the sweep is // skipped entirely. import { blendByIndex, blendIndex, findBlend } from './blend' import { BlendCompositor } from './blend-gpu' 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, RenderMode } 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 } /** v7.15 stroke-pass gains. GAINS_STD is every mode's default two-pass * glow tuning (unchanged since the original engine). GAINS_REPLACE is the * v7.14.1 replace tuning: the CORE pass at FULL alpha, so the stroke owns * its pixels outright (halo slightly stronger: it converges toward the * source color, never past it). */ interface DrawGains { halo: number core: number dotsGlow: number dotsCore: number solid: number } const GAINS_STD: DrawGains = { halo: 0.22, core: 0.9, dotsGlow: 0.18, dotsCore: 0.85, solid: 0.65 } const GAINS_REPLACE: DrawGains = { halo: 0.25, core: 1, dotsGlow: 0.25, dotsCore: 1, solid: 1 } 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 /** v7.15 scratch for gpu blend modes: the two stroke passes draw here * (source-over), then the WebGL compositor blends it onto the live * canvas in one per-pixel pass. Same lazy lifecycle as bgBuffer. */ private blendBuffer: HTMLCanvasElement | null = null private blendBufCtx: CanvasRenderingContext2D | null = null private compositor: BlendCompositor | null = null /** latched when WebGL proves unavailable -- gpu modes fall back to their * nearest native operator (spec.fallbackOp). */ private gpuBroken = false /** v7.19 — latched while the current script draws alpha-dependent art * ('behind' / 'clear', from the header blend or a per-frame env.blend * switch). The GPU trueblack sweep preserves alpha and always runs; the * Canvas2D 'difference' fallback forces pixels opaque, so it stands * down for these scripts instead of punching their holes shut. */ private alphaArt = false 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 // v7.15: alpha-capable framebuffer (default { alpha: true }) -- behind // and clear need real transparency. The stage behind the canvas is // bg-black, so a transparent buffer displays identically to the old // opaque black one; it starts clean (no fill). const ctx = canvas.getContext('2d') if (!ctx) return this.ctx = ctx this.canFilter = typeof ctx.filter === 'string' this.transition = null 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 // v7.19: pre-latch the alpha-art guard when the header blend itself is // behind/clear (a per-frame env.blend switch latches at draw time) -- // the Canvas2D fallback sweep must not punch those holes shut. const headerSpec = findBlend(script.meta.blend) this.alphaArt = headerSpec?.key === 'behind' || headerSpec?.key === 'clear' // 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 this.alphaArt = false } 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. v7.15: clearRect (transparent) instead of a // black fill — the framebuffer is alpha-capable now, and pixels with // real transparency (behind / clear art) must keep it through the // move. Over the black stage both read identically. ctx.globalCompositeOperation = 'source-over' ctx.clearRect(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() } /** v7.15 — lazy scratch canvas for the gpu blend path (mirrors bgBuffer). */ private scratchBuffer(w: number, h: number): boolean { if (!this.blendBuffer || this.blendBuffer.width !== w || this.blendBuffer.height !== h) { const buf = document.createElement('canvas') buf.width = w buf.height = h this.blendBuffer = buf this.blendBufCtx = buf.getContext('2d') } return this.blendBufCtx != null } /** v7.15 — lazy WebGL compositor; latches gpuBroken when unavailable. */ private gpuEnsure(w: number, h: number): boolean { if (this.gpuBroken) return false if (!this.compositor) this.compositor = new BlendCompositor() if (this.compositor.failed || !this.compositor.ensure(w, h)) { this.gpuBroken = true return false } return true } /** v7.19 — run the trueblack decay sweep through the WebGL compositor * (mode 22: premultiplied rgb and alpha each lose exactly 1/255, * clamped at 0 -- see the file header). Exact in 8-bit and * alpha-preserving, which is why the automatic sweep prefers it over * the Canvas2D 'difference' tail. False when WebGL is unavailable or * the pass throws (the caller then falls back, or stands down for * alpha-dependent art). */ private sweepViaGpu(w: number, h: number): boolean { if (this.gpuBroken || !this.gpuEnsure(w, h)) return false const compositor = this.compositor const canvas = this.canvas const ctx = this.ctx if (!compositor || !canvas || !ctx) return false try { compositor.sweep(canvas) ctx.globalCompositeOperation = 'copy' ctx.drawImage(compositor.canvas, 0, 0) ctx.globalCompositeOperation = 'source-over' return true } catch { this.gpuBroken = true // one bad frame never repeats; fallback next frame return false } } /** v7.15 — one place for the stroke/dot/polygon passes, target-agnostic: * the native path draws onto the live canvas with the mode's composite * op; the gpu path draws onto the scratch with source-over (the shader * then blends the whole layer in one pass). */ private drawContent( ctx: CanvasRenderingContext2D, n: number, meta: { mode: RenderMode; linewidth: number }, g: DrawGains, ): void { const canvas = this.canvas if (!canvas) return const w = canvas.width const h = canvas.height const pts = this.points const scale = h / 480 // keeps geometry consistent across resolutions if (meta.mode === 'lines') { ctx.lineCap = 'round' ctx.lineJoin = 'round' for (let pass = 0; pass < 2; pass++) { const gain = pass === 0 ? g.halo : g.core 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 gg = 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 = gg; pb = b; pa = a; pxp = x; pyp = y; plw = lw continue } if (pen) { ctx.strokeStyle = `rgba(${(((pr + r) / 2) * 255) | 0},${(((pg + gg) / 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 = gg; 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 ? g.dotsGlow : g.dotsCore 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 gg = 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},${(gg * 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 gg = 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)) * g.solid ctx.fillStyle = `rgba(${(r * 255) | 0},${(gg * 255) | 0},${(b * 255) | 0},${a.toFixed(3)})` ctx.fill() } } } 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) { // assigning width/height also clears to transparent (v7.15: no // opaque black fill -- behind/clear need the alpha channel) this.canvas.width = w this.canvas.height = 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 ------------------------------------------------- // v7.14.1: persist may reach 1.0 -- at exactly 1 the fade is skipped // entirely: old content leaves the screen only through a bg* transform // carrying it off (or by being repainted, blend 'replace'). That is // what makes "colors ride outward forever while the fresh drawing // replaces the center" possible. // v7.15: the fade is an alpha ERASE (destination-out) instead of a // black fill -- the framebuffer is alpha-capable, and scaling alpha by // persist thins trails exactly like the old rgb scaling did over the // black stage, while pixels that were painted behind / cleared keep // fading honestly. // v7.19: trueblack is AUTOMATIC (see the file header). The subtractive // tail runs right after the erase whenever the multiply can stick // (persist >= 0.5; below that every quantum value already decays), so // trails always end in pure black -- Texe-rings at 0.78, blow-outs at // 0.93, anything. Preferred path: the GPU sweep (exact, alpha-safe); // the 'difference' fill is only the no-WebGL fallback and it stands // down for behind/clear art (alphaArt). trueblack: on forces the tail // at any persist < 1 (v7.17 behavior), off disables it entirely. // persist 1 keeps its true zero-fade (permanent) semantics -- neither // the erase nor the tail runs there. const meta = this.script?.meta const persistBase = meta ? meta.persist : 0.85 const persist = Math.min(Math.max(this.env.persist ?? persistBase, 0), 1) if (persist < 1) { ctx.globalCompositeOperation = 'destination-out' ctx.fillStyle = `rgba(0,0,0,${(1 - persist).toFixed(4)})` ctx.fillRect(0, 0, w, h) ctx.globalCompositeOperation = 'source-over' const tb = meta?.trueblack ?? 'auto' const wantSweep = tb === 'on' ? true : tb === 'off' ? false : persist >= 0.5 if (wantSweep && !this.sweepViaGpu(w, h) && !this.alphaArt) { ctx.globalCompositeOperation = 'difference' ctx.fillStyle = '#010101' ctx.fillRect(0, 0, w, h) ctx.globalCompositeOperation = 'source-over' } } ctx.globalCompositeOperation = 'source-over' // ---- 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. // v7.15.1: the overall level rides along (5th arg) so the tracker can // place its SILENCE HOLD — a source that is loaded but quiet (pause, // track gap, stream stall) freezes the grid instead of faking beats; // decisive audio releases the hold and the grid resumes from the // frozen phase. Band/waveform data above is untouched by the hold. const beatFrame = this.audio.kind === 'idle' ? this.beat.standby() : this.beat.update(levels.flux, now, dt, this.beatConfig, levels.vol) // ---- 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 // v7.15.1: 1 while the silence hold is active — no beats fire and // beatphase is frozen, so scripts can dim their beat reactions (the // waveform / band data still flows; it is just near zero). env.silent = beatFrame.silent ? 1 : 0 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 // v7.15: same per-frame contract for the blend mode -- reset to the // header mode's registry index so [frame]/[beat] can switch composites // for THIS frame (the expression language is numeric-only, so the // variable carries the mode's 1-based number; the Help table lists // them). Read after the sections, before drawing; out-of-range values // fall back to the header keyword. env.blend = meta ? blendIndex(meta.blend) || 2 : (env.blend ?? 2) 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 --------------------------------------------------------------- if (n > 0 && meta) { // v7.15 — the 41-mode registry (blend.ts). The blend variable (reset // to the header mode's index every frame, so a script can switch per // frame; the Help table lists each mode's number) picks the composite; // an out-of-range value falls back to the header keyword. Native // keywords composite straight onto the live canvas; gpu keywords draw // the passes onto a scratch canvas and the WebGL compositor applies // the formula (nearest native op when WebGL is unavailable). // 'replace' keeps its v7.14.1 semantics: the core pass at FULL alpha, // so the stroke owns its pixels outright. const headerSpec = findBlend(meta.blend) const spec = blendByIndex(env.blend ?? 0) ?? headerSpec ?? undefined // v7.19: remember alpha-dependent art for the Canvas2D fallback // sweep's stand-down (the GPU sweep is alpha-safe and keeps running). if (spec && (spec.key === 'behind' || spec.key === 'clear')) this.alphaArt = true if (spec) { const viaGpu = spec.impl === 'gpu' && !this.gpuBroken && this.scratchBuffer(w, h) && this.gpuEnsure(w, h) if (viaGpu) { const sctx = this.blendBufCtx! sctx.globalCompositeOperation = 'source-over' sctx.clearRect(0, 0, w, h) this.drawContent(sctx, n, meta, GAINS_STD) try { this.compositor!.blend(this.canvas!, this.blendBuffer!, spec.gpuId ?? 0, env.frame ?? 0) // replace the live canvas with the blended result wholesale ctx.globalCompositeOperation = 'copy' ctx.drawImage(this.compositor!.canvas, 0, 0) ctx.globalCompositeOperation = 'source-over' } catch { this.gpuBroken = true // one bad frame never repeats; fallback next frame } } else { const op: GlobalCompositeOperation = spec.impl === 'gpu' ? spec.fallbackOp : spec.op ?? 'source-over' ctx.globalCompositeOperation = op this.drawContent(ctx, n, meta, spec.fullAlphaCore ? GAINS_REPLACE : GAINS_STD) ctx.globalCompositeOperation = 'source-over' } } } // ---- 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 }