Files
djAudiovisualizaerPi/public/js/output.js
T
lucianoandClaude Opus 4.8 6657d1afab Mobile: live external audio input (USB) source selector
Expose the engine's live-input mode on the remote: an "Sorgente audio"
selector in the Audio tab lists the input devices the output reports and
switches between File/Playlist and a live USB capture (useInput); picking
File again stops the input. Grant the kiosk getUserMedia access with
--use-fake-ui-for-media-stream so device enumeration/labels work without a
prompt. Make reportDevices resilient (per-probe timeout, always report even
an empty list) so the UI gets feedback when no input is connected yet.

The Pi 5 has no audio input — a USB (line) sound card is required; visuals
react to the source while the audio stays on the mixer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:27:41 +02:00

519 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OUTPUT window: renders the visuals + overlays and runs the audio engine.
// It receives commands from the control window via window.djv.onControl and
// sends back reports (meters, fps, device list, play state).
const $ = (s) => document.querySelector(s);
const canvas = $('#gl');
let viz;
try {
viz = new Visualizer(canvas);
try {
const gl = canvas.getContext('webgl2');
const d = gl && gl.getExtension('WEBGL_debug_renderer_info');
djv.report({ type: 'gpuInfo', webgl2: !!gl, renderer: d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : (gl && gl.getParameter(gl.RENDERER)) });
} catch (e2) {}
} catch (e) {
djv.report({ type: 'gpuInfo', webgl2: false, message: e.message });
document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">' +
'Errore grafica:<br><pre>' + e.message + '</pre></div>';
throw e;
}
const audio = new AudioEngine();
let speed = 1.0;
// In the Pi/web build, paths are already server URLs (e.g. /media/song.mp3),
// so they can be used directly as the resource URL.
function toFileURL(p) {
return p;
}
// ---------------------------------------------------------------- overlays
const imgLayer = $('#overlay-images');
let images = [];
let imgIndex = 0;
let slideshow = false, slideshowTimer = null, slideshowMs = 5000;
let beatPulse = true;
let imgBlend = 'screen';
function addImages(paths) {
paths.forEach(p => {
const img = document.createElement('img');
img.src = toFileURL(p);
img.style.mixBlendMode = imgBlend;
imgLayer.appendChild(img);
images.push(img);
});
if (images.length) showImage(images.length - paths.length);
hideHint();
}
function showImage(i) {
if (!images.length) return;
imgIndex = (i + images.length) % images.length;
images.forEach((im, idx) => im.classList.toggle('show', idx === imgIndex));
}
function clearImages() {
images.forEach(im => im.remove());
images = []; imgIndex = 0;
}
function restartSlideshow() {
if (slideshowTimer) { clearInterval(slideshowTimer); slideshowTimer = null; }
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
}
// ---------------------------------------------------------------- recording
// Records the output canvas (video) + the audio tap into a WebM stream that
// main transcodes to MP4 at the chosen aspect ratio.
let recorder = null, recording = false, pendingRecOpts = null;
let recCanvas = null, recCtx = null, composeRAF = null;
function pickMime() {
const cands = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
for (const m of cands) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
return 'video/webm';
}
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D
// canvas so the recording captures everything that's on screen, not just WebGL.
function drawMediaFit(ctx, media, W, H, fit) {
const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
if (!mw || !mh) { ctx.drawImage(media, 0, 0, W, H); return; }
const scale = fit === 'contain' ? Math.min(W / mw, H / mh) : Math.max(W / mw, H / mh);
const dw = mw * scale, dh = mh * scale;
ctx.drawImage(media, (W - dw) / 2, (H - dh) / 2, dw, dh);
}
function composeFrame() {
const W = recCanvas.width, H = recCanvas.height;
const sx = W / window.innerWidth, sy = H / window.innerHeight;
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
recCtx.clearRect(0, 0, W, H);
recCtx.drawImage(canvas, 0, 0, W, H);
if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) {
recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1;
const bm = bgVideo.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { drawMediaFit(recCtx, bgVideo, W, H, bgVideo.style.objectFit || 'cover'); } catch (e) {}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
}
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
recCtx.globalAlpha = parseFloat(getComputedStyle(trackVideo).opacity) || 1;
const bm = trackVideo.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { drawMediaFit(recCtx, trackVideo, W, H, trackVideo.style.objectFit || 'contain'); } catch (e) {}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
}
for (const im of images) {
if (!im.classList.contains('show')) continue;
const r = im.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(getComputedStyle(im).opacity) || 1;
const bm = im.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { recCtx.drawImage(im, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
if (sceneImage.classList.contains('show') && sceneImage.src) {
const r = sceneImage.getBoundingClientRect();
try { recCtx.drawImage(sceneImage, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
for (const l of logos) {
if (!l.classList.contains('show')) continue;
const r = l.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(l.style.opacity || '1');
try { recCtx.drawImage(l, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
if (ticker.classList.contains('show') || sideText.classList.contains('show')) {
recCtx.textBaseline = 'middle';
for (const span of tickerLetters()) {
const txt = span.textContent;
if (!txt || txt === ' ') continue;
const r = span.getBoundingClientRect();
if (r.right < 0 || r.left > window.innerWidth) continue;
const cs = getComputedStyle(span);
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
recCtx.fillStyle = cs.color;
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
recCtx.shadowBlur = 14 * sy;
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
recCtx.shadowBlur = 0;
}
recCtx.globalAlpha = 1;
}
composeRAF = requestAnimationFrame(composeFrame);
}
function stopCompose() {
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
}
async function startRecording() {
if (recording) return;
try {
await djv.recStart();
recCanvas = document.createElement('canvas');
recCanvas.width = canvas.width;
recCanvas.height = canvas.height;
recCtx = recCanvas.getContext('2d');
composeFrame();
const vstream = recCanvas.captureStream(30);
const astream = audio.recordDest.stream;
const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]);
recorder = new MediaRecorder(stream, { mimeType: pickMime(), videoBitsPerSecond: 12e6 });
// Serialise chunk delivery: ondataavailable is async, so without a chain the
// header chunk could be sent after a later one and corrupt the WebM.
let chain = Promise.resolve();
recorder.ondataavailable = (e) => {
if (!e.data || !e.data.size) return;
chain = chain.then(async () => djv.recChunk(new Uint8Array(await e.data.arrayBuffer())));
};
recorder.onstop = async () => {
stopCompose();
await chain; // ensure every chunk is flushed, in order, before muxing
const res = await djv.recStop(pendingRecOpts || {});
recording = false;
djv.report({ type: 'recState', recording: false });
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path });
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
};
recorder.start(2000); // emit a chunk every 2s
recording = true;
djv.report({ type: 'recState', recording: true });
} catch (e) {
stopCompose();
djv.report({ type: 'recError', message: e.message });
}
}
function stopRecording(opts) {
if (!recording || !recorder) return;
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
recorder.stop();
}
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
// pixels can be uploaded to a WebGL texture.
function loadCustomTexture(dataUrl) {
const img = new Image();
img.onload = () => {
const size = 1024;
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const iw = img.width || 512, ih = img.height || 512;
const s = Math.min(size / iw, size / ih);
const w = iw * s, h = ih * s;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
try { viz.setTexture(cv); hideHint(); }
catch (e) { djv.report({ type: 'error', message: 'Texture SVG: ' + e.message }); }
};
img.onerror = () => djv.report({ type: 'error', message: 'SVG/immagine non caricata' });
img.src = dataUrl;
}
const bgVideo = $('#bg-video');
const trackVideo = $('#track-video');
const sceneImage = $('#scene-image');
// Two independent logos.
const logos = [$('#logo-0'), $('#logo-1')];
function setLogo(i, url) {
if (!logos[i]) return;
if (url) { logos[i].src = url; logos[i].classList.add('show'); hideHint(); }
else { logos[i].classList.remove('show'); logos[i].removeAttribute('src'); }
}
const ticker = $('#ticker');
const tickerTrack = $('#ticker-track');
const tickCopies = ticker.querySelectorAll('.tick-copy');
const sideText = $('#side-text');
const sideCopies = sideText.querySelectorAll('.side-copy');
let tickerVisible = false, tickerDir = 'h';
function buildLetters(txt) {
const full = (txt || '') + ' • ';
let html = '';
for (let i = 0; i < full.length; i++) {
const c = full[i];
const ch = c === ' ' ? ' ' : c.replace(/&/g, '&amp;').replace(/</g, '&lt;');
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
}
return html;
}
function setTickerText(t) {
const h = buildLetters(t);
tickCopies.forEach(c => c.innerHTML = h);
sideCopies.forEach(c => c.innerHTML = h);
}
function setTickerSpeed(m) { document.documentElement.style.setProperty('--ticker-dur', (18 / m) + 's'); }
function tickerLetters() {
let out = [];
if (ticker.classList.contains('show')) out = out.concat([...tickerTrack.querySelectorAll('.tl')]);
if (sideText.classList.contains('show')) out = out.concat([...sideText.querySelectorAll('.tl')]);
return out;
}
function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; }
function updateTickerVis() {
const sides = tickerDir === 'sides';
ticker.classList.toggle('show', tickerVisible && !sides);
sideText.classList.toggle('show', tickerVisible && sides);
}
ticker.classList.add('pos-bottom');
ticker.classList.add('dir-h');
setTickerSpeed(1);
function hideHint() { $('#drop-hint').classList.add('hidden'); }
// ---------------------------------------------------------------- audio devices
// Probe duration (metadata only) for a list of file paths.
function probeDurations(paths) {
if (!paths.length) return;
const results = [];
let pending = paths.length;
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
paths.forEach(p => {
// A <video> element reads duration for both audio and video files.
const a = document.createElement('video');
a.preload = 'metadata'; a.muted = true;
let done = false;
const ok = (dur) => { if (done) return; done = true; results.push({ path: p, duration: isFinite(dur) && dur > 0 ? dur : 0 }); finish(); };
a.onloadedmetadata = () => ok(a.duration);
a.onerror = () => ok(0);
setTimeout(() => ok(0), 6000); // never hang if metadata never fires
a.src = toFileURL(p);
a.load(); // some Chromium builds need an explicit load() for detached media
});
}
async function reportDevices() {
// Always answer, even with an empty list: getUserMedia can hang/reject when no
// input device exists (e.g. before a USB sound card is plugged in), so guard
// each probe with a timeout and still report so the control UI isn't left hanging.
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))]);
let inputs = [];
try { inputs = await withTimeout(audio.listInputDevices(), 3000); } catch (e) { /* none/blocked */ }
djv.report({ type: 'devices', list: inputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
let outputs = [];
try { outputs = await withTimeout(audio.listOutputDevices(), 3000); } catch (e) { /* none/blocked */ }
djv.report({ type: 'outputs', list: outputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
}
// ---------------------------------------------------------------- command bus
djv.onControl(async (m) => {
switch (m.type) {
case 'effect': viz.setEffect(m.effect); break;
case 'svg': loadCustomTexture(m.dataUrl); break;
case 'recStart': startRecording(); break;
case 'recStop': stopRecording(m); break;
case 'gain': audio.gain = m.value; break;
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
case 'outputDevice':
try { await audio.setOutputDevice(m.deviceId || ''); }
catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'loadFile':
try {
await audio.loadFile(toFileURL(m.path));
hideHint();
djv.report({ type: 'fileLoaded' });
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'playTrack':
try {
// Switching to an audio track: stop/hide any playing video track.
trackVideo.pause(); trackVideo.classList.remove('show');
audio.setTrim(m.start || 0, m.end || 0);
audio.onEnded = () => djv.report({ type: 'trackEnded' });
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
hideHint();
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'diag':
djv.report({ type: 'diag', stage: 'poll', ctx: audio.ctx.state, mode: audio.mode });
break;
case 'playVideoTrack':
try {
audio.setTrim(m.start || 0, m.end || 0);
audio.onEnded = () => djv.report({ type: 'trackEnded' });
audio.attachVideo(trackVideo);
trackVideo.loop = false; trackVideo.muted = false;
// Force a fresh start even when re-selecting the same file:
// reassigning the same src may not reload, so pause + load() resets it.
trackVideo.pause();
trackVideo.src = toFileURL(m.path);
trackVideo.load();
trackVideo.classList.add('show');
await trackVideo.play();
audio.seekToTrimStart(trackVideo);
hideHint();
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'playSilence':
// Visual-only interlude: stop audio + hide the video, keep visuals going.
try {
trackVideo.pause(); trackVideo.classList.remove('show');
audio.onEnded = null;
audio.silence();
hideHint();
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
case 'seek': {
const el = audio.mediaEl;
if (el && isFinite(el.duration) && el.duration > 0) {
el.currentTime = Math.max(0, Math.min(el.duration - 0.05, m.time || 0));
audio._trimFired = false; // re-arm the trim-end check after seeking
}
break;
}
case 'probeDurations': probeDurations(m.paths || []); break;
case 'togglePlay': {
const playing = audio.togglePlay();
if (playing !== null) djv.report({ type: 'playState', playing });
break;
}
case 'useInput':
try { await audio.useInput(m.deviceId || null); hideHint(); await reportDevices(); }
catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'refreshDevices': reportDevices(); break;
case 'addImages': addImages(m.paths); break;
case 'clearImages': clearImages(); break;
case 'imgBlend': imgBlend = m.value; images.forEach(im => im.style.mixBlendMode = m.value); break;
case 'slideshow': slideshow = m.on; restartSlideshow(); break;
case 'slideshowInterval': slideshowMs = m.ms; restartSlideshow(); break;
case 'imgSize': imgLayer.style.setProperty('--img-size', m.value); break;
case 'imgBeat': beatPulse = m.on; break;
case 'imgNext': showImage(imgIndex + 1); break;
case 'imgPrev': showImage(imgIndex - 1); break;
case 'videoLoad':
bgVideo.src = toFileURL(m.path);
bgVideo.loop = true; bgVideo.muted = true;
bgVideo.classList.add('show');
bgVideo.play().catch(() => {});
hideHint();
break;
case 'videoToggle':
if (bgVideo.paused) bgVideo.play().catch(() => {}); else bgVideo.pause();
break;
case 'videoClear':
bgVideo.pause(); bgVideo.removeAttribute('src'); bgVideo.load();
bgVideo.classList.remove('show');
break;
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
case 'videoFit': bgVideo.style.objectFit = m.value; break;
case 'videoBlend': bgVideo.style.mixBlendMode = m.value; break;
case 'trackVideoBlend': trackVideo.style.mixBlendMode = m.value; break;
case 'trackVideoOpacity': trackVideo.style.opacity = m.value; break;
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
case 'sceneImage':
if (m.path) {
sceneImage.src = toFileURL(m.path);
const s = m.size || 60;
sceneImage.style.maxWidth = s + 'vw';
sceneImage.style.maxHeight = s + 'vh';
sceneImage.style.left = (m.x != null ? m.x : 50) + '%';
sceneImage.style.top = (m.y != null ? m.y : 50) + '%';
sceneImage.classList.add('show'); hideHint();
} else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
break;
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
case 'logoSize': logos[m.index].style.width = m.value + 'vw'; break;
case 'logoOpacity': logos[m.index].style.opacity = m.value; break;
case 'tickerText': setTickerText(m.text); break;
case 'tickerOn': tickerVisible = m.on; updateTickerVis(); break;
case 'tickerPos':
ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
ticker.classList.add('pos-' + m.pos);
break;
case 'tickerSpeed': setTickerSpeed(m.mult); break;
case 'tickerDir':
tickerDir = m.value;
if (m.value !== 'sides') {
ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown');
ticker.classList.add('dir-' + m.value);
}
updateTickerVis();
break;
case 'tickerFont': styleBoth('fontFamily', m.value); break;
case 'tickerSize': styleBoth('fontSize', m.value + 'vh'); break;
case 'tickerWeight': styleBoth('fontWeight', m.on ? '800' : '400'); break;
case 'tickerColor': styleBoth('color', m.value); break;
case 'tickerFx':
[tickerTrack, sideText].forEach(el => {
el.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
if (m.value && m.value !== 'none') el.classList.add('fx-' + m.value);
});
break;
}
});
// ---------------------------------------------------------------- render loop
const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
function frame() {
const t = (performance.now() - startTime) / 1000;
audio.update();
const a = audio.values;
viz.render(t * speed, a);
// Report beat rising-edges so the control window can auto-cycle effects.
if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' });
prevBeat = a.beat;
// Overlays react to audio.
imgLayer.style.setProperty('--beat-scale',
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
tickerTrack.style.setProperty('--ticker-glow', (a.level * 24 + a.beat * 20).toFixed(0) + 'px');
// FPS + meter report to the control window (throttled to ~15 Hz).
frames++;
const now = performance.now();
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
if (now - lastReport > 66) {
lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
}
// Stop at the trim end point if one is set.
audio.checkTrim();
// Playback progress for the currently playing file.
if (now - lastProgress > 400) {
lastProgress = now;
const el = audio.mediaEl;
if (el && !el.paused && isFinite(el.duration) && el.duration > 0) {
djv.report({ type: 'progress', currentTime: el.currentTime, duration: el.duration });
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Populate the device list once at startup so the control dropdown fills in.
reportDevices();