Files
djAudiovisualizaerPi/public/js/engine/audio.js
T
lucianoandClaude Opus 4.8 b94c787231 DJ Visualizer Pi: standalone Node+WebSocket server with mobile PWA remote
New separate project (the Electron Mac app stays untouched). Reuses the visual
engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron
IPC with a Node HTTP+WebSocket server:

- server.js: serves the kiosk output page + mobile control PWA, relays the same
  ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload,
  JSON state (pads), waveform peaks and MP4 recording (ffmpeg).
- public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact.
- public/index.html + control.js: touch-first mobile remote (effects, media/queue,
  ticker, pads, audio/EQ, fullscreen + recording).
- PWA (manifest + service worker + generated icons).
- scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5).

Verified: REST endpoints, WebSocket relay both directions, control PWA loads the
2958-effect catalog and renders. (Headless WebGL output not testable without GPU.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:06:15 +02:00

327 lines
13 KiB
JavaScript

// Audio engine: builds a Web Audio graph from either a loaded file or a live
// input device, runs an FFT analyser, and exposes smoothed bass/mid/treble/
// level values plus a simple beat detector.
class AudioEngine {
constructor() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.78;
this.freq = new Uint8Array(this.analyser.frequencyBinCount);
// Output gain so a loaded file is audible; live input stays muted to
// avoid feedback (it's already playing through the system).
this.outGain = this.ctx.createGain();
this.outGain.gain.value = 1.0;
this.outGain.connect(this.ctx.destination);
// Tap for recording: every source also feeds this destination so the
// recorder captures file playback and live input alike.
this.recordDest = this.ctx.createMediaStreamDestination();
// Silent keep-alive so the audio track always produces frames (otherwise,
// with no source connected, the recorder stalls with empty data).
try {
const keep = this.ctx.createConstantSource();
keep.offset.value = 0;
keep.connect(this.recordDest);
keep.start();
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
this.sourceNode = null; // current MediaElement / MediaStream source
this.sourceGain = null; // per-source gain (for crossfades)
this.mediaEl = null; // <audio> element when playing a file
this.stream = null; // MediaStream when using live input
this.mode = 'none';
this.onEnded = null; // callback fired when a (non-looping) track ends
this.trimStart = 0; // playback start point (s); seek here on load
this.trimEnd = 0; // playback end point (s); 0 = play to the natural end
this._trimFired = false;
this._videoSrc = null; // persistent source for the playlist video element
this._videoGain = null;
this._videoSrcEl = null;
// Smoothed band values and beat state.
this.gain = 1.0; // master reactivity multiplier
this.bassGain = 1.0; // per-band visual intensity
this.midGain = 1.0;
this.trebleGain = 1.0;
this.bass = 0; this.mid = 0; this.treble = 0; this.level = 0;
this.beat = 0;
this._bassAvg = 0; // running average for beat detection
this._beatCooldown = 0;
// Log-spaced spectrum for VU/bar visualisers.
this.NB = 32;
this.spectrum = new Float32Array(this.NB);
// Time-domain waveform (oscilloscope), -1..1.
this.NW = 256;
this.wave = new Float32Array(this.NW);
this.timeData = new Uint8Array(this.analyser.fftSize);
// Scrolling amplitude history (song-style waveform envelope).
this.NH = 256;
this.waveHist = new Float32Array(this.NH);
this.histPeak = 0;
this.histAccum = 0;
this.PUSH_EVERY = 2.4; // frames between samples at scrollRate 1 (~40ms)
this.scrollRate = 1; // tied to the visual speed slider
}
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
// Trim the current/next track to [start, end] seconds (end 0 = natural end).
setTrim(start, end) {
this.trimStart = Math.max(0, start || 0);
this.trimEnd = Math.max(0, end || 0);
this._trimFired = false;
}
// Seek a freshly-loaded media element to the trim start (waits for metadata).
seekToTrimStart(el) {
const go = () => { if (this.trimStart > 0) { try { el.currentTime = this.trimStart; } catch (e) {} } };
if (el.readyState >= 1) go(); else el.addEventListener('loadedmetadata', go, { once: true });
}
// Called every frame from the output loop: stop at the trim end point.
checkTrim() {
const el = this.mediaEl;
if (!el || el.paused || this._trimFired) return;
if (this.trimEnd > 0 && el.currentTime >= this.trimEnd) {
this._trimFired = true;
try { el.pause(); } catch (e) {}
if (this.onEnded) this.onEnded();
}
}
// Stop any current audio/video source but keep the context & analyser alive
// (meters read 0, recording keep-alive continues). Used for visual-only
// playlist interludes.
silence() {
this.resume();
this._disconnectSource();
this.mediaEl = null;
this.mode = 'silence';
}
_disconnectSource() {
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
if (this.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
}
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
// the speakers. opts.loop controls looping; opts.crossfade (ms) fades the
// previous file out while the new one fades in.
async loadFile(fileOrSrc, opts = {}) {
this.resume();
const fadeMs = Math.max(0, opts.crossfade || 0);
const el = new Audio();
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
el.loop = opts.loop !== undefined ? opts.loop : true;
el.crossOrigin = 'anonymous';
el.onended = () => { if (this.onEnded) this.onEnded(); };
const src = this.ctx.createMediaElementSource(el);
const g = this.ctx.createGain();
src.connect(g);
g.connect(this.analyser);
g.connect(this.outGain); // audible
g.connect(this.recordDest); // captured by the recorder
if (fadeMs > 0 && this.mediaEl && this.mode === 'file' && this.sourceGain) {
const now = this.ctx.currentTime, dur = fadeMs / 1000;
g.gain.setValueAtTime(0.0001, now);
g.gain.linearRampToValueAtTime(1, now + dur);
const oldEl = this.mediaEl, oldSrc = this.sourceNode, oldGain = this.sourceGain;
oldGain.gain.cancelScheduledValues(now);
oldGain.gain.setValueAtTime(oldGain.gain.value, now);
oldGain.gain.linearRampToValueAtTime(0.0001, now + dur);
setTimeout(() => {
try { oldEl.pause(); oldEl.src = ''; } catch (e) {}
try { oldSrc.disconnect(); } catch (e) {}
try { oldGain.disconnect(); } catch (e) {}
}, fadeMs + 80);
} else {
this._disconnectSource();
g.gain.value = 1;
}
this.mediaEl = el;
this.sourceNode = src;
this.sourceGain = g;
this.stream = null;
this.mode = 'file';
this._trimFired = false;
await el.play();
this.seekToTrimStart(el);
return el;
}
// Use a live input device (mic / line-in / BlackHole). Not routed to output
// to avoid feedback loops.
async useInput(deviceId) {
this.resume();
this._disconnectSource();
const constraints = {
audio: {
deviceId: deviceId ? { exact: deviceId } : undefined,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
},
video: false
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
this.stream = stream;
const src = this.ctx.createMediaStreamSource(stream);
src.connect(this.analyser); // analyser only — no output (avoid feedback)
src.connect(this.recordDest); // but capture it when recording
this.sourceNode = src;
this.mode = 'input';
}
// Play a playlist video track: route the given <video> element's audio
// through the analyser/output so visuals react and it's audible/recordable.
attachVideo(videoEl) {
this.resume();
this._disconnectSource();
if (this._videoSrcEl !== videoEl) {
// createMediaElementSource may only be called once per element.
this._videoSrc = this.ctx.createMediaElementSource(videoEl);
this._videoGain = this.ctx.createGain();
this._videoSrc.connect(this._videoGain);
this._videoGain.connect(this.analyser);
this._videoGain.connect(this.outGain);
this._videoGain.connect(this.recordDest);
this._videoSrcEl = videoEl;
}
videoEl.onended = () => { if (this.onEnded) this.onEnded(); };
this.mediaEl = videoEl;
this.sourceNode = null;
this.sourceGain = null;
this.mode = 'video';
}
async listInputDevices() {
// Labels are only populated after permission is granted.
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(d => d.kind === 'audioinput');
}
async listOutputDevices() {
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(d => d.kind === 'audiooutput');
}
// Route the file/playback audio to a specific output device (speaker).
// Uses AudioContext.setSinkId (Chromium 110+); empty id = system default.
async setOutputDevice(deviceId) {
if (typeof this.ctx.setSinkId !== 'function') return false;
try {
await this.ctx.setSinkId(deviceId || '');
return true;
} catch (e) {
console.warn('setSinkId fallito:', e);
return false;
}
}
togglePlay() {
if (!this.mediaEl) return null;
if (this.mediaEl.paused) { this.mediaEl.play(); return true; }
this.mediaEl.pause();
return false;
}
// Sample the analyser and update smoothed values. Call once per frame.
update() {
this.analyser.getByteFrequencyData(this.freq);
const bins = this.freq.length; // 1024 bins, ~0..(sr/2)
// Roughly: bass 0-6%, mid 6-25%, treble 25-65% of spectrum.
const bassEnd = Math.floor(bins * 0.06);
const midEnd = Math.floor(bins * 0.25);
const trebEnd = Math.floor(bins * 0.65);
let b = 0, m = 0, t = 0;
for (let i = 0; i < bassEnd; i++) b += this.freq[i];
for (let i = bassEnd; i < midEnd; i++) m += this.freq[i];
for (let i = midEnd; i < trebEnd; i++) t += this.freq[i];
b = b / (bassEnd * 255);
m = m / ((midEnd - bassEnd) * 255);
t = t / ((trebEnd - midEnd) * 255);
const g = this.gain;
// Smooth toward new values (attack/release), applying master + per-band gain.
this.bass = this._smooth(this.bass, Math.min(1, b * 1.6 * g * this.bassGain), 0.5, 0.12);
this.mid = this._smooth(this.mid, Math.min(1, m * 2.2 * g * this.midGain), 0.5, 0.12);
this.treble = this._smooth(this.treble, Math.min(1, t * 3.0 * g * this.trebleGain), 0.6, 0.15);
this.level = this._smooth(this.level, Math.min(1, (b + m + t) / 3 * 2.0 * g), 0.4, 0.1);
// Beat detection: bass spike above running average.
this._bassAvg = this._bassAvg * 0.94 + b * 0.06;
this._beatCooldown -= 1;
if (b > this._bassAvg * 1.35 && b > 0.12 && this._beatCooldown <= 0) {
this.beat = 1.0;
this._beatCooldown = 8; // ~min frames between beats
} else {
this.beat *= 0.86; // decay
}
// Log-spaced spectrum bands (for VU-meter style effects).
const NB = this.NB;
const minBin = 1;
for (let i = 0; i < NB; i++) {
const lo = Math.floor(minBin * Math.pow(bins / minBin, i / NB));
let hi = Math.floor(minBin * Math.pow(bins / minBin, (i + 1) / NB));
if (hi <= lo) hi = lo + 1;
let sum = 0;
for (let k = lo; k < hi; k++) sum += this.freq[k];
let val = sum / ((hi - lo) * 255);
val = Math.min(1, val * (1.4 + i * 0.06) * g); // tilt up the highs + user gain
this.spectrum[i] = this.spectrum[i] * 0.55 + val * 0.45; // smooth
}
// Time-domain waveform.
this.analyser.getByteTimeDomainData(this.timeData);
const wstep = this.timeData.length / this.NW;
let peak = 0;
for (let i = 0; i < this.NW; i++) {
const s = (this.timeData[Math.floor(i * wstep)] - 128) / 128;
this.wave[i] = s;
const a = Math.abs(s);
if (a > peak) peak = a;
}
// Scrolling waveform: accumulate the peak, push into history at a rate
// controlled by the speed slider (so it reads as a song's waveform).
this.histPeak = Math.max(this.histPeak, peak);
this.histAccum += Math.max(0.04, this.scrollRate);
while (this.histAccum >= this.PUSH_EVERY) {
this.histAccum -= this.PUSH_EVERY;
this.waveHist.copyWithin(0, 1);
this.waveHist[this.NH - 1] = this.histPeak;
this.histPeak = peak;
}
return this;
}
_smooth(cur, target, attack, release) {
const k = target > cur ? attack : release;
return cur + (target - cur) * k;
}
get values() {
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat, spectrum: this.spectrum, wave: this.wave, waveHist: this.waveHist };
}
}
window.AudioEngine = AudioEngine;