405 lines
No EOL
16 KiB
TypeScript
Executable file
405 lines
No EOL
16 KiB
TypeScript
Executable file
// 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
|
||
}
|
||
} |