openvisengine/src/lib/ava/audio.ts
2026-11-04 16:14:36 +00:00

405 lines
No EOL
16 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// AVA — audio input + 3-band spectrum analysis + onset (flux) envelope
//
// Sources:
// demo — the built-in demo TRACKS (v7.10): a ten-track collection in
// public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename
// order 1→10). Each plays ONCE through; whoever tears down an
// active demo reports WHY via onDemoEnded('ended' | 'stop' |
// 'switch') so the UI can decide whether the visitor's place in
// the collection advances (natural end + Stop button do, a
// source switch keeps the place — audio-source.ts).
// mic — live microphone / line-in (club audio feed into an interface)
// file — drop any audio file (mp3/wav/…) and loop it
// link — remote stream (YouTube / SoundCloud / direct MP3 …) resolved via
// /api/resolve and proxied same-origin so the analyser can tap it.
// Single links loop; playlist-queue tracks play once and fire
// onTrackEnded so the queue advances lazily (one track fetched at a
// time — v6.7 anti-spam model).
//
// Band analysis: AnalyserNode FFT (8192 bins → ~5.4 Hz resolution at 44.1 kHz)
// averaged over the user-tunable Hz ranges, with fast-attack / slow-release
// smoothing for the *_att variables that drive visuals — plus the full-band
// spectral-flux (onset) envelope that feeds beat detection (v7.1).
import type { AudioSourceKind, BandConfig, BandLevels } from './types'
import { ZERO_LEVELS } from './types'
const FFT_SIZE = 8192
/**
* Top of the script-visible spectrum, in Hz (v7.10.1). The bar field
* (getspec()/sv) tops out HERE instead of at Nyquist (22/24 kHz): real
* music carries almost nothing above ~16 kHz, and every lossy source
* low-passes even lower (192 kbps MP3 ≈ 18.6 kHz, YouTube AAC ≈ 15.5 kHz;
* measured across the demo collection: 0.000% of total magnitude above
* 18.6 kHz). A full-range mapping therefore left the rightmost ~20% of
* every spectrum visual frozen at zero -- bars that never moved. 16 kHz
* matches the spectral-flux cap in getBands(), and puts the right edge on
* the 12-16 kHz "air" band where cymbals, hats and synth sparkle live.
* Mirrored by SPEC_TOP_HZ in rust-agent/src/spectrum.rs.
*/
const SPEC_TOP_HZ = 16000
const ATTACK_TAU = 0.035 // seconds
const RELEASE_TAU = 0.22 // seconds
/** The embedded demo track collection (public/demo-tracks/), in filename
* order 1→10. Only ONE buffer is decoded at a time (~50–60 MB of PCM per
* track — caching all ten would be half a gigabyte), so the cache is keyed
* to the track index: switching tracks drops the old PCM and decodes the
* new one (the HTTP fetch itself stays in the browser cache). */
export const DEMO_TRACK_COUNT = 10
const demoTrackUrl = (i: number) => `/demo-tracks/demo-${String(i + 1).padStart(2, '0')}.mp3`
/** Header-chip / footer label for a demo track (matches the operator's
* file naming, minus the redundant "OVE -" prefix). */
export const demoTrackTitle = (i: number) => `Demo Track ${i + 1}`
export class AudioEngine {
kind: AudioSourceKind = 'idle'
/** Human label for the active source (file name / stream title). */
sourceLabel: string | null = null
/**
* Fired when a non-looping link track plays to its very end. The playlist
* queue (lib/ava/queue.ts) hooks this to advance to the next entry —
* lazily, so each song is only fetched right before it plays.
*/
onTrackEnded: (() => void) | null = null
/**
* Fired exactly once when the single-pass demo track ends — however it
* ends. The REASON distinguishes the cases (v7.10):
* 'ended' — the buffer ran out (natural play-through)
* 'stop' — the visitor clicked the Stop button
* 'switch' — another source replaced the demo (mic/file/link/demo
* restart) — a teardown, not a "finished listening" event
* The UI hook (audio-source.ts) advances the visitor's place in the
* ten-track collection on 'ended' and 'stop', and keeps it on 'switch'.
*/
onDemoEnded: ((reason: 'ended' | 'stop' | 'switch') => void) | null = null
private ctx: AudioContext | null = null
private analyser: AnalyserNode | null = null
private freqData: Uint8Array<ArrayBuffer> = new Uint8Array(0)
private timeData: Uint8Array<ArrayBuffer> = new Uint8Array(0)
private micStream: MediaStream | null = null
private micSource: MediaStreamAudioSourceNode | null = null
private audioEl: HTMLAudioElement | null = null
private mediaSource: MediaElementAudioSourceNode | null = null
private objectUrl: string | null = null
private demoSource: AudioBufferSourceNode | null = null
private demoBus: GainNode | null = null
/** Decoded demo track, cached so re-playing the SAME track is instant
* (keyed by index — v7.10). */
private demoBuffer: AudioBuffer | null = null
private demoBufferIndex = -1
/** Previous frame's frequency bytes — the flux (onset) envelope is the
* mean positive frame-to-frame change across the spectrum. Null after a
* stop/source switch so the first active frame seeds instead of spiking. */
private prevFreqData: Uint8Array<ArrayBuffer> | null = null
private levels: BandLevels = { ...ZERO_LEVELS }
get sampleRate(): number {
return this.ctx?.sampleRate ?? 48000
}
get isRunning(): boolean {
return this.kind !== 'idle'
}
/** Playback position of a link stream, in seconds (null when not streaming
* or not yet known). Used by the queue for its just-in-time look-ahead. */
get positionSec(): number | null {
const el = this.audioEl
if (!el || this.kind !== 'link') return null
return Number.isFinite(el.currentTime) ? el.currentTime : null
}
/** Total duration of a link stream, in seconds (null = unknown / live). */
get durationSec(): number | null {
const el = this.audioEl
if (!el || this.kind !== 'link') return null
const d = el.duration
return Number.isFinite(d) && d > 0 ? d : null
}
private ensureCtx(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = FFT_SIZE
this.analyser.smoothingTimeConstant = 0
this.freqData = new Uint8Array(this.analyser.frequencyBinCount)
this.timeData = new Uint8Array(this.analyser.fftSize)
}
return this.ctx
}
private stopSources(reason: 'ended' | 'stop' | 'switch' = 'switch'): void {
const wasDemo = this.kind === 'demo' // the demo retires along with its nodes
if (this.demoSource) {
try {
this.demoSource.stop()
this.demoSource.disconnect()
} catch {
/* already stopped */
}
this.demoSource = null
}
if (this.demoBus) {
try { this.demoBus.disconnect() } catch { /* noop */ }
this.demoBus = null
}
if (this.micSource) {
try { this.micSource.disconnect() } catch { /* noop */ }
this.micSource = null
}
if (this.micStream) {
this.micStream.getTracks().forEach((t) => t.stop())
this.micStream = null
}
if (this.audioEl) {
this.audioEl.pause()
this.audioEl = null
}
if (this.mediaSource) {
try { this.mediaSource.disconnect() } catch { /* noop */ }
this.mediaSource = null
}
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
if (this.analyser) {
try { this.analyser.disconnect() } catch { /* noop */ }
}
this.kind = 'idle'
this.sourceLabel = null
this.levels = { ...ZERO_LEVELS }
this.prevFreqData = null // next source seeds the flux baseline cleanly
// v7.3/v7.10: whoever tears down an ACTIVE demo (Stop button, source
// switch, natural end via the 'ended' listener below) reports it exactly
// once, WITH the reason — the UI unwinds the showcase state on this
// signal and advances the collection place when the pass truly ended.
if (wasDemo && this.onDemoEnded) {
const cb = this.onDemoEnded
this.onDemoEnded = null
cb(reason)
}
}
/**
* Built-in demo track — one of the ten mastered recordings in
* public/demo-tracks/ (v7.10), chosen by the caller (audio-source.ts
* tracks the visitor's place in the collection and passes the index).
* ONE play-through: when the buffer runs out the engine goes idle and
* onDemoEnded fires with the teardown reason. The decoded buffer is
* cached per index so re-playing the same track is instant.
*/
async startDemo(index = 0): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
const track = ((index % DEMO_TRACK_COUNT) + DEMO_TRACK_COUNT) % DEMO_TRACK_COUNT
if (this.demoBufferIndex !== track || !this.demoBuffer) {
const res = await fetch(demoTrackUrl(track))
if (!res.ok) throw new Error(`demo track fetch failed (${res.status})`)
const bytes = await res.arrayBuffer()
this.demoBuffer = await ctx.decodeAudioData(bytes)
this.demoBufferIndex = track
}
this.stopSources() // 'switch' -- a demo restart is a teardown, not an end
this.kind = 'demo'
this.sourceLabel = demoTrackTitle(track)
this.demoBus = ctx.createGain()
this.demoBus.gain.value = 0.9
this.demoBus.connect(this.analyser!)
this.analyser!.connect(ctx.destination) // demo is audible
const src = ctx.createBufferSource()
src.buffer = this.demoBuffer
// v7.3: single pass — no loop. When the buffer runs out the source fires
// 'ended' and the engine retires to idle (stopSources → onDemoEnded),
// which the UI uses to unwind the showcase and re-show the landing
// overlay. The stale-event guard ensures only the CURRENT demo source
// can retire the engine (a replaced source's late 'ended' is ignored).
src.addEventListener('ended', () => {
if (this.demoSource !== src) return
this.stopSources('ended')
})
src.connect(this.demoBus)
src.start()
this.demoSource = src
}
/** Live microphone / line-in. NEVER routed to the speakers (no feedback). */
async startMic(): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
})
this.stopSources()
this.kind = 'mic'
this.sourceLabel = null
this.micStream = stream
this.micSource = ctx.createMediaStreamSource(stream)
this.micSource.connect(this.analyser!) // analysis only -- no destination
}
/** Audio file playback (loops). The file audio is audible. */
async startFile(file: File): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
this.stopSources()
this.kind = 'file'
this.sourceLabel = file.name
this.objectUrl = URL.createObjectURL(file)
this.audioEl = new Audio()
this.audioEl.src = this.objectUrl
this.audioEl.loop = true
this.mediaSource = ctx.createMediaElementSource(this.audioEl)
this.mediaSource.connect(this.analyser!)
this.analyser!.connect(ctx.destination)
await this.audioEl.play()
}
/**
* Remote stream playback (YouTube / SoundCloud / direct MP3 …).
* The URL comes from POST /api/resolve — a same-origin proxy route — so the
* MediaElementSource is NOT cross-origin-tainted and the analyser can read
* it. Single links loop by default (seeking back to 0 is a cheap Range
* request through the proxy); the playlist queue passes { loop: false } so
* the element fires 'ended' and onTrackEnded advances the queue.
*/
async startUrl(streamUrl: string, title: string, opts?: { loop?: boolean }): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
this.stopSources()
this.kind = 'link'
this.sourceLabel = title
const loop = opts?.loop ?? true
const el = new Audio()
el.preload = 'auto'
el.src = streamUrl
el.loop = loop
this.audioEl = el
if (!loop) {
el.addEventListener(
'ended',
() => {
// only the CURRENT element may signal — a stale element that was
// already replaced must not advance the queue
if (this.audioEl === el) this.onTrackEnded?.()
},
{ once: true },
)
}
this.mediaSource = ctx.createMediaElementSource(el)
this.mediaSource.connect(this.analyser!)
this.analyser!.connect(ctx.destination)
await el.play()
}
stop(): void {
this.stopSources('stop')
}
/**
* Compute band levels for this frame.
* @param cfg band configuration (Hz ranges + gains)
* @param dtSec frame delta for attack/release smoothing
*/
getBands(cfg: BandConfig, dtSec: number): BandLevels {
if (!this.analyser || this.kind === 'idle' || this.ctx === null) {
// decay smoothly to silence
const decay = Math.exp(-dtSec / RELEASE_TAU)
this.levels.bassAtt *= decay
this.levels.midAtt *= decay
this.levels.trebAtt *= decay
this.levels.volAtt *= decay
this.levels.bass = 0
this.levels.mid = 0
this.levels.treb = 0
this.levels.vol = 0
this.levels.flux = 0
return this.levels
}
this.analyser.getByteFrequencyData(this.freqData)
this.analyser.getByteTimeDomainData(this.timeData)
const binHz = this.ctx.sampleRate / 2 / this.freqData.length
const levelOf = (lowHz: number, highHz: number): number => {
const lo = Math.max(1, Math.floor(lowHz / binHz))
const hi = Math.min(this.freqData.length - 1, Math.ceil(highHz / binHz))
if (hi <= lo) return 0
let sum = 0
for (let k = lo; k <= hi; k++) sum += this.freqData[k]
return sum / (hi - lo + 1) / 255
}
// Onset (spectral-flux) envelope: mean positive bin-to-bin change up to
// 16 kHz. This is what beat detection runs on — attacks, not levels.
let flux = 0
const fluxHi = Math.min(this.freqData.length - 1, Math.ceil(16000 / binHz))
const prev = this.prevFreqData
if (prev && prev.length === this.freqData.length) {
let sum = 0
for (let k = 1; k <= fluxHi; k++) {
const d = this.freqData[k] - prev[k]
if (d > 0) sum += d
}
flux = sum / fluxHi / 255
prev.set(this.freqData)
} else {
// first active frame (or analyser re-created): seed the baseline so
// the next frame's diff is measured from real data — no phantom onset
this.prevFreqData = new Uint8Array(this.freqData)
}
const bass = levelOf(cfg.bassLow, cfg.bassHigh) * cfg.bassGain
const mid = levelOf(cfg.midLow, cfg.midHigh) * cfg.midGain
const treb = levelOf(cfg.highLow, cfg.highHigh) * cfg.highGain
const smooth = (target: number, current: number): number => {
const tau = target > current ? ATTACK_TAU : RELEASE_TAU
return current + (target - current) * (1 - Math.exp(-dtSec / tau))
}
this.levels.bass = bass
this.levels.mid = mid
this.levels.treb = treb
this.levels.vol = (bass + mid + treb) / 3
this.levels.bassAtt = smooth(bass, this.levels.bassAtt)
this.levels.midAtt = smooth(mid, this.levels.midAtt)
this.levels.trebAtt = smooth(treb, this.levels.trebAtt)
this.levels.volAtt = smooth(this.levels.vol, this.levels.volAtt)
this.levels.flux = flux
return this.levels
}
/** Waveform sample at position 0..1 → -1..1 (most recent 2048-sample window). */
getWave(pos: number): number {
if (this.timeData.length === 0 || this.kind === 'idle') return 0
const start = this.timeData.length - 2048
const idx = start + Math.min(2047, Math.max(0, Math.floor(pos * 2047)))
return (this.timeData[idx] - 128) / 128
}
/**
* Spectrum magnitude at position 0..1 → 0..1 (log-ish frequency mapping).
* The field spans bin 2 (~12 Hz) to the SPEC_TOP_HZ bin (~16 kHz), so
* every position lands on frequencies music actually occupies -- see
* SPEC_TOP_HZ above for why it must not stretch to Nyquist.
*/
getSpectrum(pos: number): number {
if (this.freqData.length === 0 || this.kind === 'idle') return 0
const p = Math.min(1, Math.max(0, pos))
const binHz = this.sampleRate / 2 / this.freqData.length
const hiBin = Math.max(3, Math.min(this.freqData.length - 1, Math.ceil(SPEC_TOP_HZ / binHz)))
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (hiBin - 2))
return this.freqData[bin] / 255
}
}