openvisengine/src/lib/ava/engine.ts

661 lines
27 KiB
TypeScript
Executable file

// 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.
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<string, number> = {}
/** +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
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
// 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. 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.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.17: trueblack: on adds the subtractive tail (see the file header)
// -- the 'difference' fill runs INSIDE this branch so persist 1 keeps
// its true zero-fade (permanent) semantics.
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)
if (meta?.trueblack) {
ctx.globalCompositeOperation = 'difference'
ctx.fillStyle = '#010101'
ctx.fillRect(0, 0, w, h)
}
}
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
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
}