733 lines
31 KiB
TypeScript
Executable file
733 lines
31 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.
|
|
//
|
|
// 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<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
|
|
|
|
/** 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
|
|
}
|