// 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 = new Uint8Array(0) private timeData: Uint8Array = 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 | 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 { 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 { 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 { 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 { 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 } }