Effects engine, SVG/VU/silhouette categories, pad bank, MP4 recording, dual-window UI
- Parametric effect engine: 23 shader families × palettes × variants (~2300 presets) with a filterable library and an effect sequence (auto-cycle by time or beat). - Effect categories: fractals/abstract, dancing silhouettes, SVG/Image source (loads any SVG/image as a recolourable audio-reactive effect; 50+ bundled CC0 SVGs), and VU-Meter (32-band spectrum bars, analogue needle, stereo LEDs). - Dual-window architecture: control panel + fullscreen output on an external display (reliable via simple-fullscreen); tabbed control UI (Audio, Effetti, Pad, Immagini, Testo, Registra, Schermo). - Audio: file playback + live input + output device selection, per-band visual EQ, audio playlist (reorder, hotkeys, per-track play/pause, repeat). - Pad bank: 5×4 launchpad with persistent assignments; pressing a pad stops the current. - Overlays: slideshow images (resizable), two freely positioned/resizable logos, scrolling ticker text. - MP4 recording of the composited output (visual + overlays + audio) via MediaRecorder + ffmpeg, with selectable aspect ratios (16:9, 9:16, 1:1, 4:3, 3:4, 21:9). - App icon (.icns) and dock icon; author Dj LuZa. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b32ea47ae5
commit
47728d0d2a
76 files changed
+21794
-459
No files matched your search
+66
-10
@@ -16,17 +16,37 @@ class AudioEngine {
|
||||
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.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
|
||||
|
||||
// Smoothed band values and beat state.
|
||||
this.gain = 1.0; // user reactivity multiplier
|
||||
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);
|
||||
}
|
||||
|
||||
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
||||
@@ -38,20 +58,22 @@ class AudioEngine {
|
||||
}
|
||||
|
||||
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
|
||||
// the speakers.
|
||||
async loadFile(fileOrSrc) {
|
||||
// the speakers. opts.loop controls looping (default true for a single file).
|
||||
async loadFile(fileOrSrc, opts = {}) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
|
||||
const el = new Audio();
|
||||
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
||||
el.loop = true;
|
||||
el.loop = opts.loop !== undefined ? opts.loop : true;
|
||||
el.crossOrigin = 'anonymous';
|
||||
el.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||
this.mediaEl = el;
|
||||
|
||||
const src = this.ctx.createMediaElementSource(el);
|
||||
src.connect(this.analyser);
|
||||
src.connect(this.outGain); // file audio is audible
|
||||
src.connect(this.recordDest); // and captured by the recorder
|
||||
this.sourceNode = src;
|
||||
this.mode = 'file';
|
||||
|
||||
@@ -78,7 +100,8 @@ class AudioEngine {
|
||||
this.stream = stream;
|
||||
|
||||
const src = this.ctx.createMediaStreamSource(stream);
|
||||
src.connect(this.analyser); // analyser only — no output
|
||||
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';
|
||||
}
|
||||
@@ -90,6 +113,25 @@ class AudioEngine {
|
||||
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; }
|
||||
@@ -115,10 +157,10 @@ class AudioEngine {
|
||||
t = t / ((trebEnd - midEnd) * 255);
|
||||
|
||||
const g = this.gain;
|
||||
// Smooth toward new values (attack/release) and apply user gain.
|
||||
this.bass = this._smooth(this.bass, Math.min(1, b * 1.6 * g), 0.5, 0.12);
|
||||
this.mid = this._smooth(this.mid, Math.min(1, m * 2.2 * g), 0.5, 0.12);
|
||||
this.treble = this._smooth(this.treble, Math.min(1, t * 3.0 * g), 0.6, 0.15);
|
||||
// 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.
|
||||
@@ -131,6 +173,20 @@ class AudioEngine {
|
||||
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
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -140,7 +196,7 @@ class AudioEngine {
|
||||
}
|
||||
|
||||
get values() {
|
||||
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat };
|
||||
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat, spectrum: this.spectrum };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user