openvisengine/src/lib/ava/engine.ts
2026-10-21 14:13:58 +00:00

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
}