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